Vectrum
Sign In

tui: the mouse scrolls, selects and copies

The TUI runs in the alternate screen, where the terminal has no
scrollback of its own to roll, and it never asked for mouse reporting —
so the wheel did nothing at all, and a drag was the terminal's business
and reached nothing here.

Asking for mouse reporting fixes the wheel and breaks the drag: the
moment a program takes the mouse, the terminal stops sending drags to
its own selection. So the selection had to be replaced rather than
merely gained. Drag marks text and copies it when you let go;
double-click takes a word, triple-click a line, shift+click extends. A
word is a run of path characters too, because what is worth
double-clicking in a wall of tool output is internal/tui/model.go:222
and not "internal". Dragging against either edge scrolls, which keeps
going while the pointer moves — button-event reporting says nothing
about a hand held still, and a ticker that scrolled on its own would run
away from a hand that had stopped.

Clicking is answering: a completion row, a model, the slider's track or
labels, an approval choice, the mode line. Each goes through the same
setter its key does, so a click and an enter cannot decide differently.
The wheel over an open list moves through it rather than rolling the
transcript behind it.

The chrome is now assembled from layout(), a list of sections, and
bottom() joins that same list — a click resolves against the rows the
frame actually drew rather than against a second copy of the height
arithmetic, which is the way that sort of thing drifts.

A selection is content lines and display cells, not screen cells, so it
survives scrolling and a streaming turn appending underneath it; a
resize, ctrl+r or the wizard drops it, because those lay every line out
again. It is painted over the viewport's own output — the visible rows
only, so the transcript's render cache is untouched on the path a
streamed token takes — and the marked span is stripped before it is
restyled, because a style nested inside a background ends in a reset and
would tear a hole across a line of highlighted code.

Copying goes out twice, over OSC 52 and to the desktop clipboard,
because neither works everywhere: OSC 52 is the only one that survives
ssh, and several terminals ship with it disabled. What was copied is
reported on the activity line, where five copies cost no scrollback; a
failure is reported in the transcript, where it cannot be missed.

/mouse hands the mouse back to the terminal. Clicking in the prompt box
does not move the caret: bubbles' textarea has no mouse of its own and
soft-wrapped lines make placing one fiddly, so the prompt stays
keyboard-driven and the README says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent e9e6141commit00e7e95

Showing 16 changed files with 1700 additions and 67 deletions.

