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>
Showing 16 changed files with 1700 additions and 67 deletions.
| @@ -137,7 +137,34 @@ to six lines. | ||
| 137 | 137 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 138 | 138 | |
| 139 | 139 | 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. | |
| 141 | 168 | |
| 142 | 169 | A slash at the start of an empty prompt opens the command menu, which filters |
| 143 | 170 | as you type — a name that starts with what you typed before one that merely |
| @@ -4,6 +4,7 @@ go 1.26.8 | ||
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | 6 | github.com/alecthomas/chroma/v2 v2.20.0 |
| 7 | + github.com/atotto/clipboard v0.1.4 | |
| 7 | 8 | github.com/bmatcuk/doublestar/v4 v4.10.0 |
| 8 | 9 | github.com/charmbracelet/bubbles v1.0.0 |
| 9 | 10 | github.com/charmbracelet/bubbletea v1.3.10 |
| @@ -17,7 +18,6 @@ require ( | ||
| 17 | 18 | ) |
| 18 | 19 | |
| 19 | 20 | require ( |
| 20 | - github.com/atotto/clipboard v0.1.4 // indirect | |
| 21 | 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect |
| 22 | 22 | github.com/aymerick/douceur v0.2.0 // indirect |
| 23 | 23 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| @@ -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 | + } | |
| @@ -22,8 +22,13 @@ func forceColour(t *testing.T) { | ||
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | // 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 { | |
| 27 | 32 | var out []rune |
| 28 | 33 | active := "" |
| 29 | 34 | for i := 0; i < len(s); { |
| @@ -44,7 +49,7 @@ func unbackedCells(s string) string { | ||
| 44 | 49 | continue |
| 45 | 50 | } |
| 46 | 51 | r, size := utf8.DecodeRuneInString(s[i:]) |
| 47 | - if !strings.Contains(active, "48;") { | |
| 52 | + if strings.Contains(active, "48;") == want { | |
| 48 | 53 | out = append(out, r) |
| 49 | 54 | } |
| 50 | 55 | i += size |
| @@ -27,6 +27,7 @@ var builtins = []command{ | ||
| 27 | 27 | {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, |
| 28 | 28 | {name: "setup", desc: "the full-screen wizard for the saved defaults"}, |
| 29 | 29 | {name: "clear", desc: "empty the transcript"}, |
| 30 | + {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"}, | |
| 30 | 31 | {name: "allowed", desc: "what is permanently allowed this session"}, |
| 31 | 32 | {name: "exit", desc: "leave the harness"}, |
| 32 | 33 | } |
| @@ -78,7 +79,11 @@ func (m *model) helpText() string { | ||
| 78 | 79 | if n := len(m.opts.Skills); n > 0 { |
| 79 | 80 | fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them") |
| 80 | 81 | } |
| 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" + | |
| 82 | 87 | "\n ! runs a shell command · $ runs python — both on an empty prompt" + |
| 83 | 88 | "\n what they print goes to the agent too, so you can then say \"fix that\"" + |
| 84 | 89 | "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" + |
| @@ -123,17 +123,7 @@ func (m *model) menuView() string { | ||
| 123 | 123 | if !m.menuOpen() { |
| 124 | 124 | return "" |
| 125 | 125 | } |
| 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() | |
| 137 | 127 | |
| 138 | 128 | // One column for the names, so the descriptions line up. A long skill |
| 139 | 129 | // name is left to overflow its column rather than being truncated: the |
| @@ -166,6 +156,22 @@ func (m *model) menuView() string { | ||
| 166 | 156 | return strings.TrimRight(b.String(), "\n") |
| 167 | 157 | } |
| 168 | 158 | |
| 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 | + | |
| 169 | 175 | // pad widens s to width cells, leaving anything already wider alone. |
| 170 | 176 | func pad(s string, width int) string { |
| 171 | 177 | if n := width - lipgloss.Width(s); n > 0 { |
| @@ -94,6 +94,16 @@ func (m *model) applyEffort(level string) { | ||
| 94 | 94 | m.tr.addNotice("effort: " + level + " — from the next turn on") |
| 95 | 95 | } |
| 96 | 96 | |
| 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 | + | |
| 97 | 107 | // effortView is the slider: the poles, the track with the marker riding it, |
| 98 | 108 | // the labels under it, and the keys that drive it. |
| 99 | 109 | // |
| @@ -138,6 +148,38 @@ func (m *model) effortView() string { | ||
| 138 | 148 | truncateStyled(" "+styFaint.Render("←→ move · 1-5 jump · enter apply · esc cancel"), width) |
| 139 | 149 | } |
| 140 | 150 | |
| 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 | + | |
| 141 | 183 | // effortSlots is the column each label starts at, spread evenly across width. |
| 142 | 184 | // It returns nil when the labels cannot be laid out with room to breathe, |
| 143 | 185 | // which is the caller's cue to render something simpler. |
| @@ -145,6 +145,21 @@ type model struct { | ||
| 145 | 145 | // say so while the agent unwinds. |
| 146 | 146 | interrupting bool |
| 147 | 147 | |
| 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 | + | |
| 148 | 163 | // wiz is the setup wizard. While it is non-nil it owns the screen and |
| 149 | 164 | // every key, and this model does nothing but hold its place. |
| 150 | 165 | wiz *wizard |
| @@ -198,12 +213,14 @@ func newModel(ctx context.Context, opts Options) *model { | ||
| 198 | 213 | sp.Style = styCoral |
| 199 | 214 | |
| 200 | 215 | 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, | |
| 207 | 224 | } |
| 208 | 225 | m.tr.setVerbose(opts.Verbose) |
| 209 | 226 | m.tr.addRaw(splash(opts, 80)) |
| @@ -219,7 +236,10 @@ func newModel(ctx context.Context, opts Options) *model { | ||
| 219 | 236 | // Run starts the program and blocks until the user exits. |
| 220 | 237 | func Run(ctx context.Context, opts Options) error { |
| 221 | 238 | 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)) | |
| 223 | 243 | |
| 224 | 244 | // The bridge can only learn the program after it exists; the agent does |
| 225 | 245 | // not touch it until the first turn starts, which is after Run. |
| @@ -250,6 +270,9 @@ func (m *model) Init() tea.Cmd { | ||
| 250 | 270 | // state untouched underneath: nothing about a running turn is disturbed, |
| 251 | 271 | // which is why /setup is safe to answer at any time the agent is idle. |
| 252 | 272 | 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() | |
| 253 | 276 | m.wiz = newWizard(m.opts.Client, m.opts.Config) |
| 254 | 277 | cmds := []tea.Cmd{m.wiz.Init()} |
| 255 | 278 | if m.ready { |
| @@ -353,6 +376,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 353 | 376 | case tea.KeyMsg: |
| 354 | 377 | return m.onKey(msg) |
| 355 | 378 | |
| 379 | + case tea.MouseMsg: | |
| 380 | + return m.onMouse(msg) | |
| 381 | + | |
| 382 | + case copiedMsg: | |
| 383 | + return m, m.finishCopy(msg) | |
| 384 | + | |
| 356 | 385 | case modelsMsg: |
| 357 | 386 | return m, m.onModels(msg) |
| 358 | 387 | |
| @@ -412,6 +441,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 412 | 441 | } |
| 413 | 442 | |
| 414 | 443 | 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() | |
| 415 | 447 | m.width, m.height = w, h |
| 416 | 448 | m.tr.setWidth(w) |
| 417 | 449 | m.input.SetWidth(m.innerWidth()) |
| @@ -518,6 +550,7 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 518 | 550 | return m, tea.Quit |
| 519 | 551 | |
| 520 | 552 | case "esc": |
| 553 | + m.clearSelection() | |
| 521 | 554 | if m.phase == phaseRunning { |
| 522 | 555 | return m, m.interrupt() |
| 523 | 556 | } |
| @@ -531,6 +564,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 531 | 564 | return m, tea.Quit |
| 532 | 565 | |
| 533 | 566 | 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() | |
| 534 | 570 | m.tr.setVerbose(!m.tr.verbose) |
| 535 | 571 | state := "collapsed" |
| 536 | 572 | if m.tr.verbose { |
| @@ -568,6 +604,11 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 568 | 604 | return m.submit(line) |
| 569 | 605 | } |
| 570 | 606 | |
| 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 | + | |
| 571 | 612 | var cmd tea.Cmd |
| 572 | 613 | m.input, cmd = m.input.Update(msg) |
| 573 | 614 | m.growInput() |
| @@ -631,9 +672,13 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 631 | 672 | return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) |
| 632 | 673 | |
| 633 | 674 | case line == "/clear": |
| 675 | + m.clearSelection() | |
| 634 | 676 | m.tr.clear() |
| 635 | 677 | return m, m.sync() |
| 636 | 678 | |
| 679 | + case line == "/mouse": | |
| 680 | + return m, m.toggleMouse() | |
| 681 | + | |
| 637 | 682 | case line == "/allowed": |
| 638 | 683 | allowed := m.opts.Policy.Remembered() |
| 639 | 684 | if len(allowed) == 0 { |
| @@ -779,19 +824,29 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 779 | 824 | if !matches(c.keys, key) && key != fmt.Sprint(i+1) { |
| 780 | 825 | continue |
| 781 | 826 | } |
| 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) | |
| 791 | 828 | } |
| 792 | 829 | return m, nil |
| 793 | 830 | } |
| 794 | 831 | |
| 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 | + | |
| 795 | 850 | func matches(keys []string, key string) bool { |
| 796 | 851 | for _, k := range keys { |
| 797 | 852 | if k == key { |
| @@ -825,28 +880,27 @@ func (m *model) View() string { | ||
| 825 | 880 | m.vp.GotoBottom() |
| 826 | 881 | } |
| 827 | 882 | } |
| 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 | |
| 829 | 893 | } |
| 830 | 894 | |
| 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. | |
| 831 | 898 | 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) | |
| 846 | 902 | } |
| 847 | - b.WriteString(m.status() + "\n") | |
| 848 | - b.WriteString(m.modeLine()) | |
| 849 | - return b.String() | |
| 903 | + return strings.Join(parts, "\n") | |
| 850 | 904 | } |
| 851 | 905 | |
| 852 | 906 | // activity is the line above the prompt: what the agent is doing on the left, |
| @@ -877,9 +931,18 @@ func (m *model) activity() string { | ||
| 877 | 931 | left = styWarn.Render("● waiting for you") |
| 878 | 932 | } |
| 879 | 933 | |
| 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. | |
| 880 | 937 | right := "" |
| 938 | + if m.copied != "" { | |
| 939 | + right = styDim.Render(m.copied) | |
| 940 | + } | |
| 881 | 941 | 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") | |
| 883 | 946 | } |
| 884 | 947 | return spread(left, right, m.width) |
| 885 | 948 | } |
| @@ -149,16 +149,7 @@ func (m *model) modelView() string { | ||
| 149 | 149 | return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width) |
| 150 | 150 | } |
| 151 | 151 | |
| 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() | |
| 162 | 153 | |
| 163 | 154 | // One column for the names, so the ids line up under each other. |
| 164 | 155 | names := make([]string, len(m.models)) |
| @@ -213,6 +204,24 @@ func (m *model) modelView() string { | ||
| 213 | 204 | return strings.TrimRight(b.String(), "\n") |
| 214 | 205 | } |
| 215 | 206 | |
| 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 | + | |
| 216 | 225 | // modelRows is how many model rows fit: the same arithmetic the completion |
| 217 | 226 | // menu does, minus the heading and the line saying where the list came from. |
| 218 | 227 | func (m *model) modelRows() int { |
| @@ -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)) } | |
| @@ -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 | + } | |
| @@ -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))) } | |
| @@ -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 | + } | |
| @@ -27,6 +27,7 @@ var ( | ||
| 27 | 27 | colWarn lipgloss.AdaptiveColor |
| 28 | 28 | colBlue lipgloss.AdaptiveColor |
| 29 | 29 | colPillBg lipgloss.AdaptiveColor |
| 30 | + colSelBg lipgloss.AdaptiveColor | |
| 30 | 31 | ) |
| 31 | 32 | |
| 32 | 33 | var ( |
| @@ -75,4 +76,9 @@ var ( | ||
| 75 | 76 | // The approval box is the one place the UI is allowed to shout: it is the |
| 76 | 77 | // only moment a human has to decide something. |
| 77 | 78 | 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 | |
| 78 | 84 | ) |
| @@ -10,9 +10,9 @@ import ( | ||
| 10 | 10 | // derived from it, so a theme decides colour and nothing about layout. Adding |
| 11 | 11 | // one is adding an entry here and a name to config.Themes. |
| 12 | 12 | // |
| 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. | |
| 16 | 16 | type theme struct { |
| 17 | 17 | name string |
| 18 | 18 | // blurb is what the wizard says about it, in the wizard's own voice. |
| @@ -28,6 +28,10 @@ type theme struct { | ||
| 28 | 28 | warn lipgloss.AdaptiveColor |
| 29 | 29 | info lipgloss.AdaptiveColor |
| 30 | 30 | 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 | |
| 31 | 35 | } |
| 32 | 36 | |
| 33 | 37 | // themes are in the order the wizard offers them; ember is first because it |
| @@ -50,6 +54,7 @@ var themes = []theme{{ | ||
| 50 | 54 | warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e0a458"}, |
| 51 | 55 | info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#7aa2e3"}, |
| 52 | 56 | pillBg: lipgloss.AdaptiveColor{Light: "#ebe7e2", Dark: "#2b2825"}, |
| 57 | + selBg: lipgloss.AdaptiveColor{Light: "#f6d8cb", Dark: "#5c3527"}, | |
| 53 | 58 | }, { |
| 54 | 59 | name: "mint", |
| 55 | 60 | blurb: "Cool green. Easiest on a bright screen.", |
| @@ -63,6 +68,7 @@ var themes = []theme{{ | ||
| 63 | 68 | warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e8bc6a"}, |
| 64 | 69 | info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#79a8d8"}, |
| 65 | 70 | pillBg: lipgloss.AdaptiveColor{Light: "#e4ecea", Dark: "#232b29"}, |
| 71 | + selBg: lipgloss.AdaptiveColor{Light: "#cbe8e1", Dark: "#1f4c44"}, | |
| 66 | 72 | }, { |
| 67 | 73 | name: "iris", |
| 68 | 74 | blurb: "Violet and slate. Quiet under low light.", |
| @@ -76,6 +82,7 @@ var themes = []theme{{ | ||
| 76 | 82 | warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e2b062"}, |
| 77 | 83 | info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#86a8ec"}, |
| 78 | 84 | pillBg: lipgloss.AdaptiveColor{Light: "#eae7f2", Dark: "#282434"}, |
| 85 | + selBg: lipgloss.AdaptiveColor{Light: "#ded2f8", Dark: "#3d2f60"}, | |
| 79 | 86 | }, { |
| 80 | 87 | name: "mono", |
| 81 | 88 | blurb: "No accent hue. For screens and eyes that want none.", |
| @@ -93,6 +100,7 @@ var themes = []theme{{ | ||
| 93 | 100 | warn: lipgloss.AdaptiveColor{Light: "#7a6030", Dark: "#c8b083"}, |
| 94 | 101 | info: lipgloss.AdaptiveColor{Light: "#4a5f7a", Dark: "#9aaec8"}, |
| 95 | 102 | pillBg: lipgloss.AdaptiveColor{Light: "#e9e9e9", Dark: "#2a2a2a"}, |
| 103 | + selBg: lipgloss.AdaptiveColor{Light: "#d0d0d0", Dark: "#484848"}, | |
| 96 | 104 | }} |
| 97 | 105 | |
| 98 | 106 | // defaultTheme is what an unconfigured harness looks like, and the fallback |
| @@ -147,6 +155,7 @@ func applyTheme(name string) { | ||
| 147 | 155 | colCoral, colText, colUser = t.accent, t.text, t.user |
| 148 | 156 | colDim, colFaint = t.dim, t.faint |
| 149 | 157 | colOK, colErr, colWarn, colBlue, colPillBg = t.ok, t.err, t.warn, t.info, t.pillBg |
| 158 | + colSelBg = t.selBg | |
| 150 | 159 | |
| 151 | 160 | styText = lipgloss.NewStyle().Foreground(colText) |
| 152 | 161 | styDim = lipgloss.NewStyle().Foreground(colDim) |
| @@ -193,6 +202,12 @@ func applyTheme(name string) { | ||
| 193 | 202 | BorderForeground(colCoral). |
| 194 | 203 | Padding(0, 1) |
| 195 | 204 | |
| 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 | + | |
| 196 | 211 | // Markdown is styled from the same palette, and its stylesheet is baked |
| 197 | 212 | // rather than read per draw, so it has to be rebuilt here too. |
| 198 | 213 | resetMarkdown() |
| @@ -73,7 +73,12 @@ type transcript struct { | ||
| 73 | 73 | verbose bool |
| 74 | 74 | |
| 75 | 75 | 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 | |
| 77 | 82 | // gen is the theme generation the cache was built under. |
| 78 | 83 | gen int |
| 79 | 84 | } |
| @@ -217,10 +222,21 @@ func (t *transcript) render() string { | ||
| 217 | 222 | } |
| 218 | 223 | b.WriteString(t.renderEntry(&t.entries[i])) |
| 219 | 224 | } |
| 220 | - t.cache, t.dirty = b.String(), false | |
| 225 | + t.cache, t.cacheLines, t.dirty = b.String(), nil, false | |
| 221 | 226 | return t.cache |
| 222 | 227 | } |
| 223 | 228 | |
| 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 | + | |
| 224 | 240 | // renderEntry draws one entry. It takes a pointer because an assistant entry |
| 225 | 241 | // keeps its laid-out Markdown rather than producing it again every frame. |
| 226 | 242 | func (t *transcript) renderEntry(e *entry) string { |