From fe52488fdeab9e7497eabd27360bd82405d467e9 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Tue, 20 Oct 2026 11:39:01 +0900 Subject: [PATCH 1/2] CPLAT-12912: bring mouse support to the fleet keyboard-first convention - wheel steps 3 (tuikit mouse.WheelRows) instead of 1 - double click uses tuikit mouse.Clicks keyed by item; empty-area clicks never activate - picker (RunPicker) gets mouse: click selects, second click is Enter, wheel moves 3 - every overlay owns the mouse while open: wheel drives scrollable ones, outside click is its Esc - program options come from mouse.ProgramOptions() - bump tuikit to c905dce --- go.mod | 2 +- go.sum | 4 +- internal/cli/picker.go | 80 +++++---- internal/cli/picker_mouse.go | 139 +++++++++++++++ internal/cli/picker_mouse_test.go | 216 +++++++++++++++++++++++ internal/cli/run_pick.go | 6 +- internal/tui/app.go | 3 +- internal/tui/mouse.go | 58 +++---- internal/tui/mouse_clicks.go | 117 +++++++++++++ internal/tui/mouse_helpers_test.go | 49 ++++++ internal/tui/mouse_list_test.go | 180 +++++++++++++++++++ internal/tui/mouse_overlay.go | 151 ++++++++++++++++ internal/tui/mouse_overlay_test.go | 268 +++++++++++++++++++++++++++++ internal/tui/mouse_test.go | 48 +++++- internal/tui/splitpane.go | 8 +- main.go | 3 +- 16 files changed, 1248 insertions(+), 84 deletions(-) create mode 100644 internal/cli/picker_mouse.go create mode 100644 internal/cli/picker_mouse_test.go create mode 100644 internal/tui/mouse_clicks.go create mode 100644 internal/tui/mouse_helpers_test.go create mode 100644 internal/tui/mouse_list_test.go create mode 100644 internal/tui/mouse_overlay.go create mode 100644 internal/tui/mouse_overlay_test.go diff --git a/go.mod b/go.mod index a6536c3..ba7d3c3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.6 - github.com/keyolk/tuikit v0.0.0-20261014182304-74b15dc0eda3 + github.com/keyolk/tuikit v0.0.0-20261020003136-c905dce1f2ed github.com/mattn/go-runewidth v0.0.19 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 1799d6f..9ceea5e 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/keyolk/tuikit v0.0.0-20261014182304-74b15dc0eda3 h1:moLrBV8aRn6R7dT22JJWn2wIL/dZe+Lf9zQK3G7dnag= -github.com/keyolk/tuikit v0.0.0-20261014182304-74b15dc0eda3/go.mod h1:f1m38AtKzIx8QT7vR3N0ghjtCcn8bhcoeYtwveRIcxw= +github.com/keyolk/tuikit v0.0.0-20261020003136-c905dce1f2ed h1:6jkFbFw98o5r1P2nU0m2/aMzvmHfG7wKiXODTwIC/4Q= +github.com/keyolk/tuikit v0.0.0-20261020003136-c905dce1f2ed/go.mod h1:f1m38AtKzIx8QT7vR3N0ghjtCcn8bhcoeYtwveRIcxw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= diff --git a/internal/cli/picker.go b/internal/cli/picker.go index 6070083..7c9fb77 100644 --- a/internal/cli/picker.go +++ b/internal/cli/picker.go @@ -14,6 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tk "github.com/keyolk/tuikit/kitty" + "github.com/keyolk/tuikit/mouse" "github.com/keyolk/tuikit/render" "github.com/mattn/go-runewidth" "github.com/sendbird/ccx/internal/extract" @@ -118,6 +119,10 @@ type pickerModel struct { // the subcommand and the session file to reload. ctx pickerContext + // clicks remembers the last row press so a second press on the same row + // is Enter. + clicks pickerClicks + // langmap maps CJK jamo/letters to the Latin key at the same physical // position, so picker shortcuts fire under a CJK input source. langmap map[rune]string @@ -276,6 +281,8 @@ func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.FocusMsg: m.termFocused = true return m, kittyPaneCmd(kitty.PaneCmd(), false) + case tea.MouseMsg: + return m.handleMouse(msg) case tea.KeyMsg: if msg.String() == "ctrl+c" { m.quit = true @@ -869,37 +876,17 @@ func (m pickerModel) renderListRow(item PickerItem, cursored bool, check string, return lipgloss.NewStyle().MaxWidth(listW).Render(row) } -func (m pickerModel) View() string { - if m.width == 0 || m.height == 0 { - return "" - } - - listW := m.listWidth() - pw := m.previewWidth() - contentH := m.height - 2 - - sel := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) - dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) - cat := lipgloss.NewStyle().Foreground(lipgloss.Color("#6366F1")).Bold(true) - +// renderListLines draws the list rows that fit in a pane of contentH rows and +// reports how many lines each drawn item took. View and the mouse hit test both +// read it, so a click lands on the row the user sees. +func (m pickerModel) renderListLines(contentH, listW int, sel, dim, cat lipgloss.Style) (lines []string, heights []int) { maxListLines := contentH - 2 // reserve for scrollInfo if maxListLines < 3 { maxListLines = 3 } - // For conversation mode, estimate items per page from actual line budget - var visMax int - if m.kind == "conversation" { - visMax = max(maxListLines/3, 1) - } else { - visMax = maxListLines - } - start := 0 - if m.cursor >= start+visMax { - start = m.cursor - visMax + 1 - } + start := m.listFirstVisible(contentH) - var listLines []string for i := start; i < len(m.items); i++ { item := m.items[i] ri := m.realIndex(i) @@ -913,18 +900,51 @@ func (m pickerModel) View() string { source = item.Source } rows := renderConversationListRow(item, i == m.cursor, check, " ", "", listW, sel, dim, source) - if len(listLines)+len(rows) > maxListLines && len(listLines) > 0 { + if len(lines)+len(rows) > maxListLines && len(lines) > 0 { break } - listLines = append(listLines, rows...) + lines = append(lines, rows...) + heights = append(heights, len(rows)) continue } - if len(listLines) >= maxListLines { + if len(lines) >= maxListLines { break } - listLines = append(listLines, m.renderListRow(item, i == m.cursor, check, listW, sel, dim, cat)) + lines = append(lines, m.renderListRow(item, i == m.cursor, check, listW, sel, dim, cat)) + heights = append(heights, 1) + } + return lines, heights +} + +// listFirstVisible is the index of the first item renderListLines draws. For +// conversation mode items per page are estimated from the line budget. +func (m pickerModel) listFirstVisible(contentH int) int { + maxListLines := max(contentH-2, 3) + visMax := maxListLines + if m.kind == "conversation" { + visMax = max(maxListLines/3, 1) + } + if m.cursor >= visMax { + return m.cursor - visMax + 1 + } + return 0 +} + +func (m pickerModel) View() string { + if m.width == 0 || m.height == 0 { + return "" } + listW := m.listWidth() + pw := m.previewWidth() + contentH := m.height - 2 + + sel := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + cat := lipgloss.NewStyle().Foreground(lipgloss.Color("#6366F1")).Bold(true) + + listLines, _ := m.renderListLines(contentH, listW, sel, dim, cat) + // The search line and counter share the list pane, so they get the same // per-line clamp as the rows: lipgloss wraps before it truncates, so an // over-long line costs a row even with MaxHeight set on the box. @@ -1450,7 +1470,7 @@ func RunPicker(kind string, items []PickerItem, openerCfg opener.Config, ctx pic } kitty.Init(tmux.Client()) model := newPickerModel(kind, items, openerCfg, ctx) - p := tea.NewProgram(model, tea.WithAltScreen()) + p := tea.NewProgram(model, append([]tea.ProgramOption{tea.WithAltScreen()}, mouse.ProgramOptions()...)...) finalModel, err := p.Run() // Clear any Kitty inline images before returning so they don't linger // in the main shell screen. diff --git a/internal/cli/picker_mouse.go b/internal/cli/picker_mouse.go new file mode 100644 index 0000000..cd8e3e5 --- /dev/null +++ b/internal/cli/picker_mouse.go @@ -0,0 +1,139 @@ +package cli + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/keyolk/tuikit/mouse" +) + +// pickerClicks remembers the previous press for double-click detection. now is +// injectable so tests do not depend on wall-clock timing. +type pickerClicks struct { + clicks mouse.Clicks + now func() time.Time +} + +// listRect is the screen rectangle of the list rows, the same numbers View +// lays out: row 0 is the title, the list pane starts on row 1, and an active +// search line or applied term takes the first row of the pane. +func (m pickerModel) listRect() mouse.Rect { + contentH := m.height - 2 + y := 1 + if m.searching || m.searchTerm != "" { + y++ + } + return mouse.Rect{X: 0, Y: y, W: m.listWidth(), H: max(contentH-2, 3)} +} + +// previewRect is the preview pane including its left border column. +func (m pickerModel) previewRect() mouse.Rect { + listW := m.listWidth() + return mouse.Rect{X: listW, Y: 1, W: max(m.width-listW, 0), H: max(m.height-2, 0)} +} + +// listItemAt maps a mouse event to a filtered-item index using the drawn row +// heights, so multi-line conversation rows hit the right item. +func (m pickerModel) listItemAt(msg tea.MouseMsg) (int, bool) { + sel := lipgloss.NewStyle() + _, heights := m.renderListLines(m.height-2, m.listWidth(), sel, sel, sel) + c := mouse.BlockHit(m.listRect(), 0, heights, msg) + if !c.HasItem { + return -1, false + } + return m.listFirstVisible(m.height-2) + c.Index, true +} + +// handleMouse is the picker's mouse path. Every gesture ends in the same key +// handlers the keyboard uses: +// +// - left press on a row: select it; a second press on the same row is Enter. +// - wheel over the list: three Down/Up presses; over the preview: scroll it +// three lines (or move the artifact cursor three, which j/k does). +// - ref picker (opened by Enter on a multi-ref item): wheel moves its cursor, +// a click outside the preview is its Esc. +// - everything else, and anything while the search line owns input: ignored. +func (m pickerModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.width == 0 || m.height == 0 || m.searching { + return m, nil + } + if m.refPicking { + return m.handleRefMouse(msg) + } + if d := mouse.Delta(msg); d != 0 { + return m.handleWheel(msg, d) + } + if !mouse.IsLeftPress(msg) { + return m, nil + } + idx, ok := m.listItemAt(msg) + if !ok { + m.clicks.clicks = mouse.Clicks{} + return m, nil + } + m.cursor = idx + m.previewFocused = false + m.updatePreview() + + now := time.Now + if m.clicks.now != nil { + now = m.clicks.now + } + var double bool + m.clicks.clicks, double = m.clicks.clicks.Press(fmt.Sprintf("item:%d", m.realIndex(idx)), now()) + if double { + return m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + } + return m, nil +} + +func (m pickerModel) handleWheel(msg tea.MouseMsg, d int) (tea.Model, tea.Cmd) { + switch { + case m.previewRect().Contains(msg.X, msg.Y): + m.previewFocused = true + if m.kind == "conversation" && m.cursor >= 0 && m.cursor < len(m.items) && len(m.items[m.cursor].ConversationArtifacts) > 0 { + return m.repeatKey(d, pickerModel.handlePreviewKey) + } + if d < 0 { + m.preview.LineUp(mouse.WheelRows) + } else { + m.preview.LineDown(mouse.WheelRows) + } + return m, nil + case (mouse.Rect{X: 0, Y: 1, W: m.listWidth(), H: max(m.height-2, 0)}).Contains(msg.X, msg.Y): + m.previewFocused = false + return m.repeatKey(d, pickerModel.handleKey) + } + return m, nil +} + +func (m pickerModel) handleRefMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if d := mouse.Delta(msg); d != 0 { + return m.repeatKey(d, pickerModel.handleRefKey) + } + if mouse.IsLeftPress(msg) && !m.previewRect().Contains(msg.X, msg.Y) { + return m.handleRefKey(tea.KeyMsg{Type: tea.KeyEsc}) + } + return m, nil +} + +// repeatKey sends |d| Up or Down presses through a key handler, threading the +// model through each press because the handlers have value receivers. +func (m pickerModel) repeatKey(d int, handle func(pickerModel, tea.KeyMsg) (tea.Model, tea.Cmd)) (tea.Model, tea.Cmd) { + key := tea.KeyMsg{Type: tea.KeyDown} + if d < 0 { + key = tea.KeyMsg{Type: tea.KeyUp} + } + var cmds []tea.Cmd + cur := m + var model tea.Model = m + for range max(d, -d) { + var cmd tea.Cmd + model, cmd = handle(cur, key) + cur = model.(pickerModel) + cmds = append(cmds, cmd) + } + return model, tea.Batch(cmds...) +} diff --git a/internal/cli/picker_mouse_test.go b/internal/cli/picker_mouse_test.go new file mode 100644 index 0000000..b36267c --- /dev/null +++ b/internal/cli/picker_mouse_test.go @@ -0,0 +1,216 @@ +package cli + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" + "github.com/sendbird/ccx/internal/extract" + "github.com/sendbird/ccx/internal/opener" +) + +type pickerClock struct{ t time.Time } + +func (c *pickerClock) now() time.Time { return c.t } + +func mousePicker(t *testing.T, kind string, items []PickerItem, w, h int) (pickerModel, *pickerClock) { + t.Helper() + t.Setenv("HOME", t.TempDir()) // newPickerModel reads ~/.config/ccx/config.yaml + m := newPickerModel(kind, items, opener.Config{}, pickerContext{command: kind}) + mm, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + m = mm.(pickerModel) + clock := &pickerClock{t: time.Now()} + m.clicks.now = clock.now + return m, clock +} + +func linkItems(n int) []PickerItem { + var items []PickerItem + for i := 0; i < n; i++ { + items = append(items, PickerItem{ + Item: extract.Item{URL: fmt.Sprintf("https://example.com/%d", i), Label: fmt.Sprintf("link-%02d", i), Category: "other"}, + SessionID: "s1", + Refs: []ItemRef{{EntryUUID: fmt.Sprintf("u%d", i), Role: "user"}}, + }) + } + return items +} + +// where finds text on the rendered screen, so a test clicks where the user +// would, not where the code's own arithmetic says the row is. +func where(t *testing.T, m pickerModel, text string) (x, y int) { + t.Helper() + for y, line := range strings.Split(ansi.Strip(m.View()), "\n") { + if i := strings.Index(line, text); i >= 0 { + return runewidth.StringWidth(line[:i]), y + } + } + t.Fatalf("%q not on screen:\n%s", text, ansi.Strip(m.View())) + return 0, 0 +} + +func click(x, y int) tea.MouseMsg { + return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} +} + +func notch(x, y int, down bool) tea.MouseMsg { + b := tea.MouseButtonWheelUp + if down { + b = tea.MouseButtonWheelDown + } + return tea.MouseMsg{X: x, Y: y, Button: b, Action: tea.MouseActionPress} +} + +func feed(m pickerModel, msg tea.Msg) pickerModel { + mm, _ := m.Update(msg) + return mm.(pickerModel) +} + +func TestPickerClickSelectsAndSecondClickIsEnter(t *testing.T) { + m, clock := mousePicker(t, "urls", linkItems(10), 100, 24) + x, y := where(t, m, "link-04") + m = feed(m, click(x, y)) + if m.cursor != 4 || m.result != nil { + t.Fatalf("cursor=%d result=%v; first click must only select row 4", m.cursor, m.result) + } + clock.t = clock.t.Add(100 * time.Millisecond) + m = feed(m, click(x, y)) + if m.result == nil || m.result.EntryUUID != "u4" { + t.Fatalf("second click did not run Enter on row 4: %+v", m.result) + } +} + +func TestPickerSecondClickOnAnotherRowOnlySelects(t *testing.T) { + m, clock := mousePicker(t, "urls", linkItems(10), 100, 24) + x, y := where(t, m, "link-02") + m = feed(m, click(x, y)) + clock.t = clock.t.Add(50 * time.Millisecond) + x, y = where(t, m, "link-05") + m = feed(m, click(x, y)) + if m.cursor != 5 || m.result != nil { + t.Fatalf("cursor=%d result=%v; rows 2 then 5 must not activate", m.cursor, m.result) + } +} + +func TestPickerWheelMovesThree(t *testing.T) { + m, _ := mousePicker(t, "urls", linkItems(10), 100, 24) + x, y := where(t, m, "link-00") + m = feed(m, notch(x, y, true)) + if m.cursor != 3 { + t.Fatalf("cursor=%d after one notch down, want 3", m.cursor) + } + m = feed(m, notch(x, y, false)) + if m.cursor != 0 { + t.Fatalf("cursor=%d after one notch up, want 0", m.cursor) + } +} + +func TestPickerWheelOverPreviewScrollsItThree(t *testing.T) { + items := linkItems(3) + for i := range items[0].Refs { + items[0].Refs[i].Preview = strings.Repeat("context line\n", 60) + } + m, _ := mousePicker(t, "urls", items, 100, 24) + px := m.listWidth() + 5 + m = feed(m, notch(px, 5, true)) + if m.preview.YOffset != 3 || m.cursor != 0 { + t.Fatalf("preview offset=%d cursor=%d after a notch over the preview, want 3 and 0", m.preview.YOffset, m.cursor) + } +} + +func TestPickerEmptyAreaAndOtherGesturesDoNothing(t *testing.T) { + m, clock := mousePicker(t, "urls", linkItems(3), 100, 24) + for _, msg := range []tea.MouseMsg{ + click(3, 15), // empty rows below three items + click(3, 15), + click(3, 0), // title + click(3, 23), // footer + {X: 3, Y: 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease}, + {X: 3, Y: 2, Button: tea.MouseButtonNone, Action: tea.MouseActionMotion}, + {X: 3, Y: 2, Button: tea.MouseButtonRight, Action: tea.MouseActionPress}, + click(m.listWidth()+5, 2), // preview pane + } { + m = feed(m, msg) + clock.t = clock.t.Add(10 * time.Millisecond) + if m.cursor != 0 || m.result != nil || m.quit { + t.Fatalf("%+v changed state: cursor=%d result=%v", msg, m.cursor, m.result) + } + } +} + +func TestPickerIgnoresMouseWhileSearching(t *testing.T) { + m, _ := mousePicker(t, "urls", linkItems(10), 100, 24) + m = feed(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + x, y := where(t, m, "link-03") + m = feed(m, click(x, y)) + m = feed(m, notch(x, y, true)) + if m.cursor != 0 || !m.searching { + t.Fatalf("cursor=%d searching=%t; the search line owns input", m.cursor, m.searching) + } +} + +func TestPickerRefPickerOwnsTheMouse(t *testing.T) { + items := linkItems(3) + items[1].Refs = append(items[1].Refs, ItemRef{EntryUUID: "extra", Role: "user"}, ItemRef{EntryUUID: "extra2", Role: "user"}, ItemRef{EntryUUID: "extra3", Role: "user"}, ItemRef{EntryUUID: "extra4", Role: "user"}) + m, _ := mousePicker(t, "urls", items, 100, 24) + m.cursor = 1 + m = feed(m, tea.KeyMsg{Type: tea.KeyEnter}) + if !m.refPicking { + t.Fatal("Enter on a multi-ref item did not open the ref picker") + } + px := m.listWidth() + 5 + m = feed(m, notch(px, 5, true)) + if m.refCursor != 3 { + t.Fatalf("refCursor=%d after one notch, want 3", m.refCursor) + } + x, y := where(t, m, "link-00") + m = feed(m, click(x, y)) // a row behind the ref picker + if m.refPicking || m.cursor != 1 { + t.Fatalf("outside click: refPicking=%t cursor=%d; want Esc, and row 0 left unselected", m.refPicking, m.cursor) + } +} + +func TestPickerConversationRowsHitMultiLineItems(t *testing.T) { + var items []PickerItem + for i := 0; i < 8; i++ { + items = append(items, PickerItem{ + Item: extract.Item{URL: fmt.Sprintf("conversation:%d", i), Label: fmt.Sprintf("turn-%02d first line", i), Category: "conversation"}, + SessionID: "s1", + Refs: []ItemRef{{EntryUUID: fmt.Sprintf("u%d", i), Role: "user"}}, + ConversationText: "body line one\nbody line two", + }) + } + m, _ := mousePicker(t, "conversation", items, 120, 30) + x, y := where(t, m, "turn-03") + // The row's last line belongs to the same item as its header. + _, heights := m.renderListLines(m.height-2, m.listWidth(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) + if heights[3] < 2 { + t.Fatalf("test needs multi-line conversation rows, got heights %v", heights) + } + m = feed(m, click(x, y+heights[3]-1)) + if m.cursor != 3 { + t.Fatalf("click on the last line of item 3 selected %d", m.cursor) + } +} + +// With more items than rows the list scrolls; a click must map through the +// first visible item, not row 0. +func TestPickerClickRespectsScrollOffset(t *testing.T) { + m, _ := mousePicker(t, "urls", linkItems(60), 100, 24) + for range 45 { + m = feed(m, tea.KeyMsg{Type: tea.KeyDown}) + } + if m.listFirstVisible(m.height-2) == 0 { + t.Fatal("list did not scroll") + } + x, y := where(t, m, "link-40") + m = feed(m, click(x, y)) + if m.cursor != 40 { + t.Fatalf("click on link-40 in a scrolled list selected %d", m.cursor) + } +} diff --git a/internal/cli/run_pick.go b/internal/cli/run_pick.go index 6bf7916..c80f4a2 100644 --- a/internal/cli/run_pick.go +++ b/internal/cli/run_pick.go @@ -7,6 +7,7 @@ import ( "path/filepath" tea "github.com/charmbracelet/bubbletea" + "github.com/keyolk/tuikit/mouse" "github.com/sendbird/ccx/internal/clauderegistry" "github.com/sendbird/ccx/internal/kitty" "github.com/sendbird/ccx/internal/session" @@ -66,11 +67,10 @@ func RunPickSessionTUI(claudeDir, search string, multi bool) PickSessionExitCode // Detect Kitty graphics once, before the program: it may query tmux. kitty.Init(tmux.Client()) - p := tea.NewProgram(app, + p := tea.NewProgram(app, append([]tea.ProgramOption{ tea.WithAltScreen(), tea.WithOutput(os.Stderr), - tea.WithMouseCellMotion(), - ) + }, mouse.ProgramOptions()...)...) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "ccx: picker failed: %v\n", err) return PickSessionError diff --git a/internal/tui/app.go b/internal/tui/app.go index 224e50b..eaf3f03 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -303,8 +303,7 @@ type App struct { // Mouse state dragResizing bool - lastClickTime time.Time - lastClickY int + clicks mouseClickState breadcrumbSegs []breadcrumbSegment // Global stats view diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 694ad05..5f2d062 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -1,18 +1,21 @@ package tui import ( - "time" - "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/keyolk/tuikit/mouse" ) -const doubleClickThreshold = 400 * time.Millisecond - -const mouseScrollLines = 1 +// mouseScrollLines is one wheel notch. It is the fleet-wide step (tuikit +// mouse.WheelRows) so ccx scrolls like every other keyboard-first TUI. +const mouseScrollLines = mouse.WheelRows func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + // Overlays own the mouse while open: nothing below may see the event. + if m, cmd, handled := a.handleOverlayMouse(msg); handled { + return m, cmd + } // In copy mode, only allow scroll on the focused inspector viewport. if a.copyModeActive { if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { @@ -127,7 +130,9 @@ func (a *App) handleMouseScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if up { key = "up" } - a.handleExecutionRailKey(key) + for range mouseScrollLines { + a.handleExecutionRailKey(key) + } return a, nil } sp := a.activeSplitPane() @@ -231,27 +236,16 @@ func (a *App) handleMouseClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return a, nil } - // Double-click detection: same Y within threshold - now := time.Now() - isDoubleClick := msg.Y == a.lastClickY && now.Sub(a.lastClickTime) < doubleClickThreshold - a.lastClickTime = now - a.lastClickY = msg.Y - - if isDoubleClick { - // Double-click in preview → toggle fold. - sp := a.activeSplitPane() - if sp != nil && sp.HandleMouseDoubleClick(msg.X, a.width, a.splitRatio) { - return a, a.refreshActivePreview() - } - // A zoomed preview has no list target behind it. - if sp != nil && sp.PreviewOnly { - return a, nil - } - // Double-click in list → enter. - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} - return a.Update(enterMsg) + m, cmd := a.selectByClick(msg) + if a.registerClick(msg) { + return a.activateClicked(msg, cmd) } + return m, cmd +} +// selectByClick is the single-click half of handleMouseClick: focus the pane +// under the pointer and select the row there. It never activates anything. +func (a *App) selectByClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { contentY := msg.Y - 1 // adjust for title bar sp := a.activeSplitPane() clickedPreview := sp != nil && sp.Show && (sp.PreviewOnly || msg.X > sp.ListWidth(a.width, a.splitRatio)) @@ -346,19 +340,7 @@ func mouseScrollList(l *list.Model, up bool) { // All list chrome (status bar, filter bar, pagination) is disabled, // so items start at Y=0 within the list widget. func mouseClickList(l *list.Model, contentY int, itemHeight int) { - if contentY < 0 || itemHeight < 1 { - return - } - - visible := l.VisibleItems() - if len(visible) == 0 { - return - } - - clickOffset := contentY / itemHeight - pageStart := l.Paginator.Page * l.Paginator.PerPage - target := pageStart + clickOffset - if target >= 0 && target < len(visible) { + if target, ok := listRowAt(l, contentY, itemHeight); ok { l.Select(target) } } diff --git a/internal/tui/mouse_clicks.go b/internal/tui/mouse_clicks.go new file mode 100644 index 0000000..d35cb10 --- /dev/null +++ b/internal/tui/mouse_clicks.go @@ -0,0 +1,117 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/keyolk/tuikit/mouse" +) + +// mouseClickState remembers the previous press so a second press on the same +// item reads as a double click. now is injectable so tests do not depend on +// wall-clock timing; nil means time.Now. +type mouseClickState struct { + clicks mouse.Clicks + now func() time.Time +} + +// registerClick records a left press that just selected something and reports +// whether it completed a double click on the same item. Selection must have +// already happened: the key is derived from the post-click selection, so the +// first press of a pair can never match a stale target. A press that landed on +// no item (empty rows below a short list, a view without a list) clears the +// pair, so two clicks on empty space never read as Enter. +func (a *App) registerClick(msg tea.MouseMsg) bool { + key, ok := a.clickTargetKey(msg) + if !ok { + a.clicks.clicks = mouse.Clicks{} + return false + } + now := time.Now + if a.clicks.now != nil { + now = a.clicks.now + } + var double bool + a.clicks.clicks, double = a.clicks.clicks.Press(key, now()) + return double +} + +// clickTargetKey identifies the item under a press in a way that is stable +// across the two presses of a double click: view, pane side, and the item the +// press selected. Sessions use their ID; other lists fall back to the cursor +// index, which cannot change between two presses 400ms apart. ok is false when +// the press hit no item. +func (a *App) clickTargetKey(msg tea.MouseMsg) (key string, ok bool) { + sp := a.activeSplitPane() + if sp == nil { + return "", false + } + if sp.Show && (sp.PreviewOnly || msg.X > sp.ListWidth(a.width, a.splitRatio)) { + if sp.Folds != nil && len(sp.Folds.BlockStarts) > 0 { + return fmt.Sprintf("v%d:preview:block%d", a.state, sp.Folds.BlockCursor), true + } + return fmt.Sprintf("v%d:preview:line%d", a.state, sp.Preview.YOffset+msg.Y), true + } + row := msg.Y - 1 - sp.headerInset // same offsets selectByClick feeds the split pane + if row < 0 { + // Conversation header rows (context list) select without a list index. + if a.state == viewConversation && msg.Y-1 < sp.headerInset { + return fmt.Sprintf("v%d:header:%d", a.state, msg.Y), true + } + return "", false + } + if _, hit := listRowAt(sp.List, row, sp.ItemHeight); !hit { + return "", false + } + if a.state == viewSessions { + if sess, found := a.selectedSession(); found { + return fmt.Sprintf("v%d:list:%s", a.state, sess.ID), true + } + } + return fmt.Sprintf("v%d:list:%d", a.state, sp.List.Index()), true +} + +// listRowAt maps a row inside a paginated one-or-more-lines-per-item list to +// the visible item index, reporting false for the empty rows below a short +// page. It is what mouseClickList selects with, shared so the click that +// selects and the check that it hit something cannot disagree. +func listRowAt(l *list.Model, contentY, itemHeight int) (int, bool) { + if l == nil || contentY < 0 || itemHeight < 1 { + return -1, false + } + total := len(l.VisibleItems()) + start := l.Paginator.Page * l.Paginator.PerPage + n := min(l.Paginator.PerPage, total-start) + if n <= 0 { + return -1, false + } + heights := make([]int, n) + for i := range heights { + heights[i] = itemHeight + } + rect := mouse.Rect{W: 1, H: n * itemHeight} + c := mouse.BlockHit(rect, 0, heights, tea.MouseMsg{X: 0, Y: contentY}) + if !c.HasItem { + return -1, false + } + return start + c.Index, true +} + +// activateClicked runs the app's Enter path for a double click, so the mouse +// does exactly what the key does. selectCmd is the async work the selecting +// click produced; it must not be dropped. +func (a *App) activateClicked(msg tea.MouseMsg, selectCmd tea.Cmd) (tea.Model, tea.Cmd) { + sp := a.activeSplitPane() + // Double-click in preview toggles the block fold. + if sp != nil && sp.HandleMouseDoubleClick(msg.X, a.width, a.splitRatio) { + return a, tea.Batch(selectCmd, a.refreshActivePreview()) + } + // A zoomed preview has no list target behind it. + if sp != nil && sp.PreviewOnly { + return a, selectCmd + } + m, cmd := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + return m, tea.Batch(selectCmd, cmd) +} diff --git a/internal/tui/mouse_helpers_test.go b/internal/tui/mouse_helpers_test.go new file mode 100644 index 0000000..2893b95 --- /dev/null +++ b/internal/tui/mouse_helpers_test.go @@ -0,0 +1,49 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-runewidth" +) + +// testClock is a hand-advanced clock for double-click timing. +type testClock struct{ t time.Time } + +func fixedClock(t time.Time) *testClock { return &testClock{t: t} } +func (c *testClock) now() time.Time { return c.t } +func (c *testClock) advance(d time.Duration) { c.t = c.t.Add(d) } + +func press(x, y int) tea.MouseMsg { + return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} +} + +func wheel(x, y int, down bool) tea.MouseMsg { + b := tea.MouseButtonWheelUp + if down { + b = tea.MouseButtonWheelDown + } + return tea.MouseMsg{X: x, Y: y, Button: b, Action: tea.MouseActionPress} +} + +// find locates text on the rendered screen, so tests click where the user +// would see it instead of recomputing the layout with the code's own math. +func find(t *testing.T, app *App, text string) (x, y int) { + t.Helper() + cells := screenCells(app.View()) + for y, row := range cells { + line := strings.Join(row, "") + if i := strings.Index(line, text); i >= 0 { + return runewidth.StringWidth(line[:i]), y + } + } + t.Fatalf("%q not on screen:\n%s", text, stripANSI(app.View())) + return 0, 0 +} + +func send(app *App, msg tea.Msg) *App { + m, _ := app.Update(msg) + return m.(*App) +} diff --git a/internal/tui/mouse_list_test.go b/internal/tui/mouse_list_test.go new file mode 100644 index 0000000..7671c28 --- /dev/null +++ b/internal/tui/mouse_list_test.go @@ -0,0 +1,180 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +// manySessions builds n sessions backed by a one-line transcript each, so +// Enter (opening a conversation) has a real file to load. +func manySessions(t *testing.T, n int) []session.Session { + t.Helper() + dir := t.TempDir() + line := `{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}` + "\n" + var out []session.Session + for i := 0; i < n; i++ { + id := fmt.Sprintf("s%02d", i) + path := filepath.Join(dir, id+".jsonl") + if err := os.WriteFile(path, []byte(line), 0o600); err != nil { + t.Fatal(err) + } + out = append(out, session.Session{ + ID: id, ShortID: id, FilePath: path, ProjectPath: "/tmp/p" + id, ProjectName: "proj-" + id, + ModTime: time.Now().Add(-time.Duration(i) * time.Minute), MsgCount: 3, + }) + } + return out +} + +func newMouseApp(t *testing.T, n int) (*App, *testClock) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + app := newTestApp(manySessions(t, n)) + clock := fixedClock(time.Now()) + app.clicks.now = clock.now + return app, clock +} + +func selectedID(t *testing.T, app *App) string { + t.Helper() + s, ok := app.selectedSession() + if !ok { + t.Fatal("no selected session") + } + return s.ID +} + +func TestClickSelectsRowWithoutOpening(t *testing.T) { + app, _ := newMouseApp(t, 6) + x, y := find(t, app, "proj-s03") + app = send(app, press(x, y)) + if got := selectedID(t, app); got != "s03" { + t.Fatalf("selected %q, want s03", got) + } + if app.state != viewSessions { + t.Fatalf("single click left view %d; a click must only select", app.state) + } +} + +func TestSecondClickOnSelectedRowIsEnter(t *testing.T) { + app, clock := newMouseApp(t, 6) + x, y := find(t, app, "proj-s03") + app = send(app, press(x, y)) + clock.advance(100 * time.Millisecond) + app = send(app, press(x, y)) + if app.state != viewConversation { + t.Fatalf("second click did not run Enter: state=%d", app.state) + } + if got := app.currentSess.ID; got != "s03" { + t.Fatalf("Enter opened %q, want s03", got) + } +} + +func TestSecondClickOnDifferentRowOnlySelects(t *testing.T) { + app, clock := newMouseApp(t, 6) + x, y := find(t, app, "proj-s02") + app = send(app, press(x, y)) + clock.advance(50 * time.Millisecond) + x, y = find(t, app, "proj-s04") + app = send(app, press(x, y)) + if app.state != viewSessions || selectedID(t, app) != "s04" { + t.Fatalf("state=%d selected=%s; two rows in a row must not activate", app.state, selectedID(t, app)) + } +} + +func TestSlowSecondClickDoesNotOpen(t *testing.T) { + app, clock := newMouseApp(t, 6) + x, y := find(t, app, "proj-s03") + app = send(app, press(x, y)) + clock.advance(time.Second) + app = send(app, press(x, y)) + if app.state != viewSessions { + t.Fatalf("a click after the double-click window opened the session") + } +} + +func TestEmptyAreaClicksDoNothing(t *testing.T) { + app, clock := newMouseApp(t, 3) + before := selectedID(t, app) + // Row 40 is far below the three short rows. + for range 2 { + app = send(app, press(5, 40)) + clock.advance(50 * time.Millisecond) + } + if app.state != viewSessions || selectedID(t, app) != before { + t.Fatalf("empty-area double click changed state=%d selected=%s", app.state, selectedID(t, app)) + } +} + +func TestWheelMovesSelectionByThree(t *testing.T) { + app, _ := newMouseApp(t, 12) + x, y := find(t, app, "proj-s00") + app = send(app, wheel(x, y, true)) + if got := selectedID(t, app); got != "s03" { + t.Fatalf("one wheel notch down selected %q, want s03", got) + } + app = send(app, wheel(x, y, false)) + if got := selectedID(t, app); got != "s00" { + t.Fatalf("one wheel notch up selected %q, want s00", got) + } +} + +func TestReleaseMotionAndOtherButtonsAreIgnored(t *testing.T) { + app, _ := newMouseApp(t, 6) + x, y := find(t, app, "proj-s03") + for _, msg := range []tea.MouseMsg{ + {X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease}, + {X: x, Y: y, Button: tea.MouseButtonNone, Action: tea.MouseActionMotion}, + {X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion}, + {X: x, Y: y, Button: tea.MouseButtonRight, Action: tea.MouseActionPress}, + {X: x, Y: y, Button: tea.MouseButtonMiddle, Action: tea.MouseActionPress}, + } { + app = send(app, msg) + if got := selectedID(t, app); got != "s00" { + t.Fatalf("%v selected %q; only a left press may select", msg, got) + } + } +} + +func TestClickHitsRowWithPreviewOpen(t *testing.T) { + for _, width := range []int{160, 70} { + t.Run(fmt.Sprint(width), func(t *testing.T) { + app, _ := newMouseApp(t, 8) + app = send(app, tea.WindowSizeMsg{Width: width, Height: 30}) + app.sessSplit.Show = true + app = send(app, tea.WindowSizeMsg{Width: width, Height: 30}) + x, y := find(t, app, "s05 ") + app = send(app, press(x, y)) + if got := selectedID(t, app); got != "s05" { + t.Fatalf("width %d: selected %q, want s05", width, got) + } + }) + } +} + +// A list longer than one page paginates; clicks map through the page start. +func TestClickOnSecondPageSelectsThatRow(t *testing.T) { + app, _ := newMouseApp(t, 60) + perPage := app.sessionList.Paginator.PerPage + if perPage >= 60 { + t.Fatalf("test needs a paginated list, per page = %d", perPage) + } + for range perPage + 2 { + app = send(app, tea.KeyMsg{Type: tea.KeyDown}) + } + if app.sessionList.Paginator.Page == 0 { + t.Fatal("did not reach the second page") + } + want := fmt.Sprintf("s%02d", perPage+4) + x, y := find(t, app, "proj-"+want) + app = send(app, press(x, y)) + if got := selectedID(t, app); got != want { + t.Fatalf("click on %s selected %q", want, got) + } +} diff --git a/internal/tui/mouse_overlay.go b/internal/tui/mouse_overlay.go new file mode 100644 index 0000000..f1c3b4f --- /dev/null +++ b/internal/tui/mouse_overlay.go @@ -0,0 +1,151 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/keyolk/tuikit/mouse" + "github.com/mattn/go-runewidth" +) + +// mouseOverlay describes one modal/menu/palette that owns the mouse while it +// is open. open mirrors the flag View uses to draw it. scroll marks overlays +// whose own keys treat up/down as navigation, so the wheel can drive them +// through that same key path; overlays where any key dismisses (help, confirm, +// the letter menus) must not receive synthetic arrows, or one wheel notch +// would close them. +type mouseOverlay struct { + name string + open func(a *App) bool + close func(a *App) // clears the flag on a View-only copy; see overlayRect + scroll bool +} + +// mouseOverlays lists every overlay View can draw, topmost first. Keep it in +// step with the overlay blocks in App.View; TestEveryOverlayOwnsTheMouse +// drives each entry. +var mouseOverlays = []mouseOverlay{ + {"live input", func(a *App) bool { return a.liveInputActive }, func(a *App) { a.liveInputActive = false }, false}, + {"cross-session search", func(a *App) bool { return a.searchActive }, func(a *App) { a.searchActive = false }, true}, + {"confirm", func(a *App) bool { return a.confirmMsg != "" }, func(a *App) { a.confirmMsg = "" }, false}, + {"help", func(a *App) bool { return a.showHelp }, func(a *App) { a.showHelp = false }, false}, + {"command palette", func(a *App) bool { return a.cmdMode }, func(a *App) { a.cmdMode = false }, true}, + {"url menu", func(a *App) bool { return a.urlMenu }, func(a *App) { a.urlMenu = false }, true}, + {"full text", func(a *App) bool { return a.state == viewSessions && a.sessConvFullText != "" }, func(a *App) { a.sessConvFullText = "" }, true}, + {"project picker", func(a *App) bool { return a.state == viewConfig && a.cfgProjectPicker }, func(a *App) { a.cfgProjectPicker = false }, true}, + {"tag menu", func(a *App) bool { return a.tagMenu }, func(a *App) { a.tagMenu = false }, false}, + {"share ref", func(a *App) bool { return a.shareRefMenu }, func(a *App) { a.shareRefMenu = false }, true}, + {"actions menu", func(a *App) bool { return a.actionsMenu && a.state == viewSessions }, func(a *App) { a.actionsMenu = false }, false}, + {"page menu", func(a *App) bool { return a.sessPageMenu && a.state == viewSessions }, func(a *App) { a.sessPageMenu = false }, false}, + {"state menu", func(a *App) bool { return a.stateMenu && a.state == viewSessions }, func(a *App) { a.stateMenu = false }, false}, + {"views menu", func(a *App) bool { return a.viewsMenu }, func(a *App) { a.viewsMenu = false }, false}, + {"edit menu", func(a *App) bool { return a.editMenu }, func(a *App) { a.editMenu = false }, false}, + {"stats page menu", func(a *App) bool { return a.statsPageMenu && a.state == viewGlobalStats }, func(a *App) { a.statsPageMenu = false }, false}, + {"inspector menu", func(a *App) bool { return a.inspectorMenu && a.state == viewConversation }, func(a *App) { a.inspectorMenu = false }, false}, + {"conversation actions", func(a *App) bool { return a.convActionsMenu && a.state == viewConversation }, func(a *App) { a.convActionsMenu = false }, false}, + {"execution context menu", func(a *App) bool { return a.executionContextMenu && a.state == viewConversation }, func(a *App) { a.executionContextMenu = false }, false}, + {"config actions", func(a *App) bool { return a.cfgActionsMenu && a.state == viewConfig }, func(a *App) { a.cfgActionsMenu = false }, false}, + {"plugin actions", func(a *App) bool { return a.plgActionsMenu && a.state == viewConfig && a.cfgPluginsPage }, func(a *App) { a.plgActionsMenu = false }, false}, + {"plugin component actions", func(a *App) bool { + return a.plgCompActionsMenu && a.state == viewConfig && a.cfgPluginsPage && a.plgDetailActive + }, func(a *App) { a.plgCompActionsMenu = false }, false}, +} + +// activeOverlay returns the topmost open overlay, if any. +func (a *App) activeOverlay() (mouseOverlay, bool) { + for _, o := range mouseOverlays { + if o.open(a) { + return o, true + } + } + return mouseOverlay{}, false +} + +// handleOverlayMouse gives an open overlay the whole mouse. handled is true +// whenever an overlay is open, even for events it ignores, so nothing reaches +// the base view underneath. +// +// - wheel: drives scrollable overlays through their own up/down keys. +// - left click outside the overlay: the overlay's Esc key. +// - left click inside, right/middle click, release, motion: ignored. +func (a *App) handleOverlayMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd, bool) { + o, ok := a.activeOverlay() + if !ok { + return a, nil, false + } + if d := mouse.Delta(msg); d != 0 { + if !o.scroll { + return a, nil, true + } + key := tea.KeyMsg{Type: tea.KeyDown} + if d < 0 { + key = tea.KeyMsg{Type: tea.KeyUp} + } + var cmds []tea.Cmd + for range mouse.WheelRows { + _, cmd := a.Update(key) + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...), true + } + if !mouse.IsLeftPress(msg) { + return a, nil, true + } + if a.overlayRect().Contains(msg.X, msg.Y) { + return a, nil, true + } + m, cmd := a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + return m, cmd, true +} + +// overlayRect is the screen rectangle the open overlay covers. Overlays are +// drawn by a dozen render paths with their own sizing, so rather than repeat +// that arithmetic it renders View twice, with and without the overlay, and +// takes the bounding box of the cells that differ. The rows reserved for the +// title and footer are skipped because overlays rewrite the footer hint. +func (a *App) overlayRect() mouse.Rect { + with := screenCells(a.View()) + base := *a + for _, o := range mouseOverlays { + o.close(&base) + } + without := screenCells(base.View()) + + minX, minY, maxX, maxY := -1, -1, -1, -1 + for y := 1; y < len(with)-1; y++ { + for x, c := range with[y] { + if y < len(without) && x < len(without[y]) && without[y][x] == c { + continue + } + if minX < 0 || x < minX { + minX = x + } + if minY < 0 || y < minY { + minY = y + } + maxX = max(maxX, x) + maxY = max(maxY, y) + } + } + if minX < 0 { + return mouse.Rect{} + } + return mouse.Rect{X: minX, Y: minY, W: maxX - minX + 1, H: maxY - minY + 1} +} + +// screenCells splits a rendered screen into rows of terminal cells with ANSI +// stripped. A wide rune fills two cells so columns match the terminal's. +func screenCells(screen string) [][]string { + lines := strings.Split(ansi.Strip(screen), "\n") + rows := make([][]string, len(lines)) + for i, l := range lines { + for _, r := range l { + rows[i] = append(rows[i], string(r)) + if runewidth.RuneWidth(r) == 2 { + rows[i] = append(rows[i], "") + } + } + } + return rows +} diff --git a/internal/tui/mouse_overlay_test.go b/internal/tui/mouse_overlay_test.go new file mode 100644 index 0000000..5223aae --- /dev/null +++ b/internal/tui/mouse_overlay_test.go @@ -0,0 +1,268 @@ +package tui + +import ( + "fmt" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + "github.com/sendbird/ccx/internal/extract" +) + +// overlayCase opens one overlay. prep builds the app in the view the overlay +// belongs to (nil means the sessions list); setup raises the overlay itself. +type overlayCase struct { + name string + prep func(t *testing.T) *App + setup func(a *App) +} + +func prepConversation(t *testing.T) *App { + t.Helper() + app, _, _ := setupInspectorFlowApp(t) + return app +} + +func prepConfig(t *testing.T) *App { + t.Helper() + return setupCfgApp(t) +} + +func prepPlugins(t *testing.T) *App { + t.Helper() + app := setupCfgApp(t) + app.cfgPluginsPage = true + app.plgSplit.Show = true + return app +} + +func prepStats(t *testing.T) *App { + t.Helper() + app, _ := newMouseApp(t, 3) + app.state = viewGlobalStats + app.globalStatsLoading = false + app.globalStatsVP = viewport.New(app.width, app.height-3) + app.globalStatsVP.SetContent("stats body") + return app +} + +// baseKey summarizes the base view's state, so a test can prove an overlay +// kept every mouse event away from it. +func baseKey(app *App) string { + key := fmt.Sprintf("state=%d focus=%t/%t/%t", app.state, app.sessSplit.Focus, app.conv.split.Focus, app.cfgSplit.Focus) + if app.state == viewSessions { + if s, ok := app.selectedSession(); ok { + key += " sel=" + s.ID + } + } + if app.state == viewConversation { + key += fmt.Sprintf(" conv=%d", app.convList.Index()) + } + return key +} + +func urlItemsForTest() []extract.Item { + var items []extract.Item + for i := range 8 { + items = append(items, extract.Item{URL: fmt.Sprintf("https://example.com/%d", i), Label: fmt.Sprintf("link %d", i), Category: "other"}) + } + return items +} + +func overlayCases() []overlayCase { + return []overlayCase{ + {"live input", nil, func(a *App) { a.liveInputModal = newInputModal(); a.liveInputActive = true }}, + {"cross-session search", nil, func(a *App) { a.enterSearchMode() }}, + {"confirm", nil, func(a *App) { a.confirmMsg = "Delete this session?" }}, + {"help", nil, func(a *App) { a.showHelp = true }}, + {"command palette", nil, func(a *App) { a.startCmdMode() }}, + {"url menu", nil, func(a *App) { a.openURLMenuFromItems(urlItemsForTest(), "urls") }}, + {"full text", nil, func(a *App) { a.sessConvFullText = longText() }}, + {"tag menu", nil, func(a *App) { a.tagMenu = true }}, + {"actions menu", nil, func(a *App) { a.actionsMenu = true }}, + {"page menu", nil, func(a *App) { a.sessPageMenu = true }}, + {"state menu", nil, func(a *App) { a.stateMenu = true }}, + {"views menu", nil, func(a *App) { a.viewsMenu = true }}, + {"edit menu", nil, func(a *App) { a.openEditMenu(a.sessions[0]) }}, + {"share ref", nil, func(a *App) { + a.shareRefMenu = true + a.shareRefStage = shareRefStageItem + a.shareRefItems = []shareRefItem{{Label: "a", Path: "/a", Kind: "memory"}, {Label: "b", Path: "/b", Kind: "plan"}, {Label: "c", Path: "/c", Kind: "plan"}, {Label: "d", Path: "/d", Kind: "plan"}, {Label: "e", Path: "/e", Kind: "plan"}} + }}, + {"stats page menu", prepStats, func(a *App) { a.statsPageMenu = true }}, + {"inspector menu", prepConversation, func(a *App) { a.inspectorMenu = true }}, + {"conversation actions", prepConversation, func(a *App) { a.convActionsMenu = true }}, + {"execution context menu", prepConversation, func(a *App) { a.executionContextMenu = true }}, + {"project picker", prepConfig, func(a *App) { + ti := textinput.New() + ti.Focus() + a.cfgProjectInput = ti + a.cfgProjectEntries = []cfgProjectEntry{{path: "/a", name: "a"}, {path: "/b", name: "b"}, {path: "/c", name: "c"}, {path: "/d", name: "d"}, {path: "/e", name: "e"}} + a.cfgProjectPicker = true + }}, + {"config actions", prepConfig, func(a *App) { a.cfgActionsMenu = true }}, + {"plugin actions", prepPlugins, func(a *App) { a.plgActionsMenu = true }}, + {"plugin component actions", prepPlugins, func(a *App) { a.plgDetailActive = true; a.plgDetailSplit.Show = true; a.plgCompActionsMenu = true }}, + } +} + +func longText() string { + s := "" + for i := range 80 { + s += fmt.Sprintf("line %d of the full text\n", i) + } + return s +} + +func openOverlay(t *testing.T, tc overlayCase) *App { + t.Helper() + var app *App + if tc.prep != nil { + app = tc.prep(t) + } else { + app, _ = newMouseApp(t, 6) + } + tc.setup(app) + if o, ok := app.activeOverlay(); !ok || o.name != tc.name { + t.Fatalf("setup opened %q (open=%t), want %q", o.name, ok, tc.name) + } + if app.overlayRect().W == 0 { + t.Fatalf("overlay drew nothing:\n%s", stripANSI(app.View())) + } + return app +} + +func TestOverlayCasesCoverTheTable(t *testing.T) { + have := map[string]bool{} + for _, tc := range overlayCases() { + have[tc.name] = true + } + for _, o := range mouseOverlays { + if !have[o.name] { + t.Errorf("overlay %q has no mouse test", o.name) + } + } +} + +// Outside click is the overlay's Esc: it closes, and nothing underneath runs +// (the title row would otherwise be a breadcrumb navigation). +func TestOverlayOutsideClickClosesAndIsolatesBase(t *testing.T) { + for _, tc := range overlayCases() { + t.Run(tc.name, func(t *testing.T) { + app := openOverlay(t, tc) + before := baseKey(app) + if r := app.overlayRect(); r.Contains(0, 0) { + t.Fatal("rect covers the title row") + } + app = send(app, press(0, 0)) + if o, open := app.activeOverlay(); open { + t.Fatalf("outside click left %q open", o.name) + } + if got := baseKey(app); got != before { + t.Fatalf("outside click reached the base: %s -> %s", before, got) + } + }) + } +} + +func TestOverlayInsideClickKeepsItOpenAndBaseUntouched(t *testing.T) { + for _, tc := range overlayCases() { + t.Run(tc.name, func(t *testing.T) { + app := openOverlay(t, tc) + before := baseKey(app) + r := app.overlayRect() + app = send(app, press(r.X+r.W/2, r.Y+r.H/2)) + if o, open := app.activeOverlay(); !open || o.name != tc.name { + t.Fatalf("inside click closed %q", tc.name) + } + if got := baseKey(app); got != before { + t.Fatalf("inside click reached the base: %s -> %s", before, got) + } + }) + } +} + +func TestOverlayClickOnRowBehindDoesNotSelectIt(t *testing.T) { + for _, tc := range overlayCases() { + if tc.prep != nil { + continue // row hit-testing is the sessions list's + } + t.Run(tc.name, func(t *testing.T) { + app := openOverlay(t, tc) + r := app.overlayRect() + // Row s05 is drawn near the top of the list; use whichever visible + // row is not covered by this overlay. + for _, id := range []string{"s05 ", "s04 ", "s03 ", "s02 ", "s01 "} { + x, y := find(t, app, id) + if r.Contains(x, y) { + continue + } + app = send(app, press(x, y)) + if got := selectedID(t, app); got != "s00" { + t.Fatalf("click behind overlay selected %q", got) + } + return + } + t.Skip("overlay covers every visible row") + }) + } +} + +func TestOverlayWheelNeverReachesBaseOrDismisses(t *testing.T) { + for _, tc := range overlayCases() { + t.Run(tc.name, func(t *testing.T) { + app := openOverlay(t, tc) + before := baseKey(app) + r := app.overlayRect() + for _, down := range []bool{true, false} { + app = send(app, wheel(r.X+1, r.Y+1, down)) + } + if o, open := app.activeOverlay(); !open || o.name != tc.name { + t.Fatalf("wheel dismissed %q", tc.name) + } + if got := baseKey(app); got != before { + t.Fatalf("wheel reached the base: %s -> %s", before, got) + } + }) + } +} + +func TestScrollableOverlaysFollowTheWheelByThree(t *testing.T) { + app := openOverlay(t, overlayCase{"url menu", nil, func(a *App) { a.openURLMenuFromItems(urlItemsForTest(), "urls") }}) + r := app.overlayRect() + app = send(app, wheel(r.X+1, r.Y+1, true)) + if app.urlCursor != 3 { + t.Fatalf("url menu cursor = %d after one notch, want 3", app.urlCursor) + } + app = send(app, wheel(r.X+1, r.Y+1, false)) + if app.urlCursor != 0 { + t.Fatalf("url menu cursor = %d after notch back, want 0", app.urlCursor) + } + + app = openOverlay(t, overlayCase{"full text", nil, func(a *App) { a.sessConvFullText = longText() }}) + r = app.overlayRect() + app = send(app, wheel(r.X+1, r.Y+1, true)) + if app.sessConvFullScroll != 3 { + t.Fatalf("full text scroll = %d after one notch, want 3", app.sessConvFullScroll) + } +} + +func TestShareRefAndProjectPickerFollowTheWheel(t *testing.T) { + for _, tc := range overlayCases() { + if tc.name != "share ref" && tc.name != "project picker" { + continue + } + t.Run(tc.name, func(t *testing.T) { + app := openOverlay(t, tc) + r := app.overlayRect() + app = send(app, wheel(r.X+1, r.Y+1, true)) + got := app.shareRefCursor + if tc.name == "project picker" { + got = app.cfgProjectCursor + } + if got != 3 { + t.Fatalf("cursor = %d after one notch, want 3", got) + } + }) + } +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index fa1c123..3057bad 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -37,10 +37,13 @@ func TestZoomedInspectorMouseTreatsFullWidthAsPreview(t *testing.T) { t.Fatalf("left-side zoom scroll changed hidden list: focus=%t selected=%d want=%d", app.conv.split.Focus, app.convList.Index(), selected) } - app.lastClickY = 6 - app.lastClickTime = time.Now() - m, _ = app.handleMouseClick(tea.MouseMsg{X: 1, Y: 6, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) - app = m.(*App) + clock := fixedClock(time.Now()) + app.clicks.now = clock.now + for range 2 { + m, _ = app.handleMouseClick(tea.MouseMsg{X: 1, Y: 6, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + app = m.(*App) + clock.advance(50 * time.Millisecond) + } if app.convList.Index() != selected || !app.conv.inspector.Zoom { t.Fatalf("zoom double-click escaped to hidden list: selected=%d want=%d zoom=%t", app.convList.Index(), selected, app.conv.inspector.Zoom) } @@ -110,3 +113,40 @@ func TestSessionSingleClickSelectsVisibleFilteredItem(t *testing.T) { t.Fatalf("expected clicked filtered session proj-b, got %q", sess.ProjectName) } } + +func TestWheelScrollsFocusedPreviewByThreeLines(t *testing.T) { + app, _ := newMouseApp(t, 6) + app.sessSplit.Show = true + app = send(app, tea.WindowSizeMsg{Width: 160, Height: 30}) + var sb strings.Builder + for i := range 100 { + sb.WriteString(strings.Repeat("x", 10) + string(rune('a'+i%26)) + "\n") + } + app.sessSplit.Preview.SetContent(sb.String()) + app.sessSplit.Preview.GotoTop() + px := app.sessSplit.ListWidth(app.width, app.splitRatio) + 5 + app = send(app, wheel(px, 5, true)) + if got := app.sessSplit.Preview.YOffset; got != 3 { + t.Fatalf("preview offset = %d after one notch, want 3", got) + } +} + +func TestDragOnSplitBorderStillResizes(t *testing.T) { + app, _ := newMouseApp(t, 6) + app.sessSplit.Show = true + app = send(app, tea.WindowSizeMsg{Width: 160, Height: 30}) + border := app.sessSplit.ListWidth(app.width, app.splitRatio) + before := app.splitRatio + app = send(app, press(border, 5)) + if !app.dragResizing { + t.Fatal("press on the divider did not start a drag") + } + app = send(app, tea.MouseMsg{X: border + 20, Y: 5, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion}) + if app.splitRatio <= before { + t.Fatalf("drag right left ratio at %d (was %d)", app.splitRatio, before) + } + app = send(app, tea.MouseMsg{X: border + 20, Y: 5, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease}) + if app.dragResizing { + t.Fatal("release did not end the drag") + } +} diff --git a/internal/tui/splitpane.go b/internal/tui/splitpane.go index e9f0f0f..aa74cb0 100644 --- a/internal/tui/splitpane.go +++ b/internal/tui/splitpane.go @@ -488,10 +488,12 @@ func (sp *SplitPane) HandleMouseScroll(mouseX int, up bool, totalW, splitRatio i if sp.Show && (sp.PreviewOnly || mouseX > sp.ListWidth(totalW, splitRatio)) { // Preview side: move block cursor for fold-aware panes, scroll for simple if sp.Folds != nil && sp.Focus && len(sp.Folds.BlockStarts) > 0 { + key := "down" if up { - sp.Folds.HandleKey("up") - } else { - sp.Folds.HandleKey("down") + key = "up" + } + for range mouseScrollLines { + sp.Folds.HandleKey(key) } sp.ScrollToBlock() } else { diff --git a/main.go b/main.go index 6491a60..e0694d4 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/keyolk/tuikit/mouse" "github.com/sendbird/ccx/internal/clauderegistry" "github.com/sendbird/ccx/internal/cli" "github.com/sendbird/ccx/internal/kitty" @@ -340,7 +341,7 @@ func main() { }) // Detect Kitty graphics once, before the program: it may query tmux. kitty.Init(tmux.Client()) - p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(app, append([]tea.ProgramOption{tea.WithAltScreen()}, mouse.ProgramOptions()...)...) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) From ab53805319d6f4438ec1d55e0180f0e9fcb9f1d1 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Tue, 20 Oct 2026 11:59:49 +0900 Subject: [PATCH 2/2] CPLAT-12912: a key press between two clicks breaks the double click Codex found on the ccproxy rollout that click, j, k, click on the same row read as a double click and fired Enter, because only mouse presses touched the detector. Reset it in the KeyMsg arm of the app and the picker. --- internal/cli/picker.go | 3 +++ internal/cli/picker_mouse_test.go | 14 ++++++++++++++ internal/tui/app.go | 4 ++++ internal/tui/mouse_clicks.go | 3 ++- internal/tui/mouse_list_test.go | 14 ++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/internal/cli/picker.go b/internal/cli/picker.go index 7c9fb77..5de43b3 100644 --- a/internal/cli/picker.go +++ b/internal/cli/picker.go @@ -284,6 +284,9 @@ func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: return m.handleMouse(msg) case tea.KeyMsg: + // A key between two clicks breaks the pair: click, move away and back + // by keyboard, click again must not count as a double click. + m.clicks.clicks = mouse.Clicks{} if msg.String() == "ctrl+c" { m.quit = true return m, tea.Quit diff --git a/internal/cli/picker_mouse_test.go b/internal/cli/picker_mouse_test.go index b36267c..482976a 100644 --- a/internal/cli/picker_mouse_test.go +++ b/internal/cli/picker_mouse_test.go @@ -214,3 +214,17 @@ func TestPickerClickRespectsScrollOffset(t *testing.T) { t.Fatalf("click on link-40 in a scrolled list selected %d", m.cursor) } } + +func TestPickerKeyBetweenClicksBreaksTheDoubleClick(t *testing.T) { + m, clock := mousePicker(t, "urls", linkItems(10), 100, 24) + x, y := where(t, m, "link-04") + m = feed(m, click(x, y)) + clock.t = clock.t.Add(50 * time.Millisecond) + m = feed(m, tea.KeyMsg{Type: tea.KeyDown}) + m = feed(m, tea.KeyMsg{Type: tea.KeyUp}) + clock.t = clock.t.Add(50 * time.Millisecond) + m = feed(m, click(x, y)) + if m.cursor != 4 || m.result != nil { + t.Fatalf("click, down, up, click: cursor=%d result=%v; a key between clicks must break the pair", m.cursor, m.result) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index eaf3f03..8644cdf 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -17,6 +17,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tk "github.com/keyolk/tuikit/kitty" + "github.com/keyolk/tuikit/mouse" "github.com/keyolk/tuikit/prefs" "github.com/keyolk/tuikit/toast" "github.com/sendbird/ccx/internal/claudecmd" @@ -1691,6 +1692,9 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) { return a.handleMouse(msg) case tea.KeyMsg: + // A key between two clicks breaks the pair: click, move away and back + // by keyboard, click again must not count as a double click. + a.clicks.clicks = mouse.Clicks{} // A key acknowledges a sticky error toast; info toasts expire on // their own tick (tuikit/toast). a.toasts = toast.OnKey(a.toasts) diff --git a/internal/tui/mouse_clicks.go b/internal/tui/mouse_clicks.go index d35cb10..a9c0241 100644 --- a/internal/tui/mouse_clicks.go +++ b/internal/tui/mouse_clicks.go @@ -41,7 +41,8 @@ func (a *App) registerClick(msg tea.MouseMsg) bool { // clickTargetKey identifies the item under a press in a way that is stable // across the two presses of a double click: view, pane side, and the item the // press selected. Sessions use their ID; other lists fall back to the cursor -// index, which cannot change between two presses 400ms apart. ok is false when +// index, which only a key press could change between the two presses, and a +// key press resets the pair (Update's KeyMsg arm). ok is false when // the press hit no item. func (a *App) clickTargetKey(msg tea.MouseMsg) (key string, ok bool) { sp := a.activeSplitPane() diff --git a/internal/tui/mouse_list_test.go b/internal/tui/mouse_list_test.go index 7671c28..9df2c9d 100644 --- a/internal/tui/mouse_list_test.go +++ b/internal/tui/mouse_list_test.go @@ -178,3 +178,17 @@ func TestClickOnSecondPageSelectsThatRow(t *testing.T) { t.Fatalf("click on %s selected %q", want, got) } } + +func TestKeyBetweenClicksBreaksTheDoubleClick(t *testing.T) { + app, clock := newMouseApp(t, 6) + x, y := find(t, app, "proj-s03") + app = send(app, press(x, y)) + clock.advance(50 * time.Millisecond) + app = send(app, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + app = send(app, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + clock.advance(50 * time.Millisecond) + app = send(app, press(x, y)) + if app.state != viewSessions || selectedID(t, app) != "s03" { + t.Fatalf("click, j, k, click: state=%d selected=%s; a key between clicks must break the pair", app.state, selectedID(t, app)) + } +}