+28−1README.mdView File
@@ -137,7 +137,34 @@ to six lines.
137137 | `ctrl+c` | interrupt the turn; again when idle to exit |
138138
139139 Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
140- `/setup`, `/skills`, and one per discovered skill.
140+ `/mouse`, `/setup`, `/skills`, and one per discovered skill.
141+
142+ ### The mouse
143+
144+ | Does | How |
145+ | --- | --- |
146+ | scroll the transcript | the wheel, anywhere |
147+ | select | drag; double-click a word, triple-click a line, shift+click to extend |
148+ | copy | let go — the selection lands on the clipboard on release |
149+ | choose | click a menu row, a model, the effort slider, an approval, the mode line |
150+
151+ The prompt box is keyboard-driven: a click does not move the caret in it, and
152+ what you have typed is the one thing the mouse cannot select. Dragging against
153+ the top or bottom edge of the transcript scrolls, so a selection can be longer
154+ than the screen; it keeps going while the pointer moves, because a terminal
155+ reporting button events says nothing about a hand held still.
156+
157+ The harness runs in the alternate screen, where the terminal has no scrollback
158+ of its own to roll — so asking for mouse reporting is what makes the wheel work
159+ at all. The cost is the terminal's own text selection, which stops being the
160+ terminal's the moment drags are reported to the program. That is why the
161+ selection above exists: it is the replacement, and copying goes out over both
162+ OSC 52 (which works over ssh) and the desktop clipboard, because terminals
163+ disagree about which of the two they allow.
164+
165+ `/mouse` hands the mouse back. Selection and copy become the terminal's again,
166+ exactly as before this existed, and the wheel goes quiet. `shift`+drag usually
167+ reaches the terminal's own selection without turning reporting off at all.
141168
142169 A slash at the start of an empty prompt opens the command menu, which filters
143170 as you type — a name that starts with what you typed before one that merely
+1−1go.modView File
@@ -4,6 +4,7 @@ go 1.26.8
44
55 require (
66 github.com/alecthomas/chroma/v2 v2.20.0
7+ github.com/atotto/clipboard v0.1.4
78 github.com/bmatcuk/doublestar/v4 v4.10.0
89 github.com/charmbracelet/bubbles v1.0.0
910 github.com/charmbracelet/bubbletea v1.3.10
@@ -17,7 +18,6 @@ require (
1718 )
1819
1920 require (
20- github.com/atotto/clipboard v0.1.4 // indirect
2121 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
2222 github.com/aymerick/douceur v0.2.0 // indirect
2323 github.com/bahlo/generic-list-go v0.2.0 // indirect
+76−0internal/tui/clipboard.goaddedView File
@@ -0,0 +1,76 @@
1+ package tui
2+
3+ import (
4+ "os"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/atotto/clipboard"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/x/ansi"
11+ )
12+
13+ // Copying out of a full-screen terminal program is done twice, because
14+ // neither way works everywhere.
15+ //
16+ // OSC 52 asks the terminal itself to take the text, which is the only way
17+ // that works over ssh, in a container, or anywhere else the process is not
18+ // sitting next to the desktop it is displayed on. Several terminals ship with
19+ // it turned off, though, so the desktop's own clipboard is written too, with
20+ // the same bytes — whichever of the two lands, the text is the text.
21+ //
22+ // The OSC 52 sequence is written straight to the terminal rather than through
23+ // a frame. It carries no cursor movement and ends itself, so landing between
24+ // two frames costs nothing on screen.
25+
26+ // copyMax caps what is sent. Terminals have their own limit on an OSC 52
27+ // payload — commonly around 100k after base64 — and a selection large enough
28+ // to hit it is a mis-drag rather than something a person means to paste.
29+ const copyMax = 64 * 1024
30+
31+ // copiedMsg reports a finished copy. The text comes back with it so the
32+ // chrome can say what was taken without reaching for the selection again,
33+ // which by then may already be gone.
34+ type copiedMsg struct {
35+ text string
36+ err error
37+ }
38+
39+ func copyToClipboard(text string) error {
40+ if text == "" {
41+ return nil
42+ }
43+ if len(text) > copyMax {
44+ text = text[:copyMax]
45+ }
46+ // The desktop clipboard is best effort: no X display, no xclip, no
47+ // pbcopy — all of them are ordinary on a remote machine, and none of them
48+ // means the copy failed, because OSC 52 is still on its way.
49+ _ = clipboard.WriteAll(text)
50+
51+ _, err := os.Stdout.WriteString(ansi.SetSystemClipboard(text))
52+ return err
53+ }
54+
55+ // finishCopy is what the chrome says about it. A failure is worth a line in
56+ // the transcript — the text is not on the clipboard and the person is about
57+ // to try to paste it — but a success is not: it belongs on the status line,
58+ // where it costs no scrollback.
59+ func (m *model) finishCopy(msg copiedMsg) tea.Cmd {
60+ if msg.err != nil {
61+ m.tr.addError("could not copy: " + msg.err.Error())
62+ return m.sync()
63+ }
64+ m.copied = "✂ " + copyNote(msg.text)
65+ return nil
66+ }
67+
68+ // copyNote says what was taken. One line is quoted back, because seeing the
69+ // words is the fastest way to know the right thing was caught; more than one
70+ // is counted, because a quote would not fit.
71+ func copyNote(text string) string {
72+ if n := strings.Count(text, "\n"); n > 0 {
73+ return "copied " + plural(n+1, "line")
74+ }
75+ return "copied " + strconv.Quote(truncate(text, 28))
76+ }
+8−3internal/tui/colour_test.goView File
@@ -22,8 +22,13 @@ func forceColour(t *testing.T) {
2222 }
2323
2424 // unbackedCells returns the printable characters that were emitted with no
25- // background colour in force.
26- func unbackedCells(s string) string {
25+ // background colour in force, and backedCells the ones that had one. Between
26+ // them they say where a background actually landed: which cells a pill filled,
27+ // and which cells a mouse selection marked.
28+ func unbackedCells(s string) string { return cellsWithBackground(s, false) }
29+ func backedCells(s string) string { return cellsWithBackground(s, true) }
30+
31+ func cellsWithBackground(s string, want bool) string {
2732 var out []rune
2833 active := ""
2934 for i := 0; i < len(s); {
@@ -44,7 +49,7 @@ func unbackedCells(s string) string {
4449 continue
4550 }
4651 r, size := utf8.DecodeRuneInString(s[i:])
47- if !strings.Contains(active, "48;") {
52+ if strings.Contains(active, "48;") == want {
4853 out = append(out, r)
4954 }
5055 i += size
+6−1internal/tui/commands.goView File
@@ -27,6 +27,7 @@ var builtins = []command{
2727 {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"},
2828 {name: "setup", desc: "the full-screen wizard for the saved defaults"},
2929 {name: "clear", desc: "empty the transcript"},
30+ {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"},
3031 {name: "allowed", desc: "what is permanently allowed this session"},
3132 {name: "exit", desc: "leave the harness"},
3233 }
@@ -78,7 +79,11 @@ func (m *model) helpText() string {
7879 if n := len(m.opts.Skills); n > 0 {
7980 fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them")
8081 }
81- b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" +
82+ b.WriteString("\nmouse: wheel scrolls · drag selects and copies on release" +
83+ "\n double-click a word · triple-click a line · shift+click extends" +
84+ "\n click a menu row, a model, the slider or an approval to choose it" +
85+ "\n /mouse hands the mouse back to the terminal for its own selection" +
86+ "\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" +
8287 "\n ! runs a shell command · $ runs python — both on an empty prompt" +
8388 "\n what they print goes to the agent too, so you can then say \"fix that\"" +
8489 "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" +
+17−11internal/tui/complete.goView File
@@ -123,17 +123,7 @@ func (m *model) menuView() string {
123123 if !m.menuOpen() {
124124 return ""
125125 }
126- rows := m.menuRows()
127- shown := rows
128- if len(m.menu.items) > rows {
129- // The last row goes to the count of what did not fit.
130- shown = maxInt(1, rows-1)
131- }
132- start := 0
133- if m.menu.idx >= shown {
134- start = m.menu.idx - shown + 1
135- }
136- end := minInt(len(m.menu.items), start+shown)
126+ start, end := m.menuWindow()
137127
138128 // One column for the names, so the descriptions line up. A long skill
139129 // name is left to overflow its column rather than being truncated: the
@@ -166,6 +156,22 @@ func (m *model) menuView() string {
166156 return strings.TrimRight(b.String(), "\n")
167157 }
168158
159+ // menuWindow is the slice of the list that is on screen. The view draws it
160+ // and a click is resolved against it, so the row under the pointer and the
161+ // row that was drawn are the same row by construction.
162+ func (m *model) menuWindow() (start, end int) {
163+ rows := m.menuRows()
164+ shown := rows
165+ if len(m.menu.items) > rows {
166+ // The last row goes to the count of what did not fit.
167+ shown = maxInt(1, rows-1)
168+ }
169+ if m.menu.idx >= shown {
170+ start = m.menu.idx - shown + 1
171+ }
172+ return start, minInt(len(m.menu.items), start+shown)
173+ }
174+
169175 // pad widens s to width cells, leaving anything already wider alone.
170176 func pad(s string, width int) string {
171177 if n := width - lipgloss.Width(s); n > 0 {
+42−0internal/tui/effort.goView File
@@ -94,6 +94,16 @@ func (m *model) applyEffort(level string) {
9494 m.tr.addNotice("effort: " + level + " — from the next turn on")
9595 }
9696
97+ // The slider's four rows. Two of them are the slider — the track and the
98+ // labels under it — and two are words about it, which is the difference
99+ // between a click that moves the marker and a click on a caption.
100+ const (
101+ effortPoleRow = iota
102+ effortTrackRow
103+ effortLabelRow
104+ effortKeysRow
105+ )
106+
97107 // effortView is the slider: the poles, the track with the marker riding it,
98108 // the labels under it, and the keys that drive it.
99109 //
@@ -138,6 +148,38 @@ func (m *model) effortView() string {
138148 truncateStyled(" "+styFaint.Render("←→ move · 1-5 jump · enter apply · esc cancel"), width)
139149 }
140150
151+ // effortAt is which level was clicked at column x. The nearest label wins
152+ // rather than only a hit on the word itself: the track between two labels
153+ // belongs to one of them, and a click on a slider that does nothing reads as
154+ // a broken slider.
155+ //
156+ // It goes through effortSlots, the same function the view lays the labels out
157+ // with, so the level a click picks is the one under the pointer whatever the
158+ // width does to the spacing.
159+ func effortAt(x, width int) (int, bool) {
160+ width = maxInt(1, width)
161+ starts := effortSlots(width - 1)
162+ if starts == nil {
163+ return 0, false
164+ }
165+ best, bestDist := 0, -1
166+ for i, level := range llm.EffortLevels {
167+ // The labels are drawn one column in, after the leading margin.
168+ centre := starts[i] + 1 + lipgloss.Width(level)/2
169+ if d := absInt(x - centre); bestDist < 0 || d < bestDist {
170+ best, bestDist = i, d
171+ }
172+ }
173+ return best, true
174+ }
175+
176+ func absInt(n int) int {
177+ if n < 0 {
178+ return -n
179+ }
180+ return n
181+ }
182+
141183 // effortSlots is the column each label starts at, spread evenly across width.
142184 // It returns nil when the labels cannot be laid out with room to breathe,
143185 // which is the caller's cue to render something simpler.
+98−35internal/tui/model.goView File
@@ -145,6 +145,21 @@ type model struct {
145145 // say so while the agent unwinds.
146146 interrupting bool
147147
148+ // The mouse. mouseOn is whether the harness is asking the terminal for
149+ // mouse reporting at all: it costs the terminal's own text selection, so
150+ // /mouse gives it back. sel is the selection that replaces it, copied is
151+ // what the chrome says about the last copy, and the three click fields
152+ // are what turns a run of presses on one cell into a word or a line.
153+ mouseOn bool
154+ sel selection
155+ copied string
156+ clicks int
157+ clickAt cell
158+ clickTime time.Time
159+ // copyFn is how text leaves the harness. It is a field so a test can
160+ // watch what was copied without a clipboard, or a terminal, being there.
161+ copyFn func(string) error
162+
148163 // wiz is the setup wizard. While it is non-nil it owns the screen and
149164 // every key, and this model does nothing but hold its place.
150165 wiz *wizard
@@ -198,12 +213,14 @@ func newModel(ctx context.Context, opts Options) *model {
198213 sp.Style = styCoral
199214
200215 m := &model{
201- opts: opts,
202- bridge: &bridge{},
203- input: ta,
204- spin: sp,
205- tr: newTranscript(80),
206- parent: ctx,
216+ opts: opts,
217+ bridge: &bridge{},
218+ input: ta,
219+ spin: sp,
220+ tr: newTranscript(80),
221+ parent: ctx,
222+ mouseOn: true,
223+ copyFn: copyToClipboard,
207224 }
208225 m.tr.setVerbose(opts.Verbose)
209226 m.tr.addRaw(splash(opts, 80))
@@ -219,7 +236,10 @@ func newModel(ctx context.Context, opts Options) *model {
219236 // Run starts the program and blocks until the user exits.
220237 func Run(ctx context.Context, opts Options) error {
221238 m := newModel(ctx, opts)
222- p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx))
239+ // Cell motion rather than all motion: the drag is what has to be
240+ // followed, and reporting every pixel of idle movement would put a
241+ // message on the wire for a hand resting on the desk.
242+ p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithContext(ctx))
223243
224244 // The bridge can only learn the program after it exists; the agent does
225245 // not touch it until the first turn starts, which is after Run.
@@ -250,6 +270,9 @@ func (m *model) Init() tea.Cmd {
250270 // state untouched underneath: nothing about a running turn is disturbed,
251271 // which is why /setup is safe to answer at any time the agent is idle.
252272 func (m *model) openWizard() tea.Cmd {
273+ // The wizard takes the screen, and may come back with a different
274+ // palette; nothing selected underneath it survives that.
275+ m.clearSelection()
253276 m.wiz = newWizard(m.opts.Client, m.opts.Config)
254277 cmds := []tea.Cmd{m.wiz.Init()}
255278 if m.ready {
@@ -353,6 +376,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
353376 case tea.KeyMsg:
354377 return m.onKey(msg)
355378
379+ case tea.MouseMsg:
380+ return m.onMouse(msg)
381+
382+ case copiedMsg:
383+ return m, m.finishCopy(msg)
384+
356385 case modelsMsg:
357386 return m, m.onModels(msg)
358387
@@ -412,6 +441,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
412441 }
413442
414443 func (m *model) resize(w, h int) tea.Cmd {
444+ // Every line is about to be laid out again, so the line numbers a
445+ // selection is made of stop pointing at the text they were taken from.
446+ m.clearSelection()
415447 m.width, m.height = w, h
416448 m.tr.setWidth(w)
417449 m.input.SetWidth(m.innerWidth())
@@ -518,6 +550,7 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
518550 return m, tea.Quit
519551
520552 case "esc":
553+ m.clearSelection()
521554 if m.phase == phaseRunning {
522555 return m, m.interrupt()
523556 }
@@ -531,6 +564,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
531564 return m, tea.Quit
532565
533566 case "ctrl+r":
567+ // Collapsing or expanding tool output lays every line out again, so
568+ // the selection is about text that has moved.
569+ m.clearSelection()
534570 m.tr.setVerbose(!m.tr.verbose)
535571 state := "collapsed"
536572 if m.tr.verbose {
@@ -568,6 +604,11 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
568604 return m.submit(line)
569605 }
570606
607+ // Typing moves on from whatever was selected, the way clicking elsewhere
608+ // does. Scrolling does not: reading further up the transcript with the
609+ // keys is no reason to lose what is marked.
610+ m.clearSelection()
611+
571612 var cmd tea.Cmd
572613 m.input, cmd = m.input.Update(msg)
573614 m.growInput()
@@ -631,9 +672,13 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
631672 return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))
632673
633674 case line == "/clear":
675+ m.clearSelection()
634676 m.tr.clear()
635677 return m, m.sync()
636678
679+ case line == "/mouse":
680+ return m, m.toggleMouse()
681+
637682 case line == "/allowed":
638683 allowed := m.opts.Policy.Remembered()
639684 if len(allowed) == 0 {
@@ -779,19 +824,29 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
779824 if !matches(c.keys, key) && key != fmt.Sprint(i+1) {
780825 continue
781826 }
782- req.reply <- c.dec
783- m.pending = m.pending[1:]
784- if len(m.pending) == 0 {
785- m.phase = phaseRunning
786- }
787- // The decision belongs to the call it gated, not to whatever the
788- // transcript happens to end with once the result lands.
789- m.tr.annotateTool(req.req.Tool, c.note)
790- return m, tea.Batch(m.sync(), m.spin.Tick)
827+ return m, m.answerApproval(c)
791828 }
792829 return m, nil
793830 }
794831
832+ // answerApproval sends a decision and moves on. It is shared by the keys and
833+ // by a click on the choice, which must not be able to decide differently.
834+ func (m *model) answerApproval(c choice) tea.Cmd {
835+ if len(m.pending) == 0 {
836+ return nil
837+ }
838+ req := m.pending[0]
839+ req.reply <- c.dec
840+ m.pending = m.pending[1:]
841+ if len(m.pending) == 0 {
842+ m.phase = phaseRunning
843+ }
844+ // The decision belongs to the call it gated, not to whatever the
845+ // transcript happens to end with once the result lands.
846+ m.tr.annotateTool(req.req.Tool, c.note)
847+ return tea.Batch(m.sync(), m.spin.Tick)
848+ }
849+
795850 func matches(keys []string, key string) bool {
796851 for _, k := range keys {
797852 if k == key {
@@ -825,28 +880,27 @@ func (m *model) View() string {
825880 m.vp.GotoBottom()
826881 }
827882 }
828- return m.vp.View() + "\n" + bottom
883+ // The guard is not only tidiness: asking the transcript for its lines
884+ // splits the whole scrollback, and a streaming turn redraws every frame.
885+ // Nothing pays for that until there is a selection to paint.
886+ view := m.vp.View()
887+ if m.sel.on {
888+ if from, to, ok := m.sel.span(m.tr.lines()); ok {
889+ view = paintSelection(view, m.vp.YOffset, from, to)
890+ }
891+ }
892+ return view + "\n" + bottom
829893 }
830894
895+ // bottom is the chrome under the transcript. It is assembled from layout()
896+ // rather than written out here, because a mouse click is resolved against
897+ // that same list and the two must be the same screen.
831898 func (m *model) bottom() string {
832- var b strings.Builder
833- b.WriteString(m.activity() + "\n")
834- switch {
835- case m.phase == phaseApproving && len(m.pending) > 0:
836- b.WriteString(m.approvalView() + "\n")
837- case m.phase == phaseEffort:
838- b.WriteString(m.effortView() + "\n")
839- case m.phase == phaseModel:
840- b.WriteString(m.modelView() + "\n")
841- default:
842- b.WriteString(m.inputBox() + "\n")
843- if v := m.menuView(); v != "" {
844- b.WriteString(v + "\n")
845- }
899+ parts := make([]string, 0, 5)
900+ for _, s := range m.layout() {
901+ parts = append(parts, s.text)
846902 }
847- b.WriteString(m.status() + "\n")
848- b.WriteString(m.modeLine())
849- return b.String()
903+ return strings.Join(parts, "\n")
850904 }
851905
852906 // activity is the line above the prompt: what the agent is doing on the left,
@@ -877,9 +931,18 @@ func (m *model) activity() string {
877931 left = styWarn.Render("● waiting for you")
878932 }
879933
934+ // What was copied is reported here rather than in the transcript: five
935+ // copies should not cost five lines of scrollback, and this line is on
936+ // screen in every phase.
880937 right := ""
938+ if m.copied != "" {
939+ right = styDim.Render(m.copied)
940+ }
881941 if m.opts.Effort != "" {
882- right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort")
942+ if right != "" {
943+ right += styFaint.Render(" · ")
944+ }
945+ right += styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort")
883946 }
884947 return spread(left, right, m.width)
885948 }
+19−10internal/tui/model_picker.goView File
@@ -149,16 +149,7 @@ func (m *model) modelView() string {
149149 return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width)
150150 }
151151
152- rows := m.modelRows()
153- shown := rows
154- if len(m.models) > rows {
155- shown = maxInt(1, rows-1)
156- }
157- start := 0
158- if m.modelIdx >= shown {
159- start = m.modelIdx - shown + 1
160- }
161- end := minInt(len(m.models), start+shown)
152+ start, end := m.modelWindow()
162153
163154 // One column for the names, so the ids line up under each other.
164155 names := make([]string, len(m.models))
@@ -213,6 +204,24 @@ func (m *model) modelView() string {
213204 return strings.TrimRight(b.String(), "\n")
214205 }
215206
207+ // modelHeadRows is how many rows come before the list: the heading, and the
208+ // line saying where the models came from.
209+ const modelHeadRows = 2
210+
211+ // modelWindow is the slice of the list that is on screen, shared by the view
212+ // and by the hit test, so a click lands on the model it looks like it is on.
213+ func (m *model) modelWindow() (start, end int) {
214+ rows := m.modelRows()
215+ shown := rows
216+ if len(m.models) > rows {
217+ shown = maxInt(1, rows-1)
218+ }
219+ if m.modelIdx >= shown {
220+ start = m.modelIdx - shown + 1
221+ }
222+ return start, minInt(len(m.models), start+shown)
223+ }
224+
216225 // modelRows is how many model rows fit: the same arithmetic the completion
217226 // menu does, minus the heading and the line saying where the list came from.
218227 func (m *model) modelRows() int {
+355−0internal/tui/mouse.goaddedView File
@@ -0,0 +1,355 @@
1+ package tui
2+
3+ import (
4+ "time"
5+
6+ "github.com/charmbracelet/bubbles/textarea"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "harness/internal/llm"
11+ )
12+
13+ // The mouse is handled here the way keys are handled in onKey: one entry
14+ // point, the piece of the screen under the pointer decides who answers.
15+ //
16+ // Everything a terminal would normally do with a drag it stops doing the
17+ // moment mouse reporting is asked for, so what this file adds is not only new
18+ // behaviour — the wheel, clickable chrome — but also the replacement for what
19+ // enabling the mouse takes away. /mouse gives it all back.
20+
21+ // doubleClick is how close two presses on the same cell have to be to count
22+ // as a double-click. 400ms is the middle of what desktops use.
23+ const doubleClick = 400 * time.Millisecond
24+
25+ // sectionKind names a piece of the screen. The transcript is one of them so
26+ // that a hit test always has an answer.
27+ type sectionKind int
28+
29+ const (
30+ secTranscript sectionKind = iota
31+ secActivity
32+ secInput
33+ secMenu
34+ secStatus
35+ secMode
36+ secApproval
37+ secEffort
38+ secModel
39+ )
40+
41+ // section is one piece of the bottom chrome and what it rendered to. The
42+ // layout is built from these rather than from a tally of heights kept
43+ // somewhere else, so where a click lands and what was drawn cannot disagree.
44+ type section struct {
45+ kind sectionKind
46+ text string
47+ }
48+
49+ // layout is the bottom chrome, in order. It is what bottom() joins together
50+ // and what a mouse event is resolved against, which is the point: a click
51+ // finds the same rows the frame drew.
52+ func (m *model) layout() []section {
53+ secs := []section{{secActivity, m.activity()}}
54+ switch {
55+ case m.phase == phaseApproving && len(m.pending) > 0:
56+ secs = append(secs, section{secApproval, m.approvalView()})
57+ case m.phase == phaseEffort:
58+ secs = append(secs, section{secEffort, m.effortView()})
59+ case m.phase == phaseModel:
60+ secs = append(secs, section{secModel, m.modelView()})
61+ default:
62+ secs = append(secs, section{secInput, m.inputBox()})
63+ if v := m.menuView(); v != "" {
64+ secs = append(secs, section{secMenu, v})
65+ }
66+ }
67+ return append(secs, section{secStatus, m.status()}, section{secMode, m.modeLine()})
68+ }
69+
70+ // chromeRows is how many rows the bottom chrome takes, and so where the
71+ // transcript ends.
72+ func (m *model) chromeRows() int { return rowsOf(m.layout()) }
73+
74+ func rowsOf(secs []section) int {
75+ n := 0
76+ for _, s := range secs {
77+ n += lipgloss.Height(s.text)
78+ }
79+ return n
80+ }
81+
82+ // hitIn says what is at screen row y: which piece of the screen, and which of
83+ // that piece's own rows. top is the first row the chrome occupies, which is
84+ // also how many rows the transcript has.
85+ func hitIn(secs []section, top, y int) (sectionKind, int) {
86+ if y < top {
87+ return secTranscript, y
88+ }
89+ for _, s := range secs {
90+ n := lipgloss.Height(s.text)
91+ if y < top+n {
92+ return s.kind, y - top
93+ }
94+ top += n
95+ }
96+ return secTranscript, y
97+ }
98+
99+ func (m *model) onMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
100+ // The wizard owns the whole screen, and it is keyboard-driven; the chrome
101+ // underneath it is not on screen to be clicked.
102+ if !m.mouseOn || m.wiz != nil {
103+ return m, nil
104+ }
105+
106+ // The screen is measured once per event rather than once per question
107+ // asked about it: laying the chrome out is cheap, but a drag asks several
108+ // times a frame.
109+ secs := m.layout()
110+ rows := maxInt(1, m.height-rowsOf(secs))
111+ kind, row := hitIn(secs, rows, msg.Y)
112+
113+ // A drag that has wandered into the chrome is still that drag. Following
114+ // it past the edge of the transcript is what lets a selection run further
115+ // than one screenful.
116+ if m.sel.drag && msg.Action != tea.MouseActionPress {
117+ kind, row = secTranscript, minInt(row, rows-1)
118+ }
119+
120+ if kind == secTranscript {
121+ return m, m.onTranscriptMouse(msg, row, rows)
122+ }
123+ if isWheel(msg) {
124+ return m, m.onChromeWheel(kind, msg)
125+ }
126+ if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
127+ return m, nil
128+ }
129+ return m.onChromeClick(kind, row, msg.X)
130+ }
131+
132+ // onTranscriptMouse is the scrollback: the wheel rolls it, a drag marks text
133+ // on it.
134+ func (m *model) onTranscriptMouse(msg tea.MouseMsg, row, rows int) tea.Cmd {
135+ if isWheel(msg) {
136+ var cmd tea.Cmd
137+ m.vp, cmd = m.vp.Update(msg)
138+ return cmd
139+ }
140+
141+ switch {
142+ case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft:
143+ return m.pressTranscript(row, msg.X, msg.Shift)
144+
145+ case msg.Action == tea.MouseActionMotion && m.sel.drag:
146+ // Dragging against either edge scrolls, so a selection can be longer
147+ // than the screen. Movement is what keeps it going: with button-event
148+ // reporting a hand held still at the edge sends nothing at all.
149+ switch {
150+ case row <= 0:
151+ m.vp.ScrollUp(1)
152+ case row >= rows-1:
153+ m.vp.ScrollDown(1)
154+ }
155+ m.sel.to(m.cellAt(row, msg.X))
156+ return nil
157+
158+ case msg.Action == tea.MouseActionRelease:
159+ if !m.sel.drag {
160+ return nil
161+ }
162+ m.sel.release()
163+ if !m.sel.on {
164+ // A press and a release with nothing in between is a click, which
165+ // is how a selection is dismissed.
166+ m.clearSelection()
167+ return nil
168+ }
169+ return m.copySelection()
170+ }
171+ return nil
172+ }
173+
174+ // pressTranscript starts a selection, or widens the one a second and third
175+ // click on the same cell are asking for.
176+ func (m *model) pressTranscript(row, x int, shift bool) tea.Cmd {
177+ c := m.cellAt(row, x)
178+
179+ now := time.Now()
180+ if c == m.clickAt && now.Sub(m.clickTime) < doubleClick {
181+ m.clicks++
182+ } else {
183+ m.clicks = 1
184+ }
185+ m.clickAt, m.clickTime = c, now
186+
187+ m.copied = ""
188+ m.sel.press(c, shift)
189+ switch m.clicks {
190+ case 2:
191+ m.sel.widen(selWord)
192+ case 3:
193+ m.sel.widen(selLine)
194+ default:
195+ return nil
196+ }
197+ // A double-click is a whole gesture on its own: there is no release to
198+ // wait for before the word is there to be taken.
199+ return m.copySelection()
200+ }
201+
202+ // cellAt is which piece of the transcript is under the pointer. A column past
203+ // the end of a line clamps to it, so dragging through the empty right-hand
204+ // side of the screen selects to the end of the text rather than to nothing.
205+ func (m *model) cellAt(row, x int) cell {
206+ lines := m.tr.lines()
207+ if len(lines) == 0 {
208+ return cell{}
209+ }
210+ line := clampLine(m.vp.YOffset+row, lines)
211+ return cell{line: line, col: maxInt(0, minInt(x, lineWidth(lines, line)))}
212+ }
213+
214+ // copySelection puts the marked text on the clipboard. The write happens off
215+ // the update loop because one of the two ways it is done shells out to the
216+ // desktop's clipboard helper, and that can be slow or hang.
217+ func (m *model) copySelection() tea.Cmd {
218+ lines := m.tr.lines()
219+ from, to, ok := m.sel.span(lines)
220+ if !ok {
221+ return nil
222+ }
223+ text := selectedText(lines, from, to)
224+ if text == "" {
225+ return nil
226+ }
227+ copyFn := m.copyFn
228+ return func() tea.Msg {
229+ return copiedMsg{text: text, err: copyFn(text)}
230+ }
231+ }
232+
233+ func (m *model) clearSelection() {
234+ m.sel.clear()
235+ m.copied = ""
236+ m.clicks = 0
237+ }
238+
239+ // onChromeWheel rolls whatever is under the pointer. Over a list that is the
240+ // list — the wheel does what ↑↓ do — and over anything else it is still the
241+ // transcript, because a wheel that does nothing feels broken.
242+ func (m *model) onChromeWheel(kind sectionKind, msg tea.MouseMsg) tea.Cmd {
243+ up := msg.Button == tea.MouseButtonWheelUp
244+ switch kind {
245+ case secMenu:
246+ if n := len(m.menu.items); n > 0 {
247+ m.menu.idx = (m.menu.idx + n + step(up)) % n
248+ }
249+ return nil
250+ case secModel:
251+ if len(m.models) > 0 {
252+ m.modelIdx = clampIdx(m.modelIdx+step(up), len(m.models))
253+ }
254+ return nil
255+ case secEffort:
256+ m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels))
257+ return nil
258+ }
259+ var cmd tea.Cmd
260+ m.vp, cmd = m.vp.Update(msg)
261+ return cmd
262+ }
263+
264+ // onChromeClick answers a click on the furniture. A click is a choice: it
265+ // does what pressing enter on that row would do, rather than only moving a
266+ // highlight there and waiting to be told again.
267+ func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd) {
268+ switch kind {
269+ case secMenu:
270+ start, end := m.menuWindow()
271+ if i := start + row; i < end {
272+ m.menu.idx = i
273+ m.acceptCompletion()
274+ }
275+ return m, nil
276+
277+ case secModel:
278+ if m.modelsLoading {
279+ return m, nil
280+ }
281+ // The heading and the line saying where the list came from sit above
282+ // the rows.
283+ start, end := m.modelWindow()
284+ if i := start + row - modelHeadRows; row >= modelHeadRows && i < end {
285+ m.modelIdx = i
286+ id := m.models[i].ID
287+ m.phase, m.models = phaseIdle, nil
288+ m.applyModel(id)
289+ return m, tea.Batch(m.sync(), textarea.Blink)
290+ }
291+ return m, nil
292+
293+ case secEffort:
294+ if row != effortTrackRow && row != effortLabelRow {
295+ return m, nil
296+ }
297+ if i, ok := effortAt(x, m.width); ok {
298+ m.effortIdx = i
299+ m.phase = phaseIdle
300+ m.applyEffort(llm.EffortLevels[i])
301+ return m, tea.Batch(m.sync(), textarea.Blink)
302+ }
303+ return m, nil
304+
305+ case secApproval:
306+ if len(m.pending) == 0 {
307+ return m, nil
308+ }
309+ opts := m.choices(m.pending[0].req)
310+ if i := row - approvalChoiceRow(m.approvalView(), len(opts)); i >= 0 && i < len(opts) {
311+ return m, m.answerApproval(opts[i])
312+ }
313+ return m, nil
314+
315+ case secMode:
316+ return m, m.cycleMode()
317+ }
318+ return m, nil
319+ }
320+
321+ // toggleMouse hands the mouse to the terminal or takes it back. With
322+ // reporting on, the terminal stops sending drags to its own selection and
323+ // sends them here instead — which is the whole feature, and also the cost of
324+ // it, because the terminal's selection is what works when the harness's own
325+ // is not what you want.
326+ func (m *model) toggleMouse() tea.Cmd {
327+ m.mouseOn = !m.mouseOn
328+ m.clearSelection()
329+ if m.mouseOn {
330+ m.tr.addNotice("mouse on — drag to select, double-click a word, triple-click a line, wheel scrolls")
331+ return tea.Batch(m.sync(), tea.EnableMouseCellMotion)
332+ }
333+ m.tr.addNotice("mouse off — your terminal's own selection and copy are back. /mouse to take it again")
334+ return tea.Batch(m.sync(), tea.DisableMouse)
335+ }
336+
337+ // approvalChoiceRow is the row the first choice sits on inside the approval
338+ // box. The choices are the last thing in the box, so counting back from its
339+ // bottom border cannot drift from however the detail above them was laid out.
340+ func approvalChoiceRow(view string, choices int) int {
341+ return maxInt(0, lipgloss.Height(view)-1-choices)
342+ }
343+
344+ // isWheel reports a wheel event. MouseMsg is its own type over MouseEvent, so
345+ // the event's own test is not promoted onto it.
346+ func isWheel(msg tea.MouseMsg) bool { return tea.MouseEvent(msg).IsWheel() }
347+
348+ func step(up bool) int {
349+ if up {
350+ return -1
351+ }
352+ return 1
353+ }
354+
355+ func clampIdx(i, n int) int { return maxInt(0, minInt(i, n-1)) }
+561−0internal/tui/mouse_test.goaddedView File
@@ -0,0 +1,561 @@
1+ package tui
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ tea "github.com/charmbracelet/bubbletea"
14+ "github.com/charmbracelet/lipgloss"
15+
16+ "github.com/muesli/termenv"
17+
18+ "harness/internal/approval"
19+ "harness/internal/llm"
20+ )
21+
22+ func press(x, y int) tea.MouseMsg {
23+ return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}
24+ }
25+
26+ func motion(x, y int) tea.MouseMsg {
27+ return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionMotion, Button: tea.MouseButtonLeft}
28+ }
29+
30+ func release(x, y int) tea.MouseMsg {
31+ return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionRelease}
32+ }
33+
34+ func wheel(x, y int, up bool) tea.MouseMsg {
35+ button := tea.MouseButtonWheelDown
36+ if up {
37+ button = tea.MouseButtonWheelUp
38+ }
39+ return tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: button}
40+ }
41+
42+ // mouseModel is a model whose transcript is known text rather than the
43+ // welcome block, with the clipboard captured instead of written.
44+ func mouseModel(t *testing.T, lines ...string) (*model, *string) {
45+ t.Helper()
46+ m := newTestModel(t)
47+ m.tr.clear()
48+ for _, line := range lines {
49+ m.tr.addRaw(line)
50+ }
51+ m.sync()
52+
53+ var clipboard string
54+ m.copyFn = func(s string) error {
55+ clipboard = s
56+ return nil
57+ }
58+ return m, &clipboard
59+ }
60+
61+ // rowOf is the inverse of the hit test: which screen row a given row of a
62+ // given piece of the chrome is drawn on. Tests click what they can see, and
63+ // this is how they find it.
64+ func (m *model) rowOf(kind sectionKind, row int) (int, bool) {
65+ top := m.height - m.chromeRows()
66+ for _, s := range m.layout() {
67+ n := lipgloss.Height(s.text)
68+ if s.kind == kind {
69+ if row >= n {
70+ return 0, false
71+ }
72+ return top + row, true
73+ }
74+ top += n
75+ }
76+ return 0, false
77+ }
78+
79+ // The wheel is the whole reason mouse reporting is on: in the alternate
80+ // screen the terminal has no scrollback of its own to roll.
81+ func TestTheWheelScrollsTheTranscript(t *testing.T) {
82+ m := newTestModel(t)
83+ for i := 0; i < 200; i++ {
84+ m.tr.addNotice("line")
85+ }
86+ m.sync()
87+ m.View()
88+
89+ bottom := m.vp.YOffset
90+ if bottom == 0 {
91+ t.Fatal("a 200-entry transcript did not scroll the viewport at all")
92+ }
93+
94+ m.Update(wheel(10, 1, true))
95+ if m.vp.YOffset != bottom-3 {
96+ t.Errorf("wheel up moved %d lines, want 3", bottom-m.vp.YOffset)
97+ }
98+
99+ m.Update(wheel(10, 1, false))
100+ if m.vp.YOffset != bottom {
101+ t.Errorf("wheel down did not come back to where it started: %d, want %d", m.vp.YOffset, bottom)
102+ }
103+ }
104+
105+ func TestDraggingOverTheTranscriptCopiesWhatWasMarked(t *testing.T) {
106+ m, clipboard := mouseModel(t, "the quick brown fox", "jumps over the lazy dog")
107+
108+ m.Update(press(4, 0))
109+ m.Update(motion(8, 0))
110+ _, cmd := m.Update(release(8, 0))
111+ drain(t, m, cmd)
112+
113+ if *clipboard != "quick" {
114+ t.Errorf("clipboard holds %q, want %q", *clipboard, "quick")
115+ }
116+ if !m.sel.on {
117+ t.Error("the selection vanished when the button came up")
118+ }
119+ }
120+
121+ func TestDraggingAcrossLinesCopiesBothOfThem(t *testing.T) {
122+ m, clipboard := mouseModel(t, "the quick brown fox", "jumps over the lazy dog")
123+
124+ m.Update(press(16, 0))
125+ m.Update(motion(4, 1))
126+ _, cmd := m.Update(release(4, 1))
127+ drain(t, m, cmd)
128+
129+ if want := "fox\njumps"; *clipboard != want {
130+ t.Errorf("clipboard holds %q, want %q", *clipboard, want)
131+ }
132+ }
133+
134+ // A click is how a selection is dismissed, so it must not put a stray
135+ // character on the clipboard on the way.
136+ func TestAClickClearsTheSelectionAndCopiesNothing(t *testing.T) {
137+ m, clipboard := mouseModel(t, "the quick brown fox")
138+
139+ m.Update(press(4, 0))
140+ m.Update(motion(8, 0))
141+ m.Update(release(8, 0))
142+
143+ *clipboard = ""
144+ m.Update(press(2, 0))
145+ _, cmd := m.Update(release(2, 0))
146+ drain(t, m, cmd)
147+
148+ if m.sel.on {
149+ t.Error("a click left a selection behind")
150+ }
151+ if *clipboard != "" {
152+ t.Errorf("a click copied %q", *clipboard)
153+ }
154+ }
155+
156+ func TestDoubleClickTakesAWordAndTripleClickTheLine(t *testing.T) {
157+ m, clipboard := mouseModel(t, "edited internal/tui/model.go:222 for you")
158+
159+ m.Update(press(12, 0))
160+ _, cmd := m.Update(press(12, 0))
161+ drain(t, m, cmd)
162+ if want := "internal/tui/model.go:222"; *clipboard != want {
163+ t.Errorf("double-click copied %q, want %q", *clipboard, want)
164+ }
165+
166+ _, cmd = m.Update(press(12, 0))
167+ drain(t, m, cmd)
168+ if want := "edited internal/tui/model.go:222 for you"; *clipboard != want {
169+ t.Errorf("triple-click copied %q, want %q", *clipboard, want)
170+ }
171+ }
172+
173+ // Dragging off the top edge is how you select more than one screenful, so it
174+ // has to scroll rather than stop at the first row.
175+ func TestDraggingPastTheTopScrollsTheTranscript(t *testing.T) {
176+ m := newTestModel(t)
177+ for i := 0; i < 200; i++ {
178+ m.tr.addNotice("line")
179+ }
180+ m.sync()
181+ m.View()
182+
183+ before := m.vp.YOffset
184+ m.Update(press(4, 3))
185+ m.Update(motion(4, 0))
186+ m.Update(motion(3, 0))
187+ if m.vp.YOffset >= before {
188+ t.Errorf("dragging to the top row did not scroll up: offset %d, was %d", m.vp.YOffset, before)
189+ }
190+ }
191+
192+ // What was copied is reported where the agent's own status goes, not in the
193+ // transcript: copying five things should not leave five lines of scrollback.
194+ func TestCopyingSaysSoWithoutWritingToTheTranscript(t *testing.T) {
195+ m, _ := mouseModel(t, "the quick brown fox", "jumps over the lazy dog")
196+ entries := len(m.tr.entries)
197+
198+ m.Update(press(0, 0))
199+ m.Update(motion(4, 1))
200+ _, cmd := m.Update(release(4, 1))
201+ drain(t, m, cmd)
202+
203+ if !strings.Contains(m.activity(), "copied 2 lines") {
204+ t.Errorf("the activity line does not report the copy: %q", m.activity())
205+ }
206+ if len(m.tr.entries) != entries {
207+ t.Errorf("copying added %d transcript entries", len(m.tr.entries)-entries)
208+ }
209+ }
210+
211+ // The lines a selection names belong to a layout. Anything that lays the
212+ // transcript out again — a resize, or opening up tool output — leaves those
213+ // line numbers pointing at different text, so the selection goes.
214+ func TestRelayingOutTheTranscriptDropsTheSelection(t *testing.T) {
215+ for _, tc := range []struct {
216+ name string
217+ do func(m *model)
218+ }{
219+ {"a resize", func(m *model) { m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) }},
220+ {"expanding tool output", func(m *model) { m.Update(key("ctrl+r")) }},
221+ {"typing", func(m *model) { m.Update(key("a")) }},
222+ } {
223+ m, _ := mouseModel(t, "the quick brown fox")
224+ m.Update(press(4, 0))
225+ m.Update(motion(8, 0))
226+ m.Update(release(8, 0))
227+ if !m.sel.on {
228+ t.Fatalf("%s: no selection to begin with", tc.name)
229+ }
230+
231+ tc.do(m)
232+ if m.sel.on {
233+ t.Errorf("%s left a selection pointing at the old layout", tc.name)
234+ }
235+ }
236+ }
237+
238+ func TestClickingACompletionRowAcceptsIt(t *testing.T) {
239+ m := newTestModel(t)
240+ m.input.SetValue("/")
241+ m.refreshMenu()
242+ if !m.menuOpen() {
243+ t.Fatal("the completion menu did not open")
244+ }
245+ want := "/" + m.menu.items[2].name
246+ m.View()
247+
248+ row, ok := m.rowOf(secMenu, 2)
249+ if !ok {
250+ t.Fatal("the completion menu is not on screen anywhere")
251+ }
252+ m.Update(press(4, row))
253+
254+ if got := strings.TrimSpace(m.input.Value()); got != want {
255+ t.Errorf("clicking the third row put %q in the prompt, want %q", got, want)
256+ }
257+ }
258+
259+ func TestClickingAModelRowPicksThatModel(t *testing.T) {
260+ m := newTestModel(t)
261+ m.openModelPicker()
262+ m.Update(modelsMsg{models: someModels()})
263+ if m.phase != phaseModel {
264+ t.Fatal("the picker is not open")
265+ }
266+ m.View()
267+
268+ // Two rows of heading come before the list.
269+ row, ok := m.rowOf(secModel, 3)
270+ if !ok {
271+ t.Fatal("the model picker is not on screen anywhere")
272+ }
273+ m.Update(press(4, row))
274+
275+ if got := m.opts.Client.Model(); got != "claude-sonnet-5" {
276+ t.Errorf("clicking the second model chose %q", got)
277+ }
278+ if m.phase != phaseIdle {
279+ t.Error("the picker stayed open after a model was clicked")
280+ }
281+ }
282+
283+ // The wheel moves through a list the same way the arrow keys do: over an open
284+ // picker it is not the transcript being rolled.
285+ func TestTheWheelMovesThroughAnOpenPicker(t *testing.T) {
286+ m := newTestModel(t)
287+ m.openModelPicker()
288+ m.Update(modelsMsg{models: someModels()})
289+ m.View()
290+
291+ row, _ := m.rowOf(secModel, 2)
292+ m.Update(wheel(4, row, false))
293+ if m.modelIdx != 1 {
294+ t.Errorf("wheel down over the picker moved to %d, want 1", m.modelIdx)
295+ }
296+ m.Update(wheel(4, row, true))
297+ if m.modelIdx != 0 {
298+ t.Errorf("wheel up over the picker moved to %d, want 0", m.modelIdx)
299+ }
300+ }
301+
302+ func TestClickingTheEffortSliderPicksThatLevel(t *testing.T) {
303+ m := newTestModel(t)
304+ m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
305+ m.openEffort()
306+ m.View()
307+
308+ // The labels row, under the level the last slot holds.
309+ row, ok := m.rowOf(secEffort, 2)
310+ if !ok {
311+ t.Fatal("the slider is not on screen anywhere")
312+ }
313+ last := len(llm.EffortLevels) - 1
314+ m.Update(press(effortSlots(119)[last], row))
315+
316+ if m.opts.Effort != llm.EffortLevels[last] {
317+ t.Errorf("clicking the last label set effort to %q, want %q", m.opts.Effort, llm.EffortLevels[last])
318+ }
319+ }
320+
321+ // The slider is four rows, and two of them are words about it: the poles
322+ // above the track and the keys below the labels. Only the track and the
323+ // labels are the slider itself.
324+ func TestClickingTheSliderChromeChangesNothing(t *testing.T) {
325+ for _, row := range []int{0, 3} {
326+ m := newTestModel(t)
327+ m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
328+ m.openEffort()
329+ m.View()
330+
331+ y, ok := m.rowOf(secEffort, row)
332+ if !ok {
333+ t.Fatalf("row %d of the slider is not on screen", row)
334+ }
335+ m.Update(press(effortSlots(119)[len(llm.EffortLevels)-1], y))
336+
337+ if m.opts.Effort != "" {
338+ t.Errorf("clicking row %d of the slider set effort to %q", row, m.opts.Effort)
339+ }
340+ if m.phase != phaseEffort {
341+ t.Errorf("clicking row %d of the slider closed it", row)
342+ }
343+ }
344+ }
345+
346+ func TestClickingAnApprovalChoiceAnswersIt(t *testing.T) {
347+ m := newTestModel(t)
348+ reply := pend(m, approval.Request{Tool: "bash", Summary: "run ls"})
349+ m.View()
350+
351+ opts := m.choices(m.pending[0].req)
352+ row, ok := m.rowOf(secApproval, approvalChoiceRow(m.approvalView(), len(opts))+len(opts)-1)
353+ if !ok {
354+ t.Fatal("the approval box is not on screen anywhere")
355+ }
356+ m.Update(press(4, row))
357+
358+ select {
359+ case got := <-reply:
360+ if got != approval.Deny {
361+ t.Errorf("clicking the last choice decided %v, want deny", got)
362+ }
363+ default:
364+ t.Fatal("clicking a choice decided nothing")
365+ }
366+ }
367+
368+ func TestClickingTheModeLineCyclesApprovals(t *testing.T) {
369+ m := newTestModel(t)
370+ m.View()
371+
372+ row, ok := m.rowOf(secMode, 0)
373+ if !ok {
374+ t.Fatal("the mode line is not on screen anywhere")
375+ }
376+ m.Update(press(2, row))
377+
378+ if !m.opts.Policy.YOLO {
379+ t.Error("clicking the mode line did not cycle the approval mode")
380+ }
381+ }
382+
383+ // /mouse hands the mouse back to the terminal, which is the only way to get
384+ // the terminal's own selection back for a copy the harness cannot do.
385+ func TestMouseCanBeHandedBackToTheTerminal(t *testing.T) {
386+ m, clipboard := mouseModel(t, "the quick brown fox")
387+
388+ m.submit("/mouse")
389+ if m.mouseOn {
390+ t.Fatal("/mouse left mouse reporting on")
391+ }
392+
393+ m.Update(press(4, 0))
394+ m.Update(motion(8, 0))
395+ _, cmd := m.Update(release(8, 0))
396+ drain(t, m, cmd)
397+
398+ if m.sel.on || *clipboard != "" {
399+ t.Errorf("a drag was still handled with the mouse off: selection=%v clipboard=%q", m.sel.on, *clipboard)
400+ }
401+
402+ m.submit("/mouse")
403+ if !m.mouseOn {
404+ t.Error("/mouse did not turn reporting back on")
405+ }
406+ }
407+
408+ // Shift and a click move the far end of what is already selected, which is
409+ // how a selection is corrected without starting it again.
410+ func TestShiftClickExtendsTheSelection(t *testing.T) {
411+ m, clipboard := mouseModel(t, "the quick brown fox")
412+
413+ m.Update(press(4, 0))
414+ m.Update(motion(8, 0))
415+ m.Update(release(8, 0))
416+
417+ shift := press(14, 0)
418+ shift.Shift = true
419+ m.Update(shift)
420+ _, cmd := m.Update(release(14, 0))
421+ drain(t, m, cmd)
422+
423+ if want := "quick brown"; *clipboard != want {
424+ t.Errorf("shift+click copied %q, want %q", *clipboard, want)
425+ }
426+ }
427+
428+ func TestDraggingPastTheBottomScrollsTheTranscript(t *testing.T) {
429+ m := newTestModel(t)
430+ for i := 0; i < 200; i++ {
431+ m.tr.addNotice("line")
432+ }
433+ m.sync()
434+ m.View()
435+ m.vp.GotoTop()
436+
437+ before := m.vp.YOffset
438+ rows := m.height - m.chromeRows()
439+ m.Update(press(4, rows-4))
440+ m.Update(motion(4, rows-1))
441+ m.Update(motion(3, rows-1))
442+ if m.vp.YOffset <= before {
443+ t.Errorf("dragging to the bottom row did not scroll down: offset %d, was %d", m.vp.YOffset, before)
444+ }
445+ }
446+
447+ // A clipboard that would not take the text is worth interrupting the
448+ // transcript for: the text is not where the person is about to paste from.
449+ func TestAFailedCopySaysSoInTheTranscript(t *testing.T) {
450+ m, _ := mouseModel(t, "the quick brown fox")
451+ m.copyFn = func(string) error { return errors.New("no clipboard here") }
452+ entries := len(m.tr.entries)
453+
454+ m.Update(press(4, 0))
455+ m.Update(motion(8, 0))
456+ _, cmd := m.Update(release(8, 0))
457+ drain(t, m, cmd)
458+
459+ if len(m.tr.entries) != entries+1 {
460+ t.Fatalf("a failed copy added %d entries, want 1", len(m.tr.entries)-entries)
461+ }
462+ if last := m.tr.entries[len(m.tr.entries)-1]; !strings.Contains(last.text, "no clipboard here") {
463+ t.Errorf("the failure was not reported: %q", last.text)
464+ }
465+ }
466+
467+ // lockedBuffer is a writer the renderer's own goroutine and the test can both
468+ // touch. Bubble Tea draws on a ticker, so frames are still landing while the
469+ // test is reading what was drawn.
470+ type lockedBuffer struct {
471+ mu sync.Mutex
472+ buf bytes.Buffer
473+ }
474+
475+ func (b *lockedBuffer) Write(p []byte) (int, error) {
476+ b.mu.Lock()
477+ defer b.mu.Unlock()
478+ return b.buf.Write(p)
479+ }
480+
481+ func (b *lockedBuffer) String() string {
482+ b.mu.Lock()
483+ defer b.mu.Unlock()
484+ return b.buf.String()
485+ }
486+
487+ // Everything above drives the model with messages the program would have
488+ // made. This one starts the real program and feeds it the bytes a terminal
489+ // actually sends — SGR press, motion, release — because the wire format is
490+ // half of what "the mouse works" means, and nothing else here would catch it
491+ // being asked for wrongly.
492+ func TestARealDragPaintsAndCopies(t *testing.T) {
493+ forceColour(t)
494+
495+ m := newModel(context.Background(), Options{
496+ Client: llm.New(llm.WithAPIKey("x")),
497+ Root: "/tmp/repo",
498+ Policy: approval.New(nil),
499+ })
500+ m.resize(80, 24)
501+ m.tr.clear()
502+ m.tr.addRaw("the quick brown fox")
503+ m.sync()
504+
505+ copied := make(chan string, 1)
506+ m.copyFn = func(s string) error {
507+ select {
508+ case copied <- s:
509+ default:
510+ }
511+ return nil
512+ }
513+
514+ // SGR mouse reports: button 0 pressed at (5,1), moved (32 is button 0
515+ // held) to (9,1), released there. Columns and rows are 1-based on the
516+ // wire. Then ctrl+c, which is what ends the program — after a pause, so
517+ // that the drag gets a frame drawn for it before the screen goes away.
518+ in, drag := io.Pipe()
519+ go func() {
520+ drag.Write([]byte("\x1b[<0;5;1M" + "\x1b[<32;9;1M" + "\x1b[<0;9;1m"))
521+ time.Sleep(100 * time.Millisecond)
522+ drag.Write([]byte("\x03"))
523+ drag.Close()
524+ }()
525+
526+ var out lockedBuffer
527+ p := tea.NewProgram(m,
528+ tea.WithInput(in),
529+ tea.WithOutput(&out),
530+ tea.WithAltScreen(),
531+ tea.WithMouseCellMotion(),
532+ tea.WithoutSignalHandler(),
533+ )
534+ if _, err := p.Run(); err != nil {
535+ t.Fatalf("running the program: %v", err)
536+ }
537+
538+ select {
539+ case got := <-copied:
540+ if got != "quick" {
541+ t.Errorf("a real drag copied %q, want %q", got, "quick")
542+ }
543+ default:
544+ t.Error("a real drag copied nothing")
545+ }
546+
547+ // The frame the drag produced carries the selection behind the marked
548+ // text, which is the other half of the wire: the bytes going back out.
549+ if want := termenv.TrueColor.Color(colSelBg.Dark).Sequence(true); !strings.Contains(out.String(), want) {
550+ t.Error("no frame painted the selection")
551+ }
552+
553+ // The terminal is asked for button-event mouse reporting, and asked to
554+ // stop on the way out.
555+ if !strings.Contains(out.String(), "\x1b[?1002h") {
556+ t.Error("the program never turned mouse reporting on")
557+ }
558+ if !strings.Contains(out.String(), "\x1b[?1002l") {
559+ t.Error("the program left mouse reporting on after it exited")
560+ }
561+ }
+272−0internal/tui/selection.goaddedView File
@@ -0,0 +1,272 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "unicode"
6+
7+ "github.com/charmbracelet/x/ansi"
8+ )
9+
10+ // A selection is the harness's own answer to something the terminal normally
11+ // does for us. Asking for mouse reporting takes the terminal's text selection
12+ // away — it stops sending drags to the clipboard and starts sending them here
13+ // — so the alternative to drawing and copying a selection ourselves is not
14+ // having one at all.
15+ //
16+ // Coordinates are content cells, not screen cells: a line is an index into
17+ // the transcript's rendered rows and a column is a display cell within that
18+ // row. That is what lets a selection survive scrolling and a streaming turn,
19+ // which only ever append rows below it.
20+
21+ // cell is one position in the rendered transcript.
22+ type cell struct{ line, col int }
23+
24+ // before reports whether c comes earlier in reading order than d.
25+ func (c cell) before(d cell) bool {
26+ return c.line < d.line || (c.line == d.line && c.col < d.col)
27+ }
28+
29+ // selMode is how much of the text a drag takes: what the pointer covers,
30+ // whole words, or whole lines. Double- and triple-clicks pick the wider two.
31+ type selMode int
32+
33+ const (
34+ selChar selMode = iota
35+ selWord
36+ selLine
37+ )
38+
39+ // selection is the span the mouse has marked out.
40+ type selection struct {
41+ anchor cell // where the drag started
42+ cursor cell // where the pointer is now
43+ mode selMode
44+
45+ // on means there is a span to paint and copy. A press alone does not set
46+ // it: a click is how you dismiss a selection, so it takes movement — or a
47+ // double-click, which selects a word without any — to make one.
48+ on bool
49+ // drag means the button is still down, so motion belongs to this
50+ // selection rather than being stray movement after a release.
51+ drag bool
52+ }
53+
54+ // press starts a selection at c. shift keeps the existing anchor, which is
55+ // how a click extends a selection rather than replacing it.
56+ func (s *selection) press(c cell, shift bool) {
57+ if shift && (s.on || s.drag) {
58+ s.cursor, s.drag = c, true
59+ s.on = true
60+ return
61+ }
62+ s.anchor, s.cursor = c, c
63+ s.mode, s.on, s.drag = selChar, false, true
64+ }
65+
66+ // to moves the pointer while the button is down. The first movement is what
67+ // turns a press into a selection.
68+ func (s *selection) to(c cell) {
69+ if !s.drag {
70+ return
71+ }
72+ s.cursor = c
73+ if c != s.anchor || s.mode != selChar {
74+ s.on = true
75+ }
76+ }
77+
78+ // release ends the drag, leaving the span on screen to be copied.
79+ func (s *selection) release() { s.drag = false }
80+
81+ // widen is what a second or third click on the same cell does.
82+ func (s *selection) widen(mode selMode) {
83+ s.mode, s.on = mode, true
84+ }
85+
86+ func (s *selection) clear() { *s = selection{} }
87+
88+ // span resolves the selection against the lines it was made over: a pair of
89+ // cells in reading order, the second exclusive, with the mode applied. It
90+ // reports false when there is nothing to paint.
91+ //
92+ // The cell the drag started on is inside the span whichever way the hand
93+ // moved, which is what a terminal does and what makes dragging one character
94+ // to the right select that character rather than nothing.
95+ func (s selection) span(lines []string) (from, to cell, ok bool) {
96+ if !s.on || len(lines) == 0 {
97+ return cell{}, cell{}, false
98+ }
99+ from, to = s.anchor, s.cursor
100+ if to.before(from) {
101+ from, to = to, from
102+ }
103+ from.line, to.line = clampLine(from.line, lines), clampLine(to.line, lines)
104+
105+ switch s.mode {
106+ case selLine:
107+ from.col, to.col = 0, lineWidth(lines, to.line)
108+ case selWord:
109+ from.col = wordStart(lines[from.line], from.col)
110+ to.col = wordEnd(lines[to.line], to.col)
111+ default:
112+ // The cursor's own cell is taken, not just the ground it passed over.
113+ to.col++
114+ }
115+ from.col = maxInt(0, from.col)
116+ to.col = minInt(to.col, lineWidth(lines, to.line))
117+ if from.line == to.line && to.col <= from.col {
118+ return cell{}, cell{}, false
119+ }
120+ return from, to, true
121+ }
122+
123+ // selectedText is what the selection puts on the clipboard: the marked cells
124+ // with their styling stripped, and without the padding the viewport adds to
125+ // square off a line — nobody means to copy twenty trailing spaces.
126+ func selectedText(lines []string, from, to cell) string {
127+ if from.line > to.line || len(lines) == 0 {
128+ return ""
129+ }
130+ var out []string
131+ for i := from.line; i <= to.line && i < len(lines); i++ {
132+ plain := ansi.Strip(lines[i])
133+ start, end := 0, ansi.StringWidth(plain)
134+ if i == from.line {
135+ start = from.col
136+ }
137+ if i == to.line {
138+ end = minInt(end, to.col)
139+ }
140+ if end <= start {
141+ out = append(out, "")
142+ continue
143+ }
144+ out = append(out, strings.TrimRight(ansi.Cut(plain, start, end), " "))
145+ }
146+ return strings.Join(out, "\n")
147+ }
148+
149+ // paintSelection draws the span over the rows the viewport is showing. It
150+ // works on the viewport's own output rather than on the scrollback, so the
151+ // cost is the height of the screen and the transcript's render cache — the one
152+ // thing a streaming turn hits hardest — is never touched.
153+ //
154+ // top is the content line the first visible row holds.
155+ func paintSelection(view string, top int, from, to cell) string {
156+ rows := strings.Split(view, "\n")
157+ for i := range rows {
158+ line := top + i
159+ if line < from.line || line > to.line {
160+ continue
161+ }
162+ start, end := 0, ansi.StringWidth(ansi.Strip(rows[i]))
163+ if line == from.line {
164+ start = from.col
165+ }
166+ if line == to.line {
167+ end = minInt(end, to.col)
168+ }
169+ rows[i] = paintRow(rows[i], start, end)
170+ }
171+ return strings.Join(rows, "\n")
172+ }
173+
174+ // paintRow puts the selection colour behind cells [start, end) of one row.
175+ //
176+ // The marked cells are stripped before they are restyled. A style nested
177+ // inside a background ends with a reset, which would drop the background for
178+ // the rest of the span and tear a hole in it — the same trap the context pill
179+ // documents, and far more visible across a whole line of syntax-highlighted
180+ // code.
181+ func paintRow(row string, start, end int) string {
182+ if end <= start {
183+ return row
184+ }
185+ marked := ansi.Strip(ansi.Cut(row, start, end))
186+ if marked == "" {
187+ return row
188+ }
189+ return ansi.Truncate(row, start, "") + stySelect.Render(marked) + ansi.TruncateLeft(row, end, "")
190+ }
191+
192+ func clampLine(line int, lines []string) int {
193+ return maxInt(0, minInt(line, len(lines)-1))
194+ }
195+
196+ func lineWidth(lines []string, line int) int {
197+ if line < 0 || line >= len(lines) {
198+ return 0
199+ }
200+ return ansi.StringWidth(ansi.Strip(lines[line]))
201+ }
202+
203+ // wordRune says which characters hang together as one word. It is wider than
204+ // a letter test on purpose: the strings worth double-clicking in a transcript
205+ // are paths, ids and flags, and a word rule that stops at the first slash
206+ // makes picking "internal/tui/model.go:222" out of a tool result impossible.
207+ func wordRune(r rune) bool {
208+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
209+ return true
210+ }
211+ return strings.ContainsRune("_-./~@:+=", r)
212+ }
213+
214+ // wordStart and wordEnd grow a column out to the bounds of the word under it,
215+ // in columns. A column on a space takes just that space, so a double-click in
216+ // the gutter selects nothing surprising.
217+ //
218+ // The two go through cells rather than runes because a column is a display
219+ // cell: every wide character to the left of the pointer moves the rune the
220+ // pointer is over one further along.
221+ func wordStart(row string, col int) int {
222+ cells := rowCells(row)
223+ i := cellIndex(cells, col)
224+ if i >= len(cells) || !wordRune(cells[i]) {
225+ return col
226+ }
227+ for i > 0 && wordRune(cells[i-1]) {
228+ i--
229+ }
230+ return cellColumn(cells, i)
231+ }
232+
233+ func wordEnd(row string, col int) int {
234+ cells := rowCells(row)
235+ i := cellIndex(cells, col)
236+ if i >= len(cells) || !wordRune(cells[i]) {
237+ return col + 1
238+ }
239+ for i+1 < len(cells) && wordRune(cells[i+1]) {
240+ i++
241+ }
242+ return cellColumn(cells, i) + runeWidth(cells[i])
243+ }
244+
245+ // rowCells is a row as the runes it is made of, with its styling stripped.
246+ func rowCells(row string) []rune { return []rune(ansi.Strip(row)) }
247+
248+ // cellIndex is which rune occupies a column, and cellColumn is the column a
249+ // rune starts at: the two directions of the same walk along the row.
250+ func cellIndex(cells []rune, col int) int {
251+ w := 0
252+ for i, r := range cells {
253+ if col < w+runeWidth(r) {
254+ return i
255+ }
256+ w += runeWidth(r)
257+ }
258+ return len(cells)
259+ }
260+
261+ func cellColumn(cells []rune, idx int) int {
262+ w := 0
263+ for i, r := range cells {
264+ if i >= idx {
265+ break
266+ }
267+ w += runeWidth(r)
268+ }
269+ return w
270+ }
271+
272+ func runeWidth(r rune) int { return maxInt(1, ansi.StringWidth(string(r))) }
+175−0internal/tui/selection_test.goaddedView File
@@ -0,0 +1,175 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+ )
7+
8+ // The lines a selection is made over are the transcript's own rendered rows,
9+ // padded to the width by the viewport, which is why trailing space is never
10+ // part of what a person meant to take.
11+ var selLines = []string{
12+ "the quick brown fox ",
13+ "jumps over ",
14+ "the lazy dog ",
15+ }
16+
17+ func TestSelectedTextTakesExactlyTheMarkedCells(t *testing.T) {
18+ cases := []struct {
19+ name string
20+ from, to cell
21+ want string
22+ }{
23+ {"a word inside one line", cell{0, 4}, cell{0, 9}, "quick"},
24+ {"to the end of a line drops the padding", cell{0, 16}, cell{0, 25}, "fox"},
25+ {"across three lines keeps the middle whole", cell{0, 16}, cell{2, 8},
26+ "fox\njumps over\nthe lazy"},
27+ {"a single cell", cell{2, 4}, cell{2, 5}, "l"},
28+ {"an empty span takes nothing", cell{1, 3}, cell{1, 3}, ""},
29+ }
30+ for _, tc := range cases {
31+ if got := selectedText(selLines, tc.from, tc.to); got != tc.want {
32+ t.Errorf("%s: selectedText = %q, want %q", tc.name, got, tc.want)
33+ }
34+ }
35+ }
36+
37+ // What reaches the clipboard is text, not the escape sequences that coloured
38+ // it on screen.
39+ func TestSelectedTextCarriesNoStyling(t *testing.T) {
40+ forceColour(t)
41+
42+ lines := []string{styCoral.Render("ReadFile") + styDim.Render("(path=x)")}
43+ got := selectedText(lines, cell{0, 0}, cell{0, 16})
44+ if got != "ReadFile(path=x)" {
45+ t.Fatalf("selectedText = %q, want %q", got, "ReadFile(path=x)")
46+ }
47+ if strings.ContainsRune(got, 0x1b) {
48+ t.Errorf("copied text still carries escape sequences: %q", got)
49+ }
50+ }
51+
52+ // A drag runs in whichever direction the hand moves, and the cell the drag
53+ // started on belongs to the selection either way.
54+ func TestSpanNormalisesTheDirectionOfADrag(t *testing.T) {
55+ forward := selection{anchor: cell{0, 4}, cursor: cell{1, 2}, on: true}
56+ from, to, ok := forward.span(selLines)
57+ if !ok || from != (cell{0, 4}) || to != (cell{1, 3}) {
58+ t.Errorf("dragging down: span = %v..%v (ok=%v), want {0 4}..{1 3}", from, to, ok)
59+ }
60+
61+ backward := selection{anchor: cell{1, 2}, cursor: cell{0, 4}, on: true}
62+ from, to, ok = backward.span(selLines)
63+ if !ok || from != (cell{0, 4}) || to != (cell{1, 3}) {
64+ t.Errorf("dragging up: span = %v..%v (ok=%v), want {0 4}..{1 3}", from, to, ok)
65+ }
66+
67+ var idle selection
68+ if _, _, ok := idle.span(selLines); ok {
69+ t.Error("a selection nobody made reports a span")
70+ }
71+ }
72+
73+ func TestSpanExpandsToWordsAndLines(t *testing.T) {
74+ word := selection{anchor: cell{0, 6}, cursor: cell{0, 6}, mode: selWord, on: true}
75+ from, to, _ := word.span(selLines)
76+ if got := selectedText(selLines, from, to); got != "quick" {
77+ t.Errorf("double-click inside a word took %q, want %q", got, "quick")
78+ }
79+
80+ line := selection{anchor: cell{1, 3}, cursor: cell{1, 3}, mode: selLine, on: true}
81+ from, to, _ = line.span(selLines)
82+ if got := selectedText(selLines, from, to); got != "jumps over" {
83+ t.Errorf("triple-click took %q, want %q", got, "jumps over")
84+ }
85+ }
86+
87+ // Double-clicking a path is how a person picks one out of a wall of tool
88+ // output, so the run of characters a path is made of counts as one word.
89+ func TestWordSelectionKeepsAPathWhole(t *testing.T) {
90+ lines := []string{"edited internal/tui/model.go:222 for you"}
91+ sel := selection{anchor: cell{0, 12}, cursor: cell{0, 12}, mode: selWord, on: true}
92+ from, to, _ := sel.span(lines)
93+ if got := selectedText(lines, from, to); got != "internal/tui/model.go:222" {
94+ t.Errorf("double-click on a path took %q, want the whole path", got)
95+ }
96+ }
97+
98+ // A press alone is a click: it puts the caret somewhere and clears whatever
99+ // was selected. Only movement with the button down makes a selection.
100+ func TestAClickWithoutMovementSelectsNothing(t *testing.T) {
101+ var s selection
102+ s.press(cell{0, 4}, false)
103+ if s.on {
104+ t.Error("a press with no movement already counts as a selection")
105+ }
106+ s.to(cell{0, 9})
107+ if !s.on {
108+ t.Error("moving with the button down did not start a selection")
109+ }
110+ }
111+
112+ // A column is a display cell, not a rune: two of them go by for every wide
113+ // character to the left of the pointer.
114+ func TestWordSelectionCountsCellsNotRunes(t *testing.T) {
115+ lines := []string{"日本語 fox"}
116+ sel := selection{anchor: cell{0, 7}, cursor: cell{0, 7}, mode: selWord, on: true}
117+ from, to, _ := sel.span(lines)
118+ if got := selectedText(lines, from, to); got != "fox" {
119+ t.Errorf("double-click past a wide rune took %q, want %q", got, "fox")
120+ }
121+ }
122+
123+ func TestPaintMarksExactlyTheSelectedCells(t *testing.T) {
124+ forceColour(t)
125+
126+ view := strings.Join(selLines, "\n")
127+ rows := strings.Split(paintSelection(view, 0, cell{0, 4}, cell{0, 9}), "\n")
128+ if got := backedCells(rows[0]); got != "quick" {
129+ t.Errorf("the highlight covers %q, want %q", got, "quick")
130+ }
131+ for _, row := range rows[1:] {
132+ if got := backedCells(row); got != "" {
133+ t.Errorf("a row outside the selection was painted: %q", got)
134+ }
135+ }
136+ }
137+
138+ // The viewport hands over the rows it is showing, so which content line a row
139+ // holds depends on how far the transcript has been scrolled.
140+ func TestPaintFollowsTheScrollOffset(t *testing.T) {
141+ forceColour(t)
142+
143+ view := strings.Join(selLines, "\n")
144+ rows := strings.Split(paintSelection(view, 5, cell{6, 0}, cell{6, 5}), "\n")
145+ if got := backedCells(rows[1]); got != "jumps" {
146+ t.Errorf("row 1 with the view scrolled to line 5 shows %q, want %q", got, "jumps")
147+ }
148+ if got := backedCells(rows[0]) + backedCells(rows[2]); got != "" {
149+ t.Errorf("the wrong rows were painted: %q", got)
150+ }
151+ }
152+
153+ // The text around a selection keeps the colour it had. The marked text does
154+ // not: a style nested inside a background ends with a reset, which would drop
155+ // the background for the rest of the span and tear a hole in it.
156+ func TestPaintLeavesTheSurroundingColoursAloneAndHasNoHoles(t *testing.T) {
157+ forceColour(t)
158+
159+ view := styToolName.Render("ReadFile") + styToolArgs.Render("(path=x)")
160+ got := paintSelection(view, 0, cell{0, 0}, cell{0, 8})
161+
162+ if marked := backedCells(got); marked != "ReadFile" {
163+ t.Errorf("the highlight covers %q, want %q", marked, "ReadFile")
164+ }
165+ if rest := unbackedCells(got); rest != "(path=x)" {
166+ t.Errorf("outside the selection reads %q, want %q", rest, "(path=x)")
167+ }
168+
169+ // Selecting the whole of a line built from several styles must come back
170+ // as one unbroken run of marked cells.
171+ whole := paintSelection(view, 0, cell{0, 0}, cell{0, 16})
172+ if marked := backedCells(whole); marked != "ReadFile(path=x)" {
173+ t.Errorf("a fully selected line is torn: marked %q", marked)
174+ }
175+ }
+6−0internal/tui/styles.goView File
@@ -27,6 +27,7 @@ var (
2727 colWarn lipgloss.AdaptiveColor
2828 colBlue lipgloss.AdaptiveColor
2929 colPillBg lipgloss.AdaptiveColor
30+ colSelBg lipgloss.AdaptiveColor
3031 )
3132
3233 var (
@@ -75,4 +76,9 @@ var (
7576 // The approval box is the one place the UI is allowed to shout: it is the
7677 // only moment a human has to decide something.
7778 styModal lipgloss.Style
79+
80+ // A mouse selection. Asking for mouse reporting costs the terminal's own
81+ // selection, so this is the harness's replacement for it, and it has to
82+ // read as a selection at a glance in all four themes.
83+ stySelect lipgloss.Style
7884 )
+18−3internal/tui/theme.goView File
@@ -10,9 +10,9 @@ import (
1010 // derived from it, so a theme decides colour and nothing about layout. Adding
1111 // one is adding an entry here and a name to config.Themes.
1212 //
13- // Every theme names all ten roles rather than inheriting the missing ones from
14- // a base. Inheritance would make a half-specified theme render as a mix of two
15- // palettes, which is the one outcome worse than an ugly theme.
13+ // Every theme names all eleven roles rather than inheriting the missing ones
14+ // from a base. Inheritance would make a half-specified theme render as a mix of
15+ // two palettes, which is the one outcome worse than an ugly theme.
1616 type theme struct {
1717 name string
1818 // blurb is what the wizard says about it, in the wizard's own voice.
@@ -28,6 +28,10 @@ type theme struct {
2828 warn lipgloss.AdaptiveColor
2929 info lipgloss.AdaptiveColor
3030 pillBg lipgloss.AdaptiveColor
31+ // selBg is what a mouse selection is painted on. It is a tint of the
32+ // theme's own accent rather than a neutral grey, so a selection cannot be
33+ // mistaken for the pill background the status bar already uses.
34+ selBg lipgloss.AdaptiveColor
3135 }
3236
3337 // themes are in the order the wizard offers them; ember is first because it
@@ -50,6 +54,7 @@ var themes = []theme{{
5054 warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e0a458"},
5155 info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#7aa2e3"},
5256 pillBg: lipgloss.AdaptiveColor{Light: "#ebe7e2", Dark: "#2b2825"},
57+ selBg: lipgloss.AdaptiveColor{Light: "#f6d8cb", Dark: "#5c3527"},
5358 }, {
5459 name: "mint",
5560 blurb: "Cool green. Easiest on a bright screen.",
@@ -63,6 +68,7 @@ var themes = []theme{{
6368 warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e8bc6a"},
6469 info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#79a8d8"},
6570 pillBg: lipgloss.AdaptiveColor{Light: "#e4ecea", Dark: "#232b29"},
71+ selBg: lipgloss.AdaptiveColor{Light: "#cbe8e1", Dark: "#1f4c44"},
6672 }, {
6773 name: "iris",
6874 blurb: "Violet and slate. Quiet under low light.",
@@ -76,6 +82,7 @@ var themes = []theme{{
7682 warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e2b062"},
7783 info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#86a8ec"},
7884 pillBg: lipgloss.AdaptiveColor{Light: "#eae7f2", Dark: "#282434"},
85+ selBg: lipgloss.AdaptiveColor{Light: "#ded2f8", Dark: "#3d2f60"},
7986 }, {
8087 name: "mono",
8188 blurb: "No accent hue. For screens and eyes that want none.",
@@ -93,6 +100,7 @@ var themes = []theme{{
93100 warn: lipgloss.AdaptiveColor{Light: "#7a6030", Dark: "#c8b083"},
94101 info: lipgloss.AdaptiveColor{Light: "#4a5f7a", Dark: "#9aaec8"},
95102 pillBg: lipgloss.AdaptiveColor{Light: "#e9e9e9", Dark: "#2a2a2a"},
103+ selBg: lipgloss.AdaptiveColor{Light: "#d0d0d0", Dark: "#484848"},
96104 }}
97105
98106 // defaultTheme is what an unconfigured harness looks like, and the fallback
@@ -147,6 +155,7 @@ func applyTheme(name string) {
147155 colCoral, colText, colUser = t.accent, t.text, t.user
148156 colDim, colFaint = t.dim, t.faint
149157 colOK, colErr, colWarn, colBlue, colPillBg = t.ok, t.err, t.warn, t.info, t.pillBg
158+ colSelBg = t.selBg
150159
151160 styText = lipgloss.NewStyle().Foreground(colText)
152161 styDim = lipgloss.NewStyle().Foreground(colDim)
@@ -193,6 +202,12 @@ func applyTheme(name string) {
193202 BorderForeground(colCoral).
194203 Padding(0, 1)
195204
205+ // The foreground is named rather than inherited: the marked cells are
206+ // stripped of their own colour before they are painted, so whatever they
207+ // were — dim grey, a diff's red, a comment in a code fence — has to come
208+ // back as something that reads on this background.
209+ stySelect = lipgloss.NewStyle().Foreground(colText).Background(colSelBg)
210+
196211 // Markdown is styled from the same palette, and its stylesheet is baked
197212 // rather than read per draw, so it has to be rebuilt here too.
198213 resetMarkdown()
+18−2internal/tui/transcript.goView File
@@ -73,7 +73,12 @@ type transcript struct {
7373 verbose bool
7474
7575 cache string
76- dirty bool
76+ // cacheLines is the same text split into rows, built only when something
77+ // asks — which is the mouse, and only while it is being used. A streaming
78+ // turn re-renders on every token and must not pay for a split nobody is
79+ // looking at.
80+ cacheLines []string
81+ dirty bool
7782 // gen is the theme generation the cache was built under.
7883 gen int
7984 }
@@ -217,10 +222,21 @@ func (t *transcript) render() string {
217222 }
218223 b.WriteString(t.renderEntry(&t.entries[i]))
219224 }
220- t.cache, t.dirty = b.String(), false
225+ t.cache, t.cacheLines, t.dirty = b.String(), nil, false
221226 return t.cache
222227 }
223228
229+ // lines is the scrollback as the viewport holds it: one string per row. It is
230+ // the coordinate system a mouse selection is made in — a row here is a row on
231+ // screen once the viewport's scroll offset is taken off.
232+ func (t *transcript) lines() []string {
233+ s := t.render()
234+ if t.cacheLines == nil {
235+ t.cacheLines = strings.Split(s, "\n")
236+ }
237+ return t.cacheLines
238+ }
239+
224240 // renderEntry draws one entry. It takes a pointer because an assistant entry
225241 // keeps its laid-out Markdown rather than producing it again every frame.
226242 func (t *transcript) renderEntry(e *entry) string {