diff --git a/README.md b/README.md index 6bc4e89..f1751d4 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, 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, or 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) | @@ -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,47 @@ 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 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 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. +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 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. + +**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. + +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. @@ -1628,20 +1668,25 @@ 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 full command browser. Use arrows and Enter to browse, **C** to confirm a -setting change. **Back** (Left) returns to the previous visited command screen; -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. +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 +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 @@ -1662,14 +1707,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, @@ -1682,6 +1732,16 @@ 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. +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 every CLI command. Options and help are fetched on demand, not hard-coded or 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/english_snapshot_test.go b/internal/ui/english_snapshot_test.go index 0c93121..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 and Sessions Zero/Close All; 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 = "fc7b2b5381dd43bac332748c9fca6ac4c53abfacb130bfa49de64e76fe27f4ba" + const want = "51ffe11ef44315386abd14364c7c64e2dd3c56ada230d21be539e2e29db88f48" 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..feda264 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -70,9 +70,20 @@ 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_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_popup_preview", TestMonitorCommandPopupFitsStatuslinePreview) + 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) + 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..f79e2db 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -194,6 +194,10 @@ type Model struct { monitorCodexUp bool monitorCodexWorking bool monitorSelectedID string + monitorFilter monitorStatusFilter + monitorSummaryFocused bool + monitorAttentionFocused bool + monitorAttentionCursor string monitorDismissed map[string]monitorSessionDismissal monitorDismissHover string monitorDismissFlash string @@ -643,6 +647,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) @@ -1333,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{} @@ -2585,6 +2593,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 +2708,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 +2717,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 +2880,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 +2889,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 +2899,16 @@ func (m *Model) selectMonitorSession(direction int) { } if len(visible) == 0 { m.monitorSelectedID = "" + m.focusMonitorSummary() + return + } + if direction < 0 && selected == 0 && m.monitorContextDetail == "" { + if !m.focusMonitorAttention(false) { + m.focusMonitorSummary() + } return } + m.clearMonitorHeaderFocus() if selected < 0 { if direction < 0 { selected = len(visible) - 1 @@ -2924,7 +2941,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..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) @@ -181,7 +184,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 +223,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 +247,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 +332,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 +350,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 +373,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 +394,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 +410,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 +838,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_command_popup.go b/internal/ui/monitor_command_popup.go new file mode 100644 index 0000000..217584e --- /dev/null +++ b/internal/ui/monitor_command_popup.go @@ -0,0 +1,99 @@ +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{} + } + width := m.monitorCommandMenuWidth(area) + if m.monitorCommands.inline { + 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++ + } + 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: width, height: height} +} + +func (m Model) monitorCommandMenuWidth(area monitorRect) int { + p := m.monitorCommands + width := 4 + // 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 + } + if row.help != "" { + text += " // " + strings.Join(strings.Fields(row.help), " ") + } + width = max(width, ansi.StringWidth(text)+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 + for _, button := range m.commandFooter(area.width) { + used += ansi.StringWidth(button.text) + 2 + } + return m.monitorPopupWidth(area, max(width, used)) +} + +// Menus fit their contents, bounded only by the surrounding detail pane. +func (m Model) monitorPopupWidth(area monitorRect, wanted int) int { + return min(wanted, max(area.width, 1)) +} + +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 { + 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..274c79e --- /dev/null +++ b/internal/ui/monitor_command_popup_test.go @@ -0,0 +1,281 @@ +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" + "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}} { + 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 TestMonitorCommandPopupComposerClickKeepsExpansion(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 keep the expansion and typing focus") + } + next, _ = m.Update(key('c')) + if next.(Model).monitorPrompt.input.Value() != "/model c" || f.calls != 0 { + t.Fatal("typing into a submenu acted 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") + } +} + +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 := 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) + } + 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 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)) + if lipgloss.Width(box) != popup.width || long && !strings.Contains(ansi.Strip(box), "…") { + t.Fatal("nested menu overflowed or failed to ellipsize its help") + } + } + } + } +} + +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 555f080..0450745 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 @@ -49,8 +52,22 @@ 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 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 @@ -64,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 @@ -76,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 @@ -105,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) } @@ -154,6 +173,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 +187,23 @@ func (m Model) openMonitorCommands(path string) (Model, tea.Cmd, bool) { if id == "" { return m, nil, true } - m.monitorPrompt.input.Blur() - m.monitorContextDetail = id - m.monitorCommands = monitorCommandsState{open: true, session: id, request: m.monitorCommands.request, hover: -1} + m.clearMonitorHeaderFocus() + 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, draft: m.monitorPrompt.input.Value()}} + break + } + } + } + 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, inline: inline} cmd := m.loadMonitorCommands(path) return m, cmd, true } @@ -184,6 +220,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 } @@ -221,14 +266,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 +316,7 @@ func (m Model) confirmMonitorCommand() (Model, tea.Cmd, bool) { type commandDisplayRow struct { text string + help string choice int action string } @@ -267,9 +324,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 { @@ -286,9 +346,13 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { if label == "" { label = "Session name" } - 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 capacity > 1 { + rows = append(rows, commandDisplayRow{text: strings.ToUpper(label), 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 @@ -297,7 +361,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 @@ -340,8 +404,18 @@ func (m Model) monitorCommandRows(width, height int) []commandDisplayRow { reasonRows = reasonRows[:min(len(reasonRows), limit)] } choiceCapacity := capacity - len(reasonRows) - start := max(p.selected-choiceCapacity+1, 0) - for i := start; i < min(start+choiceCapacity, len(p.menu.Choices)); i++ { + if (p.reviewedChoice != "" || p.inline && p.detail) && choiceCapacity > 1 { + choiceCapacity-- + } + 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 { @@ -352,7 +426,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,18 +438,24 @@ 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}) + 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 { @@ -384,17 +464,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 == "" { @@ -411,7 +480,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"}) @@ -434,7 +507,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 @@ -447,12 +524,38 @@ 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) body := make([]string, max(height-2, 1)) for i, r := range rows { - if i >= len(body)-2 { + if i >= m.commandContentRows(height) { break } style := colors.label() @@ -461,15 +564,24 @@ 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) } } } - 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,24 +591,16 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { } footer = append(footer, style.Render(b.text)) } - if len(body) > 1 { - hint := "↑/↓ select" - if p.menu.Input { - hint = "" - } else if p.detail { - hint = "↑/↓ scroll" - } else if p.menu.Multiple { - hint = "↑/↓ select • Space toggle • ←/→ order" + if len(body) > 0 { + if height >= 6 && !p.inline { + body[len(body)-2] = colors.label().Render(ansi.Truncate(m.monitorCommandHint(), max(width-4, 1), "")) } - 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) - if s, ok := m.contextDetailSession(); ok { - identity = monitorSessionIdentity(s) + for i := range body { + body[i] = " " + body[i] + " " } - title := fmt.Sprintf("%s // %s", p.menu.Title, identity) - return frameSizedWithActions(width, max(height-2, 1), title, "", "", strings.Join(body, "\n"), colors.primary, colors) + return renderMonitorPopup(m.monitorCommandTitle(), strings.Join(body, "\n"), width, colors) } func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { @@ -514,6 +618,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 { @@ -553,6 +658,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 { @@ -562,6 +671,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 @@ -572,6 +684,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 @@ -586,6 +706,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 @@ -595,7 +725,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": @@ -696,18 +834,30 @@ 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 { + 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()} + } 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 { 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 @@ -718,11 +868,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), m.commandContentRows(popup.height)) && rows[y].action == "choose" { p.hover = rows[y].choice if click && v.Button == tea.MouseLeft { if p.menu.Picker { @@ -736,8 +886,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_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() diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 30570fb..79b0a6f 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() } @@ -271,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{} @@ -365,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": @@ -393,6 +397,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 +524,12 @@ 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 + } if id, ok := strings.CutPrefix(m.monitorContextHover, "copy:"); ok { next, cmd := m.activateMonitorCopy(id) return next, cmd, true @@ -532,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() @@ -575,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 99b08d7..a27b3df 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.clearMonitorHeaderFocus() + 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..fbbe688 --- /dev/null +++ b/internal/ui/monitor_filter.go @@ -0,0 +1,219 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/merefield/codexometer/internal/codex" +) + +type monitorStatusFilter int + +const ( + monitorFilterAll monitorStatusFilter = iota + monitorFilterActive + 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 { + 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 { + 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 = int(monitorFilterCount) + 1 // Tokens plus the session categories. + if width < 91 && rows >= 4 { + columns = 4 + } + if width < 51 && rows >= 6 { + columns = 3 + } + if width < 35 && rows >= 8 { + columns = 2 + } + columns = min(columns, max((width+1)/2, 1)) + 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 <= 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 + } + 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.clearMonitorHeaderFocus() + 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.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 + if key == "left" { + delta = -1 + } + m.setMonitorFilter((m.monitorFilter + monitorStatusFilter(delta) + monitorFilterCount) % monitorFilterCount) + case "down", "enter": + if !m.focusMonitorAttention(true) { + m.returnFromMonitorHeader() + } + case "up": + case "esc": + if m.monitorContextDetail != "" { + m.clearMonitorHeaderFocus() + } else { + m.setMonitorFilter(monitorFilterAll) + } + case "home": + m.setMonitorFilter(monitorFilterAll) + case "end": + m.setMonitorFilter(monitorFilterCheck) + default: + return m.monitorHeaderBlocksAction(key) + } + 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..fe1e591 --- /dev/null +++ b/internal/ui/monitor_filter_test.go @@ -0,0 +1,376 @@ +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, 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, active: true, working: true}, + {id: "undisplayed", active: true, 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", "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) + 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.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++ { + 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) + 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") + } + 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) + 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") + } +} + +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 = 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 && !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") + } + 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 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") + } + 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 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} { + 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) + 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(int(monitorFilterInput)+1, colors).Render("INPUT") + m.selectMonitorSession(1) + 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(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_header_focus.go b/internal/ui/monitor_header_focus.go new file mode 100644 index 0000000..fdfc01f --- /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() + action := m.selectedAttentionAction() + if restore { + action = m.monitorAttentionCursor + } + // 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.releaseMonitorHeaderControls() + m.monitorSummaryFocused, m.monitorAttentionFocused = false, true + return true + } + } + return false +} + +// 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.monitorAttentionCursor = "" + m.focusMonitorAttention(true) + } 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..93b9158 --- /dev/null +++ b/internal/ui/monitor_header_focus_test.go @@ -0,0 +1,328 @@ +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 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 + 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 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 + 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_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..2627a57 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,8 +30,8 @@ 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-down", enabled: count > 0 && (selected < 0 || selected < count-1)}, + {label: "[↑]", action: "session-up", enabled: count > 0 && !m.monitorSummaryFocused}, + {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_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_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_statusline.go b/internal/ui/monitor_statusline.go index 836245f..4af668e 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,37 @@ 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) { + 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) + } + } + } + return lines, selected +} + func (m Model) monitorStatusLineText(width int, copyLabel string) string { end := width - 2 if copyLabel != "" { @@ -77,7 +114,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 b748f2f..cd462c5 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,99 @@ 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 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() @@ -127,13 +221,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..3f48d37 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 @@ -82,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++ { @@ -103,11 +105,20 @@ 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() } +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 { @@ -196,9 +207,30 @@ 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 := 4 + for _, o := range choices { + 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 +} + +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 rows == 0 || w < 12 { + if w < 12 || h < 4 { return } g := m.monitorDashboardLayout() @@ -223,17 +255,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/monitor_summary.go b/internal/ui/monitor_summary.go index bea93b4..3599fb1 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -18,26 +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]++ - 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 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) @@ -46,28 +40,21 @@ 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 { + 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, 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) 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) @@ -200,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 } @@ -386,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) } @@ -458,15 +449,18 @@ 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 } 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") 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{ diff --git a/internal/ui/view.go b/internal/ui/view.go index f1d4fd3..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 { + 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)