diff --git a/internal/ui/model.go b/internal/ui/model.go index 5ddc855..c10e772 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2864,8 +2864,7 @@ func (m *Model) scrollMonitorRows(rows int) { m.monitorScroll = min(max(m.monitorScroll+rows, 0), maximum) } -func (m *Model) selectMonitorSession(direction int) { - m.clearQuotaConfirmation() +func (m *Model) monitorListSelection() ([]string, int) { visible := make([]string, 0, len(m.monitorSessionData)) selected := -1 for _, session := range m.monitorSessionData { @@ -2877,6 +2876,12 @@ func (m *Model) selectMonitorSession(direction int) { } visible = append(visible, session.id) } + return visible, selected +} + +func (m *Model) selectMonitorSession(direction int) { + m.clearQuotaConfirmation() + visible, selected := m.monitorListSelection() if len(visible) == 0 { m.monitorSelectedID = "" m.focusMonitorSummary() @@ -2918,17 +2923,7 @@ func (m *Model) selectMonitorSession(direction int) { } func (m *Model) dismissSelectedMonitorSession() { - visible := make([]string, 0, len(m.monitorSessionData)) - selected := -1 - for _, session := range m.monitorSessionData { - if !m.monitorSessionInList(session) { - continue - } - if session.id == m.monitorSelectedID { - selected = len(visible) - } - visible = append(visible, session.id) - } + visible, selected := m.monitorListSelection() if selected < 0 { m.monitorSelectedID = "" return diff --git a/internal/ui/monitor_approval.go b/internal/ui/monitor_approval.go index b438d19..1a5b533 100644 --- a/internal/ui/monitor_approval.go +++ b/internal/ui/monitor_approval.go @@ -247,6 +247,21 @@ func renderMonitorButtonRows(buttons []monitorApprovalButton, colors palette, re return strings.Join(rows, "\n") } +// monitorButtonAt uses frame-local coordinates and the same positioned labels +// as rendering. Reserved confirmation-slot padding is deliberately not active. +func monitorButtonAt(buttons []monitorApprovalButton, height, x, y int) string { + if len(buttons) == 0 { + return "" + } + _, _, controlY := monitorContextBodyLayout(height, buttons[len(buttons)-1].y+1) + for _, b := range buttons { + if y == controlY+b.y && x >= 2+b.x && x < 2+b.x+lipgloss.Width(b.label) { + return b.action + } + } + return "" +} + // Decision controls keep a solid fill even before hover, so they remain // discoverable independently of the terminal profile's default background. func (m Model) renderMonitorApprovalAction(action, label string, colors palette) string { diff --git a/internal/ui/monitor_buttons_test.go b/internal/ui/monitor_buttons_test.go index 6cad868..e676b10 100644 --- a/internal/ui/monitor_buttons_test.go +++ b/internal/ui/monitor_buttons_test.go @@ -55,3 +55,28 @@ func TestMonitorButtonRowsKeepApprovalAndProfileRenderers(t *testing.T) { } } } + +func TestMonitorButtonHitTestingUsesVisibleLabelsOnly(t *testing.T) { + if got := monitorButtonAt(nil, 8, 2, 4); got != "" { + t.Fatalf("empty controls exposed %q", got) + } + buttons := []monitorApprovalButton{ + {action: "first", label: "\x1b[31m界\x1b[0m", x: 0, y: 0, slot: 6}, + {action: "second", label: "B", x: 8, y: 0, slot: 9}, + {action: "third", label: "YES", x: 0, y: 2, slot: 10}, + } + for _, layout := range []struct{ height, controlY int }{{8, 4}, {30, 26}} { + for _, tc := range []struct { + x, row int + want string + }{ + {1, 0, ""}, {2, 0, "first"}, {3, 0, "first"}, {4, 0, ""}, {7, 0, ""}, + {9, 0, ""}, {10, 0, "second"}, {11, 0, ""}, + {2, -1, ""}, {2, 1, ""}, {2, 2, "third"}, {4, 2, "third"}, {5, 2, ""}, {2, 3, ""}, + } { + if got := monitorButtonAt(buttons, layout.height, tc.x, layout.controlY+tc.row); got != tc.want { + t.Fatalf("height=%d at %d,%d: got %q, want %q", layout.height, tc.x, layout.controlY+tc.row, got, tc.want) + } + } + } +} diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index 1254545..5cbdbda 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -441,20 +441,12 @@ func (m Model) monitorContextAt(x, y int) string { } } if s, ok := m.contextDetailSession(); ok { - if hit := profileButtonsAt(m.profileButtons(g.contentWidth, g.meterHeight, s), g.meterHeight, x, y); hit != "" { + if hit := monitorButtonAt(m.profileButtons(g.contentWidth, g.meterHeight, s), g.meterHeight, x, y); hit != "" { return hit } } - buttons := m.monitorApprovalButtons(g.contentWidth, g.meterHeight) - controlRows := 0 - if len(buttons) > 0 { - controlRows = buttons[len(buttons)-1].y + 1 - } - _, _, controlY := monitorContextBodyLayout(g.meterHeight, controlRows) - for _, b := range buttons { - if y == controlY+b.y && x >= b.x+2 && x < b.x+2+lipgloss.Width(b.label) { - return b.action - } + if hit := monitorButtonAt(m.monitorApprovalButtons(g.contentWidth, g.meterHeight), g.meterHeight, x, y); hit != "" { + return hit } if r, ok := contextActionRect(g.contentWidth, 0, monitorDismissLabel); ok && r.contains(x, y) { return "close" @@ -498,7 +490,7 @@ func (m Model) monitorContextAt(x, y int) string { if m.rowContextMode(s.id) == contextSplit { _, cw, _ = m.contextColumns(a.width, s) } - if hit := profileButtonsAt(m.profileButtons(cw, heights[i], s), heights[i], x-mw-1, y-rowY); hit != "" { + if hit := monitorButtonAt(m.profileButtons(cw, heights[i], s), heights[i], x-mw-1, y-rowY); hit != "" { return hit } } diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index 702dd7c..9b11d49 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -336,14 +336,8 @@ func (m Model) expandedContextAt(x, y int) string { } } } - buttons := m.expandedApprovalButtons(cw, heights[i], s) - if len(buttons) > 0 { - _, _, cy := monitorContextBodyLayout(heights[i], buttons[len(buttons)-1].y+1) - for _, b := range buttons { - if y == cy+b.y && x >= 2+b.x && x < 2+b.x+lipgloss.Width(b.label) { - return b.action - } - } + if hit := monitorButtonAt(m.expandedApprovalButtons(cw, heights[i], s), heights[i], x, y); hit != "" { + return hit } return "" } diff --git a/internal/ui/monitor_profile.go b/internal/ui/monitor_profile.go index cdbf7a0..aec6b73 100644 --- a/internal/ui/monitor_profile.go +++ b/internal/ui/monitor_profile.go @@ -170,19 +170,6 @@ func (m Model) renderSessionProfile(width, height int, s monitorSession, colors return frameSizedWithActions(width, max(height-2, 1), i18n.Text("QUOTA THRESHOLD"), navigation, "", strings.Join(body, "\n"), colors.primary, colors) } -func profileButtonsAt(buttons []monitorApprovalButton, height, x, y int) string { - if len(buttons) == 0 { - return "" - } - _, _, cy := monitorContextBodyLayout(height, buttons[len(buttons)-1].y+1) - for _, b := range buttons { - if y == cy+b.y && x >= 2+b.x && x < 2+b.x+lipgloss.Width(b.label) { - return b.action - } - } - return "" -} - func (m Model) visibleProfileButtons() []monitorApprovalButton { if m.meterView != viewMonitor || m.monitorPrompt.input.Focused() { return nil diff --git a/internal/ui/monitor_selection_test.go b/internal/ui/monitor_selection_test.go new file mode 100644 index 0000000..c07b5f9 --- /dev/null +++ b/internal/ui/monitor_selection_test.go @@ -0,0 +1,92 @@ +package ui + +import ( + "slices" + "testing" + "time" +) + +func TestMonitorListSelectionUsesCurrentFilterAndVisibility(t *testing.T) { + for _, tc := range []struct { + filter monitorStatusFilter + ids []string + }{ + {monitorFilterAll, []string{"work", "approval", "input", "check", "complete", "idle"}}, + {monitorFilterActive, []string{"work", "approval", "input", "complete"}}, + {monitorFilterWorking, []string{"work"}}, + {monitorFilterApproval, []string{"approval"}}, + {monitorFilterInput, []string{"input"}}, + {monitorFilterErrors, nil}, + {monitorFilterCheck, []string{"check"}}, + } { + for _, selected := range []string{"", "work", "approval", "input", "check", "complete", "idle", "dismissed", "undisplayed", "absent"} { + m := statusFilterTestModel() + m.monitorFilter, m.monitorSelectedID = tc.filter, selected + m.monitorSummaryFocused, m.monitorScroll = true, 3 + ids, index := m.monitorListSelection() + if !slices.Equal(ids, tc.ids) || index != slices.Index(tc.ids, selected) { + t.Fatalf("filter=%d selected=%q: got %v/%d, want %v/%d", tc.filter, selected, ids, index, tc.ids, slices.Index(tc.ids, selected)) + } + if m.monitorSelectedID != selected || m.monitorFilter != tc.filter || !m.monitorSummaryFocused || m.monitorScroll != 3 || m.monitorRecordedTokens() != 625 { + t.Fatal("list projection changed selection, focus, scrolling or accounting") + } + } + } +} + +func TestMonitorListSelectionKeepsNavigationAndDismissalScoped(t *testing.T) { + for _, tc := range []struct { + name string + filter monitorStatusFilter + selected, next, dismiss string + dismisses bool + }{ + {"middle row", monitorFilterAll, "approval", "input", "input", true}, + {"last row", monitorFilterAll, "idle", "idle", "complete", true}, + {"only matching row", monitorFilterApproval, "approval", "approval", "", true}, + {"selection outside filter", monitorFilterApproval, "work", "approval", "", false}, + {"no matching rows", monitorFilterErrors, "work", "", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := statusFilterTestModel() + m.monitorFilter, m.monitorSelectedID = tc.filter, tc.selected + m.selectMonitorSession(1) + if m.monitorSelectedID != tc.next || m.monitorFilter != tc.filter || len(m.monitorDismissed) != 1 { + t.Fatal("navigation changed filtering or dismissed a session") + } + if tc.filter == monitorFilterErrors && !m.monitorSummaryFocused { + t.Fatal("empty-list navigation did not return focus to the summary") + } + m = statusFilterTestModel() + m.monitorFilter, m.monitorSelectedID = tc.filter, tc.selected + m.dismissSelectedMonitorSession() + _, dismissed := m.monitorDismissed[tc.selected] + if m.monitorSelectedID != tc.dismiss || dismissed != tc.dismisses || m.monitorFilter != tc.filter || m.monitorRecordedTokens() != 625 { + t.Fatal("dismissal did not preserve filtered selection and accounting") + } + if !tc.dismisses && m.monitorSummaryFocused { + t.Fatal("invalid dismissal unexpectedly moved focus") + } + }) + } +} + +func TestMonitorListNavigationPreservesDraftAndDismissalWatermark(t *testing.T) { + m := statusFilterTestModel() + m.monitorFilter, m.monitorSelectedID, m.monitorContextExpanded = monitorFilterActive, "input", "input" + m.monitorApprovalConfirm = "old approval" + m.monitorPrompt = monitorPromptState{session: "input", input: newMonitorEditor()} + m.monitorPrompt.input.SetValue("keep this draft") + m.selectMonitorSession(1) + if m.monitorSelectedID != "complete" || m.monitorContextExpanded != "" || m.monitorApprovalConfirm != "" || m.monitorPrompt.session != "" || m.monitorDrafts["input"] != "keep this draft" { + t.Fatal("navigation lost the draft or retained old-session approval/editor state") + } + m.monitorSessionData[4].latest = 42 + m.monitorSessionData[4].callSequence, m.monitorSessionData[4].turnSequence = 9, 2 + m.monitorSessionData[4].lastActivity = time.Unix(100, 0) + m.dismissSelectedMonitorSession() + watermark := m.monitorDismissed["complete"] + if m.monitorSelectedID != "input" || watermark.latest != 42 || watermark.callSequence != 9 || watermark.turnSequence != 2 || !watermark.lastActivity.Equal(time.Unix(100, 0)) || m.monitorDrafts["input"] != "keep this draft" { + t.Fatal("dismissal lost activity watermarks, filtered selection or the saved draft") + } +}