@tiens.nguyen/gu-cli 1.0.686
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- package/version-check.mjs +72 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-owned scrollback for the `gu` terminal (task #141, second attempt).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. A bottom bar cannot survive the user scrolling as long as SCROLLING BELONGS
|
|
5
|
+
* TO THE TERMINAL: it repaints its own scrollback over the whole window and never tells the
|
|
6
|
+
* application. The way out is not the alternate screen — it is taking the wheel. With mouse
|
|
7
|
+
* reporting on, a wheel notch arrives as an INPUT EVENT and the terminal does not move, so the
|
|
8
|
+
* application decides what the window shows. Keep every printed line in a buffer, paint the
|
|
9
|
+
* slice the user has scrolled to, and the reserved bottom rows never move.
|
|
10
|
+
*
|
|
11
|
+
* That is exactly why an app that does this can offer "jump to bottom": it knows where you are.
|
|
12
|
+
*
|
|
13
|
+
* The view FOLLOWS THE BOTTOM: anything printed brings it home first, so scrolling up is the
|
|
14
|
+
* only way to leave the live tail — and the "jump to bottom" line exists precisely for the
|
|
15
|
+
* moments when you have.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is pure — a buffer plus arithmetic — so the scroll behaviour is testable
|
|
18
|
+
* without a terminal. The REPL owns the mouse, the writes, and when to render.
|
|
19
|
+
*/
|
|
20
|
+
import { charWidth } from "./terminal-layout.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The printed transcript, as physical lines.
|
|
24
|
+
*
|
|
25
|
+
* `append` takes raw output exactly as it was written to stdout: text may arrive in fragments
|
|
26
|
+
* with no trailing newline (a streamed answer), so the last line stays OPEN until its newline
|
|
27
|
+
* lands. Carriage returns rewrite the open line, which is how in-place progress text behaves.
|
|
28
|
+
* ANSI colour is kept — it is what the line looked like.
|
|
29
|
+
*/
|
|
30
|
+
export function createTranscript({ max = 20000 } = {}) {
|
|
31
|
+
// Colour is kept; anything that MOVES or ERASES is dropped. A recorded line is replayed
|
|
32
|
+
// verbatim when the user scrolls back, so a stray ESC[2A in it would move the caret and
|
|
33
|
+
// scramble the repaint — the line has to be inert text.
|
|
34
|
+
const sanitize = (s) =>
|
|
35
|
+
String(s)
|
|
36
|
+
.replace(/\x1b\[[0-9;?]*[A-LN-Za-ln-z]/g, "") // every CSI except SGR ("m")
|
|
37
|
+
.replace(/\x1b[78]/g, "") // DECSC / DECRC
|
|
38
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, ""); // OSC (title sets)
|
|
39
|
+
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
let lines = [""];
|
|
42
|
+
let total = 0; // lines that have scrolled out of the buffer, so line numbers stay meaningful
|
|
43
|
+
|
|
44
|
+
const trim = () => {
|
|
45
|
+
if (lines.length <= max) return;
|
|
46
|
+
const drop = lines.length - max;
|
|
47
|
+
lines = lines.slice(drop);
|
|
48
|
+
total += drop;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
append(text) {
|
|
53
|
+
const s = sanitize(text ?? "");
|
|
54
|
+
if (!s) return;
|
|
55
|
+
for (const ch of s) {
|
|
56
|
+
if (ch === "\n") lines.push("");
|
|
57
|
+
else if (ch === "\r") lines[lines.length - 1] = "";
|
|
58
|
+
else lines[lines.length - 1] += ch;
|
|
59
|
+
}
|
|
60
|
+
trim();
|
|
61
|
+
},
|
|
62
|
+
/** All lines, oldest first. The last one may still be open (no newline yet). */
|
|
63
|
+
lines: () => lines,
|
|
64
|
+
/** How many lines are available to scroll through. */
|
|
65
|
+
length: () => lines.length,
|
|
66
|
+
dropped: () => total,
|
|
67
|
+
/**
|
|
68
|
+
* Replace recorded lines [fromAbs..toAbs] (ABSOLUTE indices, i.e. including lines already
|
|
69
|
+
* dropped) with `next`. The one mutation the transcript allows, and it exists for exactly
|
|
70
|
+
* one reason: a printed block that can be expanded and collapsed in place. Absolute
|
|
71
|
+
* indices because the buffer trims from the front, so a stored position must survive that.
|
|
72
|
+
* Returns the new absolute end, or null when the range has scrolled out of the buffer.
|
|
73
|
+
*/
|
|
74
|
+
replaceRange(fromAbs, toAbs, next) {
|
|
75
|
+
const from = fromAbs - total;
|
|
76
|
+
const to = toAbs - total;
|
|
77
|
+
if (from < 0 || to >= lines.length || from > to) return null;
|
|
78
|
+
lines.splice(from, to - from + 1, ...next.map(sanitize));
|
|
79
|
+
trim();
|
|
80
|
+
return fromAbs + next.length - 1;
|
|
81
|
+
},
|
|
82
|
+
clear() {
|
|
83
|
+
lines = [""];
|
|
84
|
+
total = 0;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wrap ONE recorded line into the display rows a terminal would soft-wrap it into.
|
|
91
|
+
*
|
|
92
|
+
* The transcript holds logical lines; the terminal wrapped the long ones when they were first
|
|
93
|
+
* printed. A repaint has to reproduce that or the text is simply lost (a clip drops everything
|
|
94
|
+
* past the right edge) AND the arithmetic drifts, because one logical line can be three rows on
|
|
95
|
+
* screen. Colour is carried onto continuation rows so a wrapped coloured line stays coloured.
|
|
96
|
+
*
|
|
97
|
+
* Counted in COLUMNS by code point (charWidth): iterating UTF-16 units cut emoji in half at a
|
|
98
|
+
* row boundary and mis-measured every wide character, so the wrap fell out of step with the
|
|
99
|
+
* terminal's own — which is what makes a repaint lose a line.
|
|
100
|
+
*/
|
|
101
|
+
export function wrapVisible(line, width) {
|
|
102
|
+
const w = Math.max(1, width);
|
|
103
|
+
const rows = [];
|
|
104
|
+
let row = "";
|
|
105
|
+
let seen = 0;
|
|
106
|
+
let sgr = ""; // the colour state to re-open on the next row
|
|
107
|
+
const chars = [...line];
|
|
108
|
+
for (let i = 0; i < chars.length; i++) {
|
|
109
|
+
if (chars[i] === "\x1b") {
|
|
110
|
+
const m = /^\x1b\[[0-9;]*m/.exec(chars.slice(i).join(""));
|
|
111
|
+
if (m) {
|
|
112
|
+
row += m[0];
|
|
113
|
+
sgr = m[0] === "\x1b[0m" ? "" : sgr + m[0];
|
|
114
|
+
i += [...m[0]].length - 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const cw = charWidth(chars[i]);
|
|
119
|
+
// A wide character that would straddle the edge moves down whole, exactly as the terminal
|
|
120
|
+
// does — leaving the last cell of the row blank rather than splitting the glyph.
|
|
121
|
+
if (seen + cw > w) {
|
|
122
|
+
rows.push(row);
|
|
123
|
+
row = sgr;
|
|
124
|
+
seen = 0;
|
|
125
|
+
}
|
|
126
|
+
row += chars[i];
|
|
127
|
+
seen += cw;
|
|
128
|
+
}
|
|
129
|
+
rows.push(row);
|
|
130
|
+
return rows;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every recorded line as the display rows the screen actually shows. */
|
|
134
|
+
export function displayRows(lines, width) {
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const l of lines) for (const r of wrapVisible(l, width)) out.push(r);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* For every display row, the index of the recorded line it came from.
|
|
142
|
+
*
|
|
143
|
+
* The screen and the transcript do not agree on what a "row" is: one long line is several
|
|
144
|
+
* rows once wrapped. Anything that has to turn a CLICKED ROW back into a piece of content —
|
|
145
|
+
* expanding a printed thought block (task #148) — needs that correspondence, and computing it
|
|
146
|
+
* from the same wrapping the viewport uses is the only way it stays right at every width.
|
|
147
|
+
*/
|
|
148
|
+
export function displayRowOwners(lines, width) {
|
|
149
|
+
const out = [];
|
|
150
|
+
lines.forEach((l, i) => {
|
|
151
|
+
for (let r = 0; r < wrapVisible(l, width).length; r++) out.push(i);
|
|
152
|
+
});
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The FIRST display row on screen, given how many rows the transcript is painted into.
|
|
158
|
+
*
|
|
159
|
+
* `height` IS NOT ALWAYS THE HEIGHT OF THE SCROLLING AREA, and that is the whole reason this
|
|
160
|
+
* is one named function rather than a line repeated at each call site. An in-place block drawn
|
|
161
|
+
* UNDER the transcript — the live "Thinking… (12s)" status, which is deliberately never
|
|
162
|
+
* recorded in the transcript — owns the rows it covers, and the transcript stops above them.
|
|
163
|
+
* Hand this the full height while such a block is up and every screen row resolves to a line
|
|
164
|
+
* that many rows too early: hovering a printed thought highlighted the block a few lines above
|
|
165
|
+
* the pointer, and a repaint dropped transcript text on top of the thinking line.
|
|
166
|
+
*
|
|
167
|
+
* `total` is the number of display rows the transcript has (displayRowOwners(...).length).
|
|
168
|
+
*
|
|
169
|
+
* `offset` IS HOW FAR THE USER HAS SCROLLED BACK, counted from the bottom exactly as
|
|
170
|
+
* viewportSlice counts it — and it must be, because these two answer the same question from
|
|
171
|
+
* opposite ends: viewportSlice says which rows get PAINTED, this says which row the top of the
|
|
172
|
+
* paint is. Defaulting it to 0 is what the live tail means, and every caller that only ever
|
|
173
|
+
* looks at the live tail can keep passing two arguments.
|
|
174
|
+
*
|
|
175
|
+
* IT USED TO TAKE NO OFFSET AT ALL, and that is the whole of the scrolled-back selection bug
|
|
176
|
+
* (reported 2026-09-01: "when i scroll up and copy the question it is not selected; if i scroll
|
|
177
|
+
* down to bottom it is allowed to select"). Every screen-row → transcript-row mapping in the
|
|
178
|
+
* REPL runs through here, so while scrolled back they all resolved to rows near the END of the
|
|
179
|
+
* transcript — the text at the live tail, not the text under the pointer. Rather than fix the
|
|
180
|
+
* arithmetic, the callers had been gated off with `!scrolledBack()`, which is why dragging up
|
|
181
|
+
* there highlighted nothing and copied nothing.
|
|
182
|
+
*
|
|
183
|
+
* Clamped the same way viewportSlice clamps, so a wheel spun past the top and this function
|
|
184
|
+
* cannot disagree about where the view actually stopped.
|
|
185
|
+
*/
|
|
186
|
+
export function firstVisibleRow(total, height, offset = 0) {
|
|
187
|
+
const h = Math.max(1, height);
|
|
188
|
+
const maxOffset = Math.max(0, total - h);
|
|
189
|
+
const clamped = Math.max(0, Math.min(Math.max(0, offset | 0), maxOffset));
|
|
190
|
+
return Math.max(0, total - h - clamped);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Which recorded line viewport row `row` (1-based) is showing — or -1 when that row holds no
|
|
195
|
+
* transcript (above the first line, or below the last one, where an in-place block lives).
|
|
196
|
+
*
|
|
197
|
+
* `offset` is the scroll position, as in firstVisibleRow: omit it for the live tail.
|
|
198
|
+
*/
|
|
199
|
+
export function lineAtViewportRow(owners, row, height, offset = 0) {
|
|
200
|
+
// ROWS ARE 1-BASED. Without this guard row 0 quietly resolves to the line one row ABOVE the
|
|
201
|
+
// top of the view — a real index, so nothing downstream can tell it was never on screen.
|
|
202
|
+
if (!(row >= 1)) return -1;
|
|
203
|
+
const idx = firstVisibleRow(owners.length, height, offset) + (row - 1);
|
|
204
|
+
return idx < 0 || idx >= owners.length ? -1 : owners[idx];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The 1-based viewport rows currently showing recorded lines `from..to`, in order.
|
|
209
|
+
*
|
|
210
|
+
* The inverse of lineAtViewportRow, and it must stay that way — it is what lets a change that
|
|
211
|
+
* only RECOLOURS a block repaint the block's own rows instead of the whole view. Rows scrolled
|
|
212
|
+
* off the top, and rows below the transcript, are simply absent.
|
|
213
|
+
*/
|
|
214
|
+
export function viewportRowsForLines(owners, from, to, height) {
|
|
215
|
+
const first = firstVisibleRow(owners.length, height);
|
|
216
|
+
const out = [];
|
|
217
|
+
for (let i = first; i < owners.length; i++) {
|
|
218
|
+
if (owners[i] < from || owners[i] > to) continue;
|
|
219
|
+
const row = i - first + 1;
|
|
220
|
+
if (row >= 1 && row <= Math.max(1, height)) out.push({ row, index: i });
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The slice of `lines` to show, given how far back the user has scrolled.
|
|
227
|
+
*
|
|
228
|
+
* `offset` counts lines from the BOTTOM: 0 is live (the newest `height` lines). It is clamped
|
|
229
|
+
* here rather than by the caller, so a wheel spun past the top or bottom simply stops — the
|
|
230
|
+
* caller can always add its delta blindly and ask what actually happened.
|
|
231
|
+
*/
|
|
232
|
+
export function viewportSlice(lines, offset, height) {
|
|
233
|
+
const h = Math.max(1, height);
|
|
234
|
+
const maxOffset = Math.max(0, lines.length - h);
|
|
235
|
+
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
|
236
|
+
const end = lines.length - clamped;
|
|
237
|
+
const rows = lines.slice(Math.max(0, end - h), end);
|
|
238
|
+
return {
|
|
239
|
+
rows,
|
|
240
|
+
offset: clamped,
|
|
241
|
+
atBottom: clamped === 0,
|
|
242
|
+
/** Lines hidden BELOW the viewport — what "jump to bottom" would bring back. */
|
|
243
|
+
below: clamped,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Bytes that repaint the scrolling area (rows 1..height) with `rows`, top-aligned, each row
|
|
249
|
+
* cleared first and clipped so nothing wraps into the next one — a wrapped row here would push
|
|
250
|
+
* the whole view down by one and desynchronise every later repaint.
|
|
251
|
+
*
|
|
252
|
+
* The caret is left on the LAST painted row: while the user is scrolled back nothing types
|
|
253
|
+
* there, and parking it inside the region keeps the terminal from scrolling on its own.
|
|
254
|
+
*
|
|
255
|
+
* `preserveCaret:false` is what a caller that keeps its OUTPUT POSITION in the terminal's DECSC
|
|
256
|
+
* slot must pass — there is only one such slot, so bracketing this repaint with DECSC/DECRC
|
|
257
|
+
* overwrites the output position with wherever the caret happened to be (the input line), and
|
|
258
|
+
* the next printed line lands in the frozen zone, indented by the width of the prompt. Same
|
|
259
|
+
* hazard, same flag as `paintFrozenRowsSeq`.
|
|
260
|
+
*/
|
|
261
|
+
export function paintViewportSeq(rows, height, width, clip, { preserveCaret = true } = {}) {
|
|
262
|
+
const h = Math.max(1, height);
|
|
263
|
+
let out = preserveCaret ? "\x1b7" : "";
|
|
264
|
+
for (let i = 0; i < h; i++) {
|
|
265
|
+
const row = rows[i] ?? "";
|
|
266
|
+
out += `\x1b[${i + 1};1H\x1b[2K` + clip(row, Math.max(1, width - 1));
|
|
267
|
+
}
|
|
268
|
+
return out + (preserveCaret ? "\x1b8" : "");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The bar's top row while the user is scrolled back: it replaces the plain rule with the
|
|
273
|
+
* affordance that tells them where they are AND that a click brings them back. Returned as
|
|
274
|
+
* plain text plus the click target, so the caller styles it and hit-tests the row itself.
|
|
275
|
+
*/
|
|
276
|
+
export function jumpToBottomRow({ below, width }) {
|
|
277
|
+
const label = `↓ jump to bottom (click) · ${below} line${below === 1 ? "" : "s"} below`;
|
|
278
|
+
const w = Math.max(20, width);
|
|
279
|
+
const pad = Math.max(0, Math.floor((w - label.length) / 2));
|
|
280
|
+
return { text: " ".repeat(pad) + label, from: pad, to: pad + label.length };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------------------
|
|
284
|
+
// SELECTION (task #176) — turning a drag on the screen back into text.
|
|
285
|
+
//
|
|
286
|
+
// The terminal cannot help here. While gu reports mouse motion (?1003h), the terminal does
|
|
287
|
+
// NOT run its own drag-select, and no escape sequence reports what a terminal has selected. So
|
|
288
|
+
// a selection has to be tracked and extracted by us. That is also why this is worth doing at
|
|
289
|
+
// all: macOS Terminal.app has no copy-on-select setting and never has, so "select and it is
|
|
290
|
+
// copied" cannot be delegated to the emulator for half the users.
|
|
291
|
+
//
|
|
292
|
+
// Everything below is PURE — screen coordinates plus the recorded lines in, text out — so the
|
|
293
|
+
// arithmetic can be argued with in a test instead of by dragging a mouse.
|
|
294
|
+
// ---------------------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/** Colour escapes occupy no cells; selection maths is over VISIBLE characters only. */
|
|
297
|
+
const _plain = (s) => String(s ?? "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The two ends of a drag, in the order the text reads.
|
|
301
|
+
*
|
|
302
|
+
* A drag upward or right-to-left produces an anchor AFTER the focus, and every consumer would
|
|
303
|
+
* otherwise have to re-check. Ordered once, here.
|
|
304
|
+
*/
|
|
305
|
+
export function orderSelection(a, b) {
|
|
306
|
+
const A = { row: Math.max(0, a?.row | 0), col: Math.max(0, a?.col | 0) };
|
|
307
|
+
const B = { row: Math.max(0, b?.row | 0), col: Math.max(0, b?.col | 0) };
|
|
308
|
+
const backwards = B.row < A.row || (B.row === A.row && B.col < A.col);
|
|
309
|
+
return backwards ? { start: B, end: A } : { start: A, end: B };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Nothing was dragged — the two ends are the same cell. A plain click, not a selection. */
|
|
313
|
+
export function isEmptySelection(sel) {
|
|
314
|
+
if (!sel?.start || !sel?.end) return true;
|
|
315
|
+
return sel.start.row === sel.end.row && sel.start.col === sel.end.col;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The text inside a selection, given the recorded lines and the width they are shown at.
|
|
320
|
+
*
|
|
321
|
+
* `start`/`end` are DISPLAY rows — what the user dragged over — and columns are visible cells.
|
|
322
|
+
* Two things make the result text rather than a screenshot:
|
|
323
|
+
*
|
|
324
|
+
* · SOFT WRAP IS UNDONE. wrapVisible breaks a long line at the width with no word logic, so
|
|
325
|
+
* display rows belonging to one recorded line rejoin with nothing between them and the
|
|
326
|
+
* sentence is whole again. Rows from DIFFERENT recorded lines join with a newline.
|
|
327
|
+
* · THE GUTTER IS DROPPED. Every line in this UI is indented into a shared text column; that
|
|
328
|
+
* indent is layout, not content, and pasting it back into an editor reindents the world.
|
|
329
|
+
*
|
|
330
|
+
* What it deliberately does NOT do is undo a HARD wrap: renderAnswer emits each wrapped row as
|
|
331
|
+
* its own recorded line, so selecting an answer yields those rows. Task #158 is the reason the
|
|
332
|
+
* caller should prefer a registered block's SOURCE text when the selection covers one —
|
|
333
|
+
* selectionCoversLines() answers that — because a copied answer has to still run.
|
|
334
|
+
*/
|
|
335
|
+
export function selectionText(lines, sel, width, { gutter = "" } = {}) {
|
|
336
|
+
const all = Array.isArray(lines) ? lines : [];
|
|
337
|
+
const w = Math.max(1, width | 0);
|
|
338
|
+
if (!sel?.start || !sel?.end || !all.length) return "";
|
|
339
|
+
const { start, end } = orderSelection(sel.start, sel.end);
|
|
340
|
+
const rows = displayRows(all, w);
|
|
341
|
+
const owners = displayRowOwners(all, w);
|
|
342
|
+
if (!rows.length) return "";
|
|
343
|
+
// A drag that BEGINS past the last row selected nothing — clamping both ends would instead
|
|
344
|
+
// return the final row's text, which was never under the pointer. A drag that begins in the
|
|
345
|
+
// content and runs off the bottom is different, and clamps normally below.
|
|
346
|
+
if (start.row >= rows.length) return "";
|
|
347
|
+
const first = Math.min(start.row, rows.length - 1);
|
|
348
|
+
const last = Math.min(end.row, rows.length - 1);
|
|
349
|
+
if (last < first) return "";
|
|
350
|
+
|
|
351
|
+
const out = [];
|
|
352
|
+
let curOwner = null;
|
|
353
|
+
for (let r = first; r <= last; r++) {
|
|
354
|
+
const text = _plain(rows[r]);
|
|
355
|
+
const from = r === first ? Math.min(start.col, text.length) : 0;
|
|
356
|
+
const to = r === last ? Math.min(end.col, text.length) : text.length;
|
|
357
|
+
const piece = to > from ? text.slice(from, to) : "";
|
|
358
|
+
if (owners[r] === curOwner) out[out.length - 1] += piece; // same recorded line: soft wrap
|
|
359
|
+
else {
|
|
360
|
+
out.push(piece);
|
|
361
|
+
curOwner = owners[r];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const g = String(gutter || "");
|
|
365
|
+
return out
|
|
366
|
+
.map((l) => (g && l.startsWith(g) ? l.slice(g.length) : l))
|
|
367
|
+
.join("\n")
|
|
368
|
+
.replace(/[ \t]+$/gm, "");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A drag in SCREEN coordinates → the selection in ABSOLUTE display rows (task #176 → #219).
|
|
373
|
+
*
|
|
374
|
+
* The two spaces are different and the difference is the scroll position: screen rows are
|
|
375
|
+
* 1-based and count from the top of the viewport, absolute rows are 0-based and count from the
|
|
376
|
+
* top of the whole transcript. Everything downstream — the highlight painted by
|
|
377
|
+
* applySelectionHighlight, the text recovered by selectionText, the block lookup in
|
|
378
|
+
* selectionCoversLines — works in absolute rows, so this conversion is the single place the
|
|
379
|
+
* scroll position enters the selection at all. Getting it wrong does not fail loudly: it
|
|
380
|
+
* selects real text from the wrong part of the transcript.
|
|
381
|
+
*
|
|
382
|
+
* Lives here, pure, rather than in the REPL, because the REPL runs main() on import and so
|
|
383
|
+
* cannot be imported by a test — which is exactly how it went wrong. The version in the REPL
|
|
384
|
+
* open-coded `total - height`, could not see `offset`, and was therefore correct only at the
|
|
385
|
+
* live tail; the response had been to switch the whole feature off while scrolled back rather
|
|
386
|
+
* than to pass the offset in.
|
|
387
|
+
*
|
|
388
|
+
* THE COLUMN -1 BELONGS TO THE EARLIER END. It turns a 1-based mouse column into an inclusive
|
|
389
|
+
* 0-based one, and "earlier" means earlier ON SCREEN, not the anchor — which is the LATER end
|
|
390
|
+
* whenever the pointer was dragged right-to-left. Applying it to the anchor dropped a
|
|
391
|
+
* character off each end of every backwards drag, so the ordering has to happen first.
|
|
392
|
+
*/
|
|
393
|
+
export function absoluteSelection(sel, { total, height, offset = 0 } = {}) {
|
|
394
|
+
if (!sel?.start || !sel?.end) return null;
|
|
395
|
+
const firstVisible = firstVisibleRow(total | 0, height, offset);
|
|
396
|
+
const { start, end } = orderSelection(sel.start, sel.end);
|
|
397
|
+
return {
|
|
398
|
+
start: { row: firstVisible + (start.row - 1), col: Math.max(0, start.col - 1) },
|
|
399
|
+
end: { row: firstVisible + (end.row - 1), col: end.col },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Does this selection cover every display row of these recorded lines?
|
|
405
|
+
*
|
|
406
|
+
* The caller uses it to hand back a registered block's SOURCE instead of the painted rows
|
|
407
|
+
* (#158). Partial selections deliberately do not qualify: half a block cannot be mapped back
|
|
408
|
+
* into half a source string, and guessing would paste something that was never on screen.
|
|
409
|
+
*/
|
|
410
|
+
export function selectionCoversLines(sel, { fromLine, toLine, lines, width }) {
|
|
411
|
+
if (!sel?.start || !sel?.end) return false;
|
|
412
|
+
const owners = displayRowOwners(Array.isArray(lines) ? lines : [], Math.max(1, width | 0));
|
|
413
|
+
const { start, end } = orderSelection(sel.start, sel.end);
|
|
414
|
+
let firstRow = -1;
|
|
415
|
+
let lastRow = -1;
|
|
416
|
+
owners.forEach((owner, row) => {
|
|
417
|
+
if (owner >= fromLine && owner <= toLine) {
|
|
418
|
+
if (firstRow < 0) firstRow = row;
|
|
419
|
+
lastRow = row;
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
if (firstRow < 0) return false;
|
|
423
|
+
return start.row <= firstRow && end.row >= lastRow;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// A "word" for double-click: a run of characters a programmer would expect to travel together.
|
|
427
|
+
// Deliberately WIDER than \w — a path, a dotted call and a kebab flag are each one thing to
|
|
428
|
+
// someone double-clicking them, and splitting "src/api/graph.js" into five selections is the
|
|
429
|
+
// behaviour that makes people stop using the feature.
|
|
430
|
+
const _WORD_CHAR = /[A-Za-z0-9_\-./@:+~$]/;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The word around a column, as {from, to} — or null when the column is not on one.
|
|
434
|
+
*
|
|
435
|
+
* Clicking whitespace selects nothing rather than the nearest word: guessing which side the
|
|
436
|
+
* user meant is wrong half the time, and an empty result is easy to retry.
|
|
437
|
+
*/
|
|
438
|
+
export function wordRangeAt(text, col) {
|
|
439
|
+
const s = _plain(text);
|
|
440
|
+
const i = Math.max(0, Math.min(col | 0, s.length - 1));
|
|
441
|
+
if (!s.length || !_WORD_CHAR.test(s[i] ?? "")) return null;
|
|
442
|
+
let from = i;
|
|
443
|
+
let to = i + 1;
|
|
444
|
+
while (from > 0 && _WORD_CHAR.test(s[from - 1])) from--;
|
|
445
|
+
while (to < s.length && _WORD_CHAR.test(s[to])) to++;
|
|
446
|
+
return { from, to };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Drag tracking, as a pure reducer (task #176).
|
|
451
|
+
*
|
|
452
|
+
* The three facts an SGR report carries — press, motion, release — arrive at different times and
|
|
453
|
+
* mean nothing individually; a selection is the shape they make together. Keeping that shape in
|
|
454
|
+
* a reducer means the rules can be argued with in a test, and the mouse handler is left doing
|
|
455
|
+
* only decode-and-dispatch.
|
|
456
|
+
*
|
|
457
|
+
* `state` is null (nothing in progress) or { anchor, focus, moved }. Returns { state, action }:
|
|
458
|
+
*
|
|
459
|
+
* null nothing to do
|
|
460
|
+
* {type:"update"} the selection changed and should be repainted
|
|
461
|
+
* {type:"commit"} the button came up on a real selection — copy it
|
|
462
|
+
* {type:"cancel"} the button came up without moving: a CLICK, not a selection — `at` is
|
|
463
|
+
* the cell it went down on, so the caller can run the click behaviours
|
|
464
|
+
* from the release rather than guessing at press time (task #177)
|
|
465
|
+
*
|
|
466
|
+
* The commit/cancel split is the whole safety of auto-copy. Copying on every release would
|
|
467
|
+
* overwrite the clipboard on every stray click with an empty string, which is destructive and
|
|
468
|
+
* not undoable. `moved` is what separates the two, and it is set by comparing cells rather than
|
|
469
|
+
* by a timer, so a slow careful drag is still a drag.
|
|
470
|
+
*/
|
|
471
|
+
export function dragReduce(state, ev) {
|
|
472
|
+
const at = { row: Math.max(0, ev?.row | 0), col: Math.max(0, ev?.col | 0) };
|
|
473
|
+
switch (ev?.kind) {
|
|
474
|
+
case "press":
|
|
475
|
+
// A fresh press always restarts. A press arriving mid-drag means the previous release was
|
|
476
|
+
// never delivered (a lost focus, another window) — the old anchor is stale either way.
|
|
477
|
+
return { state: { anchor: at, focus: at, moved: false }, action: null };
|
|
478
|
+
case "motion": {
|
|
479
|
+
if (!state) return { state, action: null }; // hover, not a drag — the caller owns that
|
|
480
|
+
const moved = state.moved || at.row !== state.anchor.row || at.col !== state.anchor.col;
|
|
481
|
+
const next = { anchor: state.anchor, focus: at, moved };
|
|
482
|
+
return {
|
|
483
|
+
state: next,
|
|
484
|
+
action: moved ? { type: "update", sel: { start: next.anchor, end: next.focus } } : null,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
case "release": {
|
|
488
|
+
if (!state) return { state: null, action: null };
|
|
489
|
+
const sel = { start: state.anchor, end: state.moved ? at : state.focus };
|
|
490
|
+
const moved = state.moved || at.row !== state.anchor.row || at.col !== state.anchor.col;
|
|
491
|
+
return {
|
|
492
|
+
state: null,
|
|
493
|
+
// `at` is where the button went DOWN, which is the cell a click is aimed at — the
|
|
494
|
+
// caller cannot recover it once the state is cleared, and the release cell is not the
|
|
495
|
+
// same thing (a click that slid within one cell still belongs to its anchor).
|
|
496
|
+
action: moved ? { type: "commit", sel } : { type: "cancel", at: state.anchor },
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
default:
|
|
500
|
+
return { state, action: null };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* What an SGR button byte means for dragging: "press" | "motion" | "wheel" | null.
|
|
506
|
+
*
|
|
507
|
+
* Bit 5 (32) is motion, bit 6 (64) is the wheel, and the low two bits name the button — 0 being
|
|
508
|
+
* left. Anything else (right, middle, a modifier-only report) is not ours, and saying so here
|
|
509
|
+
* keeps the reducer from having to know about terminal encodings at all.
|
|
510
|
+
*/
|
|
511
|
+
export function dragEventKind(btn, press) {
|
|
512
|
+
const b = Number(btn) | 0;
|
|
513
|
+
if (b & 64) return "wheel";
|
|
514
|
+
if (press === "m") return "release";
|
|
515
|
+
if (b & 32) return (b & 0b11) === 0 ? "motion" : null; // motion with the LEFT button held
|
|
516
|
+
return (b & 0b11) === 0 ? "press" : null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The selection's colours (task #178). THE LOGO'S CYAN, as a BACKGROUND — not as text.
|
|
520
|
+
//
|
|
521
|
+
// 46 is the background of `\x1b[36m`, the exact colour the startup chevron is drawn in, so the
|
|
522
|
+
// selection is the product's own colour rather than a fourth one invented for it. It is the
|
|
523
|
+
// user's palette cyan, which means it still inherits their theme — unlike a 256-colour or
|
|
524
|
+
// truecolor background, which would fight every custom scheme and every light terminal, the
|
|
525
|
+
// same argument that kept syntax colour out of the edit cards.
|
|
526
|
+
//
|
|
527
|
+
// A BACKGROUND, because the cyan FOREGROUND is already spoken for: the ">> " prompt, where it
|
|
528
|
+
// means "this is the line you are typing", and the jump-to-bottom hint. A foreground also
|
|
529
|
+
// REPLACES the text's own — dragging across a red ✗ would recolour it, so the one signal #41
|
|
530
|
+
// reserves for "you must not miss this" would be erased by looking at it. A background leaves
|
|
531
|
+
// every foreground meaning intact and reads as a REGION, which is what a selection is.
|
|
532
|
+
//
|
|
533
|
+
// The explicit 30 (black) is not decoration: the prompt and the jump hint are CYAN TEXT, and
|
|
534
|
+
// cyan on cyan is invisible. Flattening the foreground inside the selection costs colour
|
|
535
|
+
// meaning for as long as the selection lasts, and is the price of legibility. Reverse video
|
|
536
|
+
// (\x1b[7m) avoids it by letting the terminal swap whatever is there; a named colour was chosen
|
|
537
|
+
// deliberately over it, so the selection looks like gu rather than like the terminal.
|
|
538
|
+
export const SELECTION_SGR = "\x1b[46;30m";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* One row with the visible columns [from, to) highlighted.
|
|
542
|
+
*
|
|
543
|
+
* THE NESTING TRAP, which is why this walks the row instead of slicing it: the row already
|
|
544
|
+
* carries SGR runs, and every one of them ends in `\x1b[0m`. Wrapping a span in our background
|
|
545
|
+
* and letting an inner reset through would terminate the highlight at the first coloured token
|
|
546
|
+
* and silently lose it for the rest of the span — the same failure recorded against colourising
|
|
547
|
+
* the edit cards. So escapes inside the span are DROPPED, and the colour state that was active
|
|
548
|
+
* is re-opened after it.
|
|
549
|
+
*
|
|
550
|
+
* Adds only zero-width escapes, so visibleLen is unchanged and the row still occupies exactly
|
|
551
|
+
* one physical row (#109/#138).
|
|
552
|
+
*/
|
|
553
|
+
export function highlightRow(line, from, to, sgr = SELECTION_SGR) {
|
|
554
|
+
const text = String(line ?? "");
|
|
555
|
+
const lo = Math.max(0, from | 0);
|
|
556
|
+
const hi = to === Infinity ? Infinity : Math.max(lo, to | 0);
|
|
557
|
+
if (hi <= lo) return text;
|
|
558
|
+
const chars = [...text];
|
|
559
|
+
let out = "";
|
|
560
|
+
let col = 0;
|
|
561
|
+
let state = ""; // SGR runs active here, to re-open after the highlight
|
|
562
|
+
let open = false;
|
|
563
|
+
for (let i = 0; i < chars.length; i++) {
|
|
564
|
+
if (chars[i] === "\x1b") {
|
|
565
|
+
const m = /^\x1b\[[0-9;]*m/.exec(chars.slice(i).join(""));
|
|
566
|
+
if (m) {
|
|
567
|
+
state = m[0] === "\x1b[0m" ? "" : state + m[0];
|
|
568
|
+
// Inside the highlight the escape is dropped — see THE NESTING TRAP above.
|
|
569
|
+
if (!(col >= lo && col < hi)) out += m[0];
|
|
570
|
+
i += [...m[0]].length - 1;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const inSpan = col >= lo && col < hi;
|
|
575
|
+
if (inSpan && !open) { out += sgr; open = true; }
|
|
576
|
+
if (!inSpan && open) { out += "\x1b[0m" + state; open = false; }
|
|
577
|
+
out += chars[i];
|
|
578
|
+
col += charWidth(chars[i]);
|
|
579
|
+
}
|
|
580
|
+
if (open) out += "\x1b[0m" + state;
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Every display row with the selection painted on it.
|
|
586
|
+
*
|
|
587
|
+
* `sel` is in ABSOLUTE display-row coordinates — the same space displayRows produces — so the
|
|
588
|
+
* highlight stays on the text it was drawn over when the view scrolls, rather than on whatever
|
|
589
|
+
* has moved under that screen row.
|
|
590
|
+
*/
|
|
591
|
+
export function applySelectionHighlight(rows, sel, sgr = SELECTION_SGR) {
|
|
592
|
+
const all = Array.isArray(rows) ? rows : [];
|
|
593
|
+
if (!sel?.start || !sel?.end || isEmptySelection(sel)) return all;
|
|
594
|
+
const { start, end } = orderSelection(sel.start, sel.end);
|
|
595
|
+
if (end.row < 0 || start.row >= all.length) return all;
|
|
596
|
+
return all.map((row, i) => {
|
|
597
|
+
if (i < start.row || i > end.row) return row;
|
|
598
|
+
const from = i === start.row ? start.col : 0;
|
|
599
|
+
const to = i === end.row ? end.col : Infinity;
|
|
600
|
+
return highlightRow(row, from, to, sgr);
|
|
601
|
+
});
|
|
602
|
+
}
|