From 7c4ae627ba3208e4cc17d1b077bbd0374ca23f66 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 17:30:48 +0100 Subject: [PATCH 01/12] FEAT: add Sessions status filters and configurable telemetry priority --- README.md | 41 +++- internal/ui/english_snapshot_test.go | 4 +- internal/ui/localisation_test.go | 2 + internal/ui/model.go | 22 +- internal/ui/monitor.go | 58 +++-- internal/ui/monitor_close_all.go | 2 +- internal/ui/monitor_context.go | 6 + internal/ui/monitor_context_modes.go | 9 +- internal/ui/monitor_filter.go | 187 ++++++++++++++++ internal/ui/monitor_filter_test.go | 297 +++++++++++++++++++++++++ internal/ui/monitor_model_test.go | 1 + internal/ui/monitor_name_test.go | 4 +- internal/ui/monitor_navigation.go | 4 +- internal/ui/monitor_navigation_test.go | 2 +- internal/ui/monitor_statusline.go | 23 +- internal/ui/monitor_statusline_test.go | 54 +++++ internal/ui/monitor_summary.go | 26 +-- internal/ui/monitor_test.go | 1 + 18 files changed, 682 insertions(+), 61 deletions(-) create mode 100644 internal/ui/monitor_filter.go create mode 100644 internal/ui/monitor_filter_test.go diff --git a/README.md b/README.md index 6bc4e89..aa2deee 100644 --- a/README.md +++ b/README.md @@ -592,7 +592,7 @@ codexometer --codex /path/to/codex | `o` | Arm Close All in the Sessions overview; press again to confirm, or `Esc` to cancel | | `g` | Cycle available trend periods in Quota → Pace | | `h` | Toggle the trace in Quota → Pace; in Sessions, reset all rows to graph-only / split detail-and-graph, closing full detail and clearing individual row choices | -| `Left` / `Right` | In Sessions, less / more detail for the selected session: graph ↔ split ↔ wide ↔ full screen; stops at either end | +| `Left` / `Right` | In Sessions, cycle status filters while the summary has focus; otherwise less / more detail for the selected session: graph ↔ split ↔ wide ↔ full screen; stops at either end | | `b` | Run the selected benchmark scope (Benchmark view only) | | `a` | Arm, then confirm, Run All (Benchmark view only) | | `x` | Dismiss the selected Sessions row; close Benchmark detail/Scope, or stop an active suite and retain its incomplete trial | @@ -602,7 +602,7 @@ codexometer --codex /path/to/codex | `Left` / `Right` | Page Usage history, or select the previous or next benchmark suite | | `f` | Show all, passed, or failed benchmark results | | `w` | Select Weekly in Usage, or cycle Cost, Balanced, and Speed benchmark ranking weights | -| `Up` / `Down` | Select a session row or Benchmark row, or scroll Sessions full detail / open Benchmark detail (outside a focused editor) | +| `Up` / `Down` | Select a session row or Benchmark row; Up from the first session focuses its summary filters, Down returns to the first match. Scroll Sessions full detail / open Benchmark detail (outside a focused editor) | | `Enter` / `Space` | Open a selected Benchmark result, or toggle a Scope checkbox; in Sessions full detail, `Enter` focuses an available reply editor or submits its text | | `c` | Copy an eligible Sessions reply while its detail is shown, confirm an armed session approval, select Cumulative in Usage, or copy the Benchmark matrix/open run detail; types normally in a focused editor | | `l` | Clear accumulated Benchmark results while no suite is running | @@ -1290,13 +1290,14 @@ same short ID and session name (directory fallback when unnamed), shortening their labels as space tightens. Click a highlighted session status to open its full detail and any native approval/input request; this only navigates. -Session rows prioritise the root session's latest observed model, reasoning effort -and Fast setting directly below the token count, for example -`gpt-6-astra medium fast`. These are observed selections from persisted turn contexts +Terminal session rows keep the state first, then prioritise the fields selected +through `/statusline` in their configured order, in bold. Defaults include the +root session's latest observed model/reasoning, service tier, total tokens and +directory. Model settings are observed selections from persisted turn contexts and settings events, not proof of the backend-resolved model or delivered speed. Changes appear once Codex records them; an unrecorded CLI selection cannot be seen. Child-agent settings never replace the root's selection. Missing fields are omitted; -no `fast` suffix means Fast was not observed, not proof that it is off. +missing speed information means the tier was not observed, not proof that Fast is off. The row no longer repeats the directory already shown in its border or the status already shown in its badge. Generic `ROOT` text and the extra file-activity age line @@ -1523,8 +1524,23 @@ The old `i` and Enter detail-navigation shortcuts have been removed. Clickable **[←] [→]** buttons sit at the top-right of each row's rightmost box (graph or detail), and on the full-detail box. They mirror the cursor keys: Left is dimmed and inactive at graph-only; Right is dimmed and inactive at full -detail. The **SESSION TOTALS** header also has **[↑] [↓]** session-selection buttons, -disabled at the first/last visible session. Dismissed sessions are skipped. +detail. The **SESSION TOTALS** header also has **[↑] [↓]** session-selection buttons. +Up from the first session focuses the summary categories; Left/Right then cycles +**Sessions (all) → Working → Approval → Input → Check** and filters the rows. +Click a category label or count to select it directly. The selected filter is +underlined; keyboard focus and hover add a theme-coloured highlight. Down or +Enter returns to the first matching session, where Left/Right changes detail +again. Escape while the categories have focus clears the filter. Empty categories +remain selectable and show an explicit no-results message. Filters update with +live session states and are not persisted across launches. + +The summary counts and token totals remain global, excluding dismissed sessions +from state counts but retaining their measured tokens. Attention pills stay +available across filters; opening an excluded session clears the filter to keep +its row accessible. **Close All** only dismisses matching, non-dismissed sessions +after confirmation; filtering never stops telemetry collection or changes the +Zero baseline. Dismissed sessions are skipped, and Down is disabled at the last +matching session. These up/down session-selection buttons disappear in full detail, where Up/Down scroll the text. On narrow boxes, arrow buttons are omitted if they cannot fit; keyboard and background half-click navigation still work. Hide/Show and Close retain priority. @@ -1662,14 +1678,19 @@ are clickable. Writable web mode exposes the same catalogue through | `/copy` | **Local:** reuse the selected session’s Copy action, copying the full observed reply/activity text. Ineligible or missing text gives feedback. | | `/theme` | **Local:** choose and save a Codexometer display theme; terminal picker or existing browser theme selector. | | `/exit`, `/quit` | **Local:** quit the Codexometer terminal interface; in the browser, leave session detail for Sessions. Codex sessions and the browser server continue running. | -| `/statusline` | **Local Codexometer multi-select:** choose and reorder detail-footer fields with a live preview. Works without the shared daemon. | +| `/statusline` | **Local Codexometer multi-select:** choose and reorder detail-footer fields with a live preview. In the terminal, the same selection also prioritises the values in each session's left-hand telemetry box. Works without the shared daemon. | The detail footer uses theme-highlighted text in the bottom border in terminal mode, and the last line of full session detail on web. Its default fields are observed model/reasoning, speed, total session tokens (including linked agents) and directory. `/statusline` also offers model alone, reasoning alone, session name, full session ID, observed state, linked-agent count and context source. Missing -values are omitted. This is Codexometer's supported field set; the Codex CLI's +values are omitted. In terminal session rows, selected fields appear in the same +order, in bold, below the essential state badge. Each row uses its own session's +values, not those of the selected session. Lower-priority quota, activity and +latency information uses any remaining rows; configured values are never replaced +by pagination hints. An explicit empty selection restores the usual telemetry +priorities while hiding the detail footer. This is Codexometer's supported field set; the Codex CLI's own client-side picker is not dynamically exposed by app-server. In the terminal, **Space/Enter** toggles a field, **Left/Right** changes its order, diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go index 0c93121..5e9022f 100644 --- a/internal/ui/english_snapshot_test.go +++ b/internal/ui/english_snapshot_test.go @@ -37,9 +37,9 @@ func TestEnglishPresentationSnapshot(t *testing.T) { } got := fmt.Sprintf("%x", hash.Sum(nil)) // Baseline includes Pace graph controls, explicit panel/border - // backgrounds and Sessions Zero/Close All; every theme, view and size + // backgrounds, Sessions Zero/Close All and the active summary filter; every theme, view and size // remains covered. The displayed pricing review date is now 2026-10-12. - const want = "fc7b2b5381dd43bac332748c9fca6ac4c53abfacb130bfa49de64e76fe27f4ba" + const want = "9282134b3a6e45741e60b499a28bb0b4bed8b542c45a40f3a9668882ad78c360" if got != want { t.Fatalf("English presentation changed: got %s, want %s", got, want) } diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index d38ae3a..66dcee2 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -70,9 +70,11 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("queue_editor_surfaces", TestQueueEditorButtonsAndKeyboardFocus) t.Run("monitor_copy_surfaces", TestMonitorCopySurfaces) t.Run("monitor_attention_surfaces", TestMonitorAttentionRenderedHitSurfaces) + t.Run("monitor_filter_surfaces", TestMonitorStatusFilterRenderedHitSurfaces) t.Run("monitor_attention_padding", TestMonitorAttentionTallPadding) t.Run("monitor_bottom_composer", TestMonitorDetailBottomRowsFocusComposer) t.Run("monitor_model_metadata", TestMonitorSessionModelMetadataPriority) + t.Run("monitor_priority_metadata", TestStatusLinePrioritisesPerSessionTelemetry) t.Run("monitor_attention_overflow", TestMonitorAttentionOverflowAndSafeLabels) t.Run("monitor_completion_pills", TestMonitorCompletedAttentionPills) t.Run("monitor_pill_identifiers", TestMonitorAttentionLabelsUseStableSessionIDs) diff --git a/internal/ui/model.go b/internal/ui/model.go index 5c6585b..16ef882 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -194,6 +194,8 @@ type Model struct { monitorCodexUp bool monitorCodexWorking bool monitorSelectedID string + monitorFilter monitorStatusFilter + monitorSummaryFocused bool monitorDismissed map[string]monitorSessionDismissal monitorDismissHover string monitorDismissFlash string @@ -643,6 +645,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.meterView == viewMonitor { + if m.monitorFilterKey(strings.ToLower(message.String())) { + return m, nil + } if strings.EqualFold(message.String(), "o") { if !message.IsRepeat && m.monitorContextDetail == "" && m.monitorCloseAllEnabled() { return m.pressFooterButton(footerButtonMonitorCloseAll) @@ -2585,6 +2590,7 @@ func (m *Model) startMonitorSessions(usage codex.LiveUsageSnapshot, observedAt t } func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt time.Time) { + defer m.reconcileMonitorFilterSelection() follow := false if m.monitorContextDetail != "" { if s, ok := m.contextDetailSession(); ok { @@ -2699,7 +2705,7 @@ func (m *Model) dismissMonitorSession(id string) { preview: session.preview, } m.monitorSessions = m.visibleMonitorSessionCount() - maximumScroll := max(m.monitorSessions-m.monitorPageSize(), 0) + maximumScroll := max(m.monitorListSessionCount()-m.monitorPageSize(), 0) m.monitorScroll = min(m.monitorScroll, maximumScroll) } @@ -2708,7 +2714,7 @@ func (m *Model) dismissMonitorSession(id string) { func (m *Model) dismissAllMonitorSessions() { m.stashMonitorDraft() for _, session := range m.monitorSessionData { - if m.monitorSessionVisible(session) { + if m.monitorSessionInList(session) { m.dismissMonitorSession(session.id) } } @@ -2871,7 +2877,7 @@ func (m *Model) scrollMonitorPage(direction int) { } func (m *Model) scrollMonitorRows(rows int) { - maximum := max(m.visibleMonitorSessionCount()-m.monitorPageSize(), 0) + maximum := max(m.monitorListSessionCount()-m.monitorPageSize(), 0) m.monitorScroll = min(max(m.monitorScroll+rows, 0), maximum) } @@ -2880,7 +2886,7 @@ func (m *Model) selectMonitorSession(direction int) { visible := make([]string, 0, len(m.monitorSessionData)) selected := -1 for _, session := range m.monitorSessionData { - if !m.monitorSessionVisible(session) { + if !m.monitorSessionInList(session) { continue } if session.id == m.monitorSelectedID { @@ -2890,8 +2896,14 @@ func (m *Model) selectMonitorSession(direction int) { } if len(visible) == 0 { m.monitorSelectedID = "" + m.focusMonitorSummary() + return + } + if direction < 0 && selected == 0 && m.monitorContextDetail == "" { + m.focusMonitorSummary() return } + m.monitorSummaryFocused = false if selected < 0 { if direction < 0 { selected = len(visible) - 1 @@ -2924,7 +2936,7 @@ func (m *Model) dismissSelectedMonitorSession() { visible := make([]string, 0, len(m.monitorSessionData)) selected := -1 for _, session := range m.monitorSessionData { - if !m.monitorSessionVisible(session) { + if !m.monitorSessionInList(session) { continue } if session.id == m.monitorSelectedID { diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index c8aa581..2367980 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -181,7 +181,11 @@ func (m Model) renderMonitorSummary(width, height int, colors palette, navigatio if navigation { action = m.renderMonitorNavigation(width, "", false, colors) } - return frameSizedWithTitleAction(width, max(height-2, 1), i18n.Text("SESSION TOTALS"), action, strings.Join(lines, "\n"), colors.primary, colors) + title := i18n.Text("SESSION TOTALS") + if m.monitorSummaryFocused { + title += " // ← →" + } + return frameSizedWithTitleAction(width, max(height-2, 1), title, action, strings.Join(lines, "\n"), colors.primary, colors) } func (m Model) renderMonitorButton(width, height int, label string, id footerButtonID, enabled bool, colors palette) string { @@ -216,6 +220,10 @@ func (m Model) renderMonitorGraph(width, height int, colors palette) string { func (m Model) renderMonitorSessions(width, height int, colors palette) string { visible, rowHeights, pageLabel := m.monitorSessionPage(height) if len(visible) == 0 { + if m.monitorFilter != monitorFilterAll { + message := ansi.Truncate(i18n.Text("NO RESULTS MATCH THE ACTIVE FILTER"), max(width-4, 1), "…") + return frameSized(width, max(height-2, 1), i18n.Text("SESSIONS"), colors.dimmed().Render(message), colors.primary, colors) + } return m.renderMonitorGraph(width, height, colors) } rows := make([]string, 0, len(visible)) @@ -236,7 +244,7 @@ func (m Model) renderMonitorSessions(width, height int, colors palette) string { func (m Model) monitorSessionPage(height int) ([]monitorSession, []int, string) { visible := make([]monitorSession, 0, len(m.monitorSessionData)) for _, session := range m.monitorSessionData { - if m.monitorSessionVisible(session) { + if m.monitorSessionInList(session) { visible = append(visible, session) } } @@ -321,6 +329,9 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes if session.attention != codex.SessionAttentionNone { status = monitorAttentionStatus(session.attention) } + if m.monitorError != "" || m.monitorState != monitorRunning { + status = i18n.Text("STALE") + } innerWidth := max(width-4, 1) memberLabel := "" if session.agentCount > 0 { @@ -336,9 +347,22 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes badge := m.renderMonitorSessionBadge(session, innerWidth, colors) if badge != "" { lines = append(lines, badge) + } else { + lines = append(lines, colors.label().Bold(true).Render(ansi.Truncate(status, innerWidth, ""))) + } + fields, selectedFields := m.monitorPriorityFields(session) + for _, field := range fields { + if len(lines) < bodyRows { + lines = append(lines, colors.label().Bold(true).Render(ansi.Truncate(field, innerWidth, "…"))) + } + } + appendPriority := func(value string) { + if value != "" && len(lines) < bodyRows { + lines = append(lines, colors.label().Bold(true).Render(ansi.Truncate(value, innerWidth, ""))) + } } - if len(lines) < bodyRows { - lines = append(lines, colors.label().Render(ansi.Truncate(usageLine, innerWidth, ""))) + if !selectedFields["used-tokens"] { + appendPriority(usageLine) } appendLine := func(value string) { if value != "" && len(lines) < bodyRows { @@ -346,20 +370,19 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes } } model := formatMonitorSessionModel(session) - if model != "" && len(lines) < bodyRows { - lines = append(lines, colors.label().Render(ansi.Truncate(model, innerWidth, ""))) + if !selectedFields["model"] && !selectedFields["model-with-reasoning"] { + appendPriority(model) } priorityRows := len(lines) if estimate := m.monitorSessionQuotaEstimate(share); estimate != "" { appendLine(estimate) } - if badge == "" { - appendLine(status) - } - if session.name != "" { + if session.name != "" && !selectedFields["current-dir"] { appendLine(terminalLabel(session.workingDirectory)) } - appendLine(memberLabel) + if !selectedFields["agents"] { + appendLine(memberLabel) + } appendLine(formatMonitorCallActivity(session, time.Now())) if session.latestTTFTOK || session.peakTTFTOK { appendLine(formatMonitorTTFT(session)) @@ -368,8 +391,13 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes appendLine(formatMonitorOutput(session)) } appendLine(i18n.Text("AVG TOK/MIN ") + formatTokens(session.averageRate)) - if pageLabel != "" && (badge == "" || len(lines) > 1) && (model == "" || len(lines) > priorityRows) { - lines[len(lines)-1] = colors.dimmed().Render(ansi.Truncate(pageLabel+" // PGUP/PGDN", innerWidth, "")) + pageAction := "" + if pageLabel != "" { + if len(lines) > priorityRows { + lines[len(lines)-1] = colors.dimmed().Render(ansi.Truncate(pageLabel+" // PGUP/PGDN", innerWidth, "")) + } else { + pageAction = colors.dimmed().Render(ansi.Truncate(pageLabel, max(width-4, 0), "…")) + } } borderColor := colors.primary if monitorNeedsAttention(session.attention) && session.id != m.monitorSelectedID { @@ -379,7 +407,7 @@ func (m Model) renderMonitorSessionMetrics(width, height int, session monitorSes if _, ok := monitorSessionDismissRect(width, 0); ok { action = m.renderMonitorSessionDismiss(session.id, colors) } - return frameSizedWithTitleAction(width, max(height-2, 1), title, action, strings.Join(lines, "\n"), borderColor, colors) + return frameSizedWithActions(width, max(height-2, 1), title, action, pageAction, strings.Join(lines, "\n"), borderColor, colors) } func formatMonitorSessionModel(session monitorSession) string { @@ -807,7 +835,7 @@ func (m Model) monitorResetEnabled() bool { } func (m Model) monitorCloseAllEnabled() bool { - return m.monitorState == monitorRunning && m.visibleMonitorSessionCount() > 0 + return m.monitorState == monitorRunning && m.monitorListSessionCount() > 0 } func (m Model) monitorElapsed(now time.Time) time.Duration { diff --git a/internal/ui/monitor_close_all.go b/internal/ui/monitor_close_all.go index d7e2278..df58e0d 100644 --- a/internal/ui/monitor_close_all.go +++ b/internal/ui/monitor_close_all.go @@ -8,7 +8,7 @@ import ( func (m Model) visibleMonitorSessionIDs() []string { var ids []string for _, s := range m.monitorSessionData { - if m.monitorSessionVisible(s) { + if m.monitorSessionInList(s) { ids = append(ids, s.id) } } diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 30570fb..be5c868 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -393,6 +393,9 @@ func (m Model) monitorContextAt(x, y int) string { if m.meterView != viewMonitor || m.loading && len(m.snapshot.Meters()) == 0 { return "" } + if hit := m.monitorFilterAt(x, y); hit != "" { + return hit + } if hit := m.monitorAttentionAt(x, y); hit != "" { return hit } @@ -517,6 +520,9 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool _, click := msg.(tea.MouseClickMsg) m.monitorContextHover = m.monitorContextAt(mouse.X, mouse.Y) if click && mouse.Button == tea.MouseLeft && m.monitorContextHover != "" { + if m.activateMonitorFilter(m.monitorContextHover) { + return m, nil, true + } if id, ok := strings.CutPrefix(m.monitorContextHover, "copy:"); ok { next, cmd := m.activateMonitorCopy(id) return next, cmd, true diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index 99b08d7..8d1ad53 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -22,7 +22,7 @@ func (m Model) initialMonitorContextTarget() string { var first, newest string var at time.Time for _, s := range m.monitorSessionData { - if !m.monitorSessionVisible(s) { + if !m.monitorSessionInList(s) { continue } if s.id == m.monitorSelectedID { @@ -107,6 +107,13 @@ func (m Model) contextTargetHidden() bool { } func (m *Model) setRowContext(id string, mode int) { + m.monitorSummaryFocused = false + for _, s := range m.monitorSessionData { + if s.id == id && !m.monitorSessionInList(s) { + m.monitorFilter, m.monitorScroll = monitorFilterAll, 0 + break + } + } m.clearQuotaConfirmation() m.monitorContextRows = maps.Clone(m.monitorContextRows) if m.monitorContextRows == nil { diff --git a/internal/ui/monitor_filter.go b/internal/ui/monitor_filter.go new file mode 100644 index 0000000..f95a899 --- /dev/null +++ b/internal/ui/monitor_filter.go @@ -0,0 +1,187 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/merefield/codexometer/internal/codex" +) + +type monitorStatusFilter int + +const ( + monitorFilterAll monitorStatusFilter = iota + monitorFilterWorking + monitorFilterApproval + monitorFilterInput + monitorFilterCheck + monitorFilterCount +) + +func monitorSessionStatus(s monitorSession) monitorStatusFilter { + switch s.attention { + case codex.SessionAttentionApproval: + return monitorFilterApproval + case codex.SessionAttentionInput: + return monitorFilterInput + case codex.SessionAttentionCheck: + return monitorFilterCheck + case codex.SessionAttentionNone: + if s.working { + return monitorFilterWorking + } + } + return monitorFilterAll +} + +func (m Model) monitorSessionInList(s monitorSession) bool { + return m.monitorSessionVisible(s) && (m.monitorFilter == monitorFilterAll || monitorSessionStatus(s) == m.monitorFilter) +} + +func (m Model) monitorListSessionCount() int { + n := 0 + for _, s := range m.monitorSessionData { + if m.monitorSessionInList(s) { + n++ + } + } + return n +} + +// Layout is shared by text rendering and hit testing, including wrapped rows. +func monitorSummaryColumns(width, rows int) (columns, cell int) { + columns = 6 + if width < 78 && rows >= 4 { + columns = 3 + } + if width < 44 && rows >= 6 { + columns = 2 + } + return columns, max((width-(columns-1))/columns, 1) +} + +func monitorFilterAction(filter monitorStatusFilter) string { + return fmt.Sprintf("session-filter:%d", filter) +} + +func (m Model) monitorFilterButtons(width, height int) []monitorNavigationButton { + inner, rows := max(width-4, 1), max(height-3, 1) + columns, cell := monitorSummaryColumns(inner, rows) + var buttons []monitorNavigationButton + for i := 1; i < 6; i++ { // Tokens is a measurement, not a session category. + x, y := 2+(i%columns)*(cell+1), 1+(i/columns)*2 + if x+cell > width-2 || y >= height-1 { + continue + } + buttons = append(buttons, monitorNavigationButton{ + action: monitorFilterAction(monitorStatusFilter(i - 1)), enabled: true, + rect: monitorRect{x: x, y: y, width: cell, height: min(2, height-1-y)}, + }) + } + return buttons +} + +func (m Model) monitorFilterAt(x, y int) string { + if m.meterView != viewMonitor || m.loading && len(m.snapshot.Meters()) == 0 || + m.monitorCommands.open || m.monitorQueue.open || m.scheduleUI.open { + return "" + } + g := m.dashboardLayout() + width, height := 0, 0 + if m.monitorContextDetail != "" && !m.contextTargetHidden() { + width = g.contentWidth + height, _, _ = m.monitorDetailHeader(g.contentWidth, g.meterHeight) + } else { + a := m.monitorArea(g.contentWidth, g.meterHeight) + width, height = a.readoutWidth, a.topHeight + } + return monitorNavigationButtonsHit(m.monitorFilterButtons(width, height), x-2, y-g.meterY) +} + +func (m Model) monitorFilterTitleStyle(column int, colors palette) lipgloss.Style { + style := colors.dimmed() + if column == 0 { + return style + } + filter := monitorStatusFilter(column - 1) + if m.monitorFilter == filter { + style = style.Foreground(colors.primary).Bold(true).Underline(true) + if m.monitorSummaryFocused { + style = style.Foreground(colors.background).Background(colors.primary) + } + } + if m.monitorContextHover == monitorFilterAction(filter) { + style = style.Foreground(colors.background).Background(colors.accent) + } + return style +} + +func (m *Model) focusMonitorSummary() { + m.stashMonitorDraft() + m.monitorPrompt = monitorPromptState{} + m.monitorSelectedID, m.monitorContextExpanded = "", "" + m.monitorApprovalConfirm, m.monitorApprovalNotice = "", "" + m.clearQuotaConfirmation() + m.clearMonitorCloseAllConfirmation() + m.monitorSummaryFocused = true +} + +func (m *Model) setMonitorFilter(filter monitorStatusFilter) { + m.focusMonitorSummary() + m.monitorContextDetail = "" + m.monitorContextScroll, m.monitorScroll = 0, 0 + m.monitorFilter = filter +} + +// Arrow navigation belongs to filters only while their strip owns focus. +// Composer/menu handlers run first and retain their normal editing keys. +func (m *Model) monitorFilterKey(key string) bool { + if !m.monitorSummaryFocused || m.monitorContextDetail != "" { + return false + } + switch key { + case "left", "right": + delta := 1 + if key == "left" { + delta = -1 + } + m.setMonitorFilter((m.monitorFilter + monitorStatusFilter(delta) + monitorFilterCount) % monitorFilterCount) + case "down", "enter": + m.selectMonitorSession(1) + case "up": + case "esc": + m.setMonitorFilter(monitorFilterAll) + case "home": + m.setMonitorFilter(monitorFilterAll) + case "end": + m.setMonitorFilter(monitorFilterCheck) + default: + return false + } + return true +} + +// Telemetry may move a selected row out of its filter. Retain full-detail +// reviews until the user leaves them; otherwise release stale control targets. +func (m *Model) reconcileMonitorFilterSelection() { + if m.monitorContextDetail != "" || m.monitorSelectedID == "" { + return + } + for _, s := range m.monitorSessionData { + if s.id == m.monitorSelectedID && m.monitorSessionInList(s) { + return + } + } + m.focusMonitorSummary() +} + +func (m *Model) activateMonitorFilter(action string) bool { + for filter := monitorFilterAll; filter < monitorFilterCount; filter++ { + if strings.EqualFold(action, monitorFilterAction(filter)) { + m.setMonitorFilter(filter) + return true + } + } + return false +} diff --git a/internal/ui/monitor_filter_test.go b/internal/ui/monitor_filter_test.go new file mode 100644 index 0000000..d23c979 --- /dev/null +++ b/internal/ui/monitor_filter_test.go @@ -0,0 +1,297 @@ +package ui + +import ( + "fmt" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +func statusFilterTestModel() Model { + m := contextTestModel() + m.monitorSessionData = []monitorSession{ + {id: "work", displayed: true, working: true}, + {id: "approval", displayed: true, working: true, attention: codex.SessionAttentionApproval}, + {id: "input", displayed: true, attention: codex.SessionAttentionInput}, + {id: "check", displayed: true, attention: codex.SessionAttentionCheck}, + {id: "complete", displayed: true, attention: codex.SessionAttentionComplete}, + {id: "idle", displayed: true}, + {id: "dismissed", displayed: true, working: true}, + {id: "undisplayed", working: true}, + } + m.monitorDismissed = map[string]monitorSessionDismissal{"dismissed": {}} + m.monitorBaseline, m.monitorLatest = 100, 725 + m.monitorStartedAt = time.Now().Add(-time.Minute) + return m +} + +func filterKey(t *testing.T, m Model, code rune) Model { + t.Helper() + next, cmd := m.Update(tea.KeyPressMsg{Code: code}) + if cmd != nil { + t.Fatal("filter navigation unexpectedly issued a command") + } + return next.(Model) +} + +func TestMonitorStatusFilterCategoriesKeepGlobalTotals(t *testing.T) { + m := statusFilterTestModel() + want := [][]string{{"work", "approval", "input", "check", "complete", "idle"}, {"work"}, {"approval"}, {"input"}, {"check"}} + global := strings.Join(m.monitorSummaryLines(120, 2, paletteFor(m.theme)), "\n") + for filter := monitorFilterAll; filter < monitorFilterCount; filter++ { + m.setMonitorFilter(filter) + rows, _, _ := m.monitorSessionPage(100) + var ids []string + for _, row := range rows { + ids = append(ids, row.id) + } + if !reflect.DeepEqual(ids, want[filter]) || m.monitorListSessionCount() != len(want[filter]) { + t.Fatalf("filter %d rows %v", filter, ids) + } + if m.visibleMonitorSessionCount() != 6 || m.monitorRecordedTokens() != 625 { + t.Fatal("filter changed accounting or dismissal visibility") + } + if got := ansi.Strip(strings.Join(m.monitorSummaryLines(120, 2, paletteFor(m.theme)), "\n")); got != ansi.Strip(global) { + t.Fatal("filtered summary counts lost their global scope") + } + } +} + +func TestMonitorStatusFilterKeyboardFocusAndDetailArrows(t *testing.T) { + m := statusFilterTestModel() + m.monitorSelectedID = "work" + m = filterKey(t, m, tea.KeyUp) + if !m.monitorSummaryFocused || m.monitorSelectedID != "" { + t.Fatal("Up from the first session did not focus the status strip") + } + for filter := monitorFilterWorking; filter < monitorFilterCount; filter++ { + m = filterKey(t, m, tea.KeyRight) + if m.monitorFilter != filter || !m.monitorSummaryFocused { + t.Fatalf("Right selected filter %d, want %d", m.monitorFilter, filter) + } + } + m = filterKey(t, m, tea.KeyRight) + if m.monitorFilter != monitorFilterAll { + t.Fatal("filter navigation did not wrap to all sessions") + } + m = filterKey(t, m, tea.KeyLeft) + if m.monitorFilter != monitorFilterCheck { + t.Fatal("filter navigation did not wrap backwards") + } + m = filterKey(t, m, tea.KeyDown) + if m.monitorSummaryFocused || m.monitorSelectedID != "check" { + t.Fatal("Down did not enter the first matching session") + } + before := m.rowContextMode("check") + m = filterKey(t, m, tea.KeyRight) + if m.rowContextMode("check") != before+1 || m.monitorFilter != monitorFilterCheck { + t.Fatal("Right no longer controls detail outside the status strip") + } + m = filterKey(t, m, tea.KeyUp) + if !m.monitorSummaryFocused || m.monitorContextExpanded != "" { + t.Fatal("returning to filters left a live detail control target") + } + m = filterKey(t, m, tea.KeyEscape) + if m.monitorFilter != monitorFilterAll || !m.monitorSummaryFocused { + t.Fatal("Escape did not clear the filter without quitting") + } + m = filterKey(t, m, tea.KeyEnter) + if m.monitorSelectedID != "work" || m.monitorSummaryFocused { + t.Fatal("Enter did not return to the session list") + } +} + +func TestMonitorStatusFilterRenderedHitSurfaces(t *testing.T) { + for _, size := range [][2]int{{32, 16}, {40, 24}, {64, 30}, {100, 40}, {180, 60}} { + for _, full := range []bool{false, true} { + m := contextTestModel() + m.width, m.height = size[0], size[1] + if full { + m.setRowContext("root-one", contextFull) + } + g := m.dashboardLayout() + width, height := 0, 0 + if full { + width = g.contentWidth + height, _, _ = m.monitorDetailHeader(width, g.meterHeight) + } else { + a := m.monitorArea(g.contentWidth, g.meterHeight) + width, height = a.readoutWidth, a.topHeight + } + out := ansi.Strip(m.render()) + if lipgloss.Width(out) > m.width || lipgloss.Height(out) > m.height { + t.Fatalf("filter layout overflow at %v full=%v", size, full) + } + lines := strings.Split(out, "\n") + for _, b := range m.monitorFilterButtons(width, height) { + for dy := range b.rect.height { + for dx := range b.rect.width { + x, y := 2+b.rect.x+dx, g.meterY+b.rect.y+dy + if got := m.monitorContextAt(x, y); got != b.action { + t.Fatalf("%v full=%v at %d,%d: %q want %q", size, full, x, y, got, b.action) + } + if y >= len(lines) || x >= lipgloss.Width(lines[y]) { + t.Fatal("filter target extends beyond rendered content") + } + } + } + x, y := 2+b.rect.x, g.meterY+b.rect.y + next, _ := m.Update(tea.MouseMotionMsg{X: x, Y: y}) + hovered := next.(Model) + if hovered.monitorContextHover != b.action { + t.Fatal("category has no hover feedback") + } + next, _ = hovered.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + selected := next.(Model) + if !selected.monitorSummaryFocused || selected.monitorContextDetail != "" || monitorFilterAction(selected.monitorFilter) != b.action { + t.Fatal("category click did not select a filter and return to the list") + } + } + } + } +} + +func TestMonitorStatusFilterEmptyAndLiveChanges(t *testing.T) { + m := statusFilterTestModel() + m.setMonitorFilter(monitorFilterWorking) + m = filterKey(t, m, tea.KeyDown) + m.syncMonitorSessions(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{ + {ID: "work", Active: true, Attention: codex.SessionAttentionComplete}, + }}, time.Now()) + if m.monitorListSessionCount() != 0 || !m.monitorSummaryFocused || m.monitorSelectedID != "" { + t.Fatal("a state transition retained a mismatched row or stale selection") + } + m = filterKey(t, m, tea.KeyDown) + if !m.monitorSummaryFocused { + t.Fatal("empty filter lost keyboard access to the categories") + } + if !strings.Contains(ansi.Strip(m.renderMonitorSessions(100, 20, paletteFor(m.theme))), i18n.Text("NO RESULTS MATCH THE ACTIVE FILTER")) { + t.Fatal("empty filter showed an unrelated aggregate graph") + } + if m.monitorCloseAllEnabled() { + t.Fatal("Close All enabled for an empty filter") + } + m.syncMonitorSessions(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{{ID: "work", Active: true, Working: true}}}, time.Now()) + if m.monitorListSessionCount() != 1 { + t.Fatal("working row did not reappear on the next observation") + } +} + +func TestMonitorStatusFilterPreservesDraftAndApprovalAccess(t *testing.T) { + m := contextTestModel() + m.setRowContext("root-one", contextWide) + m.monitorPrompt = monitorPromptState{session: "root-one", input: newMonitorEditor()} + m.monitorPrompt.input.SetValue("keep this draft") + m.monitorPrompt.input.Focus() + m.monitorApprovalConfirm = "armed" + m.setMonitorFilter(monitorFilterCheck) + if m.monitorDrafts["root-one"] != "keep this draft" || m.monitorPrompt.input.Focused() || m.monitorApprovalConfirm != "" { + t.Fatal("filter discarded a draft or left editing/approval focus behind") + } + m.monitorSessionData[1].attention = codex.SessionAttentionApproval + items := m.monitorAttentionSessions() + if len(items) != 1 || items[0].id != "root-two" { + t.Fatal("filter hid an approval navigation pill") + } + m.openMonitorAttention("attention:root-two") + if m.monitorContextDetail != "root-two" || m.monitorFilter != monitorFilterAll || m.monitorSummaryFocused { + t.Fatal("opening an excluded approval did not reveal its session") + } +} + +func TestMonitorStatusFilterCloseAllAndPagingAreScoped(t *testing.T) { + m := statusFilterTestModel() + for i := range 12 { + m.monitorSessionData = append(m.monitorSessionData, monitorSession{id: fmt.Sprint("work-", i), displayed: true, working: true}) + } + m.setMonitorFilter(monitorFilterApproval) + m.scrollMonitorRows(100) + if m.monitorScroll != 0 { + t.Fatal("filtered scrolling counted hidden rows") + } + m.setMonitorFilter(monitorFilterWorking) + m.requestMonitorCloseAll() + if !m.monitorCloseAllArmed() || len(m.monitorCloseAllConfirm) != 13 { + t.Fatal("Close All confirmation did not capture all matching rows") + } + m.requestMonitorCloseAll() + if m.monitorListSessionCount() != 0 || m.visibleMonitorSessionCount() != 5 || !m.monitorSessionVisible(m.monitorSessionData[1]) { + t.Fatal("Close All dismissed sessions outside the filter") + } + if m.monitorRecordedTokens() != 625 { + t.Fatal("filter-scoped dismissal changed token measurements") + } +} + +func TestMonitorStatusFilterKeepsComposerAndApprovalKeysScoped(t *testing.T) { + m, _ := promptTestModel() + m.setRowContext("root-one", contextWide) + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("keep this ordinary draft") + if !m.monitorPrompt.input.Focused() { + t.Fatal("fixture needs a focused composer") + } + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + m = next.(Model) + if m.monitorFilter != monitorFilterAll || m.monitorSummaryFocused || m.rowContextMode("root-one") != contextWide || m.monitorPrompt.input.Value() != "keep this ordinary draft" { + t.Fatal("composer arrows changed filters or detail") + } + g := m.dashboardLayout() + a := m.monitorArea(g.contentWidth, g.meterHeight) + for _, b := range m.monitorFilterButtons(a.readoutWidth, a.topHeight) { + if b.action != monitorFilterAction(monitorFilterApproval) { + continue + } + next, _ = m.Update(tea.MouseClickMsg{X: 2 + b.rect.x, Y: g.meterY + b.rect.y, Button: tea.MouseLeft}) + m = next.(Model) + } + if m.monitorFilter != monitorFilterApproval || !m.monitorSummaryFocused || m.monitorDrafts["root-one"] != "keep this ordinary draft" { + t.Fatal("filter click did not release the composer without losing its draft") + } + m = approvalTestModel() + m.setMonitorFilter(monitorFilterAll) + next, cmd := m.Update(key('1')) + if cmd != nil || next.(Model).monitorApprovalConfirm != "" { + t.Fatal("approval shortcut targeted a hidden detail while filters owned focus") + } +} + +func TestMonitorStatusFilterDoesNotEvictFullReviewOnStateChange(t *testing.T) { + m := statusFilterTestModel() + m.setMonitorFilter(monitorFilterWorking) + m.setRowContext("work", contextFull) + m.syncMonitorSessions(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{ + {ID: "work", Active: true, Attention: codex.SessionAttentionComplete}, + }}, time.Now()) + if m.monitorContextDetail != "work" || m.monitorFilter != monitorFilterWorking { + t.Fatal("a state change closed a full-detail review unexpectedly") + } + m.stepBackMonitorContext() + if m.monitorContextDetail != "" || m.monitorFilter != monitorFilterAll || m.monitorContextExpanded != "work" { + t.Fatal("leaving full detail did not reveal the now-excluded row") + } +} + +func TestMonitorStatusFilterSelectedAndFocusedStyles(t *testing.T) { + for theme := themeHacker; theme < themeCount; theme++ { + m := statusFilterTestModel() + colors := paletteFor(theme) + m.setMonitorFilter(monitorFilterInput) + focused := m.monitorFilterTitleStyle(4, colors).Render("INPUT") + m.selectMonitorSession(1) + selected := m.monitorFilterTitleStyle(4, colors).Render("INPUT") + idle := m.monitorFilterTitleStyle(3, colors).Render("APPROVAL") + m.monitorContextHover = monitorFilterAction(monitorFilterApproval) + hovered := m.monitorFilterTitleStyle(3, colors).Render("APPROVAL") + if focused == selected || selected == colors.dimmed().Render("INPUT") || hovered == idle { + t.Fatalf("theme %d does not distinguish active, focused and hovered filters", theme) + } + } +} diff --git a/internal/ui/monitor_model_test.go b/internal/ui/monitor_model_test.go index 53c5f8f..615f9cd 100644 --- a/internal/ui/monitor_model_test.go +++ b/internal/ui/monitor_model_test.go @@ -11,6 +11,7 @@ import ( func TestMonitorSessionModelMetadataPriority(t *testing.T) { m := contextTestModel() + m.monitorStatusLine = []string{} // Legacy fallback without configured fields. s := m.monitorSessionData[0] s.modelSettings = codex.SessionModelSettings{Model: "gpt-6-astra", ReasoningEffort: "medium", ServiceTier: "priority"} s.workingDirectory = "/work/projects" diff --git a/internal/ui/monitor_name_test.go b/internal/ui/monitor_name_test.go index 13a83f2..750a0e4 100644 --- a/internal/ui/monitor_name_test.go +++ b/internal/ui/monitor_name_test.go @@ -113,9 +113,9 @@ func TestNamedSessionDirectoryDoesNotDisplaceStatus(t *testing.T) { if active { want = "ACTIVE" } - m := Model{} + m := Model{monitorState: monitorRunning} short := ansi.Strip(m.renderMonitorSessionMetrics(64, 4, s, "", paletteFor(themeHacker))) - if !strings.Contains(short, want) || !strings.Contains(short, "TOKENS") || strings.Contains(short, s.workingDirectory) { + if !strings.Contains(short, want) || !strings.Contains(short, "0 tokens") || strings.Contains(short, s.workingDirectory) { t.Fatalf("short row must retain tokens and %s before directory:\n%s", want, short) } tall := ansi.Strip(m.renderMonitorSessionMetrics(64, 8, s, "", paletteFor(themeHacker))) diff --git a/internal/ui/monitor_navigation.go b/internal/ui/monitor_navigation.go index 9c782cd..d274f99 100644 --- a/internal/ui/monitor_navigation.go +++ b/internal/ui/monitor_navigation.go @@ -19,7 +19,7 @@ func (m Model) monitorNavigationButtons(width int, id string, full bool) []monit if id == "" { count, selected := 0, -1 for _, s := range m.monitorSessionData { - if m.monitorSessionVisible(s) { + if m.monitorSessionInList(s) { if s.id == m.monitorSelectedID { selected = count } @@ -30,7 +30,7 @@ func (m Model) monitorNavigationButtons(width int, id string, full bool) []monit return nil } buttons = []monitorNavigationButton{ - {label: "[↑]", action: "session-up", enabled: count > 0 && selected != 0}, + {label: "[↑]", action: "session-up", enabled: count > 0 && !m.monitorSummaryFocused}, {label: "[↓]", action: "session-down", enabled: count > 0 && (selected < 0 || selected < count-1)}, {label: m.monitorPrivacyLabel(width - 8), action: "privacy", enabled: true}, } diff --git a/internal/ui/monitor_navigation_test.go b/internal/ui/monitor_navigation_test.go index cbfb9c8..c7ce3dc 100644 --- a/internal/ui/monitor_navigation_test.go +++ b/internal/ui/monitor_navigation_test.go @@ -88,7 +88,7 @@ func TestMonitorSessionNavigationBoundaries(t *testing.T) { } m.monitorSelectedID = "root-one" buttons := m.monitorNavigationButtons(100, "", false) - if buttons[0].enabled || !buttons[1].enabled { + if !buttons[0].enabled || !buttons[1].enabled { t.Fatal("first-session state") } m.dismissMonitorSession("root-three") diff --git a/internal/ui/monitor_statusline.go b/internal/ui/monitor_statusline.go index 836245f..7b5ed93 100644 --- a/internal/ui/monitor_statusline.go +++ b/internal/ui/monitor_statusline.go @@ -9,7 +9,7 @@ import ( ) func (m Model) statusLineMenu() codex.SessionCommandMenu { - menu := codex.SessionCommandMenu{Title: "/statusline", Path: "statusline", Multiple: true, Help: "Choose fields for Codexometer's detail footer. Space/Enter toggles; C applies; Escape cancels. Left/Right reorders. This does not change Codex CLI configuration. Missing telemetry is omitted."} + menu := codex.SessionCommandMenu{Title: "/statusline", Path: "statusline", Multiple: true, Help: "Choose priority fields for session telemetry boxes and the detail footer. Space/Enter toggles; C applies; Escape cancels. Left/Right reorders. This does not change Codex CLI configuration. Missing telemetry is omitted."} selected := statusline.Normalize(m.monitorStatusLine) fields := statusline.Fields() // Keep selected fields in display order, followed by the remaining fields. @@ -47,6 +47,12 @@ func (m Model) monitorStatusValues() map[string]string { if !ok { return nil } + return m.monitorStatusValuesFor(s) +} + +// Both the detail footer and every telemetry box use the same per-session +// values. Rendering an unselected row must never borrow the selected session. +func (m Model) monitorStatusValuesFor(s monitorSession) map[string]string { state := monitorSessionAttentionLabel(s) if state == "" { if s.working { @@ -61,6 +67,21 @@ func (m Model) monitorStatusValues() map[string]string { return statusline.Values(statusline.Data{Model: s.modelSettings.Model, Effort: s.modelSettings.ReasoningEffort, Speed: s.modelSettings.ServiceTier, Directory: s.workingDirectory, Name: s.name, ID: s.id, State: state, Source: s.preview.Source, Tokens: s.latest, Agents: s.agentCount}) } +func (m Model) monitorPriorityFields(s monitorSession) ([]string, map[string]bool) { + values := m.monitorStatusValuesFor(s) + selected := make(map[string]bool) + var lines []string + for _, id := range statusline.Normalize(m.monitorStatusLine) { + if value := values[id]; value != "" { + selected[id] = true + if id != "run-state" { // The essential state line is already first. + lines = append(lines, value) + } + } + } + return lines, selected +} + func (m Model) monitorStatusLineText(width int, copyLabel string) string { end := width - 2 if copyLabel != "" { diff --git a/internal/ui/monitor_statusline_test.go b/internal/ui/monitor_statusline_test.go index b748f2f..f014b78 100644 --- a/internal/ui/monitor_statusline_test.go +++ b/internal/ui/monitor_statusline_test.go @@ -11,6 +11,7 @@ import ( uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" ) func TestStatusLineMultiSelectReorderCancelAndPersist(t *testing.T) { @@ -63,6 +64,59 @@ func TestStatusLineMultiSelectReorderCancelAndPersist(t *testing.T) { } } +func TestStatusLinePrioritisesPerSessionTelemetry(t *testing.T) { + for theme := themeHacker; theme < themeCount; theme++ { + m := contextTestModel() + m.monitorSelectedID = "root-one" + m.monitorStatusLine = []string{"current-dir", "model-with-reasoning", "fast-mode", "used-tokens", "source", "agents", "run-state"} + s := m.monitorSessionData[1] + s.workingDirectory, s.name = "/work/bravo", "Bravo" + s.modelSettings = codex.SessionModelSettings{Model: "bravo-model", ReasoningEffort: "high", ServiceTier: "fast"} + s.latest, s.baseline, s.agentCount = 1200, 1100, 2 + colors := paletteFor(theme) + out := m.renderMonitorSessionMetrics(80, 14, s, "", colors) + lines := strings.Split(ansi.Strip(out), "\n") + body := strings.Join(lines[1:len(lines)-1], "\n") + before := -1 + for _, value := range []string{"/work/bravo", "bravo-model high", "fast", "1.2K tokens", "LOCAL", "2 agents"} { + at := strings.Index(body, value) + if at <= before { + t.Fatalf("configured order not respected for %q:\n%s", value, body) + } + before = at + if strings.Count(body, value) != 1 { + t.Fatalf("duplicate configured field %q", value) + } + for _, cells := range backgroundTestLines(out) { + var text strings.Builder + for _, cell := range cells { + text.WriteString(cell.Content) + } + if index := strings.Index(text.String(), value); index >= 0 { + column := ansi.StringWidth(text.String()[:index]) + for _, cell := range cells[column : column+ansi.StringWidth(value)] { + if cell.Style.Attrs&uv.AttrBold == 0 { + t.Fatalf("configured value %q is not bold", value) + } + } + } + } + } + if strings.Count(body, i18n.Text("WORKING")) != 1 || strings.Contains(body, "100 TOKENS") { + t.Fatal("state or token field was repeated", body) + } + compact := ansi.Strip(m.renderMonitorSessionMetrics(40, 5, s, "ROWS 1-2/3", colors)) + if !strings.Contains(compact, "/work/bravo") || !strings.Contains(compact, "bravo-model high") || !strings.Contains(strings.Split(compact, "\n")[4], "ROWS 1-2/3") { + t.Fatal("low-priority paging displaced configured fields", compact) + } + m.monitorStatusLine = []string{} + fallback := ansi.Strip(m.renderMonitorSessionMetrics(80, 8, s, "", colors)) + if !strings.Contains(fallback, i18n.Format("%s TOKENS // %.0f%% LOCAL", formatTokens(100), float64(0))) || !strings.Contains(fallback, "bravo-model high fast") { + t.Fatal("empty selection did not retain ordinary telemetry", fallback) + } + } +} + func TestStatusLineFooterPreservesCopyGeometry(t *testing.T) { for _, width := range []int{24, 60, 120, 180} { m := completedCopyModel() diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index bea93b4..914c706 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -24,17 +24,8 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { continue } counts[0]++ - switch s.attention { - case codex.SessionAttentionApproval: - counts[2]++ - case codex.SessionAttentionInput: - counts[3]++ - case codex.SessionAttentionCheck: - counts[4]++ - case codex.SessionAttentionNone: - if s.working { - counts[1]++ - } + if status := monitorSessionStatus(s); status != monitorFilterAll { + counts[status]++ } } labels := []string{"TOKENS", "SESSIONS", "WORKING", "APPROVAL", "INPUT", "CHECK"} @@ -46,14 +37,7 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { } values = append(values, value) } - columns := 6 - if width < 78 && rows >= 4 { - columns = 3 - } - if width < 44 && rows >= 6 { - columns = 2 - } - cell := max((width-(columns-1))/columns, 1) + columns, cell := monitorSummaryColumns(width, rows) var lines []string for start := 0; start < 6; start += columns { var titles, numbers []string @@ -62,7 +46,7 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { if i == 5 { title += "*" } // inferred, never an authoritative input request - titles = append(titles, colors.dimmed().Render(ansi.Truncate(title, cell, "…")+strings.Repeat(" ", max(cell-lipgloss.Width(ansi.Truncate(title, cell, "…")), 0)))) + titles = append(titles, m.monitorFilterTitleStyle(i, colors).Render(ansi.Truncate(title, cell, "…")+strings.Repeat(" ", max(cell-lipgloss.Width(ansi.Truncate(title, cell, "…")), 0)))) style := colors.label().Bold(true) if i >= 2 { style = colors.dimmed().Bold(true) @@ -458,7 +442,7 @@ func (m Model) monitorDetailHeader(width, height int) (summary, rows int, button // Reserve overview padding only if every visible session would still receive // at least eleven rows, including its borders. func (m Model) monitorAttentionHasRoom(width, height int) bool { - count := m.visibleMonitorSessionCount() + count := m.monitorListSessionCount() return count > 0 && (layoutMonitorArea(width, height).graphHeight-3)/count >= 11 } diff --git a/internal/ui/monitor_test.go b/internal/ui/monitor_test.go index 31c87d1..e941164 100644 --- a/internal/ui/monitor_test.go +++ b/internal/ui/monitor_test.go @@ -277,6 +277,7 @@ func TestMonitorAttributesObservedQuotaMovementByLocalTokenShare(t *testing.T) { } alpha := model.monitorSessionData[model.monitorSessionIndex("alpha")] bravo := model.monitorSessionData[model.monitorSessionIndex("bravo")] + model.monitorStatusLine = []string{} // Exercise baseline/share fallback telemetry. alphaView := ansi.Strip(model.renderMonitorSessionMetrics(52, 8, alpha, "", paletteFor(themeHacker))) bravoView := ansi.Strip(model.renderMonitorSessionMetrics(52, 8, bravo, "", paletteFor(themeHacker))) for view, wants := range map[string][]string{ From 4800d713e6f5a0af0750aa0ca00c5efc8ef7d714 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 18:32:59 +0100 Subject: [PATCH 02/12] FEAT: browse slash commands in composer-anchored popup --- README.md | 15 ++- internal/ui/localisation_test.go | 1 + internal/ui/monitor_command_popup.go | 54 ++++++++ internal/ui/monitor_command_popup_test.go | 144 ++++++++++++++++++++++ internal/ui/monitor_commands.go | 97 ++++++++++----- internal/ui/monitor_context.go | 3 - internal/ui/monitor_statusline.go | 2 +- internal/ui/monitor_statusline_test.go | 8 +- internal/ui/monitor_suggestions.go | 40 +++--- internal/ui/view.go | 2 +- 10 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 internal/ui/monitor_command_popup.go create mode 100644 internal/ui/monitor_command_popup_test.go diff --git a/README.md b/README.md index aa2deee..e5b4865 100644 --- a/README.md +++ b/README.md @@ -1645,8 +1645,8 @@ Up/Down selects, Tab completes the name, Enter opens its options and Escape dismisses suggestions while retaining the draft and typing focus. Suggestions are fetched asynchronously and filtered locally. They also work in a selected wide terminal session row when space permits. Outside the full-detail composer, -press `/` to open the full command browser. Use arrows and Enter to browse, **C** to confirm a -setting change. **Back** (Left) returns to the previous visited command screen; +press `/` to open the root command list. Use arrows and Enter to browse, **C** to confirm a +setting change. **Back** (Left) returns to the previous visited command level; it appears only when there is a previous screen or a provisional review to undo. Opening `/fast` directly starts at its options, so it has no Back action until reviewing. **Cancel** (Escape) closes the entire menu and returns typing focus @@ -1703,6 +1703,17 @@ footer text shortens to fit and leaves the Copy button in place. A blank row separates a visible terminal status line from the global footer; very short terminals omit the status line and gap to preserve the composer and controls. +Terminal command menus now use the same bottom-anchored popup as slash-command +suggestions: selecting a command replaces that list with its live options instead +of replacing the conversation. The composer stays in place, including in a wide +session row. **Up/Down** selects, **Enter** opens or reviews, **Left/Backspace** +goes back, and **Escape** closes the popup while retaining the draft. Option help +stays subdued on one line, shortened with an ellipsis when needed. Review and +**C CONFIRM** remain separate for model, reasoning, speed, name and directory +changes; shrinking the terminal cannot confirm a hidden control. Clicking back +into the composer closes the popup and resumes typing. `/commands` also opens +the root command list, including local commands without a shared daemon. + Codex does not expose a general slash-command discovery/execution API. These are explicit adapters to supported app-server catalogues, not a reproduction of every CLI command. Options and help are fetched on demand, not hard-coded or diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 66dcee2..5cfb75f 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -71,6 +71,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_copy_surfaces", TestMonitorCopySurfaces) t.Run("monitor_attention_surfaces", TestMonitorAttentionRenderedHitSurfaces) t.Run("monitor_filter_surfaces", TestMonitorStatusFilterRenderedHitSurfaces) + t.Run("monitor_command_popup_surfaces", TestMonitorCommandPopupRenderedSurfaces) t.Run("monitor_attention_padding", TestMonitorAttentionTallPadding) t.Run("monitor_bottom_composer", TestMonitorDetailBottomRowsFocusComposer) t.Run("monitor_model_metadata", TestMonitorSessionModelMetadataPriority) diff --git a/internal/ui/monitor_command_popup.go b/internal/ui/monitor_command_popup.go new file mode 100644 index 0000000..36143f8 --- /dev/null +++ b/internal/ui/monitor_command_popup.go @@ -0,0 +1,54 @@ +package ui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// Nested command menus share the slash suggestions' bottom anchor. Grow upward +// with the choices, but leave conversation visible above the popup. +func (m Model) monitorCommandPopup() monitorRect { + if !m.monitorCommands.open || m.meterView != viewMonitor || m.monitorContextTarget() != m.monitorCommands.session { + return monitorRect{} + } + area := m.monitorComposerPopupArea() + if area.height < 5 { + return monitorRect{} + } + wanted := len(m.monitorCommands.menu.Choices) + 5 + if m.monitorCommands.menu.Multiple || m.monitorCommands.reviewedChoice != "" { + wanted++ + } + if m.monitorCommands.detail || m.monitorCommands.notice != "" { + wanted = area.height + } else if m.monitorCommands.menu.Input { + wanted = 8 + } else if m.monitorCommands.busy { + wanted = 5 + } + height := min(max(wanted, 4), area.height-1, max(area.height/2, 4)) + return monitorRect{x: area.x, y: area.y + area.height - height, width: area.width, height: height} +} + +func monitorCommandContentRows(height int) int { + rows := height - 3 // borders and the action row + if height >= 6 { + rows-- // optional keyboard hint + } + return max(rows, 0) +} + +func renderMonitorPopup(title, body string, width int, colors palette) string { + border := lipgloss.NormalBorder() + box := lipgloss.NewStyle().Border(border).BorderForeground(colors.primary). + Background(colors.background).BorderBackground(colors.background).Width(width).Render(body) + if title == "" || width < 5 { + return box + } + lines := strings.Split(box, "\n") + title = ansi.Truncate(terminalLabel(title), width-4, "…") + lines[0] = colors.label().Background(colors.background).Render(border.TopLeft + " " + title + " " + strings.Repeat(border.Top, max(width-4-ansi.StringWidth(title), 0)) + border.TopRight) + return strings.Join(lines, "\n") +} diff --git a/internal/ui/monitor_command_popup_test.go b/internal/ui/monitor_command_popup_test.go new file mode 100644 index 0000000..218844e --- /dev/null +++ b/internal/ui/monitor_command_popup_test.go @@ -0,0 +1,144 @@ +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +func TestMonitorCommandPopupPreservesContextComposerAndRowMode(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + for _, size := range [][2]int{{80, 30}, {120, 40}, {180, 65}} { + m, f := suggestionTestModel(t) + m.width, m.height = size[0], size[1] + m.monitorSessionData = m.monitorSessionData[:1] + m.monitorSessionData[0].preview.Text = "CONVERSATION STAYS VISIBLE" + m.setRowContext("root-one", mode) + m.focusMonitorPrompt() + before := m.monitorComposerPopupArea() + suggestions, _ := m.monitorSuggestionPopup() + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatal("no submenu fetch") + } + popup := m.monitorCommandPopup() + if popup.height == 0 || popup.y+popup.height != suggestions.y+suggestions.height || before != m.monitorComposerPopupArea() || m.rowContextMode("root-one") != mode { + t.Fatalf("%v mode %d moved composer or forced full detail: before=%+v after=%+v popup=%+v", size, mode, before, m.monitorComposerPopupArea(), popup) + } + next, _ = m.Update(cmd()) + m = next.(Model) + out := ansi.Strip(m.render()) + if !strings.Contains(out, "CONVERSATION STAYS VISIBLE") || !strings.Contains(out, "Medium") || f.calls != 0 { + t.Fatalf("submenu replaced context or applied a command: %s", out) + } + if lipgloss.Width(out) > m.width || lipgloss.Height(out) > m.height { + t.Fatalf("popup exceeds viewport at %v mode %d", size, mode) + } + m, _, _ = m.chooseMonitorCommand() + if !m.monitorCommands.detail || m.rowContextMode("root-one") != mode || m.monitorComposerPopupArea() != before { + t.Fatal("review moved away from its bottom popup") + } + m, _, _ = m.updateMonitorCommands(tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.monitorCommands.detail || !m.monitorCommands.open { + t.Fatal("Back did not return to the options in place") + } + cmd = m.backMonitorCommands() + if cmd == nil { + t.Fatal("drilling in from suggestions did not retain the root level") + } + next, _ = m.Update(cmd()) + m = next.(Model) + if m.monitorCommands.menu.Path != "" || !m.monitorCommands.open || m.monitorComposerPopupArea() != before { + t.Fatal("Back did not return to the root list in place") + } + } + } +} + +func TestMonitorCommandPopupRenderedSurfaces(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + for _, size := range [][2]int{{80, 30}, {120, 40}, {180, 65}} { + m, f := commandTestModel(t) + m.width, m.height = size[0], size[1] + m.monitorSessionData = m.monitorSessionData[:1] + m.monitorSessionData[0].preview.Text = "Visible context" + m.setRowContext("root-one", mode) + popup := m.monitorCommandPopup() + if popup.height == 0 { + t.Fatal("popup missing") + } + x, y := renderedTextStart(t, m, "Medium") + if !popup.contains(x, y) { + t.Fatal("choice rendered outside the popup") + } + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if !m.monitorCommands.detail || f.calls != 0 { + t.Fatal("choice click failed to review, or applied early") + } + x, y = renderedTextStart(t, m, i18n.Text("[ C CONFIRM ]")) + next, _ = m.Update(tea.MouseMotionMsg{X: x, Y: y}) + m = next.(Model) + if m.monitorCommands.footerHover != "confirm" { + t.Fatal("confirm hover missed its rendered surface") + } + next, cmd := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + if cmd == nil || !next.(Model).monitorCommands.busy { + t.Fatal("confirm click missed its rendered surface") + } + cmd() + if f.calls != 1 { + t.Fatal("confirmation did not apply exactly once") + } + } + } +} + +func TestMonitorCommandPopupOutsideComposerClickDismissesSafely(t *testing.T) { + m, f := suggestionTestModel(t) + m.monitorPrompt.input.SetValue("/model") + m, cmd, _ := m.openMonitorCommands("model") + next, _ := m.Update(cmd()) + m = next.(Model) + area := m.monitorComposerPopupArea() + popup := m.monitorCommandPopup() + x, y := area.x+1, area.y+area.height + if popup.contains(x, y) { + t.Fatal("fixture click must be in the composer, not the popup") + } + next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.monitorCommands.open || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/model" || f.calls != 0 { + t.Fatal("composer click did not close the popup and preserve its draft") + } + next, _ = m.Update(key('c')) + if next.(Model).monitorPrompt.input.Value() != "/modelc" || f.calls != 0 { + t.Fatal("typing resumed as a hidden command confirmation") + } +} + +func TestMonitorCommandPopupPreventsHiddenConfirmationAndSupportsLocalAlias(t *testing.T) { + m, f := commandTestModel(t) + m, _, _ = m.chooseMonitorCommand() + m.height = 8 + _, cmd, _ := m.confirmMonitorCommand() + if cmd != nil || f.calls != 0 { + t.Fatal("shrinking the terminal allowed an invisible confirmation") + } + m = localCommandModel() + m, cmd, _ = m.openMonitorCommands("/commands") + if cmd != nil || !m.monitorCommands.open || len(m.monitorCommands.menu.Choices) == 0 || m.monitorCommands.menu.Path != "" { + t.Fatal("/commands did not open the local root menu") + } + m.monitorCommands.menu.Choices = []codex.SessionCommandChoice{{ID: "long", Label: "Only command", Help: strings.Repeat("single-line-help ", 40)}} + rows := m.monitorCommandRows(60, 8) + if len(rows) != 1 || rows[0].help == "" { + t.Fatal("list help consumed extra rows") + } +} diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index 555f080..b4db5bf 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -49,6 +49,9 @@ func (m *Model) loadMonitorCommands(path string) tea.Cmd { p.reviewedChoice = "" p.until = time.Time{} path = strings.TrimPrefix(strings.TrimSpace(path), "/") + if path == "commands" { + path = "" + } p.menu.Path = path if menu, local := m.localMonitorMenu(path); local { p.input.Blur() @@ -154,6 +157,9 @@ func (m *Model) cancelMonitorCommands() tea.Cmd { func (m Model) openMonitorCommands(path string) (Model, tea.Cmd, bool) { path = strings.TrimPrefix(strings.TrimSpace(path), "/") + if path == "commands" { + path = "" + } if updated, cmd, local := m.runLocalMonitorCommand(path); local { return updated, cmd, true } @@ -165,9 +171,19 @@ func (m Model) openMonitorCommands(path string) (Model, tea.Cmd, bool) { if id == "" { return m, nil, true } + var history []monitorCommandLevel + if path != "" && m.suggestionsActive() && m.monitorSuggestions.session == id { + for i, choice := range m.monitorSuggestions.menu.Choices { + if choice.Next == path { + history = []monitorCommandLevel{{path: "", selected: i}} + break + } + } + } m.monitorPrompt.input.Blur() - m.monitorContextDetail = id - m.monitorCommands = monitorCommandsState{open: true, session: id, request: m.monitorCommands.request, hover: -1} + m.monitorApprovalConfirm = "" + m.clearQuotaConfirmation() + m.monitorCommands = monitorCommandsState{open: true, session: id, request: m.monitorCommands.request, hover: -1, history: history} cmd := m.loadMonitorCommands(path) return m, cmd, true } @@ -221,14 +237,25 @@ func (m Model) chooseMonitorCommand() (Model, tea.Cmd, bool) { func (m Model) confirmMonitorCommand() (Model, tea.Cmd, bool) { p := &m.monitorCommands + if p.menu.Picker && !p.until.IsZero() && time.Now().After(p.until) { + p.reviewedChoice = "" + } + popup := m.monitorCommandPopup() + visible := false + for _, button := range m.commandFooter(popup.width) { + visible = visible || button.action == "confirm" + } + if !p.open || popup.height < 4 || !visible { + return m, nil, true + } if p.open && p.menu.Multiple && p.menu.Path == "statusline" { m.monitorStatusLine = statusline.Normalize(statusLineSelection(p.menu)) if codex.IsSessionCommand(m.monitorPrompt.input.Value()) { m.monitorPrompt.input.Reset() } m.persistPreferences() - m.closeMonitorCommands() - return m, nil, true + cmd := m.finishLocalMonitorCommand() + return m, cmd, true } o, ok := m.commandChoice() reviewed := p.detail || (p.menu.Picker && p.reviewedChoice == o.ID) @@ -260,6 +287,7 @@ func (m Model) confirmMonitorCommand() (Model, tea.Cmd, bool) { type commandDisplayRow struct { text string + help string choice int action string } @@ -286,7 +314,9 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { if label == "" { label = "Session name" } - rows = append(rows, commandDisplayRow{text: strings.ToUpper(label), choice: -1}) + if capacity > 1 { + rows = append(rows, commandDisplayRow{text: strings.ToUpper(label), choice: -1}) + } p.input.SetWidth(max(w-2, 1)) rows = append(rows, commandDisplayRow{text: p.input.View(), choice: -1}) for _, line := range strings.Split(ansi.Hardwrap(p.menu.Help, w, true), "\n") { @@ -340,6 +370,9 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { reasonRows = reasonRows[:min(len(reasonRows), limit)] } choiceCapacity := capacity - len(reasonRows) + if p.menu.Picker && p.reviewedChoice != "" && choiceCapacity > 1 { + choiceCapacity-- + } start := max(p.selected-choiceCapacity+1, 0) for i := start; i < min(start+choiceCapacity, len(p.menu.Choices)); i++ { o := p.menu.Choices[i] @@ -352,7 +385,7 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { if o.Selected { mark = "[x] " } - label = mark + label + " // " + o.Help + label = mark + label } else if o.Next != "" && o.Unavailable == "" && p.menu.Path != "theme" { label += " →" } else if o.Unavailable != "" { @@ -364,7 +397,7 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { } else if !o.Action && o.Next == "" { label += " // HELP" } - rows = append(rows, commandDisplayRow{text: ansi.Truncate(label, w, "…"), choice: i, action: "choose"}) + rows = append(rows, commandDisplayRow{text: ansi.Truncate(label, w, "…"), help: o.Help, choice: i, action: "choose"}) } if len(rows) == 0 && len(reasonRows) == 0 { rows = append(rows, commandDisplayRow{text: "No available options. Use Codex for unsupported commands.", choice: -1}) @@ -384,17 +417,6 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { } } - if (p.menu.Picker || p.menu.Path == "" || p.menu.Path == "help") && len(rows) < capacity { - if o, ok := m.commandChoice(); ok { - rows = append(rows, commandDisplayRow{choice: -1}) - for _, line := range strings.Split(ansi.Hardwrap(o.Help, w, true), "\n") { - if len(rows) >= capacity { - break - } - rows = append(rows, commandDisplayRow{text: line, choice: -1}) - } - } - } if p.menu.Multiple { preview := statusline.Text(statusLineSelection(p.menu), m.monitorStatusValues()) if preview == "" { @@ -452,7 +474,7 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { rows := m.monitorCommandRows(width, height) body := make([]string, max(height-2, 1)) for i, r := range rows { - if i >= len(body)-2 { + if i >= monitorCommandContentRows(height) { break } style := colors.label() @@ -469,7 +491,12 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { } } } - body[i] = style.Render(ansi.Truncate(text, max(width-4, 1), "")) + text = ansi.Truncate(text, max(width-4, 1), "") + body[i] = style.Render(text) + if r.help != "" { + help := ansi.Truncate(" // "+strings.Join(strings.Fields(r.help), " "), max(width-4-ansi.StringWidth(text), 0), "…") + body[i] += colors.dimmed().Render(help) + } } var footer []string for _, b := range m.commandFooter(width) { @@ -479,7 +506,7 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { } footer = append(footer, style.Render(b.text)) } - if len(body) > 1 { + if len(body) > 0 { hint := "↑/↓ select" if p.menu.Input { hint = "" @@ -488,7 +515,9 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { } else if p.menu.Multiple { hint = "↑/↓ select • Space toggle • ←/→ order" } - body[len(body)-2] = colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "")) + if height >= 6 { + body[len(body)-2] = colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "")) + } body[len(body)-1] = strings.Join(footer, " ") } identity := shortSessionID(p.session) @@ -496,7 +525,10 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { identity = monitorSessionIdentity(s) } title := fmt.Sprintf("%s // %s", p.menu.Title, identity) - return frameSizedWithActions(width, max(height-2, 1), title, "", "", strings.Join(body, "\n"), colors.primary, colors) + for i := range body { + body[i] = " " + body[i] + " " + } + return renderMonitorPopup(title, strings.Join(body, "\n"), width, colors) } func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { @@ -696,12 +728,17 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, true } if mouse, ok := msg.(tea.MouseMsg); ok { - g := m.monitorDashboardLayout() + popup := m.monitorCommandPopup() v := mouse.Mouse() - x, y := v.X-4, v.Y-g.meterY-1 - if x < 0 || x >= g.contentWidth-4 || y < 0 || y >= g.meterHeight-2 { + if popup.height == 0 || !popup.contains(v.X, v.Y) { + p.hover, p.footerHover = -1, "" + if _, click := msg.(tea.MouseClickMsg); click && v.Button == tea.MouseLeft { + m.closeMonitorCommands() + m.monitorSuggestions = monitorSuggestionState{request: m.monitorSuggestions.request + 1, dismissed: m.monitorPrompt.input.Value()} + } return m, nil, false } + x, y := v.X-popup.x-2, v.Y-popup.y-1 if v.Button == tea.MouseWheelUp || v.Button == tea.MouseWheelDown { d := 3 if v.Button == tea.MouseWheelUp { @@ -718,11 +755,11 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { } return m, nil, true } - rows := m.monitorCommandRows(g.contentWidth, g.meterHeight) + rows := m.monitorCommandRows(popup.width, popup.height) p.hover = -1 p.footerHover = "" _, click := msg.(tea.MouseClickMsg) - if y < len(rows) && rows[y].action == "choose" { + if x >= 0 && y >= 0 && y < min(len(rows), monitorCommandContentRows(popup.height)) && rows[y].action == "choose" { p.hover = rows[y].choice if click && v.Button == tea.MouseLeft { if p.menu.Picker { @@ -736,8 +773,8 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { return m.chooseMonitorCommand() } } - if y == g.meterHeight-3 { - for _, b := range m.commandFooter(g.contentWidth) { + if y == popup.height-3 { + for _, b := range m.commandFooter(popup.width) { w := ansi.StringWidth(b.text) if x >= 0 && x < w { p.footerHover = b.action diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index be5c868..8784c74 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -188,9 +188,6 @@ func (m Model) contextDetailSession() (monitorSession, bool) { } func (m Model) renderMonitorContextDetail(width, height int, colors palette) string { - if m.monitorCommands.open { - return m.renderMonitorCommands(width, height, colors) - } if m.monitorQueue.open { return m.renderQueueEditor() } diff --git a/internal/ui/monitor_statusline.go b/internal/ui/monitor_statusline.go index 7b5ed93..be8070c 100644 --- a/internal/ui/monitor_statusline.go +++ b/internal/ui/monitor_statusline.go @@ -98,7 +98,7 @@ func (m Model) monitorStatusLineText(width int, copyLabel string) string { } func (m Model) monitorStatusLineVisible(width int) bool { - if m.meterView != viewMonitor || m.monitorContextDetail == "" || m.contextTargetHidden() || m.monitorCommands.open || m.monitorQueue.open { + if m.meterView != viewMonitor || m.monitorContextDetail == "" || m.contextTargetHidden() || m.monitorQueue.open { return false } return m.monitorStatusLineText(width, m.renderMonitorCopy(width, m.monitorContextDetail, paletteFor(m.theme))) != "" diff --git a/internal/ui/monitor_statusline_test.go b/internal/ui/monitor_statusline_test.go index f014b78..13555c8 100644 --- a/internal/ui/monitor_statusline_test.go +++ b/internal/ui/monitor_statusline_test.go @@ -181,13 +181,13 @@ func TestStatusLineReservesFooterGapOnlyWhenDisplayed(t *testing.T) { m.setRowContext("root-one", contextWide) } g := m.dashboardLayout() - wantGap := (scenario == "shown" || scenario == "scheduler") && base.meterHeight >= 9 + wantGap := (scenario == "shown" || scenario == "scheduler" || scenario == "commands") && base.meterHeight >= 9 if g.footerSpacer != wantGap || g.footerY != base.footerY { t.Fatalf("%v %s: incorrect footer geometry: %+v", size, scenario, g) } - // Modal editors have their own setup and rendering tests; the - // scheduler retains the status line to keep its composer anchored. - if scenario == "commands" || scenario == "scheduler" || scenario == "queue" { + // Editors have separate rendering tests. Command popups no longer + // replace detail or remove the status line and its footer gap. + if scenario == "scheduler" || scenario == "queue" { continue } view := ansi.Strip(m.render()) diff --git a/internal/ui/monitor_suggestions.go b/internal/ui/monitor_suggestions.go index 77bdf2d..998f295 100644 --- a/internal/ui/monitor_suggestions.go +++ b/internal/ui/monitor_suggestions.go @@ -75,6 +75,14 @@ func (m Model) suggestionChoices() []codex.SessionCommandChoice { } func (m Model) withMonitorSuggestions(view string, colors palette) string { + if m.monitorCommands.open { + popup := m.monitorCommandPopup() + if popup.height == 0 { + return view + } + box := m.renderMonitorCommands(popup.width, popup.height, colors) + return lipgloss.NewCompositor(lipgloss.NewLayer(view), lipgloss.NewLayer(box).X(popup.x).Y(popup.y).Z(1)).Render() + } popup, start := m.monitorSuggestionPopup() if popup.height == 0 { return view @@ -103,8 +111,7 @@ func (m Model) withMonitorSuggestions(view string, colors palette) string { lines = append(lines, lipgloss.NewStyle().Background(colors.background).Width(popup.width-2).Render(row)) } } - box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(colors.primary). - Background(colors.background).BorderBackground(colors.background).Width(popup.width).Render(strings.Join(lines, "\n")) + box := renderMonitorPopup("", strings.Join(lines, "\n"), popup.width, colors) return lipgloss.NewCompositor(lipgloss.NewLayer(view), lipgloss.NewLayer(box).X(popup.x).Y(popup.y).Z(1)).Render() } @@ -196,9 +203,24 @@ func (m Model) monitorSuggestionPopup() (popup monitorRect, start int) { if !m.suggestionsActive() || m.monitorSuggestions.session != m.monitorPrompt.session { return } + area := m.monitorComposerPopupArea() + choices := m.suggestionChoices() + count := min(max(len(choices), 1), max(area.height-3, 0)) + if count == 0 { + return + } + width := min(area.width, 44) + for _, o := range choices { + width = min(area.width, max(width, ansi.StringWidth(o.Label+" // "+strings.Join(strings.Fields(o.Help), " "))+4)) + } + start = max(min(m.monitorSuggestions.selected, max(len(choices)-1, 0))-count+1, 0) + return monitorRect{x: area.x, y: area.y + area.height - count - 2, width: width, height: count + 2}, start +} + +func (m Model) monitorComposerPopupArea() (area monitorRect) { w, h := m.monitorPromptSize() rows := m.monitorPromptRows(w, h) - if rows == 0 || w < 12 { + if w < 12 || h < 4 { return } g := m.monitorDashboardLayout() @@ -223,17 +245,7 @@ func (m Model) monitorSuggestionPopup() (popup monitorRect, start int) { } } _, _, cy := monitorContextBodyLayout(h, rows) - choices := m.suggestionChoices() - count := min(max(len(choices), 1), max(cy-3, 0)) - if count == 0 { - return - } - width := min(w-4, 44) - for _, o := range choices { - width = min(w-4, max(width, ansi.StringWidth(o.Label+" // "+strings.Join(strings.Fields(o.Help), " "))+4)) - } - start = max(min(m.monitorSuggestions.selected, max(len(choices)-1, 0))-count+1, 0) - return monitorRect{x: x, y: y + cy - count - 2, width: width, height: count + 2}, start + return monitorRect{x: x, y: y, width: w - 4, height: cy} } func (m Model) monitorSuggestionAt(x, y int) int { diff --git a/internal/ui/view.go b/internal/ui/view.go index f1d4fd3..c950e62 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -206,7 +206,7 @@ func (m Model) renderFooter(width int, colors palette) string { } } status := colors.dimmed().Render(ansi.Truncate(left, width, "")) - if m.meterView == viewMonitor { + if m.meterView == viewMonitor && !m.monitorCommands.open && !m.monitorSummaryFocused { hint := i18n.Text("←/→ DETAIL") if lipgloss.Width(status)+2+lipgloss.Width(hint) <= width { status = joinRight(status, colors.dimmed().Render(hint), width) From 8a4d13b42b20397f8ea9d8be380b0d16b74f42ba Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 19:31:05 +0100 Subject: [PATCH 03/12] FIX: keep slash command navigation in composer expansion --- README.md | 41 +-- internal/ui/localisation_test.go | 3 + internal/ui/monitor_command_popup.go | 25 ++ internal/ui/monitor_command_popup_test.go | 10 +- internal/ui/monitor_commands.go | 154 +++++++++-- internal/ui/monitor_commands_inline.go | 180 ++++++++++++ internal/ui/monitor_commands_inline_test.go | 292 ++++++++++++++++++++ internal/ui/monitor_commands_test.go | 3 +- 8 files changed, 656 insertions(+), 52 deletions(-) create mode 100644 internal/ui/monitor_commands_inline.go create mode 100644 internal/ui/monitor_commands_inline_test.go diff --git a/README.md b/README.md index e5b4865..46d5ebc 100644 --- a/README.md +++ b/README.md @@ -1644,20 +1644,22 @@ lists scroll within the available space (arrows or mouse wheel in the terminal). Up/Down selects, Tab completes the name, Enter opens its options and Escape dismisses suggestions while retaining the draft and typing focus. Suggestions are fetched asynchronously and filtered locally. They also work in a selected -wide terminal session row when space permits. Outside the full-detail composer, -press `/` to open the root command list. Use arrows and Enter to browse, **C** to confirm a -setting change. **Back** (Left) returns to the previous visited command level; -it appears only when there is a previous screen or a provisional review to undo. -Opening `/fast` directly starts at its options, so it has no Back action until -reviewing. **Cancel** (Escape) closes the entire menu and returns typing focus -to the composer with the slash-command draft intact. Back from a rename or -directory review restores the edited input. Speed menus -keep all options visible: Up/Down highlights a choice, **Enter REVIEW** stages it, +wide terminal session row when space permits. In terminal full detail, pressing +`/` also focuses the composer and starts this expansion when a composer is +available; read-only sessions retain a command picker for local display settings. +Submenus stay in that same expanding list above the command line: type to filter +the current level, Up/Down selects, Tab completes and Enter drills in or reviews. +**Alt+Left** or the **Back** button returns one level (Left/Backspace also works +when the option filter is empty). **Escape** closes the list, retaining the draft +and typing focus. Back from a rename or directory review restores the edited +argument in the composer. Speed menus +initially show all options: Up/Down highlights a choice, **Enter REVIEW** stages it, and **C CONFIRM** applies it. The separate **CURRENT** marker identifies the existing speed; the highlighted choice uses bright text without a reversed background. The action line shows only the current next step, Enter Review or C Confirm, without a second confirmation hint. Settings blocked during an active turn show -**WAITING FOR IDLE**, with **F REFRESH** to read their availability again; +**WAITING FOR IDLE**, with **Ctrl+R REFRESH** in the terminal composer +(**F REFRESH** in the read-only picker) to read their availability again; **HELP** identifies genuine browse-only entries. Model, reasoning and speed changes are provisional until C; reviewing or changing the highlight sends no settings update. Buttons are clickable. Writable web mode exposes the same catalogue through @@ -1703,16 +1705,15 @@ footer text shortens to fit and leaves the Copy button in place. A blank row separates a visible terminal status line from the global footer; very short terminals omit the status line and gap to preserve the composer and controls. -Terminal command menus now use the same bottom-anchored popup as slash-command -suggestions: selecting a command replaces that list with its live options instead -of replacing the conversation. The composer stays in place, including in a wide -session row. **Up/Down** selects, **Enter** opens or reviews, **Left/Backspace** -goes back, and **Escape** closes the popup while retaining the draft. Option help -stays subdued on one line, shortened with an ellipsis when needed. Review and -**C CONFIRM** remain separate for model, reasoning, speed, name and directory -changes; shrinking the terminal cannot confirm a hidden control. Clicking back -into the composer closes the popup and resumes typing. `/commands` also opens -the root command list, including local commands without a shared daemon. +The terminal `/statusline` checkbox list uses the same composer expansion, but +deliberately does not filter: **Up/Down** selects, **Space** toggles, **Left/Right** +reorders and **Enter** applies the local display preferences. Escape discards +unapplied changes. Other menu levels keep help subdued on one line, shortened +with an ellipsis when needed. Model, reasoning, speed, name and directory changes +still require separate review and **C CONFIRM**; shrinking the terminal cannot +confirm a hidden control. The composer and conversation remain in place through +all levels, including in a wide session row. `/commands` also opens the root +list, including local commands without a shared daemon. Codex does not expose a general slash-command discovery/execution API. These are explicit adapters to supported app-server catalogues, not a reproduction of diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 5cfb75f..902d175 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -72,6 +72,9 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_attention_surfaces", TestMonitorAttentionRenderedHitSurfaces) t.Run("monitor_filter_surfaces", TestMonitorStatusFilterRenderedHitSurfaces) t.Run("monitor_command_popup_surfaces", TestMonitorCommandPopupRenderedSurfaces) + t.Run("monitor_command_inline", TestMonitorInlineCommandsFilterAndDrillFromComposer) + t.Run("monitor_command_checkboxes", TestMonitorInlineCheckboxesWholeListSpaceToggleEnterApply) + t.Run("monitor_command_checkbox_clicks", TestMonitorInlineCheckboxClickSurfaces) t.Run("monitor_attention_padding", TestMonitorAttentionTallPadding) t.Run("monitor_bottom_composer", TestMonitorDetailBottomRowsFocusComposer) t.Run("monitor_model_metadata", TestMonitorSessionModelMetadataPriority) diff --git a/internal/ui/monitor_command_popup.go b/internal/ui/monitor_command_popup.go index 36143f8..f57eec5 100644 --- a/internal/ui/monitor_command_popup.go +++ b/internal/ui/monitor_command_popup.go @@ -17,6 +17,24 @@ func (m Model) monitorCommandPopup() monitorRect { if area.height < 5 { return monitorRect{} } + if m.monitorCommands.inline { + p := m.monitorCommands + // Match the original expanding list rather than allocating a dialog. + width := min(area.width, 44) + for _, i := range m.commandChoiceIndices() { + o := p.menu.Choices[i] + width = min(area.width, max(width, ansi.StringWidth(o.Label+" // "+strings.Join(strings.Fields(o.Help), " "))+4)) + } + // Keep confirmation and navigation controls discoverable on one row. + used := 2 + for _, button := range m.commandFooter(area.width) { + used += ansi.StringWidth(button.text) + 2 + } + width = min(area.width, max(width, used)) + wanted := len(m.monitorCommandRows(width, area.height)) + 3 + height := min(max(wanted, 4), area.height-1) + return monitorRect{x: area.x, y: area.y + area.height - height, width: width, height: height} + } wanted := len(m.monitorCommands.menu.Choices) + 5 if m.monitorCommands.menu.Multiple || m.monitorCommands.reviewedChoice != "" { wanted++ @@ -32,6 +50,13 @@ func (m Model) monitorCommandPopup() monitorRect { return monitorRect{x: area.x, y: area.y + area.height - height, width: area.width, height: height} } +func (m Model) commandContentRows(height int) int { + if m.monitorCommands.inline { + return max(height-3, 0) + } + return monitorCommandContentRows(height) +} + func monitorCommandContentRows(height int) int { rows := height - 3 // borders and the action row if height >= 6 { diff --git a/internal/ui/monitor_command_popup_test.go b/internal/ui/monitor_command_popup_test.go index 218844e..a67fcc6 100644 --- a/internal/ui/monitor_command_popup_test.go +++ b/internal/ui/monitor_command_popup_test.go @@ -100,7 +100,7 @@ func TestMonitorCommandPopupRenderedSurfaces(t *testing.T) { } } -func TestMonitorCommandPopupOutsideComposerClickDismissesSafely(t *testing.T) { +func TestMonitorCommandPopupComposerClickKeepsExpansion(t *testing.T) { m, f := suggestionTestModel(t) m.monitorPrompt.input.SetValue("/model") m, cmd, _ := m.openMonitorCommands("model") @@ -114,12 +114,12 @@ func TestMonitorCommandPopupOutsideComposerClickDismissesSafely(t *testing.T) { } next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) m = next.(Model) - if m.monitorCommands.open || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/model" || f.calls != 0 { - t.Fatal("composer click did not close the popup and preserve its draft") + if !m.monitorCommands.open || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/model " || f.calls != 0 { + t.Fatal("composer click did not keep the expansion and typing focus") } next, _ = m.Update(key('c')) - if next.(Model).monitorPrompt.input.Value() != "/modelc" || f.calls != 0 { - t.Fatal("typing resumed as a hidden command confirmation") + if next.(Model).monitorPrompt.input.Value() != "/model c" || f.calls != 0 { + t.Fatal("typing into a submenu acted as a hidden command confirmation") } } diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index b4db5bf..a976ef3 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -19,6 +19,7 @@ type monitorCommandLevel struct { path string selected int input string + draft string } type monitorCommandsState struct { @@ -34,6 +35,8 @@ type monitorCommandsState struct { input textinput.Model history []monitorCommandLevel restore *monitorCommandLevel + inline bool + prefix string } type monitorCommandsResult struct { session string @@ -53,7 +56,18 @@ func (m *Model) loadMonitorCommands(path string) tea.Cmd { path = "" } p.menu.Path = path + if p.inline && !commandInputReview(path) { + p.prefix = "/" + strings.ReplaceAll(path, "/", " ") + if path != "" { + p.prefix += " " + } + m.monitorPrompt.input.SetValue(p.prefix) + m.monitorPrompt.input.CursorEnd() + } if menu, local := m.localMonitorMenu(path); local { + if p.inline && path == "statusline" { + menu.Help = strings.Replace(menu.Help, "Space/Enter toggles; C applies", "Space toggles; Enter applies", 1) + } p.input.Blur() p.busy = false p.detail = false @@ -67,6 +81,7 @@ func (m *Model) loadMonitorCommands(path string) tea.Cmd { } if p.restore != nil { p.selected = min(p.restore.selected, max(len(menu.Choices)-1, 0)) + m.restoreCommandDraft() p.restore = nil } return nil @@ -79,6 +94,7 @@ func (m *Model) loadMonitorCommands(path string) tea.Cmd { p.scroll = 0 if p.restore != nil { p.selected = min(p.restore.selected, len(p.menu.Choices)-1) + m.restoreCommandDraft() p.restore = nil } p.busy = false @@ -108,7 +124,7 @@ func (m *Model) loadMonitorCommands(path string) tea.Cmd { // History records visited screens, not RPC path segments (tier/flex is one screen). func (m *Model) navigateMonitorCommands(path string) tea.Cmd { p := &m.monitorCommands - p.history = append(append([]monitorCommandLevel(nil), p.history...), monitorCommandLevel{path: p.menu.Path, selected: p.selected, input: p.input.Value()}) + p.history = append(append([]monitorCommandLevel(nil), p.history...), monitorCommandLevel{path: p.menu.Path, selected: p.selected, input: p.input.Value(), draft: m.monitorPrompt.input.Value()}) return m.loadMonitorCommands(path) } @@ -175,15 +191,18 @@ func (m Model) openMonitorCommands(path string) (Model, tea.Cmd, bool) { if path != "" && m.suggestionsActive() && m.monitorSuggestions.session == id { for i, choice := range m.monitorSuggestions.menu.Choices { if choice.Next == path { - history = []monitorCommandLevel{{path: "", selected: i}} + history = []monitorCommandLevel{{path: "", selected: i, draft: m.monitorPrompt.input.Value()}} break } } } - m.monitorPrompt.input.Blur() + inline := m.monitorPrompt.input.Focused() && codex.IsSessionCommand(m.monitorPrompt.input.Value()) + if !inline { + m.monitorPrompt.input.Blur() + } m.monitorApprovalConfirm = "" m.clearQuotaConfirmation() - m.monitorCommands = monitorCommandsState{open: true, session: id, request: m.monitorCommands.request, hover: -1, history: history} + m.monitorCommands = monitorCommandsState{open: true, session: id, request: m.monitorCommands.request, hover: -1, history: history, inline: inline} cmd := m.loadMonitorCommands(path) return m, cmd, true } @@ -200,6 +219,15 @@ func (m Model) commandChoice() (codex.SessionCommandChoice, bool) { if p.selected < 0 || p.selected >= len(p.menu.Choices) { return codex.SessionCommandChoice{}, false } + if p.inline { + found := false + for _, i := range m.commandChoiceIndices() { + found = found || i == p.selected + } + if !found { + return codex.SessionCommandChoice{}, false + } + } return p.menu.Choices[p.selected], true } @@ -295,9 +323,12 @@ type commandDisplayRow struct { func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { p := m.monitorCommands w := max(width-4, 1) - capacity := max(height-5, 1) - if p.menu.Multiple { - capacity = max(height-6, 1) + capacity := max(m.commandContentRows(height), 1) + if !p.inline { + capacity = max(height-5, 1) + if p.menu.Multiple { + capacity = max(height-6, 1) + } } var rows []commandDisplayRow if p.busy { @@ -317,8 +348,10 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { if capacity > 1 { rows = append(rows, commandDisplayRow{text: strings.ToUpper(label), choice: -1}) } - p.input.SetWidth(max(w-2, 1)) - rows = append(rows, commandDisplayRow{text: p.input.View(), choice: -1}) + if !p.inline { + p.input.SetWidth(max(w-2, 1)) + rows = append(rows, commandDisplayRow{text: p.input.View(), choice: -1}) + } for _, line := range strings.Split(ansi.Hardwrap(p.menu.Help, w, true), "\n") { if len(rows) >= capacity { break @@ -327,7 +360,7 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { } return rows } - if p.detail { + if p.detail && !p.inline { o, ok := m.commandChoice() if !ok { return nil @@ -370,11 +403,18 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { reasonRows = reasonRows[:min(len(reasonRows), limit)] } choiceCapacity := capacity - len(reasonRows) - if p.menu.Picker && p.reviewedChoice != "" && choiceCapacity > 1 { + if (p.reviewedChoice != "" || p.inline && p.detail) && choiceCapacity > 1 { choiceCapacity-- } - start := max(p.selected-choiceCapacity+1, 0) - for i := start; i < min(start+choiceCapacity, len(p.menu.Choices)); i++ { + indices := m.commandChoiceIndices() + position := 0 + for i, index := range indices { + if index == p.selected { + position = i + } + } + start := max(position-choiceCapacity+1, 0) + for _, i := range indices[start:min(start+max(choiceCapacity, 0), len(indices))] { o := p.menu.Choices[i] label := o.Label if (p.menu.Picker || p.menu.Path == "theme") && o.Selected { @@ -400,15 +440,21 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { rows = append(rows, commandDisplayRow{text: ansi.Truncate(label, w, "…"), help: o.Help, choice: i, action: "choose"}) } if len(rows) == 0 && len(reasonRows) == 0 { - rows = append(rows, commandDisplayRow{text: "No available options. Use Codex for unsupported commands.", choice: -1}) + text := "No available options. Use Codex for unsupported commands." + if p.inline && len(p.menu.Choices) > 0 { + text = "No matching slash commands." + } + rows = append(rows, commandDisplayRow{text: text, choice: -1}) } for _, line := range reasonRows { rows = append(rows, commandDisplayRow{text: line, choice: -1}) } - if p.menu.Picker { - if o, ok := m.commandChoice(); ok && p.reviewedChoice == o.ID { + if p.menu.Picker || p.inline && p.detail { + if o, ok := m.commandChoice(); ok && (p.reviewedChoice == o.ID || p.detail) { message := "REVIEW // " + o.Label + " — no change sent." - if time.Now().After(p.until) { + if !o.Action { + message = strings.Join(strings.Fields(o.Help), " ") + } else if time.Now().After(p.until) { message = "Review expired. Press Enter to review again." } if len(rows) < capacity { @@ -433,7 +479,11 @@ func (m Model) commandFooter(width int) []commandDisplayRow { if p.menu.Input && !p.busy && p.notice == "" && strings.TrimSpace(p.input.Value()) != "" { buttons = append(buttons, commandDisplayRow{text: i18n.Text("[ ENTER REVIEW ]"), action: "review"}) } else if p.menu.Multiple { - buttons = append(buttons, commandDisplayRow{text: i18n.Text("[ C APPLY ]"), action: "confirm"}) + label := "[ C APPLY ]" + if p.inline { + label = "[ ENTER APPLY ]" + } + buttons = append(buttons, commandDisplayRow{text: i18n.Text(label), action: "confirm"}) } else if o, ok := m.commandChoice(); ok && o.Action && !p.busy && p.notice == "" { if (p.detail || (p.menu.Picker && p.reviewedChoice == o.ID)) && time.Now().Before(p.until) { buttons = append(buttons, commandDisplayRow{text: i18n.Text("[ C CONFIRM ]"), action: "confirm"}) @@ -456,7 +506,11 @@ func (m Model) commandFooter(width int) []commandDisplayRow { buttons = append(buttons, commandDisplayRow{text: i18n.Text("[ ← BACK ]"), action: "back"}) } if o, ok := m.commandChoice(); !p.busy && (p.notice != "" || (ok && o.Unavailable != "")) { - buttons = append(buttons, commandDisplayRow{text: i18n.Text("[ F REFRESH ]"), action: "refresh"}) + label := i18n.Text("[ F REFRESH ]") + if p.inline { + label = strings.Replace(label, " F ", " CTRL+R ", 1) + } + buttons = append(buttons, commandDisplayRow{text: label, action: "refresh"}) } buttons = append(buttons, commandDisplayRow{text: i18n.Text("[ ESC CANCEL ]"), action: "close"}) used := 0 @@ -474,7 +528,7 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { rows := m.monitorCommandRows(width, height) body := make([]string, max(height-2, 1)) for i, r := range rows { - if i >= monitorCommandContentRows(height) { + if i >= m.commandContentRows(height) { break } style := colors.label() @@ -483,9 +537,13 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { style = colors.dimmed() if r.choice == p.selected { style = style.Foreground(colors.accent).Bold(true) - text = "> " + text + if !p.inline { + text = "> " + text + } } else { - text = " " + text + if !p.inline { + text = " " + text + } if r.choice == p.hover { style = style.Underline(true) } @@ -515,7 +573,7 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { } else if p.menu.Multiple { hint = "↑/↓ select • Space toggle • ←/→ order" } - if height >= 6 { + if height >= 6 && !p.inline { body[len(body)-2] = colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "")) } body[len(body)-1] = strings.Join(footer, " ") @@ -525,6 +583,9 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { identity = monitorSessionIdentity(s) } title := fmt.Sprintf("%s // %s", p.menu.Title, identity) + if p.inline { + title = "" + } for i := range body { body[i] = " " + body[i] + " " } @@ -546,6 +607,7 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { p.menu = m.localCommandsMenu() if p.restore != nil { p.selected = min(p.restore.selected, len(p.menu.Choices)-1) + m.restoreCommandDraft() p.restore = nil } } else { @@ -585,6 +647,10 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { p.restore = nil if restore != nil { p.selected = min(restore.selected, max(len(p.menu.Choices)-1, 0)) + if p.inline { + m.monitorPrompt.input.SetValue(restore.draft) + m.monitorPrompt.input.CursorEnd() + } } if p.menu.Picker && restore == nil { for i, o := range p.menu.Choices { @@ -594,6 +660,9 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { } } } + if p.inline && !p.menu.Input { + m.moveInlineCommandSelection(0) + } if p.menu.Input { p.input = textinput.New() p.input.CharLimit = p.menu.InputLimit @@ -604,6 +673,14 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { p.input.SetValue(p.menu.Value) if restore != nil { p.input.SetValue(restore.input) + } else if p.inline && m.commandQuery() != "" { + // Keep argument text entered while the catalogue loaded. + p.input.SetValue(m.commandQuery()) + } + if p.inline { + m.monitorPrompt.input.SetValue(p.prefix + p.input.Value()) + m.monitorPrompt.input.CursorEnd() + return m, nil, true } cmd := p.input.Focus() return m, cmd, true @@ -618,6 +695,16 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { } if !p.open { if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "/" && m.meterView == viewMonitor && m.monitorContextDetail != "" && !m.monitorPrompt.input.Focused() && !m.scheduleUI.open && !m.monitorQueue.open { + focus := m.focusMonitorPrompt() + if m.monitorPrompt.input.Focused() { + if m.monitorPrompt.input.Value() == "" { + m.monitorPrompt.input.SetValue("/") + } + if codex.IsSessionCommand(m.monitorPrompt.input.Value()) { + return m, tea.Batch(focus, m.syncMonitorSuggestions()), true + } + // An unfocused ordinary draft must not be overwritten by '/'. + } return m.openMonitorCommands("") } return m, nil, false @@ -627,7 +714,15 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, false } - if p.menu.Input && !p.busy { + if p.inline { + if next, cmd, handled := m.updateInlineCommands(msg); handled { + return next, cmd, true + } else { + m = next + p = &m.monitorCommands + } + } + if p.menu.Input && !p.busy && !p.inline { if key, ok := msg.(tea.KeyPressMsg); ok { switch key.String() { case "esc": @@ -733,6 +828,11 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { if popup.height == 0 || !popup.contains(v.X, v.Y) { p.hover, p.footerHover = -1, "" if _, click := msg.(tea.MouseClickMsg); click && v.Button == tea.MouseLeft { + area := m.monitorComposerPopupArea() + w, h := m.monitorPromptSize() + if p.inline && v.X >= area.x && v.X < area.x+area.width && v.Y >= area.y+area.height && v.Y < area.y+h && m.monitorPromptRows(w, h) > 0 { + return m, nil, false // Keep the expansion while editing its query. + } m.closeMonitorCommands() m.monitorSuggestions = monitorSuggestionState{request: m.monitorSuggestions.request + 1, dismissed: m.monitorPrompt.input.Value()} } @@ -744,7 +844,9 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { if v.Button == tea.MouseWheelUp { d = -3 } - if p.detail { + if p.inline { + m.moveInlineCommandSelection(d) + } else if p.detail { p.scroll = max(0, p.scroll+d) } else { before := p.selected @@ -759,7 +861,7 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { p.hover = -1 p.footerHover = "" _, click := msg.(tea.MouseClickMsg) - if x >= 0 && y >= 0 && y < min(len(rows), monitorCommandContentRows(popup.height)) && rows[y].action == "choose" { + if x >= 0 && y >= 0 && y < min(len(rows), m.commandContentRows(popup.height)) && rows[y].action == "choose" { p.hover = rows[y].choice if click && v.Button == tea.MouseLeft { if p.menu.Picker { diff --git a/internal/ui/monitor_commands_inline.go b/internal/ui/monitor_commands_inline.go new file mode 100644 index 0000000..eabfdc4 --- /dev/null +++ b/internal/ui/monitor_commands_inline.go @@ -0,0 +1,180 @@ +package ui + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" +) + +// Submenus keep the slash draft in the real composer. Only the suffix after +// the current command path filters options; no catalogue refetch is needed. +func (m Model) commandQuery() string { + return strings.TrimSpace(strings.TrimPrefix(m.monitorPrompt.input.Value(), m.monitorCommands.prefix)) +} + +func (m Model) commandChoiceIndices() []int { + p := m.monitorCommands + query := "" + if p.inline && !p.menu.Input && !p.menu.Multiple && !commandInputReview(p.menu.Path) { + query = strings.ToLower(m.commandQuery()) + } + var indices []int + for i, choice := range p.menu.Choices { + if strings.HasPrefix(strings.ToLower(strings.TrimPrefix(choice.Label, "/")), query) { + indices = append(indices, i) + } + } + return indices +} + +func commandInputReview(path string) bool { + return strings.HasPrefix(path, "rename/") || strings.HasPrefix(path, "cd/") +} + +func (m *Model) restoreCommandDraft() { + if p := m.monitorCommands; p.inline && p.restore != nil { + m.monitorPrompt.input.SetValue(p.restore.draft) + m.monitorPrompt.input.CursorEnd() + } +} + +func (m *Model) moveInlineCommandSelection(delta int) { + p := &m.monitorCommands + indices := m.commandChoiceIndices() + if len(indices) == 0 { + p.selected = -1 + return + } + position := 0 + for i, index := range indices { + if index == p.selected { + position = i + } + } + selected := indices[min(max(position+delta, 0), len(indices)-1)] + if selected != p.selected { + p.detail, p.reviewedChoice = false, "" + } + p.selected = selected +} + +func (m Model) updateInlineCommands(msg tea.Msg) (Model, tea.Cmd, bool) { + p := &m.monitorCommands + if key, ok := msg.(tea.KeyPressMsg); ok { + switch key.String() { + case "esc": + cmd := m.cancelMonitorCommands() + return m, cmd, true + case "alt+left": + cmd := m.backMonitorCommands() + return m, cmd, true + case "ctrl+r": + if !p.busy { + p.restore = &monitorCommandLevel{selected: p.selected, input: p.input.Value(), draft: m.monitorPrompt.input.Value()} + cmd := m.loadMonitorCommands(p.menu.Path) + return m, cmd, true + } + return m, nil, true + case "ctrl+c": + m.closeMonitorCommands() + return m, nil, false + case "up", "down", "pgup", "pgdown": + d := 1 + if key.String() == "up" || key.String() == "pgup" { + d = -1 + } + if strings.HasPrefix(key.String(), "pg") { + d *= 10 + } + m.moveInlineCommandSelection(d) + return m, nil, true + case "enter": + if p.busy || p.notice != "" { + return m, nil, true + } + if p.menu.Input { + return m.reviewMonitorCommandInput() + } + if p.menu.Multiple && p.menu.Path == "statusline" { + return m.confirmMonitorCommand() + } + if p.detail && time.Now().Before(p.until) { + return m, nil, true // Enter never bypasses explicit confirmation. + } + return m.chooseMonitorCommand() + case "tab": + if choice, ok := m.commandChoice(); ok && !p.menu.Input && !p.menu.Multiple && !p.busy && !p.detail { + m.monitorPrompt.input.SetValue(p.prefix + strings.TrimPrefix(choice.Label, "/")) + m.monitorPrompt.input.CursorEnd() + } + return m, nil, true + case "left", "backspace": + if !p.menu.Multiple && !p.menu.Input && m.commandQuery() == "" && m.monitorCommandsCanBack() { + cmd := m.backMonitorCommands() + return m, cmd, true + } + case "c", "C": + if !p.menu.Input && (p.detail || p.reviewedChoice != "" || p.menu.Multiple && m.commandQuery() == "") { + return m.confirmMonitorCommand() + } + case "space", "right": + if p.menu.Multiple { + return m, nil, false // Existing checkbox/reorder handling below. + } + } + if p.menu.Multiple && key.String() == "left" { + return m, nil, false + } + if p.menu.Multiple { + return m, nil, true // Checkbox lists are deliberately unfiltered. + } + } + // Use the same editor (including cursor, paste and wrapping) at every level. + // Keys such as q/t/r/f are text here, never global dashboard shortcuts. + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + if p.menu.Multiple { + return m, nil, true + } + before := m.monitorPrompt.input.Value() + var cmd tea.Cmd + m.monitorPrompt.input, cmd = m.monitorPrompt.input.Update(msg) + if before != m.monitorPrompt.input.Value() { + p.detail, p.reviewedChoice = false, "" + if !strings.HasPrefix(m.monitorPrompt.input.Value(), p.prefix) { + m.closeMonitorCommands() + return m, tea.Batch(cmd, m.syncMonitorSuggestions()), true + } + if commandInputReview(p.menu.Path) { + // Editing a reviewed name/path must obtain a new capability; + // never confirm the previous value behind an edited draft. + parent := strings.SplitN(p.menu.Path, "/", 2)[0] + if n := len(p.history); n > 0 && p.history[n-1].path == parent { + p.history = append([]monitorCommandLevel(nil), p.history[:n-1]...) + } + p.restore = &monitorCommandLevel{input: m.commandQuery(), draft: m.monitorPrompt.input.Value()} + read := m.loadMonitorCommands(parent) + return m, tea.Batch(cmd, read), true + } + if p.menu.Input { + query := m.commandQuery() + p.input.SetValue(query) + if p.input.Value() != query { + // Mirror the server's input limit/single-line normalization; + // never review a silently truncated version of visible text. + m.monitorPrompt.input.SetValue(p.prefix + p.input.Value()) + m.monitorPrompt.input.CursorEnd() + } + } else { + indices := m.commandChoiceIndices() + p.selected = -1 + if len(indices) > 0 { + p.selected = indices[0] + } + } + } + return m, cmd, true + } + return m, nil, false +} diff --git a/internal/ui/monitor_commands_inline_test.go b/internal/ui/monitor_commands_inline_test.go new file mode 100644 index 0000000..f92a101 --- /dev/null +++ b/internal/ui/monitor_commands_inline_test.go @@ -0,0 +1,292 @@ +package ui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +type inlineCommandClient struct { + *suggestionClient +} + +func (f *inlineCommandClient) SessionCommands(ctx context.Context, id, path string) (codex.SessionCommandMenu, error) { + switch path { + case "model": + f.reads++ + return codex.SessionCommandMenu{Path: path, Choices: []codex.SessionCommandChoice{ + {ID: "a", Label: "Alpha", Help: "First model", Next: "model/alpha"}, + {ID: "b", Label: "Beta", Help: "Second model", Next: "model/beta"}, + }}, nil + case "model/beta": + f.reads++ + return codex.SessionCommandMenu{Path: path, Revision: "beta", Choices: []codex.SessionCommandChoice{ + {ID: "medium", Label: "Medium", Help: "Medium reasoning", Action: true}, + {ID: "high", Label: "High", Help: "High reasoning", Action: true}, + }}, nil + } + return f.suggestionClient.SessionCommands(ctx, id, path) +} + +func inlineCommandModel(t *testing.T) (Model, *inlineCommandClient) { + t.Helper() + m, base := suggestionTestModel(t) + f := &inlineCommandClient{suggestionClient: base} + m.fetcher = f + m.monitorPrompt.input.SetValue("/mo") + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("no submenu request") + } + m = next.(Model) + next, _ = m.Update(cmd()) + return next.(Model), f +} + +func TestMonitorInlineCommandsFilterAndDrillFromComposer(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + m, f := inlineCommandModel(t) + m.height = 65 + m.monitorSessionData = m.monitorSessionData[:1] + m.setRowContext("root-one", mode) + m.monitorPrompt.input.Focus() + area := m.monitorComposerPopupArea() + if !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/model " { + t.Fatal("submenu left the command line") + } + before := m.monitorCommandPopup() + reads := f.reads + next, _ := m.Update(key('b')) + m = next.(Model) + rows := m.monitorCommandRows(80, 12) + if len(rows) != 1 || rows[0].choice != 1 || m.monitorPrompt.input.Value() != "/model b" || f.reads != reads || m.monitorCommandPopup().height >= before.height { + t.Fatal("submenu did not filter and shrink locally from composer typing") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m = next.(Model) + if m.monitorPrompt.input.Value() != "/model Beta" || f.calls != 0 { + t.Fatal("Tab did not complete the submenu choice without applying it") + } + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil || m.monitorPrompt.input.Value() != "/model beta " || !m.monitorPrompt.input.Focused() { + t.Fatal("drilling another level moved out of composer") + } + next, _ = m.Update(cmd()) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if !m.monitorCommands.detail || f.calls != 0 || m.commandChoiceIndices()[m.monitorCommands.selected] != 1 { + t.Fatal("Enter did not stage the highlighted reasoning choice") + } + out := ansi.Strip(m.render()) + if !strings.Contains(out, "Medium") || !strings.Contains(out, "High") || !strings.Contains(out, i18n.Text("[ C CONFIRM ]")) || area != m.monitorComposerPopupArea() { + t.Fatal("review replaced the expanding list or moved the composer") + } + next, cmd = m.Update(key('c')) + if cmd == nil || f.calls != 0 { + t.Fatal("missing explicit confirmation") + } + m = next.(Model) + next, _ = m.Update(cmd()) + m = next.(Model) + if f.calls != 1 || m.monitorCommands.open || m.monitorPrompt.input.Value() != "" || !m.monitorPrompt.input.Focused() { + t.Fatal("successful confirmation did not return to a clean composer") + } + } +} + +func TestMonitorInlineCommandsBackAndNoMatch(t *testing.T) { + m, f := inlineCommandModel(t) + for _, ch := range "qtrfc" { + next, _ := m.Update(key(ch)) + m = next.(Model) + } + if m.monitorPrompt.input.Value() != "/model qtrfc" || !m.monitorPrompt.input.Focused() || len(m.commandChoiceIndices()) != 0 || f.calls != 0 { + t.Fatal("typing ran a shortcut or retained hidden choices") + } + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd != nil || f.calls != 0 || m.monitorCommands.detail { + t.Fatal("no-match Enter acted on a hidden option") + } + next, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt}) + m = next.(Model) + if cmd == nil { + t.Fatal("Alt+Left did not return to the parent catalogue") + } + next, _ = m.Update(cmd()) + m = next.(Model) + if !m.monitorCommands.open || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/mo" || len(m.commandChoiceIndices()) != 1 { + t.Fatal("Back did not restore the parent command-line query") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + if m.monitorCommands.open || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/mo" { + t.Fatal("Escape at root did not dismiss the expansion and preserve draft") + } +} + +func TestMonitorInlineCheckboxesWholeListSpaceToggleEnterApply(t *testing.T) { + m, _ := suggestionTestModel(t) + store := &memoryPreferenceStore{} + m.preferenceStore = store + m.monitorPrompt.input.SetValue("/statusline") + m, _, _ = m.openMonitorCommands("statusline") + if !m.monitorCommands.inline || !m.monitorPrompt.input.Focused() || !m.monitorCommands.menu.Multiple { + t.Fatal("checkbox menu did not retain the composer expansion") + } + count := len(m.monitorCommands.menu.Choices) + was := m.monitorCommands.menu.Choices[0].Selected + next, _ := m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + if m.monitorCommands.menu.Choices[0].Selected == was { + t.Fatal("Space did not toggle selected checkbox") + } + next, _ = m.Update(key('q')) + m = next.(Model) + if len(m.commandChoiceIndices()) != count || m.monitorPrompt.input.Value() != "/statusline " { + t.Fatal("checkbox list filtered or typed into the composer") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(Model) + if m.monitorCommands.selected != 1 { + t.Fatal("Down did not navigate checkboxes") + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if m.monitorCommands.open || len(store.saves) != 1 || m.monitorPrompt.input.Value() != "" || !m.monitorPrompt.input.Focused() { + t.Fatal("Enter did not apply checkbox preferences and return to composer") + } +} + +func TestMonitorSlashShortcutStartsInComposer(t *testing.T) { + m, _ := promptTestModel() + next, cmd := m.Update(key('/')) + m = next.(Model) + if cmd == nil || m.monitorCommands.open || !m.monitorSuggestions.active || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/" { + t.Fatal("slash shortcut did not start the original composer IntelliSense") + } +} + +func TestMonitorSlashShortcutPreservesUnfocusedOrdinaryDraft(t *testing.T) { + m, _ := promptTestModel() + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("keep this unsent draft") + m.monitorPrompt.input.Blur() + next, _ := m.Update(key('/')) + m = next.(Model) + if m.monitorPrompt.input.Value() != "keep this unsent draft" || !m.monitorCommands.open || m.monitorCommands.inline { + t.Fatal("slash shortcut overwrote an ordinary draft") + } +} + +func TestMonitorInlineCheckboxClickSurfaces(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + m, _ := suggestionTestModel(t) + m.height = 65 + m.monitorSessionData = m.monitorSessionData[:1] + m.setRowContext("root-one", mode) + m.monitorPrompt.input.Focus() + m.monitorPrompt.input.SetValue("/statusline") + m, _, _ = m.openMonitorCommands("statusline") + store := &memoryPreferenceStore{} + m.preferenceStore = store + label := "[x] " + m.monitorCommands.menu.Choices[0].Label + x, y := renderedTextStart(t, m, label) + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.monitorCommands.menu.Choices[0].Selected { + t.Fatal("rendered checkbox click missed its option") + } + x, y = renderedTextStart(t, m, i18n.Text("[ ENTER APPLY ]")) + next, _ = m.Update(tea.MouseMotionMsg{X: x, Y: y}) + m = next.(Model) + if m.monitorCommands.footerHover != "confirm" { + t.Fatal("rendered Apply button did not hover") + } + next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.monitorCommands.open || len(store.saves) != 1 { + t.Fatal("rendered Apply button did not save exactly once") + } + } +} + +func TestMonitorInlineRenameUsesComposerAndInvalidatesEditedReview(t *testing.T) { + m, base := suggestionTestModel(t) + f := &renameTestClient{commandTestClient: base.commandTestClient} + m.fetcher = f + m.monitorPrompt.input.SetValue("/rename") + m, cmd, _ := m.openMonitorCommands("rename") + next, _ := m.Update(cmd()) + m = next.(Model) + if !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "/rename Current name" { + t.Fatal("rename moved input out of composer") + } + next, _ = m.Update(key('r')) + m = next.(Model) + if m.monitorCommands.input.Value() != "Current namer" { + t.Fatal("typing did not edit the command argument") + } + next, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + next, _ = m.Update(cmd()) + m = next.(Model) + if !m.monitorCommands.detail || m.monitorPrompt.input.Value() != "/rename Current namer" || !m.monitorPrompt.input.Focused() { + t.Fatal("review changed the readable command-line argument") + } + next, cmd = m.Update(key('t')) + m = next.(Model) + if cmd == nil || m.monitorCommands.detail || !m.monitorCommands.busy { + t.Fatal("editing a reviewed argument did not invalidate the capability") + } + applyResult := func(command tea.Cmd) {} + applyResult = func(command tea.Cmd) { + switch result := command().(type) { + case tea.BatchMsg: + for _, child := range result { + applyResult(child) + } + case monitorCommandsResult: + next, _ := m.Update(result) + m = next.(Model) + } + } + applyResult(cmd) + if !m.monitorCommands.menu.Input || m.monitorCommands.input.Value() != "Current namert" || m.monitorPrompt.input.Value() != "/rename Current namert" || f.calls != 0 { + t.Fatal("edited argument was lost or the previous review was applied") + } +} + +func TestMonitorInlineCommandEditedFilterInvalidatesConfirmation(t *testing.T) { + m, _ := inlineCommandModel(t) + // Use an action list without another RPC to exercise the actual editor. + m.monitorCommands.menu.Choices = []codex.SessionCommandChoice{ + {ID: "alpha", Label: "Alpha", Action: true}, + {ID: "beta", Label: "Beta", Action: true}, + } + m, _, _ = m.chooseMonitorCommand() + next, _ := m.Update(key('b')) + m = next.(Model) + if m.monitorCommands.detail || m.monitorCommands.selected != 1 { + t.Fatal("editing the filter retained a previous review") + } + _, cmd, _ := m.confirmMonitorCommand() + if cmd != nil { + t.Fatal("edited filter could confirm an unreviewed option") + } + m, _, _ = m.chooseMonitorCommand() + m.height = 8 + _, cmd, _ = m.confirmMonitorCommand() + if cmd != nil { + t.Fatal("invisible inline confirmation remained active") + } +} diff --git a/internal/ui/monitor_commands_test.go b/internal/ui/monitor_commands_test.go index 1d37983..1f0ca52 100644 --- a/internal/ui/monitor_commands_test.go +++ b/internal/ui/monitor_commands_test.go @@ -144,12 +144,13 @@ func TestMonitorCommandsSuccessfulChangesDiscardOldCatalogues(t *testing.T) { m.fetcher = f for attempt := 0; attempt < 2; attempt++ { var cmd tea.Cmd + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("/model") m, cmd, _ = m.openMonitorCommands("model/test") m, _ = approvalEvent(m, cmd()) if m.monitorCommands.menu.Revision != strconv.Itoa(attempt) { t.Fatal("reopened commands did not fetch a fresh revision") } - m.monitorPrompt.input.SetValue("/model") m.monitorSuggestions = monitorSuggestionState{active: true, busy: true, session: "root-one", request: 42, menu: m.monitorCommands.menu} m, _, _ = m.chooseMonitorCommand() m, cmd, _ = m.confirmMonitorCommand() From 1fe84e885355410f81a027caebaec261a434802d Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 19:50:16 +0100 Subject: [PATCH 04/12] FEAT: add ACTIVE Sessions summary filter --- README.md | 8 ++- internal/ui/english_snapshot_test.go | 5 +- internal/ui/monitor.go | 3 ++ internal/ui/monitor_filter.go | 28 +++++++++-- internal/ui/monitor_filter_test.go | 74 +++++++++++++++++++++++----- internal/ui/monitor_summary.go | 22 ++++++--- internal/ui/monitor_summary_test.go | 21 ++++---- 7 files changed, 122 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 46d5ebc..d4e2dec 100644 --- a/README.md +++ b/README.md @@ -1526,7 +1526,7 @@ Clickable **[←] [→]** buttons sit at the top-right of each row's rightmost b Left is dimmed and inactive at graph-only; Right is dimmed and inactive at full detail. The **SESSION TOTALS** header also has **[↑] [↓]** session-selection buttons. Up from the first session focuses the summary categories; Left/Right then cycles -**Sessions (all) → Working → Approval → Input → Check** and filters the rows. +**Sessions (all) → Active → Working → Approval → Input → Check** and filters the rows. Click a category label or count to select it directly. The selected filter is underlined; keyboard focus and hover add a theme-coloured highlight. Down or Enter returns to the first matching session, where Left/Right changes detail @@ -1534,6 +1534,12 @@ again. Escape while the categories have focus clears the filter. Empty categorie remain selectable and show an explicit no-results message. Filters update with live session states and are not persisted across launches. +**Active** includes sessions identified as active by the latest observation, +including working sessions and those waiting for input, approval or their next +turn. It excludes inferred **CHECK SESSION** entries and retained inactive rows. +**Working** is narrower: a currently processing turn. Active therefore overlaps +the other categories; the counts are not separate parts to add together. + The summary counts and token totals remain global, excluding dismissed sessions from state counts but retaining their measured tokens. Attention pills stay available across filters; opening an excluded session clears the filter to keep diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go index 5e9022f..6a35a92 100644 --- a/internal/ui/english_snapshot_test.go +++ b/internal/ui/english_snapshot_test.go @@ -37,9 +37,10 @@ func TestEnglishPresentationSnapshot(t *testing.T) { } got := fmt.Sprintf("%x", hash.Sum(nil)) // Baseline includes Pace graph controls, explicit panel/border - // backgrounds, Sessions Zero/Close All and the active summary filter; every theme, view and size + // backgrounds, Sessions Zero/Close All, the ACTIVE category and responsive + // summary filters; every theme, view and size // remains covered. The displayed pricing review date is now 2026-10-12. - const want = "9282134b3a6e45741e60b499a28bb0b4bed8b542c45a40f3a9668882ad78c360" + const want = "51ffe11ef44315386abd14364c7c64e2dd3c56ada230d21be539e2e29db88f48" if got != want { t.Fatalf("English presentation changed: got %s, want %s", got, want) } diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index 2367980..a06b91d 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -166,6 +166,9 @@ func (m Model) renderMonitorSummary(width, height int, colors palette, navigatio } innerWidth := max(width-4, 1) lines := m.monitorSummaryLines(innerWidth, max(height-3, 1), colors) + // Extremely short/narrow panes may not fit every category. Never let the + // summary enlarge its frame and push session controls outside the viewport. + lines = lines[:min(len(lines), max(height-2, 1))] status := " // " + i18n.Format("ELAPSED %s // AVG TOK/MIN %s", formatElapsed(elapsed), formatTokens(m.monitorAverageRate)) if m.monitorError != "" { status = " // " + terminalLabel(hint) diff --git a/internal/ui/monitor_filter.go b/internal/ui/monitor_filter.go index f95a899..f2caf4d 100644 --- a/internal/ui/monitor_filter.go +++ b/internal/ui/monitor_filter.go @@ -12,6 +12,7 @@ type monitorStatusFilter int const ( monitorFilterAll monitorStatusFilter = iota + monitorFilterActive monitorFilterWorking monitorFilterApproval monitorFilterInput @@ -36,7 +37,20 @@ func monitorSessionStatus(s monitorSession) monitorStatusFilter { } func (m Model) monitorSessionInList(s monitorSession) bool { - return m.monitorSessionVisible(s) && (m.monitorFilter == monitorFilterAll || monitorSessionStatus(s) == m.monitorFilter) + if !m.monitorSessionVisible(s) { + return false + } + if m.monitorFilter == monitorFilterActive { + return monitorSessionRecentlyActive(s) + } + return m.monitorFilter == monitorFilterAll || monitorSessionStatus(s) == m.monitorFilter +} + +// Active is a broader, overlapping category, not another turn/attention state. +// Use the existing live activity signal; a quiet fallback CHECK is not proof of +// recent activity, even while its writer process remains open. +func monitorSessionRecentlyActive(s monitorSession) bool { + return s.active && s.attention != codex.SessionAttentionCheck } func (m Model) monitorListSessionCount() int { @@ -51,13 +65,17 @@ func (m Model) monitorListSessionCount() int { // Layout is shared by text rendering and hit testing, including wrapped rows. func monitorSummaryColumns(width, rows int) (columns, cell int) { - columns = 6 - if width < 78 && rows >= 4 { + columns = int(monitorFilterCount) + 1 // Tokens plus the session categories. + if width < 91 && rows >= 4 { + columns = 4 + } + if width < 51 && rows >= 6 { columns = 3 } - if width < 44 && rows >= 6 { + if width < 35 && rows >= 8 { columns = 2 } + columns = min(columns, max((width+1)/2, 1)) return columns, max((width-(columns-1))/columns, 1) } @@ -69,7 +87,7 @@ func (m Model) monitorFilterButtons(width, height int) []monitorNavigationButton inner, rows := max(width-4, 1), max(height-3, 1) columns, cell := monitorSummaryColumns(inner, rows) var buttons []monitorNavigationButton - for i := 1; i < 6; i++ { // Tokens is a measurement, not a session category. + for i := 1; i <= int(monitorFilterCount); i++ { // Tokens is not a session category. x, y := 2+(i%columns)*(cell+1), 1+(i/columns)*2 if x+cell > width-2 || y >= height-1 { continue diff --git a/internal/ui/monitor_filter_test.go b/internal/ui/monitor_filter_test.go index d23c979..c612aaf 100644 --- a/internal/ui/monitor_filter_test.go +++ b/internal/ui/monitor_filter_test.go @@ -17,14 +17,14 @@ import ( func statusFilterTestModel() Model { m := contextTestModel() m.monitorSessionData = []monitorSession{ - {id: "work", displayed: true, working: true}, - {id: "approval", displayed: true, working: true, attention: codex.SessionAttentionApproval}, - {id: "input", displayed: true, attention: codex.SessionAttentionInput}, - {id: "check", displayed: true, attention: codex.SessionAttentionCheck}, - {id: "complete", displayed: true, attention: codex.SessionAttentionComplete}, + {id: "work", displayed: true, active: true, working: true}, + {id: "approval", displayed: true, active: true, working: true, attention: codex.SessionAttentionApproval}, + {id: "input", displayed: true, active: true, attention: codex.SessionAttentionInput}, + {id: "check", displayed: true, active: true, attention: codex.SessionAttentionCheck}, + {id: "complete", displayed: true, active: true, attention: codex.SessionAttentionComplete}, {id: "idle", displayed: true}, - {id: "dismissed", displayed: true, working: true}, - {id: "undisplayed", working: true}, + {id: "dismissed", displayed: true, active: true, working: true}, + {id: "undisplayed", active: true, working: true}, } m.monitorDismissed = map[string]monitorSessionDismissal{"dismissed": {}} m.monitorBaseline, m.monitorLatest = 100, 725 @@ -43,7 +43,7 @@ func filterKey(t *testing.T, m Model, code rune) Model { func TestMonitorStatusFilterCategoriesKeepGlobalTotals(t *testing.T) { m := statusFilterTestModel() - want := [][]string{{"work", "approval", "input", "check", "complete", "idle"}, {"work"}, {"approval"}, {"input"}, {"check"}} + want := [][]string{{"work", "approval", "input", "check", "complete", "idle"}, {"work", "approval", "input", "complete"}, {"work"}, {"approval"}, {"input"}, {"check"}} global := strings.Join(m.monitorSummaryLines(120, 2, paletteFor(m.theme)), "\n") for filter := monitorFilterAll; filter < monitorFilterCount; filter++ { m.setMonitorFilter(filter) @@ -71,7 +71,7 @@ func TestMonitorStatusFilterKeyboardFocusAndDetailArrows(t *testing.T) { if !m.monitorSummaryFocused || m.monitorSelectedID != "" { t.Fatal("Up from the first session did not focus the status strip") } - for filter := monitorFilterWorking; filter < monitorFilterCount; filter++ { + for filter := monitorFilterActive; filter < monitorFilterCount; filter++ { m = filterKey(t, m, tea.KeyRight) if m.monitorFilter != filter || !m.monitorSummaryFocused { t.Fatalf("Right selected filter %d, want %d", m.monitorFilter, filter) @@ -184,6 +184,54 @@ func TestMonitorStatusFilterEmptyAndLiveChanges(t *testing.T) { } } +func TestMonitorActiveFilterFollowsLatestObservation(t *testing.T) { + m := statusFilterTestModel() + m.setMonitorFilter(monitorFilterActive) + m = filterKey(t, m, tea.KeyDown) + if m.monitorSelectedID != "work" { + t.Fatal("Active did not include a working session") + } + now := time.Now() + for _, tc := range []struct { + active bool + attention codex.SessionAttention + want int + }{ + {true, codex.SessionAttentionCheck, 0}, + {true, codex.SessionAttentionComplete, 1}, + {true, codex.SessionAttentionApproval, 1}, + {true, codex.SessionAttentionInput, 1}, + {false, codex.SessionAttentionNone, 0}, + {true, codex.SessionAttentionNone, 1}, + } { + m.syncMonitorSessions(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{{ID: "work", Active: tc.active, Attention: tc.attention}}}, now) + if got := m.monitorListSessionCount(); got != tc.want { + t.Fatalf("active=%v attention=%v: got %d rows, want %d", tc.active, tc.attention, got, tc.want) + } + } + // Missing from a successful observation means inactive, not retained Active. + m.syncMonitorSessions(codex.LiveUsageSnapshot{}, now.Add(time.Second)) + if m.monitorListSessionCount() != 0 || !m.monitorSummaryFocused || m.monitorSelectedID != "" { + t.Fatal("Active retained a missing session or its stale selection") + } +} + +func TestMonitorSummaryAllCategoriesFitReservedHeight(t *testing.T) { + m := statusFilterTestModel() + for _, width := range []int{24, 32, 38, 39, 40, 54, 55, 64, 94, 95, 120, 180} { + height := monitorSummaryHeight(width) + lines := m.monitorSummaryLines(width-4, height-3, paletteFor(m.theme)) + if len(lines) > height-3 || len(m.monitorFilterButtons(width, height)) != int(monitorFilterCount) { + t.Fatalf("width %d: missing category or summary overflow", width) + } + for _, line := range lines { + if lipgloss.Width(line) > width-4 { + t.Fatalf("width %d: summary line overflow", width) + } + } + } +} + func TestMonitorStatusFilterPreservesDraftAndApprovalAccess(t *testing.T) { m := contextTestModel() m.setRowContext("root-one", contextWide) @@ -284,12 +332,12 @@ func TestMonitorStatusFilterSelectedAndFocusedStyles(t *testing.T) { m := statusFilterTestModel() colors := paletteFor(theme) m.setMonitorFilter(monitorFilterInput) - focused := m.monitorFilterTitleStyle(4, colors).Render("INPUT") + focused := m.monitorFilterTitleStyle(int(monitorFilterInput)+1, colors).Render("INPUT") m.selectMonitorSession(1) - selected := m.monitorFilterTitleStyle(4, colors).Render("INPUT") - idle := m.monitorFilterTitleStyle(3, colors).Render("APPROVAL") + selected := m.monitorFilterTitleStyle(int(monitorFilterInput)+1, colors).Render("INPUT") + idle := m.monitorFilterTitleStyle(int(monitorFilterApproval)+1, colors).Render("APPROVAL") m.monitorContextHover = monitorFilterAction(monitorFilterApproval) - hovered := m.monitorFilterTitleStyle(3, colors).Render("APPROVAL") + hovered := m.monitorFilterTitleStyle(int(monitorFilterApproval)+1, colors).Render("APPROVAL") if focused == selected || selected == colors.dimmed().Render("INPUT") || hovered == idle { t.Fatalf("theme %d does not distinguish active, focused and hovered filters", theme) } diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index 914c706..e063362 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -18,17 +18,20 @@ import ( // from dismissed rows. State counts describe visible rows and never add agents // again: linked agents are already included in their parent session. func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { - counts := [5]int{} + counts := [monitorFilterCount]int{} for _, s := range m.monitorSessionData { if !m.monitorSessionVisible(s) { continue } counts[0]++ + if monitorSessionRecentlyActive(s) { + counts[monitorFilterActive]++ + } if status := monitorSessionStatus(s); status != monitorFilterAll { counts[status]++ } } - labels := []string{"TOKENS", "SESSIONS", "WORKING", "APPROVAL", "INPUT", "CHECK"} + labels := []string{"TOKENS", "SESSIONS", "ACTIVE", "WORKING", "APPROVAL", "INPUT", "CHECK"} values := []string{formatTokens(m.monitorRecordedTokens())} for i, n := range counts { value := strconv.Itoa(n) @@ -39,11 +42,11 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { } columns, cell := monitorSummaryColumns(width, rows) var lines []string - for start := 0; start < 6; start += columns { + for start := 0; start < len(labels); start += columns { var titles, numbers []string - for i := start; i < min(start+columns, 6); i++ { + for i := start; i < min(start+columns, len(labels)); i++ { title := i18n.Text(labels[i]) - if i == 5 { + if i == int(monitorFilterCheck)+1 { title += "*" } // inferred, never an authoritative input request titles = append(titles, m.monitorFilterTitleStyle(i, colors).Render(ansi.Truncate(title, cell, "…")+strings.Repeat(" ", max(cell-lipgloss.Width(ansi.Truncate(title, cell, "…")), 0)))) @@ -51,7 +54,7 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { if i >= 2 { style = colors.dimmed().Bold(true) if m.monitorState == monitorRunning && m.monitorError == "" && counts[i-1] > 0 { - if i == 2 { + if i-1 == int(monitorFilterActive) || i-1 == int(monitorFilterWorking) { style = style.Foreground(colors.success) } else { style = style.Foreground(colors.warning) @@ -447,10 +450,13 @@ func (m Model) monitorAttentionHasRoom(width, height int) bool { } func monitorSummaryHeight(width int) int { - if width < 48 { + if width < 39 { + return 11 + } + if width < 55 { return 9 } - if width < 82 { + if width < 95 { return 7 } return 5 diff --git a/internal/ui/monitor_summary_test.go b/internal/ui/monitor_summary_test.go index d286998..e972bdf 100644 --- a/internal/ui/monitor_summary_test.go +++ b/internal/ui/monitor_summary_test.go @@ -31,22 +31,22 @@ func TestMonitorSummaryCountersAndObservationState(t *testing.T) { m.monitorSessionData[0].agentCount = 8 lines := m.monitorSummaryLines(120, 2, paletteFor(themeHacker)) values := strings.Fields(ansi.Strip(lines[1])) - if got := strings.Join(values, ","); got != "625,3,1,1,1,0" { + if got := strings.Join(values, ","); got != "625,3,3,1,1,1,0" { t.Fatalf("counts %s", got) } m.monitorSessionData[2].attention = codex.SessionAttentionCheck values = strings.Fields(ansi.Strip(m.monitorSummaryLines(120, 2, paletteFor(themeHacker))[1])) - if strings.Join(values, ",") != "625,3,0,1,1,1" { + if strings.Join(values, ",") != "625,3,2,0,1,1,1" { t.Fatal(values) } m.monitorDismissed = map[string]monitorSessionDismissal{"root-one": {}} values = strings.Fields(ansi.Strip(m.monitorSummaryLines(120, 2, paletteFor(themeHacker))[1])) - if strings.Join(values, ",") != "625,2,0,0,1,1" { + if strings.Join(values, ",") != "625,2,1,0,0,1,1" { t.Fatal("dismissal changed historical tokens or retained visible count", values) } m.monitorError = "lost" values = strings.Fields(ansi.Strip(m.monitorSummaryLines(120, 2, paletteFor(themeHacker))[1])) - if strings.Join(values, ",") != "625,2,—,—,—,—" { + if strings.Join(values, ",") != "625,2,—,—,—,—,—" { t.Fatal("stale live counts", values) } if len(m.monitorAttentionSessions()) != 0 { @@ -73,14 +73,15 @@ func TestMonitorSummaryCountColours(t *testing.T) { if i >= 2 { style = colors.dimmed().Bold(true) if state == "active" { - if i == 2 { + if i == 2 || i == 3 { style = style.Foreground(colors.success) } else { style = style.Foreground(colors.warning) } } } - cells = append(cells, style.Render(value+strings.Repeat(" ", 19-lipgloss.Width(value)))) + _, cell := monitorSummaryColumns(120, 2) + cells = append(cells, style.Render(value+strings.Repeat(" ", cell-lipgloss.Width(value)))) } if line != strings.Join(cells, " ") { t.Fatalf("theme %d state %s: unexpected count colours", theme, state) @@ -193,9 +194,9 @@ func TestMonitorAttentionTallPadding(t *testing.T) { func TestMonitorAttentionPaddingTracksSessionCount(t *testing.T) { m := attentionTestModel() - // At this width, 42 available rows leave exactly 11 per session after + // At this width, 44 available rows leave exactly 11 per session after // summary, navigation and padding; one fewer available row must not pad. - if m.monitorAttentionHasRoom(120, 41) || !m.monitorAttentionHasRoom(120, 42) { + if m.monitorAttentionHasRoom(120, 43) || !m.monitorAttentionHasRoom(120, 44) { t.Fatal("padding did not respect per-session height threshold") } for _, full := range []bool{false, true} { @@ -205,10 +206,10 @@ func TestMonitorAttentionPaddingTracksSessionCount(t *testing.T) { } rows := func() int { if full { - _, rows, _ := n.monitorDetailHeader(120, 42) + _, rows, _ := n.monitorDetailHeader(120, 44) return rows } - return n.monitorArea(120, 42).attentionRows + return n.monitorArea(120, 44).attentionRows } if rows() != 3 { t.Fatal("spacious rows did not get padding") From 2901c22fd7c0524dfd8c8a9a8241534fb8d17e7b Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 20:19:15 +0100 Subject: [PATCH 05/12] FIX: size slash menus to session detail mode --- README.md | 3 ++ internal/ui/localisation_test.go | 1 + internal/ui/monitor_command_popup.go | 57 +++++++++++++++++------ internal/ui/monitor_command_popup_test.go | 49 +++++++++++++++++++ internal/ui/monitor_commands.go | 2 +- internal/ui/monitor_suggestions.go | 26 +++++++---- 6 files changed, 114 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d4e2dec..d2eba1a 100644 --- a/README.md +++ b/README.md @@ -1655,6 +1655,9 @@ wide terminal session row when space permits. In terminal full detail, pressing available; read-only sessions retain a command picker for local display settings. Submenus stay in that same expanding list above the command line: type to filter the current level, Up/Down selects, Tab completes and Enter drills in or reviews. +In a session-list detail pane, the list fills the available width. In full-screen +detail, it fits its contents up to 66% of the available composer width; longer +option labels and help are shortened with an ellipsis. Width updates on resize. **Alt+Left** or the **Back** button returns one level (Left/Backspace also works when the option filter is empty). **Escape** closes the list, retaining the draft and typing focus. Back from a rename or directory review restores the edited diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 902d175..743e005 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -72,6 +72,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_attention_surfaces", TestMonitorAttentionRenderedHitSurfaces) t.Run("monitor_filter_surfaces", TestMonitorStatusFilterRenderedHitSurfaces) t.Run("monitor_command_popup_surfaces", TestMonitorCommandPopupRenderedSurfaces) + t.Run("monitor_command_popup_widths", TestMonitorCommandPopupWidthsFollowDetailMode) t.Run("monitor_command_inline", TestMonitorInlineCommandsFilterAndDrillFromComposer) t.Run("monitor_command_checkboxes", TestMonitorInlineCheckboxesWholeListSpaceToggleEnterApply) t.Run("monitor_command_checkbox_clicks", TestMonitorInlineCheckboxClickSurfaces) diff --git a/internal/ui/monitor_command_popup.go b/internal/ui/monitor_command_popup.go index f57eec5..e3c0c94 100644 --- a/internal/ui/monitor_command_popup.go +++ b/internal/ui/monitor_command_popup.go @@ -17,20 +17,8 @@ func (m Model) monitorCommandPopup() monitorRect { if area.height < 5 { return monitorRect{} } + width := m.monitorCommandMenuWidth(area) if m.monitorCommands.inline { - p := m.monitorCommands - // Match the original expanding list rather than allocating a dialog. - width := min(area.width, 44) - for _, i := range m.commandChoiceIndices() { - o := p.menu.Choices[i] - width = min(area.width, max(width, ansi.StringWidth(o.Label+" // "+strings.Join(strings.Fields(o.Help), " "))+4)) - } - // Keep confirmation and navigation controls discoverable on one row. - used := 2 - for _, button := range m.commandFooter(area.width) { - used += ansi.StringWidth(button.text) + 2 - } - width = min(area.width, max(width, used)) wanted := len(m.monitorCommandRows(width, area.height)) + 3 height := min(max(wanted, 4), area.height-1) return monitorRect{x: area.x, y: area.y + area.height - height, width: width, height: height} @@ -47,7 +35,48 @@ func (m Model) monitorCommandPopup() monitorRect { wanted = 5 } height := min(max(wanted, 4), area.height-1, max(area.height/2, 4)) - return monitorRect{x: area.x, y: area.y + area.height - height, width: area.width, height: height} + return monitorRect{x: area.x, y: area.y + area.height - height, width: width, height: height} +} + +func (m Model) monitorCommandMenuWidth(area monitorRect) int { + p := m.monitorCommands + width := 4 + for _, i := range m.commandChoiceIndices() { + o := p.menu.Choices[i] + label := o.Label + if p.menu.Multiple { + label = "[x] " + label + } else if (p.menu.Picker || p.menu.Path == "theme") && o.Selected { + label += " // CURRENT" + } else if o.Next != "" { + label += " →" + } + width = max(width, ansi.StringWidth(label+" // "+strings.Join(strings.Fields(o.Help), " "))+4) + } + if p.menu.Input { + width = max(width, ansi.StringWidth(p.menu.InputLabel)+4, ansi.StringWidth(strings.Join(strings.Fields(p.menu.Help), " "))+4) + } + if p.busy { + width = max(width, ansi.StringWidth("Loading / sending…")+4) + } + if p.notice != "" { + width = max(width, ansi.StringWidth(strings.Join(strings.Fields(p.notice), " "))+4) + } + // Keep confirmation and navigation controls discoverable on one row. + used := 2 + for _, button := range m.commandFooter(area.width) { + used += ansi.StringWidth(button.text) + 2 + } + return m.monitorPopupWidth(area, max(width, used)) +} + +// A row menu uses its entire detail pane. Full-page menus fit their contents +// up to 66% of the available composer width; rendering ellipsizes the excess. +func (m Model) monitorPopupWidth(area monitorRect, wanted int) int { + if m.monitorContextDetail == "" { + return area.width + } + return min(wanted, max(area.width*66/100, 1)) } func (m Model) commandContentRows(height int) int { diff --git a/internal/ui/monitor_command_popup_test.go b/internal/ui/monitor_command_popup_test.go index a67fcc6..a8a4422 100644 --- a/internal/ui/monitor_command_popup_test.go +++ b/internal/ui/monitor_command_popup_test.go @@ -142,3 +142,52 @@ func TestMonitorCommandPopupPreventsHiddenConfirmationAndSupportsLocalAlias(t *t t.Fatal("list help consumed extra rows") } } + +func TestMonitorCommandPopupWidthsFollowDetailMode(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + for _, long := range []bool{false, true} { + m, _ := suggestionTestModel(t) + m.height = 65 + m.monitorSessionData = m.monitorSessionData[:1] + m.setRowContext("root-one", mode) + m.monitorPrompt.input.Focus() + help := "Choose model" + if long { + help = strings.Repeat("Long help ", 100) + } + m.monitorSuggestions.menu.Choices = []codex.SessionCommandChoice{{ID: "model", Label: "/model", Help: help, Next: "model"}} + // Recalculate width on resize, without changing the selected command. + for _, width := range []int{240, 120, 80} { + m.width = width + area := m.monitorComposerPopupArea() + popup, _ := m.monitorSuggestionPopup() + want := area.width + if mode == contextFull { + want = min(ansi.StringWidth("/model // "+strings.Join(strings.Fields(help), " "))+4, area.width*66/100) + } + if popup.width != want || popup.y+popup.height != area.y+area.height { + t.Fatalf("mode %d width %d: popup %+v, want width %d", mode, width, popup, want) + } + if long && !strings.Contains(ansi.Strip(m.render()), "…") { + t.Fatal("long root help did not ellipsize") + } + } + m, cmd, _ := m.openMonitorCommands("model") + next, _ := m.Update(cmd()) + m = next.(Model) + m.monitorCommands.menu.Choices[0].Help = help + for _, width := range []int{240, 120, 80} { + m.width = width + area := m.monitorComposerPopupArea() + popup := m.monitorCommandPopup() + if mode == contextWide && popup.width != area.width || mode == contextFull && popup.width > area.width*66/100 { + t.Fatalf("nested mode %d width %d: popup width %d, pane width %d", mode, width, popup.width, area.width) + } + box := m.renderMonitorCommands(popup.width, popup.height, paletteFor(m.theme)) + if lipgloss.Width(box) != popup.width || long && !strings.Contains(ansi.Strip(box), "…") { + t.Fatal("nested menu overflowed or failed to ellipsize its help") + } + } + } + } +} diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index a976ef3..96b7350 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -549,7 +549,7 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { } } } - text = ansi.Truncate(text, max(width-4, 1), "") + text = ansi.Truncate(text, max(width-4, 1), "…") body[i] = style.Render(text) if r.help != "" { help := ansi.Truncate(" // "+strings.Join(strings.Fields(r.help), " "), max(width-4-ansi.StringWidth(text), 0), "…") diff --git a/internal/ui/monitor_suggestions.go b/internal/ui/monitor_suggestions.go index 998f295..80855b5 100644 --- a/internal/ui/monitor_suggestions.go +++ b/internal/ui/monitor_suggestions.go @@ -90,13 +90,7 @@ func (m Model) withMonitorSuggestions(view string, colors palette) string { choices := m.suggestionChoices() var lines []string if len(choices) == 0 { - text := "No matching slash commands." - if m.monitorSuggestions.busy { - text = "Loading slash commands…" - } else if m.monitorSuggestions.err { - text = "Command catalogue unavailable. Use Codex." - } - lines = append(lines, colors.dimmed().Render(ansi.Truncate(text, popup.width-4, "…"))) + lines = append(lines, colors.dimmed().Render(ansi.Truncate(m.monitorSuggestionMessage(), popup.width-4, "…"))) } else { selected := min(m.monitorSuggestions.selected, len(choices)-1) for i := start; i < min(start+popup.height-2, len(choices)); i++ { @@ -115,6 +109,16 @@ func (m Model) withMonitorSuggestions(view string, colors palette) string { return lipgloss.NewCompositor(lipgloss.NewLayer(view), lipgloss.NewLayer(box).X(popup.x).Y(popup.y).Z(1)).Render() } +func (m Model) monitorSuggestionMessage() string { + if m.monitorSuggestions.busy { + return "Loading slash commands…" + } + if m.monitorSuggestions.err { + return "Command catalogue unavailable. Use Codex." + } + return "No matching slash commands." +} + func (m Model) updateMonitorSuggestions(msg tea.Msg) (Model, tea.Cmd, bool) { s := &m.monitorSuggestions if r, ok := msg.(monitorSuggestionResult); ok { @@ -209,10 +213,14 @@ func (m Model) monitorSuggestionPopup() (popup monitorRect, start int) { if count == 0 { return } - width := min(area.width, 44) + width := 4 for _, o := range choices { - width = min(area.width, max(width, ansi.StringWidth(o.Label+" // "+strings.Join(strings.Fields(o.Help), " "))+4)) + width = max(width, ansi.StringWidth(o.Label+" // "+strings.Join(strings.Fields(o.Help), " "))+4) + } + if len(choices) == 0 { + width = ansi.StringWidth(m.monitorSuggestionMessage()) + 4 } + width = m.monitorPopupWidth(area, width) start = max(min(m.monitorSuggestions.selected, max(len(choices)-1, 0))-count+1, 0) return monitorRect{x: area.x, y: area.y + area.height - count - 2, width: width, height: count + 2}, start } From e20acc1dbe9cd94bb169182e7c72ea9757ab529c Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 20:38:03 +0100 Subject: [PATCH 06/12] FIX: keep daemon-confirmed ready sessions active --- README.md | 4 + internal/codex/live_usage.go | 4 +- internal/codex/session_ready_activity_test.go | 120 ++++++++++++++++++ internal/ui/monitor_filter_test.go | 22 ++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 internal/codex/session_ready_activity_test.go diff --git a/README.md b/README.md index d2eba1a..c901119 100644 --- a/README.md +++ b/README.md @@ -1537,6 +1537,10 @@ live session states and are not persisted across launches. **Active** includes sessions identified as active by the latest observation, including working sessions and those waiting for input, approval or their next turn. It excludes inferred **CHECK SESSION** entries and retained inactive rows. +An app-server-confirmed loaded, idle/ready session remains Active even after +five minutes without session-file updates; that confirmation is not carried +forward if it disappears or the daemon disconnects. Without the daemon, the +existing recent-activity and attention signals remain best-effort. **Working** is narrower: a currently processing turn. Active therefore overlaps the other categories; the counts are not separate parts to add together. diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go index 619cb0f..68def7e 100644 --- a/internal/codex/live_usage.go +++ b/internal/codex/live_usage.go @@ -1338,9 +1338,11 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string groupWorking := make(map[string]bool) for _, cursor := range r.files { quietFor := now.Sub(cursor.lastModified) - active := quietFor <= 5*time.Minute _, writerActive := liveWriters[cursor.threadID] exactStatus, exact := exactStatuses[cursor.threadID] + // A currently loaded, ready daemon thread is still active even without + // recent rollout writes. Do not confuse that with processing a turn. + active := quietFor <= 5*time.Minute || exact && exactStatus == sessionRuntimeIdle exactWorking := exact && exactStatus == sessionRuntimeWorking localWorking := !exact && writerActive && cursor.attention == sessionAttentionWorking attention := sessionAttention(cursor.attention, writerActive, writerLocksSupported, quietFor, exactStatus, exact) diff --git a/internal/codex/session_ready_activity_test.go b/internal/codex/session_ready_activity_test.go new file mode 100644 index 0000000..264c581 --- /dev/null +++ b/internal/codex/session_ready_activity_test.go @@ -0,0 +1,120 @@ +package codex + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +// Ready activity is independent of how recently the rollout was written. +func TestLiveUsageReaderQuietReadyActivityFallsBackAfterDisconnect(t *testing.T) { + home := t.TempDir() + old := time.Now().Add(-time.Hour) + path := testRolloutPath(t, home, old, "ready") + writeRollout(t, path, sessionMetaLine("ready", `"cli"`, "/work", nil)+"\n") + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + r, err := NewLiveUsageReader(home) + if err != nil { + t.Fatal(err) + } + r.statusProvider = stubSessionStatusProvider{statuses: map[string]sessionRuntimeStatus{"ready": sessionRuntimeIdle}} + usage, err := r.FetchTokenUsage(context.Background()) + if err != nil || len(usage.Sessions) != 1 || !usage.Sessions[0].Active || usage.SessionCount != 1 || usage.Sessions[0].Working { + t.Fatalf("confirmed ready session absent or misclassified: %+v, %v", usage, err) + } + r.statusProvider = stubSessionStatusProvider{unavailable: true} + usage, err = r.FetchTokenUsage(context.Background()) + if err != nil || usage.SessionCount != 0 { + t.Fatalf("disconnected daemon retained ready activity: %+v, %v", usage, err) + } +} + +func TestSessionSnapshotsQuietActivityUsesCurrentDaemonStatus(t *testing.T) { + now := time.Now() + for _, tokens := range []int64{0, 100} { + for _, tc := range []struct { + name string + statuses map[string]sessionRuntimeStatus + active bool + working bool + attention SessionAttention + }{ + {"ready", map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}, true, false, SessionAttentionNone}, + {"working", map[string]sessionRuntimeStatus{"root": sessionRuntimeWorking}, true, true, SessionAttentionNone}, + {"complete", map[string]sessionRuntimeStatus{"root": sessionRuntimeComplete}, true, false, SessionAttentionComplete}, + {"input", map[string]sessionRuntimeStatus{"root": sessionRuntimeInput}, true, false, SessionAttentionInput}, + {"approval", map[string]sessionRuntimeStatus{"root": sessionRuntimeApproval}, true, false, SessionAttentionApproval}, + {"unknown", map[string]sessionRuntimeStatus{"root": sessionRuntimeUnknown}, false, false, SessionAttentionNone}, + {"unloaded", nil, false, false, SessionAttentionNone}, + } { + t.Run(fmt.Sprintf("%s/tokens=%d", tc.name, tokens), func(t *testing.T) { + r := &LiveUsageReader{files: map[string]*rolloutCursor{ + "root": {threadID: "root", lastModified: now.Add(-8 * time.Hour), observedTokens: tokens}, + }} + sessions, active, working := r.sessionSnapshots(now, nil, true, tc.statuses) + if !tc.active && tokens == 0 { + if len(sessions) != 0 || active != 0 || working { + t.Fatal("unobserved historical session was promoted to active", sessions) + } + return + } + wantCount := 0 + if tc.active { + wantCount = 1 + } + if len(sessions) != 1 || active != wantCount || working != tc.working { + t.Fatalf("sessions=%+v active=%d working=%v", sessions, active, working) + } + if sessions[0].Active != tc.active || sessions[0].Working != tc.working || sessions[0].Attention != tc.attention { + t.Fatalf("unexpected activity/attention: %+v", sessions[0]) + } + }) + } + } +} + +func TestSessionSnapshotsReadyActivityClearsWithoutCurrentDaemonStatus(t *testing.T) { + now := time.Now() + r := &LiveUsageReader{files: map[string]*rolloutCursor{ + "root": {threadID: "root", lastModified: now.Add(-time.Hour), observedTokens: 100}, + }} + sessions, active, _ := r.sessionSnapshots(now, nil, true, map[string]sessionRuntimeStatus{"root": sessionRuntimeIdle}) + if len(sessions) != 1 || !sessions[0].Active || active != 1 { + t.Fatal("confirmed ready session was not active", sessions) + } + // Disconnection or an unloaded thread removes its exact status. Retaining + // historical token data must not retain the earlier proof of activity. + sessions, active, working := r.sessionSnapshots(now.Add(time.Minute), nil, true, nil) + if len(sessions) != 1 || sessions[0].Active || active != 0 || working { + t.Fatal("ready activity survived without current confirmation", sessions) + } +} + +func TestSessionSnapshotsOneReadySessionAndThreeChecks(t *testing.T) { + now := time.Now() + r := &LiveUsageReader{files: make(map[string]*rolloutCursor)} + writers := make(map[string]struct{}) + for _, id := range []string{"ready", "check-1", "check-2", "check-3"} { + r.files[id] = &rolloutCursor{threadID: id, lastModified: now.Add(-time.Hour), observedTokens: 100, attention: sessionAttentionWorking} + writers[id] = struct{}{} + } + sessions, _, _ := r.sessionSnapshots(now, writers, true, map[string]sessionRuntimeStatus{"ready": sessionRuntimeIdle}) + active, checks := 0, 0 + for _, s := range sessions { + if s.Attention == SessionAttentionCheck { + checks++ + } else if s.Active { + active++ + } + if s.ID == "ready" && (s.Working || s.Attention != SessionAttentionNone) { + t.Fatal("confirmed idle was relabelled working or needing attention", s) + } + } + if len(sessions) != 4 || checks != 3 || active != 1 { + t.Fatalf("sessions=%d check=%d active=%d; want 4/3/1", len(sessions), checks, active) + } +} diff --git a/internal/ui/monitor_filter_test.go b/internal/ui/monitor_filter_test.go index c612aaf..3ebcddf 100644 --- a/internal/ui/monitor_filter_test.go +++ b/internal/ui/monitor_filter_test.go @@ -216,6 +216,28 @@ func TestMonitorActiveFilterFollowsLatestObservation(t *testing.T) { } } +func TestMonitorActiveSummaryOneReadySessionAndThreeChecks(t *testing.T) { + m := contextTestModel() + m.monitorSessionData = nil + old := time.Now().Add(-time.Hour) + usage := codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{ + {ID: "ready", Active: true, LastActivity: old}, + {ID: "check-1", Active: true, LastActivity: old, Attention: codex.SessionAttentionCheck}, + {ID: "check-2", Active: true, LastActivity: old, Attention: codex.SessionAttentionCheck}, + {ID: "check-3", Active: true, LastActivity: old, Attention: codex.SessionAttentionCheck}, + }} + m.syncMonitorSessions(usage, time.Now()) + lines := m.monitorSummaryLines(120, 2, paletteFor(m.theme)) + if got := strings.Join(strings.Fields(ansi.Strip(lines[1])), ","); got != "0,4,1,0,0,0,3" { + t.Fatalf("unexpected summary: %s; want 4 sessions / 1 active / 3 check", got) + } + m.setMonitorFilter(monitorFilterActive) + rows, _, _ := m.monitorSessionPage(30) + if len(rows) != 1 || rows[0].id != "ready" { + t.Fatal("Active did not select the confirmed ready session", rows) + } +} + func TestMonitorSummaryAllCategoriesFitReservedHeight(t *testing.T) { m := statusFilterTestModel() for _, width := range []int{24, 32, 38, 39, 40, 54, 55, 64, 94, 95, 120, 180} { From b5138d5d9041833a2604885248eaccad9dbeed3b Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 21:09:08 +0100 Subject: [PATCH 07/12] FEAT: navigate Sessions detail through attention pills and status filters --- README.md | 24 ++- internal/ui/localisation_test.go | 3 + internal/ui/model.go | 9 +- internal/ui/monitor_commands.go | 1 + internal/ui/monitor_context.go | 22 +- internal/ui/monitor_context_modes.go | 2 +- internal/ui/monitor_filter.go | 22 +- internal/ui/monitor_filter_test.go | 19 +- internal/ui/monitor_header_focus.go | 138 +++++++++++++ internal/ui/monitor_header_focus_test.go | 249 +++++++++++++++++++++++ internal/ui/monitor_navigation.go | 2 +- internal/ui/monitor_prompt.go | 1 + internal/ui/monitor_queue.go | 1 + internal/ui/monitor_schedule.go | 1 + internal/ui/monitor_summary.go | 4 + internal/ui/view.go | 2 +- 16 files changed, 476 insertions(+), 24 deletions(-) create mode 100644 internal/ui/monitor_header_focus.go create mode 100644 internal/ui/monitor_header_focus_test.go diff --git a/README.md b/README.md index c901119..918126c 100644 --- a/README.md +++ b/README.md @@ -592,7 +592,7 @@ codexometer --codex /path/to/codex | `o` | Arm Close All in the Sessions overview; press again to confirm, or `Esc` to cancel | | `g` | Cycle available trend periods in Quota → Pace | | `h` | Toggle the trace in Quota → Pace; in Sessions, reset all rows to graph-only / split detail-and-graph, closing full detail and clearing individual row choices | -| `Left` / `Right` | In Sessions, cycle status filters while the summary has focus; otherwise less / more detail for the selected session: graph ↔ split ↔ wide ↔ full screen; stops at either end | +| `Left` / `Right` | In Sessions, highlight attention pills or cycle status filters when that header area has focus; otherwise less / more detail for the selected session: graph ↔ split ↔ wide ↔ full screen; stops at either end | | `b` | Run the selected benchmark scope (Benchmark view only) | | `a` | Arm, then confirm, Run All (Benchmark view only) | | `x` | Dismiss the selected Sessions row; close Benchmark detail/Scope, or stop an active suite and retain its incomplete trial | @@ -602,8 +602,8 @@ codexometer --codex /path/to/codex | `Left` / `Right` | Page Usage history, or select the previous or next benchmark suite | | `f` | Show all, passed, or failed benchmark results | | `w` | Select Weekly in Usage, or cycle Cost, Balanced, and Speed benchmark ranking weights | -| `Up` / `Down` | Select a session row or Benchmark row; Up from the first session focuses its summary filters, Down returns to the first match. Scroll Sessions full detail / open Benchmark detail (outside a focused editor) | -| `Enter` / `Space` | Open a selected Benchmark result, or toggle a Scope checkbox; in Sessions full detail, `Enter` focuses an available reply editor or submits its text | +| `Up` / `Down` | Select a session row or scroll full detail. Up beyond the first row/top of detail moves through visible attention pills to summary filters; Down retraces that path. Select a Benchmark row or scroll its open detail. Focused editors retain their own arrows | +| `Enter` / `Space` | Open a selected Benchmark result, or toggle a Scope checkbox; in Sessions, Enter opens a keyboard-highlighted attention pill. Otherwise in full detail, Enter focuses an available reply editor or submits its text | | `c` | Copy an eligible Sessions reply while its detail is shown, confirm an armed session approval, select Cumulative in Usage, or copy the Benchmark matrix/open run detail; types normally in a focused editor | | `l` | Clear accumulated Benchmark results while no suite is running | | `Page Up` / `Page Down` | Page Usage history, session rows, or Benchmark results; scroll Sessions full detail / Benchmark detail (outside a focused editor) | @@ -1525,12 +1525,22 @@ Clickable **[←] [→]** buttons sit at the top-right of each row's rightmost b (graph or detail), and on the full-detail box. They mirror the cursor keys: Left is dimmed and inactive at graph-only; Right is dimmed and inactive at full detail. The **SESSION TOTALS** header also has **[↑] [↓]** session-selection buttons. -Up from the first session focuses the summary categories; Left/Right then cycles +Up beyond the first session (or the top of full-detail text) focuses the visible +attention pills first. Left/Right highlights a pill without opening it; Enter +opens its session detail or specific review. A further Up focuses the summary +categories; Left/Right then cycles **Sessions (all) → Active → Working → Approval → Input → Check** and filters the rows. Click a category label or count to select it directly. The selected filter is -underlined; keyboard focus and hover add a theme-coloured highlight. Down or -Enter returns to the first matching session, where Left/Right changes detail -again. Escape while the categories have focus clears the filter. Empty categories +underlined; keyboard focus and hover add a theme-coloured highlight. Down retraces +the path through pills to the session list or current full detail, skipping +header levels that are not displayed. The currently open detail remains visible +until a pill is opened or a filter is chosen. Pill keyboard focus is underlined +and tracked by action identity, separately from the current-session marker; +reordering cannot redirect Enter to another pill, and a vanished or hidden pill +cannot be opened. Overflow's More pill is also keyboard accessible. Escape from +the pills returns to the session/detail; Escape from summary filters clears the +filter in the overview, or returns to the existing full detail without changing +it. Composer and menu arrows keep their own behaviour. Empty categories remain selectable and show an explicit no-results message. Filters update with live session states and are not persisted across launches. diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 743e005..7ac20bb 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -71,6 +71,9 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_copy_surfaces", TestMonitorCopySurfaces) t.Run("monitor_attention_surfaces", TestMonitorAttentionRenderedHitSurfaces) t.Run("monitor_filter_surfaces", TestMonitorStatusFilterRenderedHitSurfaces) + t.Run("monitor_header_focus_surfaces", TestMonitorHeaderFocusHighlightPreservesHitSurfaces) + t.Run("monitor_header_focus_navigation", TestMonitorHeaderFocusDetailPillsSummaryAndBack) + t.Run("monitor_header_focus_mouse", TestMonitorHeaderFocusMouseArrowsMirrorKeyboard) t.Run("monitor_command_popup_surfaces", TestMonitorCommandPopupRenderedSurfaces) t.Run("monitor_command_popup_widths", TestMonitorCommandPopupWidthsFollowDetailMode) t.Run("monitor_command_inline", TestMonitorInlineCommandsFilterAndDrillFromComposer) diff --git a/internal/ui/model.go b/internal/ui/model.go index 16ef882..f79e2db 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -196,6 +196,8 @@ type Model struct { monitorSelectedID string monitorFilter monitorStatusFilter monitorSummaryFocused bool + monitorAttentionFocused bool + monitorAttentionCursor string monitorDismissed map[string]monitorSessionDismissal monitorDismissHover string monitorDismissFlash string @@ -1338,6 +1340,7 @@ func (m Model) pressViewTab(view meterViewID) (tea.Model, tea.Cmd) { m.monitorApprovalConfirm = "" } if view != viewMonitor { + m.clearMonitorHeaderFocus() m.monitorDetailSent = detailSentState{} m.stashMonitorDraft() m.monitorPrompt = monitorPromptState{} @@ -2900,10 +2903,12 @@ func (m *Model) selectMonitorSession(direction int) { return } if direction < 0 && selected == 0 && m.monitorContextDetail == "" { - m.focusMonitorSummary() + if !m.focusMonitorAttention(false) { + m.focusMonitorSummary() + } return } - m.monitorSummaryFocused = false + m.clearMonitorHeaderFocus() if selected < 0 { if direction < 0 { selected = len(visible) - 1 diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index 96b7350..a7a82de 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -187,6 +187,7 @@ func (m Model) openMonitorCommands(path string) (Model, tea.Cmd, bool) { if id == "" { return m, nil, true } + m.clearMonitorHeaderFocus() var history []monitorCommandLevel if path != "" && m.suggestionsActive() && m.monitorSuggestions.session == id { for i, choice := range m.monitorSuggestions.menu.Choices { diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 8784c74..79b0a6f 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -268,6 +268,7 @@ func monitorContextBodyLayout(height, controls int) (textRows, gap, controlY int } func (m *Model) toggleMonitorContext() { + m.clearMonitorHeaderFocus() m.clearQuotaConfirmation() m.stashMonitorDraft() m.monitorPrompt = monitorPromptState{} @@ -362,7 +363,13 @@ func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { case "end": m.scrollMonitorContext(m.monitorContextScrollLimit()) case "up": - m.scrollMonitorContext(-1) + if m.monitorContextScroll == 0 { + if !m.focusMonitorAttention(false) { + m.focusMonitorHeaderSummary() + } + } else { + m.scrollMonitorContext(-1) + } case "down": m.scrollMonitorContext(1) case "pgup": @@ -517,6 +524,9 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool _, click := msg.(tea.MouseClickMsg) m.monitorContextHover = m.monitorContextAt(mouse.X, mouse.Y) if click && mouse.Button == tea.MouseLeft && m.monitorContextHover != "" { + if m.monitorContextHover != "session-up" && m.monitorContextHover != "session-down" { + m.clearMonitorHeaderFocus() + } if m.activateMonitorFilter(m.monitorContextHover) { return m, nil, true } @@ -535,9 +545,13 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool case "navigation-disabled": return m, nil, true case "session-up": - m.selectMonitorSession(-1) + if !m.monitorFilterKey("up") { + m.selectMonitorSession(-1) + } case "session-down": - m.selectMonitorSession(1) + if !m.monitorFilterKey("down") { + m.selectMonitorSession(1) + } case "prompt": m.showLiveMonitorHistory() cmd := m.focusMonitorPrompt() @@ -578,8 +592,10 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool if mouse.Y >= g.meterY && mouse.Y < g.meterY+g.meterHeight { switch mouse.Button { case tea.MouseWheelUp: + m.clearMonitorHeaderFocus() m.scrollMonitorContext(-3) case tea.MouseWheelDown: + m.clearMonitorHeaderFocus() m.scrollMonitorContext(3) } return m, nil, true diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index 8d1ad53..a27b3df 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -107,7 +107,7 @@ func (m Model) contextTargetHidden() bool { } func (m *Model) setRowContext(id string, mode int) { - m.monitorSummaryFocused = false + m.clearMonitorHeaderFocus() for _, s := range m.monitorSessionData { if s.id == id && !m.monitorSessionInList(s) { m.monitorFilter, m.monitorScroll = monitorFilterAll, 0 diff --git a/internal/ui/monitor_filter.go b/internal/ui/monitor_filter.go index f2caf4d..fbbe688 100644 --- a/internal/ui/monitor_filter.go +++ b/internal/ui/monitor_filter.go @@ -136,6 +136,7 @@ func (m Model) monitorFilterTitleStyle(column int, colors palette) lipgloss.Styl } func (m *Model) focusMonitorSummary() { + m.clearMonitorHeaderFocus() m.stashMonitorDraft() m.monitorPrompt = monitorPromptState{} m.monitorSelectedID, m.monitorContextExpanded = "", "" @@ -155,9 +156,16 @@ func (m *Model) setMonitorFilter(filter monitorStatusFilter) { // Arrow navigation belongs to filters only while their strip owns focus. // Composer/menu handlers run first and retain their normal editing keys. func (m *Model) monitorFilterKey(key string) bool { - if !m.monitorSummaryFocused || m.monitorContextDetail != "" { + if m.monitorAttentionFocused { + return m.monitorAttentionKey(key) + } + if !m.monitorSummaryFocused { return false } + if visible, _ := m.visibleMonitorHeader(); !visible { + m.clearMonitorHeaderFocus() + return true + } switch key { case "left", "right": delta := 1 @@ -166,16 +174,22 @@ func (m *Model) monitorFilterKey(key string) bool { } m.setMonitorFilter((m.monitorFilter + monitorStatusFilter(delta) + monitorFilterCount) % monitorFilterCount) case "down", "enter": - m.selectMonitorSession(1) + if !m.focusMonitorAttention(true) { + m.returnFromMonitorHeader() + } case "up": case "esc": - m.setMonitorFilter(monitorFilterAll) + if m.monitorContextDetail != "" { + m.clearMonitorHeaderFocus() + } else { + m.setMonitorFilter(monitorFilterAll) + } case "home": m.setMonitorFilter(monitorFilterAll) case "end": m.setMonitorFilter(monitorFilterCheck) default: - return false + return m.monitorHeaderBlocksAction(key) } return true } diff --git a/internal/ui/monitor_filter_test.go b/internal/ui/monitor_filter_test.go index 3ebcddf..fe1e591 100644 --- a/internal/ui/monitor_filter_test.go +++ b/internal/ui/monitor_filter_test.go @@ -68,7 +68,11 @@ func TestMonitorStatusFilterKeyboardFocusAndDetailArrows(t *testing.T) { m := statusFilterTestModel() m.monitorSelectedID = "work" m = filterKey(t, m, tea.KeyUp) - if !m.monitorSummaryFocused || m.monitorSelectedID != "" { + if !m.monitorAttentionFocused { + t.Fatal("Up from the first session did not focus the visible pills") + } + m = filterKey(t, m, tea.KeyUp) + if !m.monitorSummaryFocused || m.monitorSelectedID != "work" { t.Fatal("Up from the first session did not focus the status strip") } for filter := monitorFilterActive; filter < monitorFilterCount; filter++ { @@ -86,6 +90,7 @@ func TestMonitorStatusFilterKeyboardFocusAndDetailArrows(t *testing.T) { t.Fatal("filter navigation did not wrap backwards") } m = filterKey(t, m, tea.KeyDown) + m = filterKey(t, m, tea.KeyDown) // Status strip -> pills -> matching rows. if m.monitorSummaryFocused || m.monitorSelectedID != "check" { t.Fatal("Down did not enter the first matching session") } @@ -95,14 +100,16 @@ func TestMonitorStatusFilterKeyboardFocusAndDetailArrows(t *testing.T) { t.Fatal("Right no longer controls detail outside the status strip") } m = filterKey(t, m, tea.KeyUp) - if !m.monitorSummaryFocused || m.monitorContextExpanded != "" { - t.Fatal("returning to filters left a live detail control target") + m = filterKey(t, m, tea.KeyUp) + if !m.monitorSummaryFocused || m.monitorContextExpanded != "check" || m.monitorApprovalConfirm != "" { + t.Fatal("header navigation changed row presentation or left an armed approval") } m = filterKey(t, m, tea.KeyEscape) if m.monitorFilter != monitorFilterAll || !m.monitorSummaryFocused { t.Fatal("Escape did not clear the filter without quitting") } m = filterKey(t, m, tea.KeyEnter) + m = filterKey(t, m, tea.KeyDown) if m.monitorSelectedID != "work" || m.monitorSummaryFocused { t.Fatal("Enter did not return to the session list") } @@ -162,6 +169,7 @@ func TestMonitorStatusFilterEmptyAndLiveChanges(t *testing.T) { m := statusFilterTestModel() m.setMonitorFilter(monitorFilterWorking) m = filterKey(t, m, tea.KeyDown) + m = filterKey(t, m, tea.KeyDown) m.syncMonitorSessions(codex.LiveUsageSnapshot{Sessions: []codex.LiveUsageSession{ {ID: "work", Active: true, Attention: codex.SessionAttentionComplete}, }}, time.Now()) @@ -169,8 +177,8 @@ func TestMonitorStatusFilterEmptyAndLiveChanges(t *testing.T) { t.Fatal("a state transition retained a mismatched row or stale selection") } m = filterKey(t, m, tea.KeyDown) - if !m.monitorSummaryFocused { - t.Fatal("empty filter lost keyboard access to the categories") + if !m.monitorSummaryFocused && !m.monitorAttentionFocused { + t.Fatal("empty filter lost keyboard access to the header") } if !strings.Contains(ansi.Strip(m.renderMonitorSessions(100, 20, paletteFor(m.theme))), i18n.Text("NO RESULTS MATCH THE ACTIVE FILTER")) { t.Fatal("empty filter showed an unrelated aggregate graph") @@ -188,6 +196,7 @@ func TestMonitorActiveFilterFollowsLatestObservation(t *testing.T) { m := statusFilterTestModel() m.setMonitorFilter(monitorFilterActive) m = filterKey(t, m, tea.KeyDown) + m = filterKey(t, m, tea.KeyDown) if m.monitorSelectedID != "work" { t.Fatal("Active did not include a working session") } diff --git a/internal/ui/monitor_header_focus.go b/internal/ui/monitor_header_focus.go new file mode 100644 index 0000000..cb3fc82 --- /dev/null +++ b/internal/ui/monitor_header_focus.go @@ -0,0 +1,138 @@ +package ui + +import ( + "strconv" + "strings" +) + +func (m *Model) clearMonitorHeaderFocus() { + m.monitorSummaryFocused, m.monitorAttentionFocused = false, false +} + +func (m Model) visibleMonitorHeader() (summary bool, buttons []monitorNavigationButton) { + g := m.dashboardLayout() + if m.monitorContextDetail != "" && !m.contextTargetHidden() { + height, _, pills := m.monitorDetailHeader(g.contentWidth, g.meterHeight) + return height > 0, pills + } + a := m.monitorArea(g.contentWidth, g.meterHeight) + return len(m.monitorFilterButtons(a.readoutWidth, a.topHeight)) > 0, a.attention +} + +func (m *Model) releaseMonitorHeaderControls() { + m.stashMonitorDraft() + m.monitorPrompt.input.Blur() + m.monitorApprovalConfirm = "" + m.monitorApprovalNumberReleased = false + m.clearQuotaConfirmation() + m.clearMonitorCloseAllConfirmation() +} + +func (m *Model) focusMonitorAttention(restore bool) bool { + _, buttons := m.visibleMonitorHeader() + if len(buttons) == 0 { + return false + } + action := m.selectedAttentionAction() + if restore && m.monitorAttentionCursor != "" { + action = m.monitorAttentionCursor + } else if action == "" { + action = "attention:" + m.monitorContextTarget() + } + m.monitorAttentionCursor = buttons[0].action + for _, b := range buttons { + if b.action == action { + m.monitorAttentionCursor = action + break + } + } + m.releaseMonitorHeaderControls() + m.monitorSummaryFocused, m.monitorAttentionFocused = false, true + return true +} + +// Unlike choosing a filter, moving focus must not replace the detail being read. +func (m *Model) focusMonitorHeaderSummary() bool { + summary, _ := m.visibleMonitorHeader() + if !summary { + return false + } + m.releaseMonitorHeaderControls() + m.monitorSummaryFocused, m.monitorAttentionFocused = true, false + return true +} + +func (m *Model) returnFromMonitorHeader() { + m.clearMonitorHeaderFocus() + if m.monitorContextDetail == "" { + m.selectMonitorSession(0) + } +} + +// Global footer controls still work, but approval/copy shortcuts belong to the +// detail, never to a merely highlighted navigation pill or summary category. +func (m *Model) monitorHeaderBlocksAction(key string) bool { + switch key { + case "q", "ctrl+c", "tab", "shift+tab": + m.clearMonitorHeaderFocus() + return false + case "t", "r", "h", "z", "o": + return false + default: + return true + } +} + +func (m *Model) monitorAttentionKey(key string) bool { + switch key { + case "q", "ctrl+c", "tab", "shift+tab", "t", "r", "h", "z", "o": + return m.monitorHeaderBlocksAction(key) + } + _, buttons := m.visibleMonitorHeader() + index := -1 + for i, b := range buttons { + if b.action == m.monitorAttentionCursor { + index = i + break + } + } + if index < 0 { + // Disappearance, paging or resizing must not retarget this key to + // another request. A subsequent navigation key can select a new pill. + m.clearMonitorHeaderFocus() + m.focusMonitorHeaderSummary() + return true + } + switch key { + case "left", "right", "home", "end": + target := index + switch key { + case "left": + target-- + case "right": + target++ + case "home": + target = 0 + case "end": + target = len(buttons) - 1 + } + m.monitorAttentionCursor = buttons[min(max(target, 0), len(buttons)-1)].action + case "up": + m.focusMonitorHeaderSummary() + case "down", "esc": + m.returnFromMonitorHeader() + case "enter": + action := m.monitorAttentionCursor + m.clearMonitorHeaderFocus() + if page, ok := strings.CutPrefix(action, "attention-next:"); ok { + m.monitorAttentionPage, _ = strconv.Atoi(page) + m.focusMonitorAttention(false) + } else { + m.openMonitorAttention(action) + } + case "pgup", "pgdown": + default: + return m.monitorHeaderBlocksAction(key) + } + return true +} diff --git a/internal/ui/monitor_header_focus_test.go b/internal/ui/monitor_header_focus_test.go new file mode 100644 index 0000000..6e080f8 --- /dev/null +++ b/internal/ui/monitor_header_focus_test.go @@ -0,0 +1,249 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" +) + +func headerFocusModel() Model { + m := attentionTestModel() + m.height = 65 + m.monitorSessionData[0].preview.Text = strings.Repeat("Detail text\n", 100) + m.setRowContext("root-one", contextFull) + return m +} + +func TestMonitorHeaderFocusDetailPillsSummaryAndBack(t *testing.T) { + m := headerFocusModel() + m.monitorContextScroll = 1 + m = filterKey(t, m, tea.KeyUp) + if m.monitorContextScroll != 0 || m.monitorAttentionFocused || m.monitorSummaryFocused { + t.Fatal("Up did not scroll before leaving detail") + } + m = filterKey(t, m, tea.KeyUp) + if !m.monitorAttentionFocused || m.monitorAttentionCursor != "attention:root-one" || m.monitorContextDetail != "root-one" { + t.Fatal("Up at top did not focus the current detail's pill") + } + m = filterKey(t, m, tea.KeyRight) + if m.monitorAttentionCursor != "attention:root-two" || m.monitorContextDetail != "root-one" { + t.Fatal("highlighting another pill opened it prematurely") + } + m = filterKey(t, m, tea.KeyUp) + if !m.monitorSummaryFocused || m.monitorAttentionFocused || m.monitorContextDetail != "root-one" { + t.Fatal("moving to summary replaced detail") + } + m = filterKey(t, m, tea.KeyDown) + if !m.monitorAttentionFocused || m.monitorAttentionCursor != "attention:root-two" { + t.Fatal("Down did not restore the highlighted pill") + } + m = filterKey(t, m, tea.KeyDown) + if m.monitorAttentionFocused || m.monitorSummaryFocused || m.monitorContextDetail != "root-one" || m.monitorContextScroll != 0 { + t.Fatal("Down did not return to the original detail") + } + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyRight) + m = filterKey(t, m, tea.KeyEnter) + if m.monitorContextDetail != "root-two" || m.monitorAttentionFocused || m.monitorSummaryFocused || m.monitorPrompt.input.Focused() { + t.Fatal("Enter did not open the highlighted session detail") + } +} + +func TestMonitorHeaderFocusFilterOnlyChangesDetailWhenChosen(t *testing.T) { + m := headerFocusModel() + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyUp) + if m.monitorContextDetail != "root-one" { + t.Fatal("focus alone closed detail") + } + m = filterKey(t, m, tea.KeyRight) + if m.monitorFilter != monitorFilterActive || m.monitorContextDetail != "" || !m.monitorSummaryFocused { + t.Fatal("choosing a filter did not return to the filtered list") + } +} + +func TestMonitorHeaderFocusSkipsHiddenLevels(t *testing.T) { + m := contextTestModel() // No attention pills. + m.height = 65 + m.setRowContext("root-one", contextFull) + m = filterKey(t, m, tea.KeyUp) + if !m.monitorSummaryFocused || m.monitorAttentionFocused || m.monitorContextDetail != "root-one" { + t.Fatal("did not skip absent pills") + } + m = filterKey(t, m, tea.KeyDown) + if m.monitorSummaryFocused || m.monitorContextDetail != "root-one" { + t.Fatal("Down did not skip absent pills back to detail") + } + m.height = 12 + m = filterKey(t, m, tea.KeyUp) + if m.monitorSummaryFocused || m.monitorAttentionFocused { + t.Fatal("focused hidden header controls") + } + found := false + for height := 16; height < 40; height++ { + m = headerFocusModel() + m.height = height + summary, buttons := m.visibleMonitorHeader() + if summary || len(buttons) == 0 { + continue + } + found = true + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyUp) + if !m.monitorAttentionFocused || m.monitorSummaryFocused { + t.Fatal("Up left visible pills for a hidden status bar") + } + break + } + if !found { + t.Fatal("fixture never exercised pills without summary") + } +} + +func TestMonitorHeaderFocusTracksIdentityAndRejectsMissingPill(t *testing.T) { + for _, disappears := range []bool{false, true} { + m := headerFocusModel() + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + m.monitorSessionData[1].attention = codex.SessionAttentionComplete + m.monitorSessionData[0].working, m.monitorSessionData[1].working = false, false + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyRight) + if m.monitorAttentionCursor != "attention:root-two" { + t.Fatal("fixture did not highlight second pill") + } + if disappears { + m.monitorSessionData[1].attention = codex.SessionAttentionNone + } else { + m.monitorSessionData[0], m.monitorSessionData[1] = m.monitorSessionData[1], m.monitorSessionData[0] + } + m = filterKey(t, m, tea.KeyEnter) + want := "root-two" + if disappears { + want = "root-one" + } + if m.monitorContextDetail != want { + t.Fatal("Enter followed a position rather than the pill's identity") + } + } +} + +func TestMonitorHeaderFocusDoesNotStealComposerOrApprovalKeys(t *testing.T) { + m, _ := promptTestModel() + m.height = 65 + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + m.focusMonitorPrompt() + m.monitorPrompt.input.SetValue("draft") + m = filterKey(t, m, tea.KeyUp) + if m.monitorAttentionFocused || m.monitorSummaryFocused || !m.monitorPrompt.input.Focused() || m.monitorPrompt.input.Value() != "draft" { + t.Fatal("composer arrows escaped to header") + } + m.monitorPrompt.input.Blur() + m.monitorApprovalConfirm = "armed" + m = filterKey(t, m, tea.KeyUp) + if m.monitorApprovalConfirm != "" || m.monitorPrompt.input.Value() != "draft" { + t.Fatal("entering header retained an armed approval or lost the draft") + } + for _, ch := range []rune{'1', 'c'} { + next, cmd := m.Update(key(ch)) + m = next.(Model) + if cmd != nil || m.monitorApprovalConfirm != "" { + t.Fatal("header key acted on a detail approval or clipboard") + } + } + m = filterKey(t, m, tea.KeyEscape) + if m.monitorAttentionFocused || m.monitorContextDetail != "root-one" { + t.Fatal("Escape did not release pill focus without closing detail") + } +} + +func TestMonitorHeaderFocusHighlightPreservesHitSurfaces(t *testing.T) { + for theme := themeHacker; theme < themeCount; theme++ { + m := headerFocusModel() + m.theme = theme + g := m.dashboardLayout() + summary, rows, buttons := m.monitorDetailHeader(g.contentWidth, g.meterHeight) + colors := paletteFor(theme) + before := m.renderMonitorAttention(g.contentWidth, rows, buttons, colors) + m = filterKey(t, m, tea.KeyUp) + after := m.renderMonitorAttention(g.contentWidth, rows, buttons, colors) + if before == after || ansi.Strip(before) != ansi.Strip(after) || lipgloss.Width(before) != lipgloss.Width(after) { + t.Fatal("focus was invisible or changed pill geometry") + } + m.monitorContextHover = m.monitorAttentionCursor + if m.renderMonitorAttention(g.contentWidth, rows, buttons, colors) != after { + t.Fatal("hover over focused pill removed keyboard focus styling") + } + b := buttons[1] + x, y := 2+b.rect.x, g.meterY+summary+b.rect.y + if m.monitorAttentionAt(x, y) != b.action { + t.Fatal("keyboard focus moved pill hit targets") + } + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.monitorAttentionFocused || m.monitorContextDetail != "root-two" { + t.Fatal("mouse selection did not supersede keyboard pill focus") + } + } +} + +func TestMonitorHeaderFocusPagingAndResize(t *testing.T) { + m := headerFocusModel() + m.width = 80 + for i := range 12 { + m.monitorSessionData = append(m.monitorSessionData, monitorSession{id: fmt.Sprintf("extra-%d", i), displayed: true, attention: codex.SessionAttentionComplete}) + } + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyEnd) + if !strings.HasPrefix(m.monitorAttentionCursor, "attention-next:") { + t.Fatal("overflow pager was not keyboard reachable") + } + m = filterKey(t, m, tea.KeyEnter) + if m.monitorAttentionPage == 0 || !m.monitorAttentionFocused || m.monitorContextDetail != "root-one" { + t.Fatal("Enter did not page the pills without opening a session") + } + m.width, m.height = 20, 12 + m = filterKey(t, m, tea.KeyEnter) + if m.monitorAttentionFocused || m.monitorSummaryFocused || m.monitorContextDetail != "root-one" || m.monitorPrompt.input.Focused() { + t.Fatal("resize activated an invisible pill or composer") + } +} + +func TestMonitorHeaderFocusMouseArrowsMirrorKeyboard(t *testing.T) { + m := attentionTestModel() + m.height = 65 + m.monitorSessionData = m.monitorSessionData[:1] + m.monitorSelectedID = "root-one" + for _, label := range []string{"[↑]", "[↑]", "[↓]", "[↓]"} { + x, y := renderedTextStart(t, m, label) + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + } + if m.monitorAttentionFocused || m.monitorSummaryFocused || m.monitorSelectedID != "root-one" { + t.Fatal("mouse arrows did not retrace row -> pills -> summary -> pills -> row") + } +} + +func TestMonitorHeaderFocusKeepsSiblingReviewsDistinct(t *testing.T) { + m, f := profileTestModel(t) + m.height = 65 + m.monitorSessionData[0].attention = codex.SessionAttentionApproval + m.monitorSessionData[0].preview.Kind = codex.SessionContextApproval + m.openMonitorAttention("attention-profile:one") + m = filterKey(t, m, tea.KeyUp) + if m.monitorAttentionCursor != "attention-profile:one" { + t.Fatal("did not start from the current review's specific pill") + } + m = filterKey(t, m, tea.KeyLeft) + if m.monitorAttentionCursor != "attention:one" { + t.Fatal("did not highlight the separate Codex approval for the same session") + } + m = filterKey(t, m, tea.KeyEnter) + if m.monitorContextDetail != "one" || m.monitorContextRows["one"].review != "context" || f.updates != 0 { + t.Fatal("Enter opened/applied the wrong sibling review") + } +} diff --git a/internal/ui/monitor_navigation.go b/internal/ui/monitor_navigation.go index d274f99..2627a57 100644 --- a/internal/ui/monitor_navigation.go +++ b/internal/ui/monitor_navigation.go @@ -31,7 +31,7 @@ func (m Model) monitorNavigationButtons(width int, id string, full bool) []monit } buttons = []monitorNavigationButton{ {label: "[↑]", action: "session-up", enabled: count > 0 && !m.monitorSummaryFocused}, - {label: "[↓]", action: "session-down", enabled: count > 0 && (selected < 0 || selected < count-1)}, + {label: "[↓]", action: "session-down", enabled: count > 0 && (m.monitorAttentionFocused || m.monitorSummaryFocused || selected < 0 || selected < count-1)}, {label: m.monitorPrivacyLabel(width - 8), action: "privacy", enabled: true}, } } else { diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go index a89d9b4..5fa2f4a 100644 --- a/internal/ui/monitor_prompt.go +++ b/internal/ui/monitor_prompt.go @@ -201,6 +201,7 @@ func (m Model) monitorPromptSize() (int, int) { } func (m *Model) focusMonitorPrompt() tea.Cmd { + m.clearMonitorHeaderFocus() m.showLiveMonitorHistory() m.restoreMonitorDraft() o := m.monitorPromptOffer() diff --git a/internal/ui/monitor_queue.go b/internal/ui/monitor_queue.go index 0b1a2c7..f0965f6 100644 --- a/internal/ui/monitor_queue.go +++ b/internal/ui/monitor_queue.go @@ -359,6 +359,7 @@ func (m *Model) openQueueItem(index int, remove bool) tea.Cmd { return nil } q.item = item + m.clearMonitorHeaderFocus() q.open = true q.deleting = remove q.focused = false diff --git a/internal/ui/monitor_schedule.go b/internal/ui/monitor_schedule.go index 560e35b..80647a4 100644 --- a/internal/ui/monitor_schedule.go +++ b/internal/ui/monitor_schedule.go @@ -65,6 +65,7 @@ func (m *Model) openSchedule() tea.Cmd { if !m.hasSchedule(id) && (o.Token == "" || o.TurnID != "" || o.ThreadID != id || len(o.Questions) > 0) { return nil } + m.clearMonitorHeaderFocus() m.showLiveMonitorHistory() q := m.scheduleUI.queue if q == nil { diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index e063362..3599fb1 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -187,6 +187,7 @@ func (m *Model) settleMonitorApprovalOrder(now time.Time) { // Validate the action, not only the session ID; sibling pills must open // different documents and disarm each other's confirmation. func (m *Model) openMonitorAttention(action string) { + m.clearMonitorHeaderFocus() if strings.HasPrefix(action, "attention-profile:") && (m.monitorPrompt.input.Focused() || m.monitorPrompt.busy) { return } @@ -373,6 +374,9 @@ func (m Model) renderMonitorAttention(width, rows int, buttons []monitorNavigati color = colors.primary } style := colors.label().Foreground(color).Bold(!completed[b.action]) + if m.monitorAttentionFocused && m.monitorAttentionCursor == b.action { + style = style.Foreground(colors.background).Background(color).Bold(true).Underline(true) + } if m.monitorContextHover == b.action { style = style.Foreground(colors.background).Background(color) } diff --git a/internal/ui/view.go b/internal/ui/view.go index c950e62..f41dd06 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -206,7 +206,7 @@ func (m Model) renderFooter(width int, colors palette) string { } } status := colors.dimmed().Render(ansi.Truncate(left, width, "")) - if m.meterView == viewMonitor && !m.monitorCommands.open && !m.monitorSummaryFocused { + if m.meterView == viewMonitor && !m.monitorCommands.open && !m.monitorSummaryFocused && !m.monitorAttentionFocused { hint := i18n.Text("←/→ DETAIL") if lipgloss.Width(status)+2+lipgloss.Width(hint) <= width { status = joinRight(status, colors.dimmed().Render(hint), width) From 8747a1af8801e8b07ebe122a0d633223e91b966c Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 21:12:39 +0100 Subject: [PATCH 08/12] FIX: enter the first attention pill from Sessions overview --- README.md | 4 ++- internal/ui/monitor_header_focus.go | 4 +-- internal/ui/monitor_header_focus_test.go | 34 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 918126c..01f70e9 100644 --- a/README.md +++ b/README.md @@ -1527,7 +1527,9 @@ Left is dimmed and inactive at graph-only; Right is dimmed and inactive at full detail. The **SESSION TOTALS** header also has **[↑] [↓]** session-selection buttons. Up beyond the first session (or the top of full-detail text) focuses the visible attention pills first. Left/Right highlights a pill without opening it; Enter -opens its session detail or specific review. A further Up focuses the summary +opens its session detail or specific review. The session list enters at the +first visible pill; full detail prefers the pill for its current review. +A further Up focuses the summary categories; Left/Right then cycles **Sessions (all) → Active → Working → Approval → Input → Check** and filters the rows. Click a category label or count to select it directly. The selected filter is diff --git a/internal/ui/monitor_header_focus.go b/internal/ui/monitor_header_focus.go index cb3fc82..3b6da40 100644 --- a/internal/ui/monitor_header_focus.go +++ b/internal/ui/monitor_header_focus.go @@ -36,9 +36,9 @@ func (m *Model) focusMonitorAttention(restore bool) bool { action := m.selectedAttentionAction() if restore && m.monitorAttentionCursor != "" { action = m.monitorAttentionCursor - } else if action == "" { - action = "attention:" + m.monitorContextTarget() } + // The overview enters at the first visible pill, independent of its row + // selection. Only full detail has a specific review's pill to prefer. m.monitorAttentionCursor = buttons[0].action for _, b := range buttons { if b.action == action { diff --git a/internal/ui/monitor_header_focus_test.go b/internal/ui/monitor_header_focus_test.go index 6e080f8..56f4d10 100644 --- a/internal/ui/monitor_header_focus_test.go +++ b/internal/ui/monitor_header_focus_test.go @@ -19,6 +19,40 @@ func headerFocusModel() Model { return m } +func TestMonitorHeaderFocusOverviewEntersFirstPill(t *testing.T) { + for _, mode := range []int{contextGraph, contextSplit, contextWide} { + m := attentionTestModel() + m.height = 65 + // Put an input-waiting session first in the list, while a different + // session's higher-priority approval remains first in the pill strip. + m.monitorSessionData[0], m.monitorSessionData[1] = m.monitorSessionData[1], m.monitorSessionData[0] + m.setRowContext("root-two", mode) + _, buttons := m.visibleMonitorHeader() + if len(buttons) < 2 || buttons[0].action != "attention:root-one" { + t.Fatal("fixture must have a different session's pill first") + } + m = filterKey(t, m, tea.KeyUp) + if !m.monitorAttentionFocused || m.monitorAttentionCursor != buttons[0].action || m.monitorSelectedID != "root-two" || m.monitorContextDetail != "" || m.rowContextMode("root-two") != mode { + t.Fatal("overview did not enter the first pill while preserving its row selection") + } + m = filterKey(t, m, tea.KeyRight) + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyDown) + if !m.monitorAttentionFocused || m.monitorAttentionCursor != "attention:root-two" { + t.Fatal("returning from summary did not retrace the pill selection") + } + m = filterKey(t, m, tea.KeyDown) + if m.monitorAttentionFocused || m.monitorSummaryFocused || m.monitorSelectedID != "root-two" || m.rowContextMode("root-two") != mode { + t.Fatal("Down did not return to the original overview row") + } + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyEnter) + if m.monitorContextDetail != "root-one" { + t.Fatal("Enter did not open the first visible pill's session") + } + } +} + func TestMonitorHeaderFocusDetailPillsSummaryAndBack(t *testing.T) { m := headerFocusModel() m.monitorContextScroll = 1 From 3d0b2a694c1986a7e3256d727ec0b4865f90f622 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 23:01:49 +0100 Subject: [PATCH 09/12] TEST: cover read-only slash picker geometry and clicks --- internal/ui/monitor_command_popup_test.go | 53 +++++++++++++++++++++++ internal/ui/monitor_suggestions.go | 2 + 2 files changed, 55 insertions(+) diff --git a/internal/ui/monitor_command_popup_test.go b/internal/ui/monitor_command_popup_test.go index a8a4422..61683b0 100644 --- a/internal/ui/monitor_command_popup_test.go +++ b/internal/ui/monitor_command_popup_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "testing" @@ -11,6 +12,58 @@ import ( "github.com/merefield/codexometer/internal/i18n" ) +func TestMonitorCommandPopupReadOnlySession(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + for _, size := range [][2]int{{80, 30}, {120, 40}, {180, 65}} { + t.Run(fmt.Sprintf("mode%d/%dx%d", mode, size[0], size[1]), func(t *testing.T) { + m := localCommandModel() // No fetcher or live prompt offer. + m.width, m.height = size[0], size[1] + m.monitorSessionData = m.monitorSessionData[:1] + m.setRowContext("root-one", mode) + w, h := m.monitorPromptSize() + if m.monitorPromptRows(w, h) != 0 { + t.Fatal("fixture must not have a composer") + } + if mode == contextFull { + next, _ := m.Update(key('/')) + m = next.(Model) + } else { + // The bare '/' shortcut is detail-only without a composer; + // also exercise the shared popup geometry in a wide row. + m, _, _ = m.openMonitorCommands("") + } + if !m.monitorCommands.open || m.monitorCommands.inline || m.monitorCommandPopup().height == 0 { + t.Fatal("read-only slash picker is not visible") + } + x, y := renderedTextStart(t, m, "/statusline") + if !m.monitorCommandPopup().contains(x, y) { + t.Fatal("read-only choice rendered outside its click surface") + } + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.monitorCommands.menu.Path != "statusline" { + t.Fatal("read-only choice click did not open the picker") + } + before := m.monitorCommands.menu.Choices[0].Selected + next, _ = m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + if m.monitorCommands.menu.Choices[0].Selected == before { + t.Fatal("read-only checkbox keyboard navigation did not toggle") + } + x, y = renderedTextStart(t, m, "[ C APPLY ]") + next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if m.monitorCommands.open || m.monitorStatusLine == nil { + t.Fatal("read-only Apply click did not save and close the picker") + } + if m.rowContextMode("root-one") != mode { + t.Fatal("read-only picker changed the session detail mode") + } + }) + } + } +} + func TestMonitorCommandPopupPreservesContextComposerAndRowMode(t *testing.T) { for _, mode := range []int{contextFull, contextWide} { for _, size := range [][2]int{{80, 30}, {120, 40}, {180, 65}} { diff --git a/internal/ui/monitor_suggestions.go b/internal/ui/monitor_suggestions.go index 80855b5..3f48d37 100644 --- a/internal/ui/monitor_suggestions.go +++ b/internal/ui/monitor_suggestions.go @@ -227,6 +227,8 @@ func (m Model) monitorSuggestionPopup() (popup monitorRect, start int) { func (m Model) monitorComposerPopupArea() (area monitorRect) { w, h := m.monitorPromptSize() + // Zero composer rows still leave the detail body available to read-only + // command pickers; a live prompt offer is not required for local commands. rows := m.monitorPromptRows(w, h) if w < 12 || h < 4 { return From 92bfef0cbadc9bf5759d91dcc3254bd8c4150973 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 23:01:49 +0100 Subject: [PATCH 10/12] FIX: preserve attention identity when restoring header focus --- README.md | 4 ++- internal/ui/monitor_header_focus.go | 26 +++++++------- internal/ui/monitor_header_focus_test.go | 45 ++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 01f70e9..8c0a9dc 100644 --- a/README.md +++ b/README.md @@ -1528,7 +1528,9 @@ detail. The **SESSION TOTALS** header also has **[↑] [↓]** session-selection Up beyond the first session (or the top of full-detail text) focuses the visible attention pills first. Left/Right highlights a pill without opening it; Enter opens its session detail or specific review. The session list enters at the -first visible pill; full detail prefers the pill for its current review. +first visible pill; full detail targets the pill for its current review, skipping +to the summary if that pill is no longer visible. Returning from the summary +never substitutes another pill for a review or pager that has disappeared. A further Up focuses the summary categories; Left/Right then cycles **Sessions (all) → Active → Working → Approval → Input → Check** and filters the rows. diff --git a/internal/ui/monitor_header_focus.go b/internal/ui/monitor_header_focus.go index 3b6da40..fdfc01f 100644 --- a/internal/ui/monitor_header_focus.go +++ b/internal/ui/monitor_header_focus.go @@ -30,25 +30,24 @@ func (m *Model) releaseMonitorHeaderControls() { func (m *Model) focusMonitorAttention(restore bool) bool { _, buttons := m.visibleMonitorHeader() - if len(buttons) == 0 { - return false - } action := m.selectedAttentionAction() - if restore && m.monitorAttentionCursor != "" { + if restore { action = m.monitorAttentionCursor } - // The overview enters at the first visible pill, independent of its row - // selection. Only full detail has a specific review's pill to prefer. - m.monitorAttentionCursor = buttons[0].action + // Only a new overview entry or explicit page navigation chooses the first + // pill. Restoring an identity must never retarget a vanished review/pager. + if action == "" && len(buttons) > 0 { + action = buttons[0].action + } + m.monitorAttentionCursor = action for _, b := range buttons { if b.action == action { - m.monitorAttentionCursor = action - break + m.releaseMonitorHeaderControls() + m.monitorSummaryFocused, m.monitorAttentionFocused = false, true + return true } } - m.releaseMonitorHeaderControls() - m.monitorSummaryFocused, m.monitorAttentionFocused = false, true - return true + return false } // Unlike choosing a filter, moving focus must not replace the detail being read. @@ -126,7 +125,8 @@ func (m *Model) monitorAttentionKey(key string) bool { m.clearMonitorHeaderFocus() if page, ok := strings.CutPrefix(action, "attention-next:"); ok { m.monitorAttentionPage, _ = strconv.Atoi(page) - m.focusMonitorAttention(false) + m.monitorAttentionCursor = "" + m.focusMonitorAttention(true) } else { m.openMonitorAttention(action) } diff --git a/internal/ui/monitor_header_focus_test.go b/internal/ui/monitor_header_focus_test.go index 56f4d10..93b9158 100644 --- a/internal/ui/monitor_header_focus_test.go +++ b/internal/ui/monitor_header_focus_test.go @@ -166,6 +166,51 @@ func TestMonitorHeaderFocusTracksIdentityAndRejectsMissingPill(t *testing.T) { } } +func TestMonitorHeaderFocusDoesNotRestoreMissingIdentity(t *testing.T) { + for _, fromSummary := range []bool{false, true} { + t.Run(fmt.Sprintf("fromSummary=%t", fromSummary), func(t *testing.T) { + m := headerFocusModel() + if fromSummary { + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyRight) + m = filterKey(t, m, tea.KeyUp) + m.monitorSessionData[1].attention = codex.SessionAttentionNone + m = filterKey(t, m, tea.KeyDown) + } else { + m.monitorSessionData[0].attention = codex.SessionAttentionNone + m = filterKey(t, m, tea.KeyUp) + } + if m.monitorAttentionFocused || m.monitorContextDetail != "root-one" { + t.Fatal("missing review silently retargeted focus to an unrelated pill") + } + m = filterKey(t, m, tea.KeyEnter) + if m.monitorAttentionFocused || m.monitorContextDetail != "root-one" { + t.Fatal("Enter after a missing review opened an unrelated session") + } + }) + } +} + +func TestMonitorHeaderFocusDoesNotRestoreDisappearedPager(t *testing.T) { + m := headerFocusModel() + m.width = 80 + for i := range 12 { + m.monitorSessionData = append(m.monitorSessionData, monitorSession{id: fmt.Sprintf("extra-%d", i), displayed: true, attention: codex.SessionAttentionComplete}) + } + m = filterKey(t, m, tea.KeyUp) + m = filterKey(t, m, tea.KeyEnd) + cursor := m.monitorAttentionCursor + if !strings.HasPrefix(cursor, "attention-next:") { + t.Fatal("fixture must highlight a pager") + } + m = filterKey(t, m, tea.KeyUp) + m.width = 1000 // All pills now fit; the highlighted pager is gone. + m = filterKey(t, m, tea.KeyDown) + if m.monitorAttentionFocused || m.monitorAttentionCursor != cursor || m.monitorContextDetail != "root-one" { + t.Fatal("missing pager silently selected the first pill") + } +} + func TestMonitorHeaderFocusDoesNotStealComposerOrApprovalKeys(t *testing.T) { m, _ := promptTestModel() m.height = 65 From 41b1fd029f1e3c4e9cfb8775321c4691f73f920d Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 23:01:49 +0100 Subject: [PATCH 11/12] FIX: deduplicate overlapping model telemetry fields --- internal/ui/monitor_statusline.go | 18 +++++++++++- internal/ui/monitor_statusline_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/ui/monitor_statusline.go b/internal/ui/monitor_statusline.go index be8070c..4af668e 100644 --- a/internal/ui/monitor_statusline.go +++ b/internal/ui/monitor_statusline.go @@ -72,7 +72,23 @@ func (m Model) monitorPriorityFields(s monitorSession) ([]string, map[string]boo selected := make(map[string]bool) var lines []string for _, id := range statusline.Normalize(m.monitorStatusLine) { - if value := values[id]; value != "" { + value := values[id] + switch id { + case "model-with-reasoning": + var parts []string + for _, field := range []string{"model", "reasoning"} { + if values[field] != "" && !selected[field] { + parts = append(parts, values[field]) + selected[field] = true + } + } + value = strings.Join(parts, " ") + case "model", "reasoning": + if selected[id] { + continue + } + } + if value != "" { selected[id] = true if id != "run-state" { // The essential state line is already first. lines = append(lines, value) diff --git a/internal/ui/monitor_statusline_test.go b/internal/ui/monitor_statusline_test.go index 13555c8..cd462c5 100644 --- a/internal/ui/monitor_statusline_test.go +++ b/internal/ui/monitor_statusline_test.go @@ -117,6 +117,46 @@ func TestStatusLinePrioritisesPerSessionTelemetry(t *testing.T) { } } +func TestStatusLinePriorityDeduplicatesModelComponentsInOrder(t *testing.T) { + for _, tt := range []struct { + ids []string + model string + effort string + want []string + }{ + {[]string{"model-with-reasoning", "source", "model", "reasoning"}, "test-model", "high", []string{"test-model high", "LOCAL"}}, + {[]string{"model", "source", "model-with-reasoning", "reasoning"}, "test-model", "high", []string{"test-model", "LOCAL", "high"}}, + {[]string{"reasoning", "source", "model-with-reasoning", "model"}, "test-model", "high", []string{"high", "LOCAL", "test-model"}}, + {[]string{"reasoning", "model", "model-with-reasoning"}, "test-model", "high", []string{"high", "test-model"}}, + {[]string{"model-with-reasoning", "model", "reasoning"}, "test-model", "", []string{"test-model"}}, + {[]string{"model-with-reasoning", "model", "reasoning"}, "", "high", []string{"high"}}, + {[]string{"model", "model-with-reasoning", "reasoning"}, "", "high", []string{"high"}}, + {[]string{"reasoning", "model-with-reasoning", "model"}, "test-model", "", []string{"test-model"}}, + {[]string{"model-with-reasoning", "model", "reasoning"}, "", "", nil}, + } { + t.Run(strings.Join(tt.ids, "/")+"/"+tt.model+"/"+tt.effort, func(t *testing.T) { + m := contextTestModel() + m.monitorSelectedID = "root-one" + m.monitorStatusLine = tt.ids + s := m.monitorSessionData[1] + s.modelSettings = codex.SessionModelSettings{Model: tt.model, ReasoningEffort: tt.effort} + lines, selected := m.monitorPriorityFields(s) + if !reflect.DeepEqual(lines, tt.want) { + t.Fatalf("priority fields = %q, want %q", lines, tt.want) + } + if tt.model != "" && !selected["model"] || tt.effort != "" && !selected["reasoning"] { + t.Fatal("rendered model components must suppress fallback duplication") + } + body := ansi.Strip(m.renderMonitorSessionMetrics(80, 16, s, "", paletteFor(m.theme))) + for _, value := range []string{tt.model, tt.effort} { + if value != "" && strings.Count(body, value) != 1 { + t.Fatalf("model component %q repeated in telemetry:\n%s", value, body) + } + } + }) + } +} + func TestStatusLineFooterPreservesCopyGeometry(t *testing.T) { for _, width := range []int{24, 60, 120, 180} { m := completedCopyModel() From f6e4fde4af28f00c9d6431aacedfdc650fcda63a Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 18 Oct 2026 23:15:23 +0100 Subject: [PATCH 12/12] FIX: size slash menus to all content within the detail pane --- README.md | 6 +-- internal/ui/localisation_test.go | 1 + internal/ui/monitor_command_popup.go | 39 ++++++++----------- internal/ui/monitor_command_popup_test.go | 45 +++++++++++++++++++--- internal/ui/monitor_commands.go | 46 ++++++++++++++--------- 5 files changed, 87 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 8c0a9dc..f1751d4 100644 --- a/README.md +++ b/README.md @@ -1673,9 +1673,9 @@ wide terminal session row when space permits. In terminal full detail, pressing available; read-only sessions retain a command picker for local display settings. Submenus stay in that same expanding list above the command line: type to filter the current level, Up/Down selects, Tab completes and Enter drills in or reviews. -In a session-list detail pane, the list fills the available width. In full-screen -detail, it fits its contents up to 66% of the available composer width; longer -option labels and help are shortened with an ellipsis. Width updates on resize. +In both session-list and full-screen detail, the list fits its contents, including +previews and controls, up to the width of the surrounding detail pane. Text that +still cannot fit is shortened with an ellipsis. Width updates on resize. **Alt+Left** or the **Back** button returns one level (Left/Backspace also works when the option filter is empty). **Escape** closes the list, retaining the draft and typing focus. Back from a rename or directory review restores the edited diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 7ac20bb..feda264 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -76,6 +76,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_header_focus_mouse", TestMonitorHeaderFocusMouseArrowsMirrorKeyboard) t.Run("monitor_command_popup_surfaces", TestMonitorCommandPopupRenderedSurfaces) t.Run("monitor_command_popup_widths", TestMonitorCommandPopupWidthsFollowDetailMode) + t.Run("monitor_command_popup_preview", TestMonitorCommandPopupFitsStatuslinePreview) t.Run("monitor_command_inline", TestMonitorInlineCommandsFilterAndDrillFromComposer) t.Run("monitor_command_checkboxes", TestMonitorInlineCheckboxesWholeListSpaceToggleEnterApply) t.Run("monitor_command_checkbox_clicks", TestMonitorInlineCheckboxClickSurfaces) diff --git a/internal/ui/monitor_command_popup.go b/internal/ui/monitor_command_popup.go index e3c0c94..217584e 100644 --- a/internal/ui/monitor_command_popup.go +++ b/internal/ui/monitor_command_popup.go @@ -41,26 +41,21 @@ func (m Model) monitorCommandPopup() monitorRect { func (m Model) monitorCommandMenuWidth(area monitorRect) int { p := m.monitorCommands width := 4 - for _, i := range m.commandChoiceIndices() { - o := p.menu.Choices[i] - label := o.Label - if p.menu.Multiple { - label = "[x] " + label - } else if (p.menu.Picker || p.menu.Path == "theme") && o.Selected { - label += " // CURRENT" - } else if o.Next != "" { - label += " →" + // Measure the actual rows, including previews, review text and notices. + // Include all choices so scrolling alone does not resize the menu. Measure + // at the parent width so text only wraps/truncates at that physical limit. + for _, row := range m.monitorCommandRows(area.width, max(area.height, len(p.menu.Choices)+8)) { + text := strings.TrimSpace(ansi.Strip(row.text)) + if row.action != "" && !p.inline { + text = "> " + text } - width = max(width, ansi.StringWidth(label+" // "+strings.Join(strings.Fields(o.Help), " "))+4) - } - if p.menu.Input { - width = max(width, ansi.StringWidth(p.menu.InputLabel)+4, ansi.StringWidth(strings.Join(strings.Fields(p.menu.Help), " "))+4) - } - if p.busy { - width = max(width, ansi.StringWidth("Loading / sending…")+4) + if row.help != "" { + text += " // " + strings.Join(strings.Fields(row.help), " ") + } + width = max(width, ansi.StringWidth(text)+4) } - if p.notice != "" { - width = max(width, ansi.StringWidth(strings.Join(strings.Fields(p.notice), " "))+4) + if !p.inline { + width = max(width, ansi.StringWidth(m.monitorCommandTitle())+4, ansi.StringWidth(m.monitorCommandHint())+4) } // Keep confirmation and navigation controls discoverable on one row. used := 2 @@ -70,13 +65,9 @@ func (m Model) monitorCommandMenuWidth(area monitorRect) int { return m.monitorPopupWidth(area, max(width, used)) } -// A row menu uses its entire detail pane. Full-page menus fit their contents -// up to 66% of the available composer width; rendering ellipsizes the excess. +// Menus fit their contents, bounded only by the surrounding detail pane. func (m Model) monitorPopupWidth(area monitorRect, wanted int) int { - if m.monitorContextDetail == "" { - return area.width - } - return min(wanted, max(area.width*66/100, 1)) + return min(wanted, max(area.width, 1)) } func (m Model) commandContentRows(height int) int { diff --git a/internal/ui/monitor_command_popup_test.go b/internal/ui/monitor_command_popup_test.go index 61683b0..274c79e 100644 --- a/internal/ui/monitor_command_popup_test.go +++ b/internal/ui/monitor_command_popup_test.go @@ -214,10 +214,7 @@ func TestMonitorCommandPopupWidthsFollowDetailMode(t *testing.T) { m.width = width area := m.monitorComposerPopupArea() popup, _ := m.monitorSuggestionPopup() - want := area.width - if mode == contextFull { - want = min(ansi.StringWidth("/model // "+strings.Join(strings.Fields(help), " "))+4, area.width*66/100) - } + want := min(ansi.StringWidth("/model // "+strings.Join(strings.Fields(help), " "))+4, area.width) if popup.width != want || popup.y+popup.height != area.y+area.height { t.Fatalf("mode %d width %d: popup %+v, want width %d", mode, width, popup, want) } @@ -233,7 +230,7 @@ func TestMonitorCommandPopupWidthsFollowDetailMode(t *testing.T) { m.width = width area := m.monitorComposerPopupArea() popup := m.monitorCommandPopup() - if mode == contextWide && popup.width != area.width || mode == contextFull && popup.width > area.width*66/100 { + if popup.width > area.width || long && popup.width != area.width { t.Fatalf("nested mode %d width %d: popup width %d, pane width %d", mode, width, popup.width, area.width) } box := m.renderMonitorCommands(popup.width, popup.height, paletteFor(m.theme)) @@ -244,3 +241,41 @@ func TestMonitorCommandPopupWidthsFollowDetailMode(t *testing.T) { } } } + +func TestMonitorCommandPopupFitsStatuslinePreview(t *testing.T) { + for _, mode := range []int{contextFull, contextWide} { + for _, inline := range []bool{false, true} { + m := localCommandModel() + m.height = 65 + m.monitorSessionData = m.monitorSessionData[:1] + m.monitorSessionData[0].name = strings.Repeat("session", 24) + m.setRowContext("root-one", mode) + m, _, _ = m.openMonitorCommands("statusline") + m.monitorCommands.inline = inline + m.monitorCommands.menu.Choices = []codex.SessionCommandChoice{{ID: "thread-name", Label: "Session name", Help: "Name", Selected: true}} + preview := "Preview // " + m.monitorSessionData[0].name + for _, width := range []int{360, 240, 120, 80} { + m.width = width + area := m.monitorComposerPopupArea() + popup := m.monitorCommandPopup() + want := min(ansi.StringWidth(preview)+4, area.width) + if popup.width < want || popup.width > area.width || inline && popup.width != want { + t.Fatalf("mode %d inline %t width %d: popup width %d, preview needs %d", mode, inline, width, popup.width, want) + } + box := m.renderMonitorCommands(popup.width, popup.height, paletteFor(m.theme)) + if lipgloss.Width(box) != popup.width || lipgloss.Height(box) != popup.height { + t.Fatal("preview overflowed the popup bounds") + } + if ansi.StringWidth(preview)+4 <= area.width && !strings.Contains(ansi.Strip(box), preview) { + t.Fatal("preview shortened despite sufficient room in the parent pane") + } + x, y := renderedTextStart(t, m, i18n.Text(map[bool]string{true: "[ ENTER APPLY ]", false: "[ C APPLY ]"}[inline])) + next, _ := m.Update(tea.MouseMotionMsg{X: x, Y: y}) + m = next.(Model) + if m.monitorCommands.footerHover != "confirm" { + t.Fatal("resized popup moved Apply away from its hover target") + } + } + } + } +} diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index a7a82de..0450745 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -524,6 +524,32 @@ func (m Model) commandFooter(width int) []commandDisplayRow { return buttons } +func (m Model) monitorCommandHint() string { + p := m.monitorCommands + switch { + case p.menu.Input: + return "" + case p.detail: + return "↑/↓ scroll" + case p.menu.Multiple: + return "↑/↓ select • Space toggle • ←/→ order" + default: + return "↑/↓ select" + } +} + +func (m Model) monitorCommandTitle() string { + p := m.monitorCommands + if p.inline { + return "" + } + identity := shortSessionID(p.session) + if s, ok := m.contextDetailSession(); ok { + identity = monitorSessionIdentity(s) + } + return fmt.Sprintf("%s // %s", p.menu.Title, identity) +} + func (m Model) renderMonitorCommands(width, height int, colors palette) string { p := m.monitorCommands rows := m.monitorCommandRows(width, height) @@ -566,31 +592,15 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { footer = append(footer, style.Render(b.text)) } if len(body) > 0 { - hint := "↑/↓ select" - if p.menu.Input { - hint = "" - } else if p.detail { - hint = "↑/↓ scroll" - } else if p.menu.Multiple { - hint = "↑/↓ select • Space toggle • ←/→ order" - } if height >= 6 && !p.inline { - body[len(body)-2] = colors.label().Render(ansi.Truncate(hint, max(width-4, 1), "")) + body[len(body)-2] = colors.label().Render(ansi.Truncate(m.monitorCommandHint(), max(width-4, 1), "")) } body[len(body)-1] = strings.Join(footer, " ") } - identity := shortSessionID(p.session) - if s, ok := m.contextDetailSession(); ok { - identity = monitorSessionIdentity(s) - } - title := fmt.Sprintf("%s // %s", p.menu.Title, identity) - if p.inline { - title = "" - } for i := range body { body[i] = " " + body[i] + " " } - return renderMonitorPopup(title, strings.Join(body, "\n"), width, colors) + return renderMonitorPopup(m.monitorCommandTitle(), strings.Join(body, "\n"), width, colors) } func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) {