commit 510baee3ba1ecd14c340fce8902d61c02746ef0f
parent 0d72a4ceb3a67beecde238aeaa05df782dd9d496
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 25 Aug 2026 21:52:40 -0700
Increase TUI layout density
Diffstat:
2 files changed, 246 insertions(+), 64 deletions(-)
diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go
@@ -2,6 +2,7 @@ package tui
import (
"context"
+ "fmt"
"strings"
"testing"
"time"
@@ -199,3 +200,66 @@ func TestPausedLiveScreenIgnoresStreamClosure(t *testing.T) {
t.Fatalf("paused stream reconnected: state=%q cmd=%v", model.current().streamState, cmd != nil)
}
}
+func TestPostListUsesDenseRowsAndShowsActivityContext(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{})
+ model.width, model.height = 80, 24
+ s := model.current()
+ s.loading = false
+ s.entries = []ActivityEntry{{Post: textlog.Post{ID: 1, Body: "hello", Author: textlog.UserReference{Handle: "alice"}}, Context: "mentioned you"}}
+ s.posts = []textlog.Post{s.entries[0].Post}
+ view := model.View()
+ if strings.Contains(view, "╭") || !strings.Contains(view, "┃") {
+ t.Fatalf("post list is not using a dense selected row:\n%s", view)
+ }
+ if !strings.Contains(view, "mentioned you") {
+ t.Fatalf("activity context is missing:\n%s", view)
+ }
+}
+func TestPostListFillsAvailableHeight(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{})
+ model.width, model.height = 120, 40
+ s := model.current()
+ s.loading = false
+ for i := 1; i <= 15; i++ {
+ s.posts = append(s.posts, textlog.Post{ID: i, Body: "body", Author: textlog.UserReference{Handle: fmt.Sprintf("user-%02d", i)}})
+ }
+ if view := model.View(); !strings.Contains(view, "@user-15") {
+ t.Fatalf("list did not fill the viewport:\n%s", view)
+ }
+}
+func TestEditorUsesOneCompactHintAndStableField(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{})
+ model.width, model.height = 80, 24
+ s := model.newScreen(screenSearch)
+ s.phase = "input"
+ model.stack = append(model.stack, s)
+ view := model.View()
+ if strings.Count(view, "Enter continue") != 1 || strings.Contains(view, "╭") || !strings.Contains(view, "┃") {
+ t.Fatalf("editor is not compact and coherent:\n%s", view)
+ }
+}
+func TestProfileHeaderDoesNotRepeatScreenIdentity(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{})
+ profile := model.newScreen(screenProfile)
+ profile.handle = "alice"
+ profile.profile = &textlog.Profile{Handle: "alice", Bio: "short bio"}
+ model.stack = append(model.stack, profile)
+ if view := model.View(); strings.Count(view, "@alice") != 1 {
+ t.Fatalf("profile identity is repeated:\n%s", view)
+ }
+}
+func TestReferenceListKeepsSelectionVisible(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeLight, NoColor: true}, Options{})
+ model.width, model.height = 65, 30
+ s := model.newScreen(screenProfile)
+ s.handle = "alice"
+ s.profile = &textlog.Profile{Handle: "alice"}
+ s.activeTab, s.selected = 2, 30
+ for i := 0; i < 40; i++ {
+ s.users = append(s.users, textlog.UserReference{Handle: fmt.Sprintf("user-%02d", i)})
+ }
+ model.stack = append(model.stack, s)
+ if view := model.View(); !strings.Contains(view, "› @user-30") {
+ t.Fatalf("selected reference is outside the viewport:\n%s", view)
+ }
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -44,27 +44,29 @@ func (m Model) View() string {
}
func (m *Model) header(s *screen, p palette) string {
active := m.title(s)
- auth := m.config.Token != ""
- tabs := []string{}
- if auth {
+ tabs := []string{"1 hot", "2 latest", "3 live"}
+ if m.config.Token != "" {
tabs = []string{"1 for you", "2 to me", "3 hot", "4 latest", "5 live"}
- } else {
- tabs = []string{"1 hot", "2 latest", "3 live"}
}
- for i, t := range tabs {
- if strings.Contains(t, active) {
- tabs[i] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(t)
- } else {
- tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(t)
+ activeTab := strings.ReplaceAll(active, "-", " ")
+ for i, tab := range tabs {
+ style := lipgloss.NewStyle().Foreground(p.muted)
+ if strings.Contains(tab, activeTab) {
+ style = style.Bold(true).Foreground(p.accent)
}
+ tabs[i] = style.Render(tab)
}
who := "guest"
if m.me != nil {
who = "0 @" + m.me.Handle
}
- left := lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(">_ textlog") + " " + strings.Join(tabs, " ")
+ brand := lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("textlog")
+ left := brand + " " + strings.Join(tabs, " ")
+ if s.kind != screenFeed && s.kind != screenLive {
+ left += lipgloss.NewStyle().Foreground(p.soft).Render(" │ ") + lipgloss.NewStyle().Foreground(p.ink).Render(active)
+ }
space := max(1, m.width-lipgloss.Width(left)-lipgloss.Width(who)-2)
- return lipgloss.NewStyle().BorderBottom(true).BorderForeground(p.soft).Width(max(1, m.width)).Render(" " + left + strings.Repeat(" ", space) + lipgloss.NewStyle().Foreground(p.muted).Render(who))
+ return lipgloss.NewStyle().Width(max(1, m.width)).Render(" " + left + strings.Repeat(" ", space) + lipgloss.NewStyle().Foreground(p.muted).Render(who))
}
func (m *Model) title(s *screen) string {
switch s.kind {
@@ -97,13 +99,22 @@ func editingView(s *screen) bool {
}
func (m *Model) footer(s *screen) string {
+ if s.kind == screenCompose || (s.kind == screenAccount && s.phase == "bio") {
+ return " Enter newline · Ctrl+S preview/submit · Esc cancel"
+ }
if editingView(s) {
- return " Enter continue · Esc cancel"
+ return " Enter continue · Esc cancel"
}
if s.kind == screenPost {
- return " Tab target · Enter open · r reply · u profile · f/F follow · b/B block · ! report · e edit · d delete"
+ if m.width < 90 {
+ return " Tab target · Enter open · r reply · u user · ! actions · q back"
+ }
+ return " Tab target · Enter open · r reply · u profile · f/F follow · b/B block · ! report · e edit · d delete"
}
- return " ↑↓/jk move enter open / search w write ? help q back"
+ if s.kind == screenHelp {
+ return " q back"
+ }
+ return " j/k move · Enter open · / search · w write · ? help · q back"
}
func (m *Model) screenView(s *screen, p palette) string {
if s.loading {
@@ -117,7 +128,7 @@ func (m *Model) screenView(s *screen, p palette) string {
return m.postsView(s, p)
case screenSearch:
if s.query == "" {
- return editor("search public notes", s.input, false, p)
+ return editor("search public notes", s.input, false, false, m.width, p)
}
return lipgloss.NewStyle().Foreground(p.muted).Render(" results for “"+s.query+"”") + "\n" + m.postsView(s, p)
case screenPost:
@@ -135,7 +146,7 @@ func (m *Model) screenView(s *screen, p palette) string {
if s.phase == "code" {
label = "code sent to " + s.query
}
- return editor(label, s.input, s.phase == "code", p)
+ return editor(label, s.input, s.phase == "code", false, m.width, p)
case screenAccount:
return m.accountView(s, p)
case screenSettings:
@@ -148,19 +159,10 @@ func (m *Model) screenView(s *screen, p palette) string {
func (m *Model) postsView(s *screen, p palette) string {
if len(s.posts) == 0 {
- return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("No notes here.")
- }
- rows := max(1, (m.height-6)/5)
- start := s.selected - rows/2
- if start < 0 {
- start = 0
+ return lipgloss.NewStyle().Foreground(p.muted).Padding(0, 1).Render("No notes here.")
}
- end := min(len(s.posts), start+rows)
- if end-start < rows {
- start = max(0, end-rows)
- }
- parts := make([]string, 0, end-start+1)
- for i := start; i < end; i++ {
+ rendered := make([]string, len(s.posts))
+ for i := range s.posts {
depth := 0
if i < len(s.depths) {
depth = s.depths[i]
@@ -169,7 +171,15 @@ func (m *Model) postsView(s *screen, p palette) string {
if i == s.selected {
focused = s.target
}
- note := renderNote(s.posts[i], i == s.selected, focused, max(20, m.width-depth*2), p)
+ context := ""
+ if i < len(s.entries) {
+ context = s.entries[i].Context
+ }
+ bodyLines := 3
+ if s.kind == screenPost && i == s.selected {
+ bodyLines = 8
+ }
+ note := renderNote(s.posts[i], i == s.selected, focused, context, max(20, m.width-depth*2), bodyLines, p)
if depth > 0 {
lines := strings.Split(note, "\n")
for j := range lines {
@@ -177,32 +187,95 @@ func (m *Model) postsView(s *screen, p palette) string {
}
note = strings.Join(lines, "\n")
}
- parts = append(parts, note)
if i < len(s.more) && s.more[i] > 0 {
- parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+fmt.Sprintf("more (%d replies)", s.more[i])))
+ note += "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+fmt.Sprintf("more · %d replies", s.more[i]))
+ }
+ rendered[i] = note
+ }
+
+ available := max(1, m.height-4)
+ switch s.kind {
+ case screenSearch, screenLive, screenTag:
+ available--
+ case screenProfile:
+ available -= 1 + strings.Count(s.profile.Bio, "\n")
+ if s.profile.Bio != "" {
+ available--
+ }
+ }
+ available = max(1, available)
+ if s.kind == screenFeed || s.kind == screenSearch {
+ available--
+ }
+ selected := clamp(s.selected, len(rendered))
+ start := selected
+ used := lipgloss.Height(rendered[selected])
+ for start > 0 {
+ height := lipgloss.Height(rendered[start-1])
+ if used+height > available/2 {
+ break
+ }
+ start--
+ used += height
+ }
+ end := start
+ used = 0
+ for end < len(rendered) {
+ height := lipgloss.Height(rendered[end])
+ if end > start && used+height > available {
+ break
+ }
+ used += height
+ end++
+ }
+ for start > 0 {
+ height := lipgloss.Height(rendered[start-1])
+ if used+height > available {
+ break
}
+ start--
+ used += height
}
+ parts := append([]string(nil), rendered[start:end]...)
if s.kind == screenFeed || s.kind == screenSearch {
- parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).Render(fmt.Sprintf(" page %d%s", s.page+1, map[bool]string{true: " · n next", false: ""}[s.nextCursor != ""])))
+ page := fmt.Sprintf("page %d", s.page+1)
+ if s.page > 0 {
+ page = "p previous · " + page
+ }
+ if s.nextCursor != "" {
+ page += " · n next"
+ }
+ parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).PaddingLeft(2).Render(page))
}
return strings.Join(parts, "\n")
}
-func renderNote(post textlog.Post, selected bool, focused, width int, p palette) string {
+
+func renderNote(post textlog.Post, selected bool, focused int, context string, width, maxBodyLines int, p palette) string {
replies := fmt.Sprintf("%d replies", post.ReplyCount)
if post.ReplyCount == 1 {
replies = "1 reply"
}
- when := relative(post.CreatedAt)
- authorStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 0)
+ authorStyle := lipgloss.NewStyle().Foreground(p.accent).Bold(selected).Reverse(focused == 0)
threadStyle := lipgloss.NewStyle().Foreground(p.muted).Reverse(focused == 1)
replyStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 2)
- meta := authorStyle.Render("@"+post.Author.Handle) + " " + threadStyle.Render(when+" · "+replies) + " " + replyStyle.Render("reply")
- body := renderRich(post.Body, post.Tags, focused-3, p)
- border := p.soft
+ meta := authorStyle.Render("@"+post.Author.Handle) + " " + threadStyle.Render(relative(post.CreatedAt)+" · "+replies)
+ if context != "" {
+ meta += lipgloss.NewStyle().Foreground(p.muted).Render(" · " + context)
+ }
+ meta += " " + replyStyle.Render("reply")
+ content := meta
+ if post.Body != "" {
+ bodyWidth := max(8, width-4)
+ body := lipgloss.NewStyle().Width(bodyWidth).MaxHeight(maxBodyLines).Render(renderRich(post.Body, post.Tags, focused-3, p))
+ content += "\n" + body
+ }
+ style := lipgloss.NewStyle().Width(max(10, width-2))
if selected {
- border = p.accent
+ style = style.BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(p.accent).PaddingLeft(1)
+ } else {
+ style = style.PaddingLeft(2)
}
- return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(0, 1).Width(max(10, width-2)).Render(meta + "\n" + body)
+ return style.Render(content)
}
func relative(value time.Time) string {
if value.IsZero() {
@@ -249,13 +322,20 @@ func renderRich(body string, tags []string, focused int, p palette) string {
}
return b.String()
}
-func editor(label, value string, secret bool, p palette) string {
+func editor(label, value string, secret, multiline bool, width int, p palette) string {
shown := value
if secret {
shown = strings.Repeat("•", domain.CodePointLength(value))
}
- box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.accent).Padding(0, 1).Render(shown + lipgloss.NewStyle().Reverse(true).Render(" "))
- return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(label) + "\n" + box + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render("Enter continue · Esc cancel"))
+ field := shown + lipgloss.NewStyle().Reverse(true).Render(" ")
+ style := lipgloss.NewStyle().BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderBottom(true).BorderForeground(p.accent).
+ PaddingLeft(1).Width(max(20, width-4))
+ if multiline {
+ style = style.Height(4)
+ }
+ return lipgloss.NewStyle().Padding(0, 1).Render(
+ lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(label) + "\n" + style.Render(field),
+ )
}
func (m *Model) composeView(s *screen, p palette) string {
@@ -267,7 +347,7 @@ func (m *Model) composeView(s *screen, p palette) string {
label = "edit note"
}
if !s.preview {
- return editor(label, s.input, false, p) + fmt.Sprintf("\n %d/280", domain.CodePointLength(s.input))
+ return editor(label, s.input, false, true, m.width, p) + fmt.Sprintf("\n %d/280", domain.CodePointLength(s.input))
}
preview := textlog.Post{Body: s.input, CreatedAt: time.Now(), Author: textlog.UserReference{Handle: m.me.Handle}}
items := []string{"publish", "keep editing", "cancel"}
@@ -280,14 +360,14 @@ func (m *Model) composeView(s *screen, p palette) string {
items[i] = prefix + items[i]
}
}
- return lipgloss.NewStyle().Padding(1).Bold(true).Render(label) + "\n" + renderNote(preview, true, -1, m.width, p) + "\n" + lipgloss.NewStyle().Bold(true).Render("Publish this note?") + "\n" + strings.Join(items, "\n")
+ return lipgloss.NewStyle().Padding(1).Bold(true).Render(label) + "\n" + renderNote(preview, true, -1, "", m.width, 8, p) + "\n" + lipgloss.NewStyle().Bold(true).Render("Publish this note?") + "\n" + strings.Join(items, "\n")
}
func (m *Model) profileView(s *screen, p palette) string {
if s.profile == nil {
return "Profile not found"
}
profile := s.profile
- tabs := []string{fmt.Sprintf("%d notes", profile.PostCount), fmt.Sprintf("%d replies", profile.RepliesCount), fmt.Sprintf("%d following users", profile.FollowingUserCount), fmt.Sprintf("%d following tags", profile.FollowingTagCount), fmt.Sprintf("%d followers", profile.FollowerCount)}
+ tabs := []string{fmt.Sprintf("%d notes", profile.PostCount), fmt.Sprintf("%d replies", profile.RepliesCount), fmt.Sprintf("%d users", profile.FollowingUserCount), fmt.Sprintf("%d tags", profile.FollowingTagCount), fmt.Sprintf("%d followers", profile.FollowerCount)}
if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) {
tabs = append(tabs, fmt.Sprintf("%d blocked", profile.BlockedUserCount))
}
@@ -298,14 +378,19 @@ func (m *Model) profileView(s *screen, p palette) string {
tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(tabs[i])
}
}
- header := lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("@"+profile.Handle) + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(profile.Bio) + "\n" + strings.Join(tabs, " "))
+ headerText := strings.Join(tabs, " ")
+ if profile.Bio != "" {
+ headerText = renderRich(profile.Bio, nil, -1, p) + "\n" + headerText
+ }
+ header := lipgloss.NewStyle().Padding(0, 1).Render(headerText)
if s.activeTab < 2 {
return header + "\n" + m.postsView(s, p)
}
+ height := max(1, m.height-3-lipgloss.Height(header))
if s.activeTab == 3 {
- return header + "\n" + referenceTags(s.tags, s.selected, p)
+ return header + "\n" + referenceTags(s.tags, s.selected, height, p)
}
- return header + "\n" + referenceUsers(s.users, s.selected, p)
+ return header + "\n" + referenceUsers(s.users, s.selected, height, p)
}
func (m *Model) tagView(s *screen, p palette) string {
if len(s.tags) == 0 {
@@ -314,45 +399,59 @@ func (m *Model) tagView(s *screen, p palette) string {
details := &s.tags[0]
tabs := []string{fmt.Sprintf("%d notes", details.PostCount), fmt.Sprintf("%d followers", details.FollowerCount)}
tabs[s.activeTab] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(tabs[s.activeTab])
- header := lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("#"+details.Tag) + "\n" + strings.Join(tabs, " "))
+ header := lipgloss.NewStyle().Padding(0, 1).Render(strings.Join(tabs, " "))
if s.activeTab == 0 {
return header + "\n" + m.postsView(s, p)
}
- return header + "\n" + referenceUsers(s.users, s.selected, p)
+ return header + "\n" + referenceUsers(s.users, s.selected, max(1, m.height-3-lipgloss.Height(header)), p)
}
-func referenceUsers(items []textlog.UserReference, selected int, p palette) string {
+func referenceUsers(items []textlog.UserReference, selected, height int, p palette) string {
if len(items) == 0 {
return lipgloss.NewStyle().Foreground(p.muted).Render(" No users here.")
}
- lines := make([]string, len(items))
- for i, v := range items {
+ start, end := referenceWindow(selected, len(items), height)
+ lines := make([]string, 0, end-start)
+ for i := start; i < end; i++ {
prefix := " "
if i == selected {
prefix = "› "
- lines[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + "@" + v.Handle)
+ lines = append(lines, lipgloss.NewStyle().Foreground(p.accent).Render(prefix+"@"+items[i].Handle))
} else {
- lines[i] = prefix + "@" + v.Handle
+ lines = append(lines, prefix+"@"+items[i].Handle)
}
}
return strings.Join(lines, "\n")
}
-func referenceTags(items []textlog.TagReference, selected int, p palette) string {
+
+func referenceTags(items []textlog.TagReference, selected, height int, p palette) string {
if len(items) == 0 {
return lipgloss.NewStyle().Foreground(p.muted).Render(" No tags here.")
}
- lines := make([]string, len(items))
- for i, v := range items {
+ start, end := referenceWindow(selected, len(items), height)
+ lines := make([]string, 0, end-start)
+ for i := start; i < end; i++ {
prefix := " "
if i == selected {
prefix = "› "
}
- lines[i] = prefix + "#" + v.Tag
+ lines = append(lines, prefix+"#"+items[i].Tag)
}
return strings.Join(lines, "\n")
}
+
+func referenceWindow(selected, length, height int) (int, int) {
+ height = max(1, height)
+ start := max(0, clamp(selected, length)-height/2)
+ end := min(length, start+height)
+ if end-start < height {
+ start = max(0, end-height)
+ }
+ return start, end
+}
+
func (m *Model) accountView(s *screen, p palette) string {
if s.phase == "bio" {
- return editor("edit bio", s.input, false, p)
+ return editor("edit bio", s.input, false, true, m.width, p)
}
if m.me == nil {
return "You are not signed in."
@@ -361,7 +460,7 @@ func (m *Model) accountView(s *screen, p palette) string {
}
func (m *Model) settingsView(s *screen, p palette) string {
if s.phase == "url" {
- return editor("Textlog instance URL", s.input, false, p)
+ return editor("Textlog instance URL", s.input, false, false, m.width, p)
}
items := []string{"theme: " + string(m.config.Theme), "change instance URL", "open web app", "back"}
for i := range items {
@@ -376,5 +475,24 @@ func (m *Model) settingsView(s *screen, p palette) string {
return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Render("settings · "+m.config.BaseURL) + "\n" + strings.Join(items, "\n"))
}
func (m *Model) helpView(p palette) string {
- return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("textlog · a quieter place for your thoughts") + "\n\n0 profile 1-5 feeds / search w write a account\n↑↓ j/k move Enter/l open h/q back g/G first/last p/n page R refresh\nTab/Shift+Tab changes profile and tag tabs.\nr reply u author profile o browser f/F follow b/B block e edit d delete ! report\n\n" + lipgloss.NewStyle().Foreground(p.muted).Render("Activity tabs require sign-in. API: "+m.config.BaseURL+"/api"))
+ feeds := "1–3 feeds"
+ if m.config.Token != "" {
+ feeds = "1–5 feeds"
+ }
+ label := func(value string) string {
+ return lipgloss.NewStyle().Width(9).Foreground(p.muted).Render(value)
+ }
+ lines := []string{
+ lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("a quieter place for your thoughts"),
+ "",
+ label("global") + feeds + " · / search · w write · a account · 0 profile",
+ label("move") + "↑↓ j/k · Enter/l open · h/q back · g/G first/last",
+ label("pages") + "p/n previous/next · R refresh",
+ label("notes") + "Tab target · r reply · u author · t tag · o browser",
+ label("actions") + "f/F follow · b/B block · e edit · d delete · ! report",
+ label("tabs") + "Tab or ←/→ on profiles and tags",
+ "",
+ lipgloss.NewStyle().Foreground(p.muted).Render("Activity feeds require sign-in · API " + m.config.BaseURL + "/api"),
+ }
+ return lipgloss.NewStyle().Padding(0, 1).Render(strings.Join(lines, "\n"))
}