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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
83 changes: 53 additions & 30 deletions internal/cli/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -276,7 +281,12 @@ 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:
// 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
Expand Down Expand Up @@ -869,37 +879,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)
Expand All @@ -913,18 +903,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.
Expand Down Expand Up @@ -1450,7 +1473,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.
Expand Down
139 changes: 139 additions & 0 deletions internal/cli/picker_mouse.go
Original file line number Diff line number Diff line change
@@ -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...)
}
Loading
Loading