Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions internal/ui/monitor_approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions internal/ui/monitor_buttons_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
16 changes: 4 additions & 12 deletions internal/ui/monitor_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
Expand Down
10 changes: 2 additions & 8 deletions internal/ui/monitor_context_modes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
Expand Down
13 changes: 0 additions & 13 deletions internal/ui/monitor_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions internal/ui/monitor_selection_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading