From edcedd294f87f4322e7afa8415f011114f3de932 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 13 Jul 2026 01:51:59 +0000 Subject: [PATCH 1/2] feat(tui): revamp responsive session dashboard --- README.md | 13 +- docs/responsive-tui.md | 31 +- internal/app/app.go | 187 ++++----- internal/app/app_more_test.go | 14 +- internal/app/app_test.go | 41 +- internal/app/dashboard_revamp.go | 495 ++++++++++++++++++++++++ internal/app/dashboard_revamp_test.go | 530 ++++++++++++++++++++++++++ internal/app/layout_test.go | 18 +- internal/app/lifecycle_3c_test.go | 9 +- 9 files changed, 1172 insertions(+), 166 deletions(-) create mode 100644 internal/app/dashboard_revamp.go create mode 100644 internal/app/dashboard_revamp_test.go diff --git a/README.md b/README.md index 21daf8f..546e49f 100644 --- a/README.md +++ b/README.md @@ -122,15 +122,18 @@ uam version | `Ctrl+X` | Stop and remove the selected record, or restart it, with confirmation | | `Ctrl+S` | Toggle group-by-directory | | `Shift+↑/↓` | Reorder rows | +| `/` with an empty command | Filter by name, provider, task, workspace, ID, or lifecycle | | `e` | Open the guided dispatch wizard | | `?` | Open help | | `Esc` | Close overlays, clear input, or quit | -The dashboard responds to every terminal resize. Wide terminals show sessions -beside selected details or Peek; standard terminals stack those surfaces; and -compact or keyboard-constrained mobile terminals give Peek and the New Session -wizard the full primary surface. See [Responsive TUI design and operations](docs/responsive-tui.md) -for layout thresholds, mobile guidance, lifecycle markers, and accessibility. +The dashboard responds to every terminal resize. Operations always use a +full-width, bordered session list; the selected row expands in place with its +task, Workspace, identity, and pull request. Wide terminals split only when Peek +is open. Compact or keyboard-constrained mobile terminals keep ordinary rows to +one line and expand the selected row to two. See +[Responsive TUI design and operations](docs/responsive-tui.md) for layout +thresholds, filtering, mobile guidance, lifecycle labels, and accessibility. ## Attached sessions diff --git a/docs/responsive-tui.md b/docs/responsive-tui.md index 2b0a583..c3bdb61 100644 --- a/docs/responsive-tui.md +++ b/docs/responsive-tui.md @@ -17,6 +17,11 @@ UAM does not infer "working," "waiting," or "completed" by scraping provider text. The selected row's prompt is kept as its task summary; failure detail is added without replacing it. +Every row includes its provider, evidence-based lifecycle label, and age since +the Managed Session was created. Age is deliberately not an activity indicator: +live discovery timestamps change on every refresh and cannot prove that an agent +is busy or idle. + Attaching to Running reconnects to the existing host. Acting on Stopped resumes the provider when supported. If that resume can only select the provider's most recent conversation and several retained sessions share the Workspace, the TUI @@ -28,9 +33,9 @@ The layout is derived from the current terminal dimensions on every resize. | Layout | Geometry | Operations view | Peek view | |---|---|---|---| -| **Wide** | At least 96 columns and 28 rows | Session list and selected-session details are side by side. | Session list remains beside the output tail. | -| **Standard** | At least 58 columns and 24 rows, but below Wide | Selected-session summary appears above the list. | Output tail replaces the list so it has useful width. | -| **Compact** | Fewer than 58 columns or fewer than 24 rows | A bounded selected summary and single-line session rows share the screen. | Output tail becomes the primary surface. | +| **Wide** | At least 96 columns and 28 rows | Full-width list; the selected row expands with task, Workspace, exact ID, and PR. | Session list remains beside the output tail. | +| **Standard** | At least 58 columns and 24 rows, but below Wide | Full-width list with an expanded selected row. | Output tail replaces the list so it has useful width. | +| **Compact** | Fewer than 58 columns or fewer than 24 rows | Ordinary rows use one line; the selected row uses a second task line. | Output tail becomes the primary surface. | The prompt is reserved at the bottom before the remaining rows are allocated. The New Session wizard is a primary surface in all layouts, so every step remains @@ -54,6 +59,7 @@ by terminal-cell width without splitting Unicode text. | `Ctrl+X` | Confirm stop **and record removal**; press `r` in the confirmation to restart in place. Use CLI `uam stop ` to retain a Stopped row. | | `Ctrl+S` | Toggle Workspace grouping. | | `Shift+↑` / `Shift+↓` | Reorder within the same lifecycle, pin, and visible Workspace group. | +| `/` with an empty command | Enter live filtering. Type to narrow, use arrows to move, and press `Esc` to clear. | | `?` | Open key help. | | `Esc` | Close the current overlay or input; from the base dashboard, quit. | @@ -61,6 +67,22 @@ Inside an attached session, `Ctrl+B d` detaches. A bare left arrow also detaches when the provider input is empty and the quick-detach option is enabled. See the README for the complete attach-key contract. +## Filtering sessions + +Press `/` while the command composer is empty to filter the existing dashboard. +Matching is case-insensitive across display name, managed-session ID, provider, +command alias, task, Workspace, and lifecycle label. Space-separated terms must +all match the same session. The dashboard shows matched/total counts and removes +empty Workspace sections without changing the stored order. + +Filtering is a temporary presentation state. It is not stored, and pin, rename, +stop, attach, resume, grouping, and reorder actions still use the session's +provider-and-ID identity. `Esc` clears the query and restores the prior selection +when it still exists. A slash typed after command text has begun remains literal +prompt content. Peek replies also keep `/` as literal input rather than entering +filter mode; an empty Operations dashboard still opens the filter and shows a +zero-result state. + ## Workspace grouping and parallel sessions `Ctrl+S` groups rows by normalized absolute working directory without resolving @@ -87,7 +109,8 @@ touch-only substitutes for those terminal keys. 1. Keep the terminal narrower than 58 columns or let the keyboard reduce it below 24 rows. -2. Use `Space` to dedicate the primary surface to Peek; use `Esc` to return. +2. Use the one-line rows and expanded two-line selection to scan sessions; use + `Space` to dedicate the primary surface to Peek and `Esc` to return. 3. Use `e` for the bounded wizard instead of composing a long inline dispatch. 4. Use `Ctrl+G` with a terminal/editor combination that supports external editor handoff when a multi-line prompt is easier outside the small viewport. diff --git a/internal/app/app.go b/internal/app/app.go index 2781c58..bcf3402 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -33,6 +33,10 @@ type Model struct { sessions []adapter.Session selected int input string + filterActive bool + filterQuery string + filterRestore sessionIdentity + filterSaved bool defaultAgent string message string messageSetAt time.Time @@ -83,6 +87,10 @@ type Model struct { // for that gate. lastPeekAt map[string]time.Time peekClock func() time.Time + // now is the presentation clock used for deterministic session-age labels. + // Discovery refreshes LastChange on every scan, so the dashboard deliberately + // derives age from CreatedAt instead. + now func() time.Time } // messageTTL is how long a status/error line stays on screen before a refresh @@ -409,11 +417,17 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { for i, sess := range m.sessions { if sess.AgentType == selectedAgent && sess.ID == selectedID { m.selected = i + if m.filterActive { + m.reconcileFilterSelection() + } return m } } } m.selected = max(0, min(m.selected, len(m.sessions)-1)) + if m.filterActive { + m.reconcileFilterSelection() + } return m } @@ -471,6 +485,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if handled, model, cmd := m.handleModalKey(msg, key); handled { return model, cmd } + // Peek owns the command composer for replies. Keep the filtered projection, + // but route text/Enter/Esc through the established reply flow while it is open. + if m.filterActive && !m.peekOpen { + if handled, cmd := m.handleFilterKey(msg, key); handled { + return m, cmd + } + } + if key == "/" && m.input == "" && !m.peekOpen { + m.enterFilter() + return m, nil + } if handled, cmd := m.handleMovementKey(key); handled { return m, cmd } @@ -601,6 +626,24 @@ func (m *Model) moveSelectionPeek(delta int) tea.Cmd { } func (m *Model) moveSelection(delta int) { + if m.filterActive { + visible := m.visibleSessionIndices() + if len(visible) == 0 { + return + } + position := 0 + for i, index := range visible { + if index == m.selected { + position = i + break + } + } + next := position + delta + if next >= 0 && next < len(visible) { + m.selected = visible[next] + } + return + } next := m.selected + delta if next >= 0 && next < len(m.sessions) { m.selected = next @@ -647,10 +690,24 @@ func (m Model) shouldPollFocusedPeek(id string, now time.Time) bool { } func (m *Model) moveSession(delta int) tea.Cmd { + if m.filterActive { + return m.moveFilteredSession(delta) + } next := m.selected + delta if next < 0 || next >= len(m.sessions) { return nil } + return m.moveSessionTo(next) +} + +// moveSessionTo applies the shared identity-safe reorder invariants between two +// canonical indices. Both normal and filtered navigation resolve their target +// index before entering this path, so hidden rows are never mistaken for the +// selected session. +func (m *Model) moveSessionTo(next int) tea.Cmd { + if m.selected < 0 || m.selected >= len(m.sessions) || next < 0 || next >= len(m.sessions) || next == m.selected { + return nil + } // SortSessions buckets rows by process liveness, then Pinned, before honoring // SortIndex. A swap that crosses either boundary is undone on the next // refresh (the row snaps back to its partition), so reject it and give @@ -1583,12 +1640,17 @@ func (m Model) View() string { if m.quitting { return "" } - // Before Bubble Tea sends its first WindowSizeMsg preserve the historical - // unconstrained view. Once dimensions are known all composition is budgeted. + // Before Bubble Tea sends its first WindowSizeMsg, keep the first frame small + // and stable instead of flashing the legacy unbounded dashboard. if !m.sizeKnown { - return m.unboundedView() + // A confirmation can arrive before a WindowSizeMsg in tests and on very + // slow remote terminals. Never hide a safety-critical modal behind loading. + if m.helpOpen || m.confirmLatest || m.confirmStop || m.wizard || m.renaming { + return m.unboundedView() + } + return bar() + " " + brandStyle.Render("UAM") + " " + hintStyle.Render("loading dashboard…") } - return m.responsiveView() + return m.dashboardView() } func (m Model) unboundedView() string { @@ -1682,68 +1744,7 @@ func (m Model) responsiveBody(width, budget int) []string { if m.dashboardMode() == ModeNew { return takeLines(boundedNonBlankLines(m.renderWizard(), width), budget) } - class := m.layoutClass() - if class == LayoutWide { - leftWidth := max(28, width*52/100) - rightWidth := max(20, width-leftWidth-3) - left := m.sessionListLines(leftWidth, budget, class) - right := m.detailPaneLines(rightWidth, budget, m.dashboardMode()) - return joinColumns(left, right, leftWidth, rightWidth, budget) - } - if m.dashboardMode() == ModePeek { - return m.peekSurfaceLines(width, budget, class) - } - detailLimit := 3 - if class == LayoutCompact { - detailLimit = 2 - } - details := takeLines(m.selectedSummaryLines(width, class), min(detailLimit, budget)) - list := m.sessionListLines(width, budget-len(details), class) - return append(details, list...) -} - -func (m Model) selectedSummaryLines(width int, class LayoutClass) []string { - sess, ok := m.selectedSession() - if !ok { - return nil - } - name := truncate(firstNonEmpty(sess.DisplayName, sess.ID), max(1, width-11)) - lines := []string{ansi.Truncate(sectionStyle.Render("SELECTED")+" "+titleStyle.Render(name), width, "…")} - if class == LayoutCompact { - meta := firstNonEmpty(boundedTaskSummary(sess, max(1, width-2)), "agent: "+sess.AgentType) - return append(lines, ansi.Truncate(" "+taskStyle.Render(meta), width, "…")) - } - lines = append(lines, - ansi.Truncate(" "+hintStyle.Render("agent: "+displaytext.Sanitize(firstNonEmpty(sess.AgentType, "?"))), width, "…"), - ansi.Truncate(" "+hintStyle.Render("cwd: "+absCwd(sess.Cwd)), width, "…"), - ) - return lines -} - -func (m Model) detailPaneLines(width, budget int, mode DashboardMode) []string { - if mode == ModePeek { - return m.peekSurfaceLines(width, budget, LayoutWide) - } - return takeLines(m.selectedSummaryLines(width, LayoutWide), budget) -} - -func (m Model) peekSurfaceLines(width, budget int, class LayoutClass) []string { - if budget <= 0 { - return nil - } - lines := []string{sectionStyle.Render("PEEK")} - if sess, ok := m.selectedSession(); ok && budget > 1 { - lines = append(lines, ansi.Truncate(" "+titleStyle.Render(firstNonEmpty(sess.DisplayName, sess.ID)), width, "…")) - } - remaining := budget - len(lines) - if remaining > 0 { - peek := boundedTailLines(m.peekText, remaining, width) - if len(peek) == 0 { - peek = []string{hintStyle.Render("waiting for output…")} - } - lines = append(lines, takeLinesFromEnd(peek, remaining)...) - } - return takeLines(lines, budget) + return m.dashboardBody(width, budget) } // boundedTailLines scans backward only far enough to find the requested tail. @@ -1774,54 +1775,6 @@ func boundedTailLines(s string, n, width int) []string { return lines } -func (m Model) sessionListLines(width, budget int, class LayoutClass) []string { - if budget <= 0 { - return nil - } - if m.groupByDir { - return m.groupedSessionListLines(width, budget, class) - } - if len(m.sessions) == 0 { - return takeLines([]string{m.renderSectionAtWidth("SESSIONS", "0", width), " " + hintStyle.Render("no sessions")}, budget) - } - stopped := 0 - for _, sess := range m.sessions { - if sess.ProcAlive == adapter.Exited { - stopped++ - } - } - rowBudget := max(0, budget-2) - if class == LayoutCompact { - rowBudget = max(0, budget-1) - } - start, end := visibleWindow(len(m.sessions), m.selected, rowBudget) - nameWidth, taskWidth, showTask := tableWidthsFor(width, class) - lines := make([]string, 0, budget) - if class == LayoutCompact { - right := fmt.Sprintf("%d", len(m.sessions)) - if stopped > 0 { - right = fmt.Sprintf("%d · %d stopped", len(m.sessions), stopped) - } - lines = append(lines, m.renderSectionAtWidth("SESSIONS", right, width)) - } else { - label := "RUNNING" - if m.sessions[start].ProcAlive == adapter.Exited { - label = "STOPPED" - } - lines = append(lines, m.renderSectionAtWidth(label, "", width)) - } - lastAlive := m.sessions[start].ProcAlive - for i := start; i < end && len(lines) < budget; i++ { - sess := m.sessions[i] - if class != LayoutCompact && sess.ProcAlive != lastAlive && len(lines) < budget-1 { - lines = append(lines, m.renderSectionAtWidth("STOPPED", "", width)) - lastAlive = sess.ProcAlive - } - lines = append(lines, ansi.Truncate(renderRow(sess, i == m.selected, nameWidth, taskWidth, showTask), width, "…")) - } - return takeLines(lines, budget) -} - func (m Model) renderSectionAtWidth(label, right string, width int) string { head := sectionStyle.Render(label) rightWidth := ansi.StringWidth(right) @@ -1892,11 +1845,6 @@ func takeLines(lines []string, n int) []string { return lines[:min(len(lines), max(0, n))] } -func takeLinesFromEnd(lines []string, n int) []string { - n = min(len(lines), max(0, n)) - return lines[len(lines)-n:] -} - func fitScreen(lines []string, width, height int) string { if len(lines) > height { lines = lines[len(lines)-height:] @@ -2222,6 +2170,7 @@ func (m Model) renderHelp() string { rows := []string{ "↑/↓ move Shift+↑/↓ reorder Enter/→ attach/resume", "Space peek running / resume stopped", + "/ filter sessions when the command line is empty", "Tab cycle agent Ctrl+T pin Ctrl+R rename", "Ctrl+X stop+remove / restart Ctrl+S group-by-dir", "e new session Esc quit", diff --git a/internal/app/app_more_test.go b/internal/app/app_more_test.go index 805c28f..8f1c0a8 100644 --- a/internal/app/app_more_test.go +++ b/internal/app/app_more_test.go @@ -15,8 +15,9 @@ import ( func TestModelViewBasics(t *testing.T) { m := modelWithTwoSessions() + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) out := m.View() - if !strings.Contains(out, "RUNNING") || !strings.Contains(out, "SELECTED") { + if !strings.Contains(out, "RUNNING") || !strings.Contains(out, "SESSIONS") || !strings.Contains(out, "[fake]") { t.Fatalf("view=%s", out) } if strings.Contains(out, "TMUX: LIVE") || strings.Contains(out, "TMUX: DEAD") { @@ -304,21 +305,24 @@ func TestDispatchedFailureWithoutSessionDoesNotAttach(t *testing.T) { } } -func TestViewShowsDetailsOnTopAndTaskInTable(t *testing.T) { +func TestViewExpandsSelectedSessionInsideDashboard(t *testing.T) { m := NewWithDeps(nil, nil) m.sessions = []adapter.Session{ {ID: "1", AgentType: "fake", DisplayName: "one", Prompt: "fix the parser", Cwd: "/tmp/project", SessionName: "uam-fake-1", ProcAlive: adapter.Alive}, {ID: "2", AgentType: "fake", DisplayName: "old", Prompt: "old prompt", Cwd: "/tmp/old", SessionName: "uam-fake-2", ProcAlive: adapter.Exited, Closed: true}, } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) view := m.View() - if !strings.Contains(view, "cwd: /tmp/project") { - t.Fatalf("view should show the absolute cwd in details: %s", view) + for _, want := range []string{"/tmp/project", "id 1", "[fake]", "fix the parser", "RUNNING", "STOPPED"} { + if !strings.Contains(view, want) { + t.Fatalf("expanded dashboard missing %q: %s", want, view) + } } if strings.Contains(view, "⠋") || strings.Contains(view, "💀") || strings.Contains(view, "TMUX: LIVE") || strings.Contains(view, "🚀") || strings.Contains(view, "🟢") { t.Fatalf("view should use compact styling, no spinner/skull/large emoji: %s", view) } if strings.Contains(view, "1 live") || strings.Contains(view, "1 dead") || strings.Contains(view, "agent fake") { - t.Fatalf("view should not show aggregate header stats: %s", view) + t.Fatalf("view should use dashboard metadata instead of legacy aggregates: %s", view) } table := m.renderTable() if !strings.Contains(table, "RUNNING") || !strings.Contains(table, "STOPPED") { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6a3f14a..a6810bb 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -7,6 +7,7 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/version" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -132,22 +133,23 @@ func TestThemeUsesAdaptiveProfessionalPaletteWithoutSelectedBackground(t *testin } } -func TestViewShowsUAMBrandingNameAndANSILogo(t *testing.T) { +func TestViewShowsCompactUAMBrandingAndDashboard(t *testing.T) { oldVersion := version.Override version.Override = "v9.9.9" t.Cleanup(func() { version.Override = oldVersion }) m := NewWithDeps(nil, nil) - m.width = 80 m.sessions = []adapter.Session{{ID: "1", DisplayName: "clean", Cwd: "/tmp/repo", ProcAlive: adapter.Alive}} + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) view := m.View() for _, want := range []string{ - " _ _ _ __ __", - "| | | |/_\\ | \\/ |", + "UAM", "Unified Agent Manager", "v9.9.9", - "SELECTED", + "SESSIONS", + "clean", + "RUNNING", } { if !strings.Contains(view, want) { t.Fatalf("view missing UAM branding %q:\n%s", want, view) @@ -156,39 +158,38 @@ func TestViewShowsUAMBrandingNameAndANSILogo(t *testing.T) { if strings.Contains(view, "1 live") || strings.Contains(view, "1 dead") || strings.Contains(view, "agent fake") { t.Fatalf("branding should not reintroduce aggregate header stats: %s", view) } + if strings.Contains(view, uamANSILogo) { + t.Fatalf("responsive dashboard should not spend rows on the legacy ASCII logo: %s", view) + } } -func TestViewUsesLightDividerWithoutBorders(t *testing.T) { +func TestViewUsesBorderedSessionsPanel(t *testing.T) { m := NewWithDeps(nil, nil) - m.width = 80 m.sessions = []adapter.Session{{ID: "1", DisplayName: "clean", Cwd: "/tmp/repo", ProcAlive: adapter.Alive}} + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) view := m.View() - if strings.ContainsAny(view, "╭╮╰╯│┌┐└┘") { - t.Fatalf("view should not render box borders: %s", view) - } - if !strings.Contains(view, "────") { - t.Fatalf("view should keep a light divider between details and sessions: %s", view) + for _, want := range []string{"╭─ SESSIONS", "│", "╰─"} { + if !strings.Contains(view, want) { + t.Fatalf("view should render a complete sessions panel, missing %q: %s", want, view) + } } } -func TestViewIsCompactAndBorderlessOnNarrowScreens(t *testing.T) { +func TestViewIsInformationRichAndBoundedOnNarrowScreens(t *testing.T) { m := NewWithDeps(nil, nil) - m.width = 44 m.sessions = []adapter.Session{ - {ID: "1", DisplayName: "active-one", Prompt: "fixing spacing", Cwd: "/tmp/repo", ProcAlive: adapter.Alive}, - {ID: "2", DisplayName: "old-one", Cwd: "/tmp/old", ProcAlive: adapter.Exited, Closed: true}, + {ID: "1", AgentType: "codex", DisplayName: "active-one", Prompt: "fixing spacing", Cwd: "/tmp/repo", ProcAlive: adapter.Alive}, + {ID: "2", AgentType: "claude", DisplayName: "old-one", Cwd: "/tmp/old", ProcAlive: adapter.Exited, Closed: true}, } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 12}) view := m.View() - for _, want := range []string{"SELECTED", "RUNNING", "STOPPED", "active-one", "old-one", "fixing spacing"} { + for _, want := range []string{"SESSIONS", "RUNNING", "STOPPED", "active-one", "old-one", "fixing spacing", "codex", "claude"} { if !strings.Contains(view, want) { t.Fatalf("narrow view missing %q:\n%s", want, view) } } - if strings.ContainsAny(view, "╭╮╰╯│┌┐└┘") { - t.Fatalf("narrow view should stay borderless:\n%s", view) - } if strings.Contains(view, "🚀") || strings.Contains(view, "🔴") || strings.Contains(view, "🟢") { t.Fatalf("view should avoid large emoji on mobile:\n%s", view) } diff --git a/internal/app/dashboard_revamp.go b/internal/app/dashboard_revamp.go new file mode 100644 index 0000000..7380da4 --- /dev/null +++ b/internal/app/dashboard_revamp.go @@ -0,0 +1,495 @@ +package app + +import ( + "fmt" + "strings" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" + "github.com/RandomCodeSpace/unified-agent-manager/internal/version" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +type dashboardEntry struct { + text string + sessionIndex int +} + +type workspaceDashboardSection struct { + workspace string + liveness adapter.ProcLiveness + pinned bool +} + +func dashboardSectionFor(sess adapter.Session) workspaceDashboardSection { + return workspaceDashboardSection{workspace: workspaceKey(sess.Cwd), liveness: sess.ProcAlive, pinned: sess.Pinned} +} + +func (m Model) dashboardNow() time.Time { + if m.now != nil { + return m.now() + } + return time.Now() +} + +func sessionAge(createdAt, now time.Time) string { + if createdAt.IsZero() || createdAt.After(now) { + return "now" + } + age := now.Sub(createdAt) + switch { + case age < time.Minute: + return "now" + case age < time.Hour: + return fmt.Sprintf("%dm", int(age/time.Minute)) + case age < 48*time.Hour: + return fmt.Sprintf("%dh", int(age/time.Hour)) + default: + return fmt.Sprintf("%dd", int(age/(24*time.Hour))) + } +} + +func lifecycleBadge(sess adapter.Session) string { + if sess.ProcAlive == adapter.Alive { + return "RUNNING" + } + if detail := failureExitDetail(sess); detail != "" { + return strings.ToUpper(detail) + } + return "STOPPED" +} + +func providerBadge(sess adapter.Session) string { + return "[" + displaytext.Sanitize(firstNonEmpty(sess.AgentType, "?")) + "]" +} + +func statusBadge(sess adapter.Session) string { + label := lifecycleBadge(sess) + if sess.ProcAlive == adapter.Alive { + return liveGlyphStyle.Render("[" + label + "]") + } + if failureExitDetail(sess) != "" { + return failGlyphStyle.Render("[" + label + "]") + } + return hintStyle.Render("[" + label + "]") +} + +func (m Model) sessionMatchesFilter(sess adapter.Session) bool { + query := strings.TrimSpace(m.filterQuery) + if !m.filterActive || query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + displaytext.Sanitize(sess.DisplayName), + displaytext.Sanitize(sess.ID), + displaytext.Sanitize(sess.AgentType), + displaytext.Sanitize(sess.CommandAlias), + displaytext.Sanitize(sess.Prompt), + displaytext.Sanitize(sess.Cwd), + lifecycleBadge(sess), + }, "\n")) + for _, term := range strings.Fields(strings.ToLower(query)) { + if !strings.Contains(haystack, term) { + return false + } + } + return true +} + +func (m Model) visibleSessionIndices() []int { + indices := make([]int, 0, len(m.sessions)) + for i, sess := range m.sessions { + if m.sessionMatchesFilter(sess) { + indices = append(indices, i) + } + } + return indices +} + +func (m *Model) enterFilter() { + if m.filterActive { + return + } + if sess, ok := m.selectedSession(); ok { + m.filterRestore = sessionIdentity{agent: sess.AgentType, id: sess.ID} + m.filterSaved = true + } + m.filterActive = true + m.filterQuery = "" + m.reconcileFilterSelection() +} + +func (m *Model) exitFilter() { + restore, saved := m.filterRestore, m.filterSaved + m.filterActive = false + m.filterQuery = "" + m.filterRestore = sessionIdentity{} + m.filterSaved = false + if !saved { + return + } + for i, sess := range m.sessions { + if sess.AgentType == restore.agent && sess.ID == restore.id { + m.selected = i + return + } + } +} + +func (m *Model) reconcileFilterSelection() { + if !m.filterActive { + return + } + for _, index := range m.visibleSessionIndices() { + if index == m.selected { + return + } + } + visible := m.visibleSessionIndices() + if len(visible) > 0 { + m.selected = visible[0] + } +} + +func (m *Model) handleFilterKey(msg tea.KeyMsg, key string) (bool, tea.Cmd) { + noMatches := len(m.visibleSessionIndices()) == 0 + switch key { + case "esc": + m.exitFilter() + return true, nil + case "backspace": + if m.filterQuery == "" { + m.exitFilter() + return true, nil + } + runes := []rune(m.filterQuery) + m.filterQuery = string(runes[:len(runes)-1]) + m.reconcileFilterSelection() + return true, nil + case "up": + return true, m.moveSelectionPeek(-1) + case "down": + return true, m.moveSelectionPeek(1) + case "shift+up": + return true, m.moveSession(-1) + case "shift+down": + return true, m.moveSession(1) + case "enter", "right": + if noMatches { + return true, nil + } + return true, m.handleEnterKey() + case " ": + if noMatches { + return true, nil + } + return true, m.handleSpaceKey(key) + case "ctrl+t", "ctrl+r", "ctrl+x": + if noMatches { + return true, nil + } + } + if msg.Type == tea.KeyRunes && !msg.Alt { + m.filterQuery += string(msg.Runes) + m.reconcileFilterSelection() + return true, nil + } + return false, nil +} + +func (m *Model) moveFilteredSession(delta int) tea.Cmd { + visible := m.visibleSessionIndices() + position := -1 + for i, index := range visible { + if index == m.selected { + position = i + break + } + } + if position < 0 || position+delta < 0 || position+delta >= len(visible) { + return nil + } + return m.moveSessionTo(visible[position+delta]) +} + +func (m Model) dashboardView() string { + w, h := max(1, m.width), max(0, m.height) + if h == 0 { + return "" + } + if m.helpOpen || m.confirmLatest || m.confirmStop || m.wizard || m.renaming { + return m.responsiveView() + } + header := m.dashboardHeader(w) + bottom := m.dashboardBottom(w, h) + if len(bottom) >= h { + return fitScreen(bottom[:h], w, h) + } + bodyBudget := max(0, h-1-len(bottom)) + body := m.dashboardBody(w, bodyBudget) + lines := []string{header} + lines = append(lines, body...) + lines = append(lines, bottom...) + return fitScreen(lines, w, h) +} + +func (m Model) dashboardHeader(width int) string { + left := bar() + " " + brandStyle.Render("UAM") + if m.layoutClass() != LayoutCompact { + left += " " + hintStyle.Render("Unified Agent Manager") + " " + hintStyle.Render(version.String()) + } + right := "/ filter" + if m.filterActive { + right = "/ " + displaytext.Sanitize(m.filterQuery) + if strings.TrimSpace(m.filterQuery) == "" { + right = "/ filter sessions" + } + } + available := width - ansi.StringWidth(left) - ansi.StringWidth(right) + if available < 1 { + return ansi.Truncate(left+" "+right, width, "…") + } + return left + strings.Repeat(" ", available) + hintStyle.Render(right) +} + +func (m Model) dashboardBottom(width, height int) []string { + field := hintStyle.Render("type a command…") + label := "" + if m.input != "" { + field = titleStyle.Render(displaytext.Sanitize(m.input)) + } + if m.peekOpen { + label = hintStyle.Render("reply ") + if m.input == "" { + field = hintStyle.Render("type a reply…") + } + } else if m.filterActive { + label = hintStyle.Render("filter / ") + field = hintStyle.Render("type to filter…") + if m.filterQuery != "" { + field = titleStyle.Render(displaytext.Sanitize(m.filterQuery)) + } + } + composer := ansi.Truncate(bar()+" "+label+brandStyle.Render("›")+" "+field+brandStyle.Render("▏"), width, "…") + if height <= 12 { + hint := firstNonEmpty(m.defaultAgent, "agent") + " ↑↓ Enter" + if m.peekOpen { + hint = "Enter send Esc close" + } else if m.filterActive { + hint = "↑↓ Enter Esc clear" + } + return []string{joinDashboardEnds(composer, hintStyle.Render(hint), width)} + } + footer := firstNonEmpty(m.defaultAgent, "agent") + " Tab provider · ↑↓ move Enter attach Space peek / filter ? help e new Esc quit" + if m.peekOpen { + footer = "↑↓ session Enter send Space close peek Esc close" + } else if m.filterActive { + footer = "type to filter ↑↓ move Enter open Esc clear" + } + lines := []string{composer, ansi.Truncate(" "+hintStyle.Render(footer), width, "…")} + if m.message != "" && height >= 16 { + lines = append(lines, ansi.Truncate(" "+hintStyle.Render(displaytext.Sanitize(m.message)), width, "…")) + } + return lines +} + +func joinDashboardEnds(left, right string, width int) string { + right = ansi.Truncate(right, width, "…") + leftWidth := max(0, width-ansi.StringWidth(right)-1) + left = ansi.Truncate(left, leftWidth, "…") + gap := max(1, width-ansi.StringWidth(left)-ansi.StringWidth(right)) + return ansi.Truncate(left+strings.Repeat(" ", gap)+right, width, "…") +} + +func (m Model) dashboardBody(width, budget int) []string { + if budget <= 0 { + return nil + } + if m.peekOpen && m.layoutClass() == LayoutWide { + leftWidth := max(46, width*58/100) + rightWidth := width - leftWidth - 3 + left := m.dashboardSessionPanel(leftWidth, budget) + right := m.dashboardPeekPanel(rightWidth, budget) + return joinColumns(left, right, leftWidth, rightWidth, budget) + } + if m.peekOpen { + return m.dashboardPeekPanel(width, budget) + } + return m.dashboardSessionPanel(width, budget) +} + +func (m Model) dashboardSessionPanel(width, budget int) []string { + visible := m.visibleSessionIndices() + right := fmt.Sprintf("%d sessions", len(m.sessions)) + if m.filterActive { + right = fmt.Sprintf("%d/%d sessions", len(visible), len(m.sessions)) + } + entries := m.dashboardEntries(max(1, width-2), visible) + return borderedPanel("SESSIONS", right, entries, m.selected, width, budget) +} + +func (m Model) dashboardPeekPanel(width, budget int) []string { + name := "" + if sess, ok := m.selectedSession(); ok { + name = firstNonEmpty(sess.DisplayName, sess.ID) + } + contentBudget := max(0, budget-2) + content := boundedTailLines(m.peekText, contentBudget, max(1, width-4)) + if len(content) == 0 { + content = []string{hintStyle.Render("waiting for output…")} + } + entries := make([]dashboardEntry, 0, len(content)) + for _, line := range content { + entries = append(entries, dashboardEntry{text: " " + line, sessionIndex: -1}) + } + return borderedPanel("PEEK", displaytext.Sanitize(name), entries, -1, width, budget) +} + +func (m Model) dashboardEntries(width int, visible []int) []dashboardEntry { + if len(visible) == 0 { + if m.filterActive { + query := displaytext.Sanitize(m.filterQuery) + return []dashboardEntry{ + {text: " " + titleStyle.Render("No sessions match “"+query+"”"), sessionIndex: -1}, + {text: " " + hintStyle.Render("Esc clear filter"), sessionIndex: -1}, + } + } + return []dashboardEntry{{text: " " + hintStyle.Render("No sessions yet — type a command or press e"), sessionIndex: -1}} + } + entries := make([]dashboardEntry, 0, len(visible)*2) + shownBySection := map[workspaceDashboardSection]int{} + totalBySection := map[workspaceDashboardSection]int{} + if m.groupByDir { + for _, sess := range m.sessions { + totalBySection[dashboardSectionFor(sess)]++ + } + for _, index := range visible { + shownBySection[dashboardSectionFor(m.sessions[index])]++ + } + } + liveByWorkspace := liveWorkspaceCounts(m.sessions) + warned := map[string]bool{} + var lastSection workspaceDashboardSection + haveSection := false + for _, index := range visible { + sess := m.sessions[index] + if m.groupByDir { + section := dashboardSectionFor(sess) + key := section.workspace + if !haveSection || section != lastSection { + count := fmt.Sprintf("%d", totalBySection[section]) + if m.filterActive { + count = fmt.Sprintf("%d/%d", shownBySection[section], totalBySection[section]) + } + heading := " WORKSPACE " + displaytext.Sanitize(workspaceDisplayName(key)) + " " + hintStyle.Render(count) + entries = append(entries, dashboardEntry{text: ansi.Truncate(sectionStyle.Render(heading), width, "…"), sessionIndex: -1}) + if liveByWorkspace[key] > 1 && !warned[key] { + warning := fmt.Sprintf(" ⚠ %d sessions share this workspace", liveByWorkspace[key]) + entries = append(entries, dashboardEntry{text: ansi.Truncate(warnStyle.Render(warning), width, "…"), sessionIndex: -1}) + warned[key] = true + } + lastSection = section + haveSection = true + } + } + entries = append(entries, m.dashboardSessionEntries(sess, index, width)...) + } + return entries +} + +func (m Model) dashboardSessionEntries(sess adapter.Session, index, width int) []dashboardEntry { + selected := index == m.selected + primary := m.dashboardSessionPrimary(sess, selected, width) + entries := []dashboardEntry{{text: primary, sessionIndex: index}} + if !selected { + return entries + } + details := []string{taskStyle.Render(boundedTaskSummary(sess, max(1, width-4)))} + if m.layoutClass() != LayoutCompact { + details = append(details, hintStyle.Render("cwd "+absCwd(sess.Cwd))) + details = append(details, hintStyle.Render("id "+displaytext.Sanitize(sess.ID))) + if sess.PR != nil { + details = append(details, hintStyle.Render(fmt.Sprintf("PR #%d %s", sess.PR.Number, strings.ToLower(string(sess.PR.Status))))) + } + } + for i, detail := range details { + marker := brandStyle.Render("│") + " " + if i == len(details)-1 { + marker = brandStyle.Render("╰─") + " " + } + entries = append(entries, dashboardEntry{text: ansi.Truncate(marker+detail, width, "…"), sessionIndex: index}) + } + return entries +} + +func (m Model) dashboardSessionPrimary(sess adapter.Session, selected bool, width int) string { + cursor := " " + if selected { + cursor = brandStyle.Render("╭▸") + " " + } + pin := "" + if sess.Pinned { + pin = "★ " + } + right := providerBadge(sess) + " " + statusBadge(sess) + " " + hintStyle.Render(sessionAge(sess.CreatedAt, m.dashboardNow())) + leftWidth := max(1, width-ansi.StringWidth(cursor)-ansi.StringWidth(right)-1) + name := displaytext.Sanitize(firstNonEmpty(sess.DisplayName, sess.ID)) + if !selected && m.layoutClass() != LayoutCompact { + task := displaytext.Sanitize(taskSummaryText(sess)) + if task != "" { + name += " · " + task + } + } + style := titleStyle + if selected { + style = selectedStyle + } + left := cursor + style.Render(ansi.Truncate(pin+name, leftWidth, "…")) + return padRightANSI(left, width-ansi.StringWidth(right)-1) + " " + right +} + +func borderedPanel(title, right string, entries []dashboardEntry, selected, width, budget int) []string { + if budget <= 0 || width <= 0 { + return nil + } + if budget == 1 || width < 4 { + return []string{ansi.Truncate(title+" "+right, width, "…")} + } + inner := width - 2 + topLeft := "─ " + title + " " + topRight := " " + right + " ─" + if ansi.StringWidth(topLeft)+ansi.StringWidth(topRight) > inner { + topRight = " ─" + topLeft = ansi.Truncate(topLeft, max(1, inner-ansi.StringWidth(topRight)), "…") + } + top := "╭" + topLeft + strings.Repeat("─", max(0, inner-ansi.StringWidth(topLeft)-ansi.StringWidth(topRight))) + topRight + "╮" + contentBudget := max(0, budget-2) + window := dashboardEntryWindow(entries, selected, contentBudget) + lines := make([]string, 0, budget) + lines = append(lines, ansi.Truncate(top, width, "…")) + for _, entry := range window { + lines = append(lines, "│"+padRightANSI(entry.text, inner)+"│") + } + for len(lines) < budget-1 { + lines = append(lines, "│"+strings.Repeat(" ", inner)+"│") + } + lines = append(lines, "╰"+strings.Repeat("─", inner)+"╯") + return takeLines(lines, budget) +} + +func dashboardEntryWindow(entries []dashboardEntry, selected, budget int) []dashboardEntry { + if budget <= 0 || len(entries) == 0 { + return nil + } + selectedLine := 0 + for i, entry := range entries { + if entry.sessionIndex == selected { + selectedLine = i + break + } + } + start, end := visibleWindow(len(entries), selectedLine, budget) + return entries[start:end] +} diff --git a/internal/app/dashboard_revamp_test.go b/internal/app/dashboard_revamp_test.go new file mode 100644 index 0000000..d3e26eb --- /dev/null +++ b/internal/app/dashboard_revamp_test.go @@ -0,0 +1,530 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + tea "github.com/charmbracelet/bubbletea" +) + +func TestDashboardDesktopShowsOperationalMetadataWithoutDefaultSplitPane(t *testing.T) { + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + m := NewWithDeps(nil, nil) + m.now = func() time.Time { return now } + m.sessions = []adapter.Session{ + { + ID: "full-session-identity", AgentType: "codex", DisplayName: "release-check", + Prompt: "verify the release pipeline", Cwd: "/work/unified-agent-manager", + SessionName: "uam-codex-full-session-identity", ProcAlive: adapter.Alive, + CreatedAt: now.Add(-2 * time.Hour), PR: &adapter.PRRef{Number: 41, Status: adapter.PROpen}, + }, + { + ID: "stopped-session", AgentType: "claude", DisplayName: "failed-tests", + Prompt: "repair integration tests", Cwd: "/work/other", ProcAlive: adapter.Exited, + CreatedAt: now.Add(-3 * 24 * time.Hour), ExitCode: exitCode(7), + }, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 120, Height: 40}) + + view := m.View() + assertViewGeometry(t, view, 120, 40) + for _, want := range []string{ + "SESSIONS", "2 sessions", "/ filter", "release-check", "codex", "RUNNING", "2h", + "verify the release pipeline", "/work/unified-agent-manager", "full-session-identity", "PR #41", + "failed-tests", "claude", "EXIT 7", "3d", + } { + if !strings.Contains(view, want) { + t.Fatalf("desktop dashboard missing %q:\n%s", want, view) + } + } + if lineContainsAll(view, "SESSIONS", "SELECTED") { + t.Fatalf("operations view must use the full list instead of a default selected split pane:\n%s", view) + } + if !strings.ContainsAny(view, "╭┌") || !strings.ContainsAny(view, "╯┘") { + t.Fatalf("dashboard should render a bordered sessions panel:\n%s", view) + } +} + +func TestDashboardCompactUsesOneLineRowsAndTwoLineSelection(t *testing.T) { + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + m := NewWithDeps(nil, nil) + m.now = func() time.Time { return now } + m.sessions = []adapter.Session{ + {ID: "one", AgentType: "codex", DisplayName: "selected", Prompt: "fix copy and paste", ProcAlive: adapter.Alive, CreatedAt: now.Add(-8 * time.Minute)}, + {ID: "two", AgentType: "claude", DisplayName: "ordinary", Prompt: "this task stays collapsed", ProcAlive: adapter.Exited, CreatedAt: now.Add(-90 * time.Minute)}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 12}) + + view := m.View() + assertViewGeometry(t, view, 44, 12) + for _, want := range []string{"╭▸", "╰─", "selected", "codex", "RUNNING", "8m", "fix copy and paste", "ordinary", "claude", "STOPPED", "1h"} { + if !strings.Contains(view, want) { + t.Fatalf("compact dashboard missing %q:\n%s", want, view) + } + } + if strings.Contains(view, "this task stays collapsed") { + t.Fatalf("ordinary compact rows must stay one line:\n%s", view) + } + assertBottomContains(t, view, "›") +} + +func TestDashboardFilterUsesEmptyPromptSlashAndPreservesPromptSlash(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "one", AgentType: "codex", DisplayName: "release", Prompt: "ship pipeline", Cwd: "/work/uam", ProcAlive: adapter.Alive}, + {ID: "two", AgentType: "claude", DisplayName: "docs", Prompt: "write guide", Cwd: "/work/docs", ProcAlive: adapter.Exited}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + if !m.filterActive || m.input != "" { + t.Fatalf("empty-prompt slash should enter filter without editing command: active=%v input=%q", m.filterActive, m.input) + } + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("claude")}) + m = model.(Model) + view := m.View() + if !strings.Contains(view, "/ claude") || !strings.Contains(view, "docs") || strings.Contains(view, "release") { + t.Fatalf("live provider filter did not project visible sessions:\n%s", view) + } + selected, ok := m.selectedSession() + if !ok || selected.AgentType != "claude" || selected.ID != "two" { + t.Fatalf("filter selection did not target matched identity: %+v ok=%v", selected, ok) + } + + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + if m.filterActive || m.filterQuery != "" { + t.Fatalf("Esc should clear and exit filtering: active=%v query=%q", m.filterActive, m.filterQuery) + } + selected, ok = m.selectedSession() + if !ok || selected.AgentType != "codex" || selected.ID != "one" { + t.Fatalf("Esc did not restore the pre-filter selection: %+v ok=%v", selected, ok) + } + + m.input = "open" + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + if m.filterActive || m.input != "open/" { + t.Fatalf("slash in an existing prompt must stay literal: active=%v input=%q", m.filterActive, m.input) + } +} + +func TestDashboardFilterShowsNoMatchesAndMatchesLifecycleWorkspaceAndTask(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "one", AgentType: "codex", DisplayName: "release", Prompt: "ship pipeline", Cwd: "/work/uam", ProcAlive: adapter.Alive}, + {ID: "two", AgentType: "claude", DisplayName: "docs", Prompt: "write guide", Cwd: "/work/docs", ProcAlive: adapter.Exited}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + + for _, query := range []string{"pipeline", "docs", "stopped"} { + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(query)}) + m = model.(Model) + view := m.View() + if !strings.Contains(view, "docs") && query != "pipeline" { + t.Fatalf("query %q did not match expected session:\n%s", query, view) + } + if query == "pipeline" && !strings.Contains(view, "release") { + t.Fatalf("task query did not match release session:\n%s", view) + } + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + } + + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("definitely absent")}) + m = model.(Model) + view := m.View() + if !strings.Contains(view, "No sessions match") || !strings.Contains(view, "0/2 sessions") { + t.Fatalf("empty filter result needs an explicit state and matched count:\n%s", view) + } +} + +func TestDashboardAgeAndLifecycleLabelsAreEvidenceBased(t *testing.T) { + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + session adapter.Session + wantAge string + want string + }{ + {"future", adapter.Session{ProcAlive: adapter.Alive, CreatedAt: now.Add(time.Hour)}, "now", "RUNNING"}, + {"seconds", adapter.Session{ProcAlive: adapter.Alive, CreatedAt: now.Add(-45 * time.Second)}, "now", "RUNNING"}, + {"minutes", adapter.Session{ProcAlive: adapter.Alive, CreatedAt: now.Add(-59 * time.Minute)}, "59m", "RUNNING"}, + {"hours", adapter.Session{ProcAlive: adapter.Exited, CreatedAt: now.Add(-47 * time.Hour)}, "47h", "STOPPED"}, + {"days", adapter.Session{ProcAlive: adapter.Exited, CreatedAt: now.Add(-48 * time.Hour), ExitCode: exitCode(9)}, "2d", "EXIT 9"}, + {"signal", adapter.Session{ProcAlive: adapter.Exited, CreatedAt: now.Add(-time.Hour), ExitCode: exitCode(-1)}, "1h", "SIGNAL"}, + {"explicit stop", adapter.Session{ProcAlive: adapter.Exited, CreatedAt: now.Add(-time.Hour), ExitCode: exitCode(-1), Closed: true}, "1h", "STOPPED"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := sessionAge(tc.session.CreatedAt, now); got != tc.wantAge { + t.Fatalf("sessionAge() = %q, want %q", got, tc.wantAge) + } + if got := lifecycleBadge(tc.session); got != tc.want { + t.Fatalf("lifecycleBadge() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDashboardFilterRefreshAndNavigationUseCompositeIdentity(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "same", AgentType: "codex", DisplayName: "release", Prompt: "ship", ProcAlive: adapter.Alive}, + {ID: "hidden", AgentType: "claude", DisplayName: "docs", Prompt: "write", ProcAlive: adapter.Alive}, + {ID: "same", AgentType: "claude", DisplayName: "release notes", Prompt: "ship", ProcAlive: adapter.Alive}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")}) + m = model.(Model) + + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = model.(Model) + selected, ok := m.selectedSession() + if !ok || selected.AgentType != "claude" || selected.ID != "same" { + t.Fatalf("filtered navigation did not skip hidden row: %+v ok=%v", selected, ok) + } + + m = m.handleSessionsLoaded(sessionsLoadedMsg{sessions: []adapter.Session{ + {ID: "same", AgentType: "claude", DisplayName: "release notes", Prompt: "ship", ProcAlive: adapter.Alive}, + {ID: "same", AgentType: "codex", DisplayName: "release", Prompt: "ship", ProcAlive: adapter.Alive}, + {ID: "hidden", AgentType: "claude", DisplayName: "docs", Prompt: "write", ProcAlive: adapter.Alive}, + }}) + selected, ok = m.selectedSession() + if !ok || selected.AgentType != "claude" || selected.ID != "same" { + t.Fatalf("refresh retargeted duplicate ID across providers: %+v ok=%v", selected, ok) + } +} + +func TestDashboardSlashDoesNotStealReplyAndCanFilterEmptyDashboard(t *testing.T) { + m := NewWithDeps(nil, nil) + m.peekOpen = true + m.sessions = []adapter.Session{{ID: "one", ProcAlive: adapter.Alive}} + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + if m.filterActive || m.input != "/" { + t.Fatalf("slash should remain literal in Peek reply input: active=%v input=%q", m.filterActive, m.input) + } + + m = NewWithDeps(nil, nil) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + if !m.filterActive || m.input != "" { + t.Fatalf("empty dashboard slash should enter filter mode: active=%v input=%q", m.filterActive, m.input) + } +} + +func TestDashboardTinyKeyboardLayoutAndGroupedFilterStayBounded(t *testing.T) { + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + root := t.TempDir() + m := NewWithDeps(nil, nil) + m.now = func() time.Time { return now } + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "one", AgentType: "codex", DisplayName: "release", Prompt: "ship", Cwd: root, ProcAlive: adapter.Alive, CreatedAt: now.Add(-time.Minute)}, + {ID: "two", AgentType: "claude", DisplayName: "docs", Prompt: "write", Cwd: root, ProcAlive: adapter.Alive, CreatedAt: now.Add(-time.Hour)}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 10}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("codex")}) + m = model.(Model) + view := m.View() + assertViewGeometry(t, view, 44, 10) + for _, want := range []string{"1/2 sessions", "WORKSPACE", "1/2", "⚠ 2 sessions share this workspace", "release", "codex", "RUNNING", "ship", "›"} { + if !strings.Contains(view, want) { + t.Fatalf("tiny grouped filter missing %q:\n%s", want, view) + } + } +} + +func TestDashboardFooterShowsDefaultProviderAndFilterComposer(t *testing.T) { + m := NewWithDeps(nil, nil) + m.defaultAgent = "opencode" + m.sessions = []adapter.Session{{ID: "one", AgentType: "opencode", DisplayName: "one", ProcAlive: adapter.Alive}} + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + view := m.View() + if !strings.Contains(view, "opencode") || !strings.Contains(view, "Tab provider") { + t.Fatalf("footer must expose the provider selected for bare dispatches:\n%s", view) + } + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + view = m.View() + if !strings.Contains(view, "filter / ") || !strings.Contains(view, "type to filter") { + t.Fatalf("active filter must replace the command-looking composer:\n%s", view) + } +} + +func TestDashboardWorkspaceCountsAreScopedToLifecycleAndPinSections(t *testing.T) { + m := NewWithDeps(nil, nil) + m.groupByDir = true + m.sessions = []adapter.Session{ + {ID: "running", AgentType: "codex", Cwd: "/work/shared", ProcAlive: adapter.Alive}, + {ID: "stopped", AgentType: "codex", Cwd: "/work/shared", ProcAlive: adapter.Exited}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + view := m.View() + if got := strings.Count(view, "WORKSPACE shared"); got != 2 { + t.Fatalf("workspace should have one heading per lifecycle section, got %d:\n%s", got, view) + } + if strings.Contains(view, "WORKSPACE shared 2") { + t.Fatalf("workspace heading must not claim rows from a different lifecycle section:\n%s", view) + } +} + +func TestDashboardSelectedIdentityAndPRSurviveLongWorkspaceAtStandardWidth(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{{ + ID: "12345678-1234-1234-1234-123456789abc", AgentType: "codex", DisplayName: "selected", + Prompt: "review", Cwd: "/home/developer/projects/a/very/long/workspace/path/that/needs/truncation", + ProcAlive: adapter.Alive, PR: &adapter.PRRef{Number: 99, Status: adapter.PRMerged}, + }} + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + view := m.View() + for _, want := range []string{"cwd /home/developer", "id 12345678-1234-1234-1234-123456789abc", "PR #99"} { + if !strings.Contains(view, want) { + t.Fatalf("selected metadata lost %q behind long workspace:\n%s", want, view) + } + } +} + +func TestDashboardTinyFooterKeepsGuidanceWithLongInput(t *testing.T) { + m := NewWithDeps(nil, nil) + m.defaultAgent = "codex" + m.input = strings.Repeat("界", 80) + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 10}) + view := m.View() + assertViewGeometry(t, view, 44, 10) + assertBottomContains(t, view, "↑↓ Enter") +} + +func TestDashboardFilterNoMatchActionsAreSafeAndBackspaceExits(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{{ID: "one", AgentType: "codex", DisplayName: "release", ProcAlive: adapter.Alive}} + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("absent")}) + m = model.(Model) + for _, key := range []tea.KeyType{tea.KeyEnter, tea.KeySpace, tea.KeyCtrlT, tea.KeyCtrlR, tea.KeyCtrlX} { + model, cmd := m.Update(tea.KeyMsg{Type: key}) + m = model.(Model) + if cmd != nil || m.renaming || m.confirmStop { + t.Fatalf("no-match key %v acted on an invisible session: cmd=%v rename=%v confirm=%v", key, cmd, m.renaming, m.confirmStop) + } + } + for range len([]rune("absent")) { + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + m = model.(Model) + } + if !m.filterActive || m.filterQuery != "" { + t.Fatalf("backspace should edit the Unicode-safe query before exiting: active=%v query=%q", m.filterActive, m.filterQuery) + } + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + m = model.(Model) + if m.filterActive { + t.Fatal("backspace on an empty filter should exit filtering") + } +} + +func TestDashboardFilterHandlesUnicodePasteAndFilteredReorder(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "one", AgentType: "codex", DisplayName: "first 界", Prompt: "review 世界", ProcAlive: adapter.Alive, SortIndex: 0}, + {ID: "hidden", AgentType: "codex", DisplayName: "plain", Prompt: "unrelated", ProcAlive: adapter.Alive, SortIndex: 1}, + {ID: "two", AgentType: "codex", DisplayName: "second 界", Prompt: "review 世界", ProcAlive: adapter.Alive, SortIndex: 2}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("世界")}) + m = model.(Model) + view := m.View() + if !strings.Contains(view, "first 界") || !strings.Contains(view, "second 界") || strings.Contains(view, "plain") { + t.Fatalf("Unicode pasted filter did not preserve rune semantics:\n%s", view) + } + model, cmd := m.Update(tea.KeyMsg{Type: tea.KeyShiftDown}) + m = model.(Model) + if cmd == nil || m.sessions[0].ID != "two" || m.sessions[1].ID != "hidden" || m.sessions[2].ID != "one" || m.selected != 2 { + t.Fatalf("filtered reorder did not exchange matching endpoints safely: ids=%v selected=%d cmd=%v", sessionIDs(m.sessions), m.selected, cmd) + } +} + +func TestDashboardFilterMatchesEveryDocumentedFieldWithANDTerms(t *testing.T) { + base := []adapter.Session{ + {ID: "managed-ABC-123", AgentType: "codex", CommandAlias: "nightly", DisplayName: "Release Captain", Prompt: "Ship the pipeline", Cwd: "/work/Unified-Agent-Manager", ProcAlive: adapter.Alive}, + {ID: "other", AgentType: "claude", DisplayName: "Documentation", Prompt: "Write a guide", Cwd: "/work/docs", ProcAlive: adapter.Exited}, + } + for _, query := range []string{"abc-123", "NIGHTLY", "release captain", "ship PIPELINE", "unified-agent-manager", "codex running", "claude stopped"} { + t.Run(query, func(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = append([]adapter.Session(nil), base...) + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(query)}) + m = model.(Model) + if len(m.visibleSessionIndices()) != 1 { + t.Fatalf("query %q matched %d sessions, want 1:\n%s", query, len(m.visibleSessionIndices()), m.View()) + } + }) + } +} + +func TestDashboardFilteredActionsTargetMatchedCompositeIdentityAndEscClosesPeekFirst(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "same", AgentType: "codex", DisplayName: "release", ProcAlive: adapter.Alive}, + {ID: "same", AgentType: "claude", DisplayName: "docs", ProcAlive: adapter.Alive}, + } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("claude")}) + m = model.(Model) + + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlR}) + m = model.(Model) + if !m.renaming || m.renameTargetAgent != "claude" || m.renameTargetID != "same" { + t.Fatalf("rename targeted the wrong filtered session: agent=%q id=%q", m.renameTargetAgent, m.renameTargetID) + } + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + + model, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + m = model.(Model) + if !m.peekOpen || m.peekTargetAgent != "claude" || m.peekTargetID != "same" { + t.Fatalf("peek targeted the wrong filtered session: open=%v agent=%q id=%q", m.peekOpen, m.peekTargetAgent, m.peekTargetID) + } + query := m.filterQuery + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("send reply")}) + m = model.(Model) + if m.input != "send reply" || m.filterQuery != query { + t.Fatalf("Peek reply text leaked into filter: input=%q query=%q", m.input, m.filterQuery) + } + model, reply := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + if reply == nil || m.input != "" || m.filterQuery != query { + t.Fatalf("Enter did not route filtered Peek text as a reply: cmd=%v input=%q query=%q", reply, m.input, m.filterQuery) + } + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + if m.peekOpen || !m.filterActive { + t.Fatalf("first Esc should close Peek while retaining filter: peek=%v filter=%v", m.peekOpen, m.filterActive) + } + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = model.(Model) + if m.filterActive { + t.Fatal("second Esc should clear the retained filter") + } +} + +func TestDashboardFilteredAttachPinStopAndGroupingStayOnMatchedIdentity(t *testing.T) { + id := "same0001" + codexSession := adapter.Session{ID: id, AgentType: "codex", DisplayName: "release", SessionName: "uam-codex-same0001", Cwd: "/work/codex", ProcAlive: adapter.Alive} + claudeSession := adapter.Session{ID: id, AgentType: "claude", DisplayName: "docs", SessionName: "uam-claude-same0001", Cwd: "/work/claude", ProcAlive: adapter.Alive} + codex := &svcFakeAdapter{name: "codex", available: true, sessions: []adapter.Session{codexSession}} + claude := &svcFakeAdapter{name: "claude", available: true, sessions: []adapter.Session{claudeSession}} + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("codex", id)] = RecordFromSession(codexSession, store.ModeYolo) + cfg.Sessions[store.Key("claude", id)] = RecordFromSession(claudeSession, store.ModeYolo) + return nil + }); err != nil { + t.Fatal(err) + } + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{codex, claude})) + m.sessions = []adapter.Session{codexSession, claudeSession} + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 80, Height: 30}) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = model.(Model) + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("claude")}) + m = model.(Model) + + model, attach := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + if attach == nil { + t.Fatal("filtered attach returned no command") + } + if msg := attach(); msg == nil { + t.Fatal("filtered attach returned no message") + } + if claude.attachedID != id || codex.attachedID != "" { + t.Fatalf("filtered attach crossed provider identity: claude=%q codex=%q", claude.attachedID, codex.attachedID) + } + + model, pin := m.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + m = model.(Model) + if pin == nil { + t.Fatal("filtered pin returned no command") + } + _ = pin() + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if !cfg.Sessions[store.Key("claude", id)].Pinned || cfg.Sessions[store.Key("codex", id)].Pinned { + t.Fatalf("filtered pin crossed provider identity: claude=%v codex=%v", cfg.Sessions[store.Key("claude", id)].Pinned, cfg.Sessions[store.Key("codex", id)].Pinned) + } + + model, groupCmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + selected, ok := m.selectedSession() + if groupCmd == nil || !m.groupByDir || !ok || selected.AgentType != "claude" || selected.ID != id { + t.Fatalf("filtered grouping lost selection/persistence command: cmd=%v grouped=%v selected=%+v", groupCmd, m.groupByDir, selected) + } + + model, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) + m = model.(Model) + if !m.confirmStop || m.confirmStopAgent != "claude" || m.confirmStopID != id { + t.Fatalf("filtered stop confirmation targeted wrong identity: agent=%q id=%q", m.confirmStopAgent, m.confirmStopID) + } + model, stop := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + if stop == nil { + t.Fatal("filtered stop returned no command") + } + _ = stop() + if claude.stoppedID != id || codex.stoppedID != "" { + t.Fatalf("filtered stop crossed provider identity: claude=%q codex=%q", claude.stoppedID, codex.stoppedID) + } +} + +func BenchmarkDashboardRenderAndFilter(b *testing.B) { + for _, count := range []int{100, 1000} { + b.Run(fmt.Sprintf("sessions-%d", count), func(b *testing.B) { + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + m := NewWithDeps(nil, nil) + m.now = func() time.Time { return now } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 120, Height: 40}) + for i := range count { + m.sessions = append(m.sessions, adapter.Session{ + ID: fmt.Sprintf("session-%04d", i), AgentType: []string{"codex", "claude"}[i%2], + DisplayName: fmt.Sprintf("session %04d", i), Prompt: "review the release pipeline", + Cwd: "/work/unified-agent-manager", ProcAlive: adapter.Alive, CreatedAt: now.Add(-time.Duration(i) * time.Minute), + }) + } + m.filterActive = true + m.filterQuery = "codex release" + b.ResetTimer() + for range b.N { + _ = m.View() + } + }) + } +} diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index c9df09c..97239b0 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -48,7 +48,7 @@ func TestLayoutClassAndDashboardModeAreDerived(t *testing.T) { } func TestDashboardRequiredFixturesStayWithinTerminal(t *testing.T) { - sizes := []struct{ width, height int }{{120, 40}, {80, 30}, {44, 20}, {44, 12}} + sizes := []struct{ width, height int }{{120, 40}, {80, 30}, {44, 20}, {44, 12}, {44, 10}} modes := []struct { name string set func(*Model) @@ -74,15 +74,15 @@ func TestDashboardRequiredFixturesStayWithinTerminal(t *testing.T) { } } -func TestWideOperationsAndPeekUseTwoPanes(t *testing.T) { +func TestWideOperationsUsesFullListAndPeekUsesTwoPanes(t *testing.T) { m := responsiveFixture(120, 40) operations := m.View() - if !lineContainsAll(operations, "RUNNING", "SELECTED") { - t.Fatalf("wide operations should place list and selected pane side-by-side:\n%s", operations) + if !strings.Contains(operations, "╭─ SESSIONS") || strings.Contains(operations, "SELECTED") { + t.Fatalf("wide operations should use one full-width sessions panel:\n%s", operations) } m.peekOpen = true peek := m.View() - if !lineContainsAll(peek, "RUNNING", "PEEK") { + if !lineContainsAll(peek, "SESSIONS", "PEEK") { t.Fatalf("wide peek should retain the list beside the peek pane:\n%s", peek) } } @@ -135,9 +135,9 @@ func TestNoColorResponsiveViewKeepsSemanticGlyphs(t *testing.T) { if strings.Contains(view, "\x1b[") { t.Fatalf("NO_COLOR view contains SGR escapes: %q", view) } - for _, glyph := range []string{"▸", "★", "⟳", "◦", "!", "●"} { - if !strings.Contains(view, glyph) { - t.Fatalf("NO_COLOR view lost semantic glyph %q:\n%s", glyph, view) + for _, semantic := range []string{"▸", "★", "RUNNING", "STOPPED", "EXIT 1"} { + if !strings.Contains(view, semantic) { + t.Fatalf("NO_COLOR view lost semantic marker %q:\n%s", semantic, view) } } } @@ -228,7 +228,7 @@ func TestCompactWizardEveryStepKeepsEssentialAffordances(t *testing.T) { func TestPeekSurfacePreservesBlankPhysicalLines(t *testing.T) { m := responsiveFixture(44, 12) m.peekText = "older\nblank-before\n\nblank-after\nnewest" - lines := m.peekSurfaceLines(44, 7, LayoutCompact) + lines := boundedTailLines(m.peekText, 7, 44) got := strings.Join(lines, "\n") if !strings.Contains(got, "blank-before\n\nblank-after") { t.Fatalf("peek collapsed a physical blank line: %q", got) diff --git a/internal/app/lifecycle_3c_test.go b/internal/app/lifecycle_3c_test.go index 6e55d6c..a3a4732 100644 --- a/internal/app/lifecycle_3c_test.go +++ b/internal/app/lifecycle_3c_test.go @@ -252,9 +252,9 @@ func TestFailureDetailAppendsToPromptWithoutReplacingOrDuplicatingIt(t *testing. } m := Model{width: 44, height: 20, sizeKnown: true, sessions: []adapter.Session{sess}} - summary := strings.Join(m.selectedSummaryLines(44, LayoutCompact), "\n") + summary := m.View() if !strings.Contains(summary, want) || strings.Count(summary, tc.detail) != 1 { - t.Fatalf("selected failure summary = %q, want one %q", summary, want) + t.Fatalf("selected dashboard summary = %q, want one %q", summary, want) } compactRow := renderRow(sess, false, 39, 0, false) @@ -268,10 +268,11 @@ func TestFailureDetailAppendsToPromptWithoutReplacingOrDuplicatingIt(t *testing. t.Fatalf("bounded task column lost failure suffix: width=%d row=%q", ansi.StringWidth(boundedRow), boundedRow) } m.sessions[0] = sess - boundedSummary := strings.Join(m.selectedSummaryLines(44, LayoutCompact), "\n") - if !strings.Contains(boundedSummary, " · "+tc.detail) || ansi.StringWidth(strings.Split(boundedSummary, "\n")[1]) > 44 { + boundedSummary := m.View() + if !strings.Contains(boundedSummary, " · "+tc.detail) { t.Fatalf("bounded selected summary lost failure suffix: %q", boundedSummary) } + assertViewGeometry(t, boundedSummary, 44, 20) sess.Prompt = "already recorded · " + tc.detail if got := boundedTaskSummary(sess, 44); strings.Count(got, tc.detail) != 1 { From a7f630e1fd3f56edb7a14836406fb598268532e0 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 13 Jul 2026 03:02:26 +0000 Subject: [PATCH 2/2] feat(tui): show version in compact header --- internal/app/app_test.go | 4 ++++ internal/app/dashboard_revamp.go | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a6810bb..1be90d5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -161,6 +161,10 @@ func TestViewShowsCompactUAMBrandingAndDashboard(t *testing.T) { if strings.Contains(view, uamANSILogo) { t.Fatalf("responsive dashboard should not spend rows on the legacy ASCII logo: %s", view) } + m = m.handleWindowSize(tea.WindowSizeMsg{Width: 44, Height: 12}) + if compact := m.View(); !strings.Contains(compact, "v9.9.9") { + t.Fatalf("compact dashboard should retain the version label: %s", compact) + } } func TestViewUsesBorderedSessionsPanel(t *testing.T) { diff --git a/internal/app/dashboard_revamp.go b/internal/app/dashboard_revamp.go index 7380da4..0aa0988 100644 --- a/internal/app/dashboard_revamp.go +++ b/internal/app/dashboard_revamp.go @@ -237,7 +237,9 @@ func (m Model) dashboardView() string { func (m Model) dashboardHeader(width int) string { left := bar() + " " + brandStyle.Render("UAM") - if m.layoutClass() != LayoutCompact { + if m.layoutClass() == LayoutCompact { + left += " " + hintStyle.Render(version.String()) + } else { left += " " + hintStyle.Render("Unified Agent Manager") + " " + hintStyle.Render(version.String()) } right := "/ filter"