@aragon-agent/cli 0.6.1 → 0.6.3
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/CHANGELOG.md +216 -0
- package/README.md +241 -18
- package/dist/agent/agent-mode.d.ts +39 -0
- package/dist/agent/agent-mode.js +36 -0
- package/dist/agent/controller.d.ts +74 -1
- package/dist/agent/controller.js +200 -1
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +47 -0
- package/dist/agent/reducer.d.ts +120 -0
- package/dist/agent/reducer.js +201 -2
- package/dist/cli.js +297 -6
- package/dist/commands/builtins.js +73 -23
- package/dist/commands/registry.d.ts +27 -0
- package/dist/compaction/archive.d.ts +175 -0
- package/dist/compaction/archive.js +267 -0
- package/dist/compaction/child.d.ts +97 -0
- package/dist/compaction/child.js +113 -0
- package/dist/compaction/command.d.ts +41 -0
- package/dist/compaction/command.js +342 -0
- package/dist/compaction/compactor.d.ts +268 -0
- package/dist/compaction/compactor.js +689 -0
- package/dist/compaction/digest.d.ts +62 -0
- package/dist/compaction/digest.js +164 -0
- package/dist/compaction/limits.d.ts +165 -0
- package/dist/compaction/limits.js +172 -0
- package/dist/compaction/pressure.d.ts +135 -0
- package/dist/compaction/pressure.js +167 -0
- package/dist/compaction/summarize-call.d.ts +70 -0
- package/dist/compaction/summarize-call.js +86 -0
- package/dist/compaction/summary-prompt.d.ts +85 -0
- package/dist/compaction/summary-prompt.js +197 -0
- package/dist/compaction/tail-budget.d.ts +52 -0
- package/dist/compaction/tail-budget.js +67 -0
- package/dist/compaction/types.d.ts +179 -0
- package/dist/compaction/types.js +22 -0
- package/dist/compaction/wiring.d.ts +255 -0
- package/dist/compaction/wiring.js +556 -0
- package/dist/config/app-paths.d.ts +14 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/cli-commands.d.ts +41 -0
- package/dist/config/cli-commands.js +77 -1
- package/dist/config/env.d.ts +4 -1
- package/dist/config/env.js +92 -2
- package/dist/config/load.d.ts +31 -0
- package/dist/config/load.js +63 -1
- package/dist/config/schema.d.ts +159 -0
- package/dist/config/schema.js +137 -0
- package/dist/config/store.js +17 -1
- package/dist/config/ui-state.d.ts +44 -6
- package/dist/config/ui-state.js +21 -12
- package/dist/diagnostics/doctor.js +41 -1
- package/dist/exec/events.d.ts +50 -1
- package/dist/exec/index.js +8 -4
- package/dist/exec/runner.js +44 -2
- package/dist/input/mouse-events.d.ts +31 -5
- package/dist/input/mouse-events.js +86 -27
- package/dist/input/stdin-mouse-filter.d.ts +8 -3
- package/dist/logging/install.d.ts +51 -1
- package/dist/logging/install.js +28 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/cli-commands.js +23 -1
- package/dist/session/persist.js +19 -0
- package/dist/session/store.d.ts +18 -0
- package/dist/session/store.js +59 -2
- package/dist/team/report.js +7 -1
- package/dist/team/runtime.d.ts +10 -0
- package/dist/team/runtime.js +7 -0
- package/dist/team/subagent.d.ts +31 -1
- package/dist/team/subagent.js +42 -15
- package/dist/team/types.d.ts +8 -0
- package/dist/ui/ActivityLine.d.ts +19 -1
- package/dist/ui/ActivityLine.js +10 -2
- package/dist/ui/App.d.ts +48 -1
- package/dist/ui/App.js +317 -17
- package/dist/ui/Composer.d.ts +26 -1
- package/dist/ui/Composer.js +6 -6
- package/dist/ui/PromptInput.d.ts +34 -1
- package/dist/ui/PromptInput.js +54 -3
- package/dist/ui/StatusBar.d.ts +40 -0
- package/dist/ui/StatusBar.js +13 -4
- package/dist/ui/TeamPanel.js +7 -1
- package/dist/ui/Transcript.d.ts +21 -1
- package/dist/ui/Transcript.js +32 -2
- package/dist/ui/clipboard.d.ts +52 -0
- package/dist/ui/clipboard.js +91 -0
- package/dist/ui/entries/CompactionCard.d.ts +75 -0
- package/dist/ui/entries/CompactionCard.js +158 -0
- package/dist/ui/frame-differ.d.ts +46 -0
- package/dist/ui/frame-differ.js +94 -10
- package/dist/ui/gauge.d.ts +16 -1
- package/dist/ui/gauge.js +25 -6
- package/dist/ui/glyphs.d.ts +11 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/ScrollViewport.d.ts +43 -6
- package/dist/ui/layout/ScrollViewport.js +128 -21
- package/dist/ui/layout/budget.d.ts +10 -3
- package/dist/ui/layout/budget.js +10 -3
- package/dist/ui/layout/follow-state.d.ts +137 -0
- package/dist/ui/layout/follow-state.js +150 -0
- package/dist/ui/layout/virtual-window.d.ts +11 -0
- package/dist/ui/layout/virtual-window.js +34 -0
- package/dist/ui/overlays/HelpOverlay.js +18 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
- package/dist/ui/overlays/SettingsScreen.js +60 -1
- package/dist/ui/palettes.d.ts +16 -0
- package/dist/ui/palettes.js +6 -0
- package/dist/ui/screen.d.ts +22 -0
- package/dist/ui/screen.js +70 -8
- package/dist/ui/selection/highlight.d.ts +39 -0
- package/dist/ui/selection/highlight.js +64 -0
- package/dist/ui/selection/screen-mirror.d.ts +24 -0
- package/dist/ui/selection/screen-mirror.js +35 -0
- package/dist/ui/selection/selection-controller.d.ts +88 -0
- package/dist/ui/selection/selection-controller.js +282 -0
- package/dist/ui/selection/selection.d.ts +90 -0
- package/dist/ui/selection/selection.js +223 -0
- package/dist/ui/stdout-frame-writer.d.ts +33 -0
- package/dist/ui/stdout-frame-writer.js +15 -0
- package/dist/ui/theme.d.ts +11 -0
- package/dist/ui/theme.js +2 -0
- package/dist/ui/transcript-text.js +23 -0
- package/dist/ui/use-startup-notices.d.ts +62 -5
- package/dist/ui/use-startup-notices.js +81 -17
- package/dist/ui/use-wheel-routing.js +8 -0
- package/dist/ui/win-vt-force.d.ts +136 -0
- package/dist/ui/win-vt-force.js +237 -0
- package/package.json +4 -2
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection controller (tui-selection-and-scroll-follow §4.4). NON-REACT: it
|
|
3
|
+
* owns the mouse lifecycle, the 16 ms drag coalescer, the `decorate` hook and the
|
|
4
|
+
* copy-on-release, and it reaches the React tree only through the two callbacks
|
|
5
|
+
* `App` registers on mount.
|
|
6
|
+
*
|
|
7
|
+
* WHY IT IS NOT A COMPONENT (D-6). A React re-render per motion report goes
|
|
8
|
+
* through the render governor and the whole component tree; the frame pipeline
|
|
9
|
+
* repaints one to three rows with no commit at all. A fast drag across 40 columns
|
|
10
|
+
* therefore costs about three repaints of a few rows each, not forty full frames.
|
|
11
|
+
*
|
|
12
|
+
* THREE THINGS IN HERE FAIL SILENTLY AND PERMANENTLY IF DROPPED:
|
|
13
|
+
*
|
|
14
|
+
* - **I-11 · `hold` must always have a way out.** A press with no matching
|
|
15
|
+
* release freezes the viewport for the rest of the session: content anchoring
|
|
16
|
+
* never lets go, the resume timer's third condition never passes, and nothing
|
|
17
|
+
* is raised anywhere. Three ways to never get a release — the button comes up
|
|
18
|
+
* while the terminal does not have focus, the emulator swallows it, or the
|
|
19
|
+
* terminal fell back to X10, whose reports cannot express a release at all.
|
|
20
|
+
* So `hold` is released by a `release` report, by `clear()`, OR by the
|
|
21
|
+
* `holdMaxMs` watchdog, and the parser refuses to emit buttons from X10.
|
|
22
|
+
* - **I-9 · a highlight that survives the rows moving is a lie about what would
|
|
23
|
+
* be copied.** `App` clears on `shiftUp` change — the DERIVED form, which a
|
|
24
|
+
* new way to move the viewport cannot forget to join — and additionally on
|
|
25
|
+
* key, resize, overlay and `differ.invalidate()`.
|
|
26
|
+
* - **I-4 · the highlight and the copy read the SAME mirror**, written by the
|
|
27
|
+
* same `decorate` call. What you see is what you get by construction, rather
|
|
28
|
+
* than by a staleness check that can be wrong.
|
|
29
|
+
*/
|
|
30
|
+
import { createScreenMirror } from './screen-mirror.js';
|
|
31
|
+
import { paintRow, selectionOpen } from './highlight.js';
|
|
32
|
+
import { isEmpty, normalize, rowSpan, selectedText, } from './selection.js';
|
|
33
|
+
/**
|
|
34
|
+
* Motion coalescing window. The same 16 ms and the same reasoning as
|
|
35
|
+
* `use-wheel-routing.ts`: this one is in the direct path of a hand gesture, so
|
|
36
|
+
* it is deliberately tighter than the 33 ms streaming coalescer.
|
|
37
|
+
*/
|
|
38
|
+
const DRAG_COALESCE_MS = 16;
|
|
39
|
+
/**
|
|
40
|
+
* How long a nominally-held button may sit MOTIONLESS before the watchdog lets
|
|
41
|
+
* go of it (I-11). Reset by every motion report, so it only fires when the
|
|
42
|
+
* pointer has not moved for the whole window — which is indistinguishable from a
|
|
43
|
+
* lost release, and the failure it prevents is unrecoverable while the one it
|
|
44
|
+
* risks costs a re-drag (R-12).
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_HOLD_MAX_MS = 30_000;
|
|
47
|
+
function clamp(value, min, max) {
|
|
48
|
+
if (!Number.isFinite(value))
|
|
49
|
+
return min;
|
|
50
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
51
|
+
}
|
|
52
|
+
export function createSelectionController(options) {
|
|
53
|
+
const mirror = createScreenMirror();
|
|
54
|
+
const holdListeners = new Set();
|
|
55
|
+
const holdMaxMs = options.holdMaxMs ?? DEFAULT_HOLD_MAX_MS;
|
|
56
|
+
const isSelectable = options.isSelectable ?? (() => true);
|
|
57
|
+
let enabled = true;
|
|
58
|
+
let sel = null;
|
|
59
|
+
let dragging = false;
|
|
60
|
+
let pendingFocus = null;
|
|
61
|
+
let flushTimer = null;
|
|
62
|
+
let holdWatchdog = null;
|
|
63
|
+
let disposed = false;
|
|
64
|
+
/** Guards `decorate` against being re-entered from inside a repaint. */
|
|
65
|
+
let painting = false;
|
|
66
|
+
const emitHold = (hold) => {
|
|
67
|
+
for (const listener of [...holdListeners])
|
|
68
|
+
listener(hold);
|
|
69
|
+
};
|
|
70
|
+
const clearFlushTimer = () => {
|
|
71
|
+
if (!flushTimer)
|
|
72
|
+
return;
|
|
73
|
+
clearTimeout(flushTimer);
|
|
74
|
+
flushTimer = null;
|
|
75
|
+
};
|
|
76
|
+
const clearWatchdog = () => {
|
|
77
|
+
if (!holdWatchdog)
|
|
78
|
+
return;
|
|
79
|
+
clearTimeout(holdWatchdog);
|
|
80
|
+
holdWatchdog = null;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* `0` disables it, which is what the tests that are not about it use. Every
|
|
84
|
+
* motion report re-arms it, so the window measures STILLNESS rather than the
|
|
85
|
+
* duration of the drag (R-12).
|
|
86
|
+
*/
|
|
87
|
+
const armWatchdog = () => {
|
|
88
|
+
clearWatchdog();
|
|
89
|
+
if (holdMaxMs <= 0)
|
|
90
|
+
return;
|
|
91
|
+
holdWatchdog = setTimeout(() => {
|
|
92
|
+
holdWatchdog = null;
|
|
93
|
+
if (!dragging)
|
|
94
|
+
return;
|
|
95
|
+
// Let go of `hold` and the drag; KEEP whatever was selected. The user may
|
|
96
|
+
// still want to copy it, and throwing it away would turn a terminal quirk
|
|
97
|
+
// into lost work.
|
|
98
|
+
dragging = false;
|
|
99
|
+
pendingFocus = null;
|
|
100
|
+
clearFlushTimer();
|
|
101
|
+
emitHold(false);
|
|
102
|
+
}, holdMaxMs);
|
|
103
|
+
holdWatchdog.unref?.();
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Repaint, falling back to a React redraw when the differ has no cache.
|
|
107
|
+
*
|
|
108
|
+
* `wanted` says whether something must APPEAR on screen. Clearing a highlight
|
|
109
|
+
* after an `invalidate()` needs no fallback — the raw frame is already on the
|
|
110
|
+
* screen without it — and asking for one there would turn every resize and
|
|
111
|
+
* every foreign write into a redraw nonce bump.
|
|
112
|
+
*/
|
|
113
|
+
const paint = (wanted) => {
|
|
114
|
+
if (disposed)
|
|
115
|
+
return;
|
|
116
|
+
const painted = options.repaint();
|
|
117
|
+
if (!painted && wanted)
|
|
118
|
+
options.requestRedraw();
|
|
119
|
+
};
|
|
120
|
+
const clear = () => {
|
|
121
|
+
// Nothing to undo: no timers, no repaint, no redraw. This is what keeps
|
|
122
|
+
// `onInvalidate` — which fires on every resize and every foreign write —
|
|
123
|
+
// free for a session that has never selected anything.
|
|
124
|
+
if (sel === null && !dragging && pendingFocus === null)
|
|
125
|
+
return;
|
|
126
|
+
const wasDragging = dragging;
|
|
127
|
+
sel = null;
|
|
128
|
+
dragging = false;
|
|
129
|
+
pendingFocus = null;
|
|
130
|
+
clearFlushTimer();
|
|
131
|
+
clearWatchdog();
|
|
132
|
+
if (wasDragging)
|
|
133
|
+
emitHold(false);
|
|
134
|
+
paint(false);
|
|
135
|
+
};
|
|
136
|
+
const cellOf = (event) => {
|
|
137
|
+
// SGR reports are 1-based, frame line `i` is terminal row `i + 1`. A report
|
|
138
|
+
// from a terminal that resized mid-drag must not index past the mirror.
|
|
139
|
+
const rows = Math.max(1, mirror.raw.length);
|
|
140
|
+
const cols = Math.max(1, options.cols());
|
|
141
|
+
return { row: clamp(event.y - 1, 0, rows - 1), col: clamp(event.x - 1, 0, cols - 1) };
|
|
142
|
+
};
|
|
143
|
+
const flushDrag = () => {
|
|
144
|
+
clearFlushTimer();
|
|
145
|
+
const focus = pendingFocus;
|
|
146
|
+
pendingFocus = null;
|
|
147
|
+
if (!focus || !sel)
|
|
148
|
+
return;
|
|
149
|
+
sel = { anchor: sel.anchor, focus };
|
|
150
|
+
paint(true);
|
|
151
|
+
};
|
|
152
|
+
const finishSelection = () => {
|
|
153
|
+
if (!sel)
|
|
154
|
+
return;
|
|
155
|
+
const normal = normalize(sel);
|
|
156
|
+
if (isEmpty(normal)) {
|
|
157
|
+
// A plain click. It must never leave a one-cell highlight behind, and it
|
|
158
|
+
// must never clobber the clipboard.
|
|
159
|
+
sel = null;
|
|
160
|
+
paint(false);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const text = selectedText(mirror.plain, normal);
|
|
164
|
+
if (text.length === 0) {
|
|
165
|
+
sel = null;
|
|
166
|
+
paint(false);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const via = options.copy(text);
|
|
170
|
+
options.onCopied(via, text.split('\n').length, text.length);
|
|
171
|
+
// The highlight STAYS after a release: it is the receipt for what was taken,
|
|
172
|
+
// and it goes away on the next thing that moves the rows (I-9).
|
|
173
|
+
paint(true);
|
|
174
|
+
};
|
|
175
|
+
const onMouse = (event) => {
|
|
176
|
+
if (disposed)
|
|
177
|
+
return;
|
|
178
|
+
if (event.kind === 'wheel') {
|
|
179
|
+
// A wheel notch during a drag is a user changing their mind, not extending
|
|
180
|
+
// a selection — N1 already declines auto-scroll-while-dragging. The notch
|
|
181
|
+
// then scrolls normally, because the wheel router is a separate subscriber
|
|
182
|
+
// on the same channel.
|
|
183
|
+
if (dragging)
|
|
184
|
+
clear();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (event.kind === 'press') {
|
|
188
|
+
if (event.button !== 0 || !enabled || !isSelectable()) {
|
|
189
|
+
// Any other button, or a closed gate: drop whatever was selected and do
|
|
190
|
+
// nothing else. A right-click that left a stale highlight behind would be
|
|
191
|
+
// the same false promise I-9 forbids.
|
|
192
|
+
clear();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const cell = cellOf(event);
|
|
196
|
+
sel = { anchor: cell, focus: cell };
|
|
197
|
+
pendingFocus = null;
|
|
198
|
+
clearFlushTimer();
|
|
199
|
+
dragging = true;
|
|
200
|
+
emitHold(true); // freezes the transcript for the duration of the drag
|
|
201
|
+
armWatchdog();
|
|
202
|
+
paint(true);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (event.kind === 'drag') {
|
|
206
|
+
if (!dragging || event.button !== 0 || !sel)
|
|
207
|
+
return;
|
|
208
|
+
pendingFocus = cellOf(event);
|
|
209
|
+
armWatchdog();
|
|
210
|
+
if (!flushTimer) {
|
|
211
|
+
flushTimer = setTimeout(flushDrag, DRAG_COALESCE_MS);
|
|
212
|
+
flushTimer.unref?.();
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// release
|
|
217
|
+
if (!dragging)
|
|
218
|
+
return;
|
|
219
|
+
if (pendingFocus && sel)
|
|
220
|
+
sel = { anchor: sel.anchor, focus: pendingFocus };
|
|
221
|
+
pendingFocus = null;
|
|
222
|
+
clearFlushTimer();
|
|
223
|
+
clearWatchdog();
|
|
224
|
+
dragging = false;
|
|
225
|
+
emitHold(false);
|
|
226
|
+
finishSelection();
|
|
227
|
+
};
|
|
228
|
+
const unsubscribe = options.source.subscribe(onMouse);
|
|
229
|
+
return {
|
|
230
|
+
decorate(lines) {
|
|
231
|
+
mirror.set(lines);
|
|
232
|
+
if (painting || sel === null)
|
|
233
|
+
return lines;
|
|
234
|
+
const normal = normalize(sel);
|
|
235
|
+
if (isEmpty(normal))
|
|
236
|
+
return lines;
|
|
237
|
+
painting = true;
|
|
238
|
+
try {
|
|
239
|
+
const open = selectionOpen(options.theme(), options.caps);
|
|
240
|
+
const cols = Math.max(1, options.cols());
|
|
241
|
+
const out = lines.slice();
|
|
242
|
+
for (let row = normal.start.row; row <= normal.end.row; row += 1) {
|
|
243
|
+
if (row < 0 || row >= out.length)
|
|
244
|
+
continue;
|
|
245
|
+
const span = rowSpan(normal, row);
|
|
246
|
+
if (!span)
|
|
247
|
+
continue;
|
|
248
|
+
const to = Math.min(Number.isFinite(span.to) ? span.to : cols, cols);
|
|
249
|
+
out[row] = paintRow(out[row], span.from, to, open);
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
painting = false;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onHoldChange(listener) {
|
|
258
|
+
holdListeners.add(listener);
|
|
259
|
+
return () => holdListeners.delete(listener);
|
|
260
|
+
},
|
|
261
|
+
clear,
|
|
262
|
+
setEnabled(next) {
|
|
263
|
+
if (enabled === next)
|
|
264
|
+
return;
|
|
265
|
+
enabled = next;
|
|
266
|
+
if (!next)
|
|
267
|
+
clear();
|
|
268
|
+
},
|
|
269
|
+
dispose() {
|
|
270
|
+
if (disposed)
|
|
271
|
+
return;
|
|
272
|
+
disposed = true;
|
|
273
|
+
unsubscribe();
|
|
274
|
+
clearFlushTimer();
|
|
275
|
+
clearWatchdog();
|
|
276
|
+
holdListeners.clear();
|
|
277
|
+
sel = null;
|
|
278
|
+
dragging = false;
|
|
279
|
+
pendingFocus = null;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection model (tui-selection-and-scroll-follow §4.4.4). PURE — no React,
|
|
3
|
+
* no I/O, no terminal.
|
|
4
|
+
*
|
|
5
|
+
* Coordinates are SCREEN CELLS, 0-based: frame line `i` is terminal row `i + 1`
|
|
6
|
+
* and column `x - 1`, so an SGR report at `(x, y)` becomes `{ row: y - 1, col: x
|
|
7
|
+
* - 1 }`. Screen coordinates are only sound while the rows hold still, and
|
|
8
|
+
* holding them still is Stage 2's job — `press` sets `hold`, which anchors the
|
|
9
|
+
* content and suspends the resume timer, so the transcript is frozen for the
|
|
10
|
+
* duration of a drag (D-7).
|
|
11
|
+
*
|
|
12
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
* I-12 — `sliceColumns` IS THE ONE COLUMN→CHARACTER MAPPING IN THIS FEATURE,
|
|
14
|
+
* and `stringWidth` is its only width oracle.
|
|
15
|
+
*
|
|
16
|
+
* The painter and the extractor are handed the SAME half-open column range by
|
|
17
|
+
* `rowSpan`. If they turn that range into characters by different rules, the
|
|
18
|
+
* copied text is not the highlighted text — silently, which is the one failure
|
|
19
|
+
* mode I-4 exists to name. That is not hypothetical: `slice-ansi` advances its
|
|
20
|
+
* cursor by `isFullWidth ? 2 : character.length`, i.e. display columns for CJK
|
|
21
|
+
* but UTF-16 CODE UNITS for everything else, so a combining mark costs it a
|
|
22
|
+
* column that occupies none and an astral code point costs two; and the obvious
|
|
23
|
+
* `String.slice` for the extractor is code units throughout. Two mappings from
|
|
24
|
+
* one range agree on ASCII and diverge on exactly the rows a user is most likely
|
|
25
|
+
* to want to copy.
|
|
26
|
+
*
|
|
27
|
+
* So both call the function below, which walks GRAPHEME CLUSTERS and charges
|
|
28
|
+
* each one `stringWidth(cluster)` columns. What is hand-rolled here is only the
|
|
29
|
+
* column arithmetic — the width table is still `string-width`'s, and the escape
|
|
30
|
+
* bookkeeping is `slice-ansi`'s idea re-indexed (R-13).
|
|
31
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
*/
|
|
33
|
+
export interface Cell {
|
|
34
|
+
/** 0-based frame line. */
|
|
35
|
+
row: number;
|
|
36
|
+
/** 0-based screen column. */
|
|
37
|
+
col: number;
|
|
38
|
+
}
|
|
39
|
+
export interface Selection {
|
|
40
|
+
anchor: Cell;
|
|
41
|
+
focus: Cell;
|
|
42
|
+
}
|
|
43
|
+
/** `start <= end` in reading order. */
|
|
44
|
+
export interface NormalSelection {
|
|
45
|
+
start: Cell;
|
|
46
|
+
end: Cell;
|
|
47
|
+
}
|
|
48
|
+
/** A half-open column range on one row; `to` may be `Infinity` (to end of line). */
|
|
49
|
+
export interface RowSpan {
|
|
50
|
+
from: number;
|
|
51
|
+
to: number;
|
|
52
|
+
}
|
|
53
|
+
export declare function normalize(sel: Selection): NormalSelection;
|
|
54
|
+
export declare function isEmpty(sel: NormalSelection): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The half-open column range to highlight on `row`, or `null` when the row is
|
|
57
|
+
* outside the selection.
|
|
58
|
+
*
|
|
59
|
+
* LINEAR, NOT RECTANGULAR — what every terminal does: the first row runs from
|
|
60
|
+
* `start.col` to end of line, whole middle rows are taken entirely, and the last
|
|
61
|
+
* row stops at `end.col`. `Infinity` means "to the end of the line"; the painter
|
|
62
|
+
* clamps it to the terminal width so a multi-row selection gets a straight right
|
|
63
|
+
* edge, and the extractor treats it as "no upper bound".
|
|
64
|
+
*/
|
|
65
|
+
export declare function rowSpan(sel: NormalSelection, row: number): RowSpan | null;
|
|
66
|
+
/**
|
|
67
|
+
* The text a release would put on the clipboard.
|
|
68
|
+
*
|
|
69
|
+
* `plainRows` are the mirror's ANSI-STRIPPED rows — the same rows the highlight
|
|
70
|
+
* was painted over, which is what makes I-4 hold by construction rather than by
|
|
71
|
+
* a staleness check that can be wrong.
|
|
72
|
+
*/
|
|
73
|
+
export declare function selectedText(plainRows: readonly string[], sel: NormalSelection): string;
|
|
74
|
+
/**
|
|
75
|
+
* Slice `text` by SCREEN COLUMNS, half-open `[from, to)`.
|
|
76
|
+
*
|
|
77
|
+
* Three properties are load-bearing:
|
|
78
|
+
*
|
|
79
|
+
* - **Escape codes opened before `from` are carried into the result.** Without
|
|
80
|
+
* it the painter's tail — `sliceColumns(line, to)` — would lose the colour
|
|
81
|
+
* `Markdown` and `cli-highlight` gave it, on every row a selection touches.
|
|
82
|
+
* - **A wide cluster straddling either boundary becomes spaces for the part
|
|
83
|
+
* that is inside.** That keeps the arithmetic EXACT in columns, which is the
|
|
84
|
+
* whole point of I-12: half a CJK glyph is not a character, and inventing one
|
|
85
|
+
* would put the painter and the extractor back out of step.
|
|
86
|
+
* - **`to` is optional and means "to the end of the line".**
|
|
87
|
+
*/
|
|
88
|
+
export declare function sliceColumns(text: string, from: number, to?: number): string;
|
|
89
|
+
/** Display width in columns, using the same oracle `sliceColumns` charges with. */
|
|
90
|
+
export declare function columnsOf(text: string): number;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection model (tui-selection-and-scroll-follow §4.4.4). PURE — no React,
|
|
3
|
+
* no I/O, no terminal.
|
|
4
|
+
*
|
|
5
|
+
* Coordinates are SCREEN CELLS, 0-based: frame line `i` is terminal row `i + 1`
|
|
6
|
+
* and column `x - 1`, so an SGR report at `(x, y)` becomes `{ row: y - 1, col: x
|
|
7
|
+
* - 1 }`. Screen coordinates are only sound while the rows hold still, and
|
|
8
|
+
* holding them still is Stage 2's job — `press` sets `hold`, which anchors the
|
|
9
|
+
* content and suspends the resume timer, so the transcript is frozen for the
|
|
10
|
+
* duration of a drag (D-7).
|
|
11
|
+
*
|
|
12
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
* I-12 — `sliceColumns` IS THE ONE COLUMN→CHARACTER MAPPING IN THIS FEATURE,
|
|
14
|
+
* and `stringWidth` is its only width oracle.
|
|
15
|
+
*
|
|
16
|
+
* The painter and the extractor are handed the SAME half-open column range by
|
|
17
|
+
* `rowSpan`. If they turn that range into characters by different rules, the
|
|
18
|
+
* copied text is not the highlighted text — silently, which is the one failure
|
|
19
|
+
* mode I-4 exists to name. That is not hypothetical: `slice-ansi` advances its
|
|
20
|
+
* cursor by `isFullWidth ? 2 : character.length`, i.e. display columns for CJK
|
|
21
|
+
* but UTF-16 CODE UNITS for everything else, so a combining mark costs it a
|
|
22
|
+
* column that occupies none and an astral code point costs two; and the obvious
|
|
23
|
+
* `String.slice` for the extractor is code units throughout. Two mappings from
|
|
24
|
+
* one range agree on ASCII and diverge on exactly the rows a user is most likely
|
|
25
|
+
* to want to copy.
|
|
26
|
+
*
|
|
27
|
+
* So both call the function below, which walks GRAPHEME CLUSTERS and charges
|
|
28
|
+
* each one `stringWidth(cluster)` columns. What is hand-rolled here is only the
|
|
29
|
+
* column arithmetic — the width table is still `string-width`'s, and the escape
|
|
30
|
+
* bookkeeping is `slice-ansi`'s idea re-indexed (R-13).
|
|
31
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
*/
|
|
33
|
+
import stringWidth from 'string-width';
|
|
34
|
+
export function normalize(sel) {
|
|
35
|
+
const { anchor, focus } = sel;
|
|
36
|
+
const anchorFirst = anchor.row < focus.row || (anchor.row === focus.row && anchor.col <= focus.col);
|
|
37
|
+
return anchorFirst ? { start: anchor, end: focus } : { start: focus, end: anchor };
|
|
38
|
+
}
|
|
39
|
+
export function isEmpty(sel) {
|
|
40
|
+
return sel.start.row === sel.end.row && sel.start.col === sel.end.col;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The half-open column range to highlight on `row`, or `null` when the row is
|
|
44
|
+
* outside the selection.
|
|
45
|
+
*
|
|
46
|
+
* LINEAR, NOT RECTANGULAR — what every terminal does: the first row runs from
|
|
47
|
+
* `start.col` to end of line, whole middle rows are taken entirely, and the last
|
|
48
|
+
* row stops at `end.col`. `Infinity` means "to the end of the line"; the painter
|
|
49
|
+
* clamps it to the terminal width so a multi-row selection gets a straight right
|
|
50
|
+
* edge, and the extractor treats it as "no upper bound".
|
|
51
|
+
*/
|
|
52
|
+
export function rowSpan(sel, row) {
|
|
53
|
+
const { start, end } = sel;
|
|
54
|
+
if (row < start.row || row > end.row)
|
|
55
|
+
return null;
|
|
56
|
+
if (start.row === end.row) {
|
|
57
|
+
return end.col > start.col ? { from: start.col, to: end.col } : null;
|
|
58
|
+
}
|
|
59
|
+
if (row === start.row)
|
|
60
|
+
return { from: start.col, to: Number.POSITIVE_INFINITY };
|
|
61
|
+
if (row === end.row)
|
|
62
|
+
return end.col > 0 ? { from: 0, to: end.col } : null;
|
|
63
|
+
return { from: 0, to: Number.POSITIVE_INFINITY };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The text a release would put on the clipboard.
|
|
67
|
+
*
|
|
68
|
+
* `plainRows` are the mirror's ANSI-STRIPPED rows — the same rows the highlight
|
|
69
|
+
* was painted over, which is what makes I-4 hold by construction rather than by
|
|
70
|
+
* a staleness check that can be wrong.
|
|
71
|
+
*/
|
|
72
|
+
export function selectedText(plainRows, sel) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let row = sel.start.row; row <= sel.end.row; row += 1) {
|
|
75
|
+
const span = rowSpan(sel, row);
|
|
76
|
+
if (!span)
|
|
77
|
+
continue;
|
|
78
|
+
const line = plainRows[row] ?? '';
|
|
79
|
+
const to = Number.isFinite(span.to) ? span.to : undefined;
|
|
80
|
+
out.push(sliceColumns(line, span.from, to).trimEnd());
|
|
81
|
+
}
|
|
82
|
+
return out.join('\n');
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// sliceColumns — the one mapping (I-12)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
/**
|
|
88
|
+
* CSI / OSC sequences. They occupy ZERO columns and are copied through so a
|
|
89
|
+
* slice keeps the styling of the text around it.
|
|
90
|
+
*/
|
|
91
|
+
const ANSI_RE = /\x1b(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
92
|
+
/** SGR "all attributes off" — everything carried before it is cancelled. */
|
|
93
|
+
const SGR_RESET_RE = /^\x1b\[0?m$/;
|
|
94
|
+
const segmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
|
95
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
96
|
+
: null;
|
|
97
|
+
/**
|
|
98
|
+
* Width cache. `stringWidth` re-segments and re-classifies on every call, and a
|
|
99
|
+
* repaint slices every row of the selection; the key space is the set of
|
|
100
|
+
* grapheme clusters on screen, which is tiny and bounded by the frame.
|
|
101
|
+
*/
|
|
102
|
+
const widthCache = new Map();
|
|
103
|
+
const WIDTH_CACHE_MAX = 4096;
|
|
104
|
+
function clusterWidth(cluster) {
|
|
105
|
+
const hit = widthCache.get(cluster);
|
|
106
|
+
if (hit !== undefined)
|
|
107
|
+
return hit;
|
|
108
|
+
const w = stringWidth(cluster);
|
|
109
|
+
// A plain ceiling with a wholesale drop, like `virtual-window.ts`'s height
|
|
110
|
+
// cache: an LRU here would cost more bookkeeping than the values are worth.
|
|
111
|
+
if (widthCache.size >= WIDTH_CACHE_MAX)
|
|
112
|
+
widthCache.clear();
|
|
113
|
+
widthCache.set(cluster, w);
|
|
114
|
+
return w;
|
|
115
|
+
}
|
|
116
|
+
/** Split into ANSI sequences and grapheme clusters, in order. */
|
|
117
|
+
function tokenize(text) {
|
|
118
|
+
const tokens = [];
|
|
119
|
+
ANSI_RE.lastIndex = 0;
|
|
120
|
+
let last = 0;
|
|
121
|
+
let m;
|
|
122
|
+
const pushPlain = (chunk) => {
|
|
123
|
+
if (chunk.length === 0)
|
|
124
|
+
return;
|
|
125
|
+
if (segmenter) {
|
|
126
|
+
for (const { segment } of segmenter.segment(chunk))
|
|
127
|
+
tokens.push({ text: segment, ansi: false });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// No `Intl.Segmenter`: code points are a strictly worse but never wrong
|
|
131
|
+
// approximation — a combining mark becomes its own zero-width token, which
|
|
132
|
+
// the walk below already handles.
|
|
133
|
+
for (const cp of chunk)
|
|
134
|
+
tokens.push({ text: cp, ansi: false });
|
|
135
|
+
};
|
|
136
|
+
while ((m = ANSI_RE.exec(text)) !== null) {
|
|
137
|
+
pushPlain(text.slice(last, m.index));
|
|
138
|
+
tokens.push({ text: m[0], ansi: true });
|
|
139
|
+
last = m.index + m[0].length;
|
|
140
|
+
}
|
|
141
|
+
pushPlain(text.slice(last));
|
|
142
|
+
return tokens;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Slice `text` by SCREEN COLUMNS, half-open `[from, to)`.
|
|
146
|
+
*
|
|
147
|
+
* Three properties are load-bearing:
|
|
148
|
+
*
|
|
149
|
+
* - **Escape codes opened before `from` are carried into the result.** Without
|
|
150
|
+
* it the painter's tail — `sliceColumns(line, to)` — would lose the colour
|
|
151
|
+
* `Markdown` and `cli-highlight` gave it, on every row a selection touches.
|
|
152
|
+
* - **A wide cluster straddling either boundary becomes spaces for the part
|
|
153
|
+
* that is inside.** That keeps the arithmetic EXACT in columns, which is the
|
|
154
|
+
* whole point of I-12: half a CJK glyph is not a character, and inventing one
|
|
155
|
+
* would put the painter and the extractor back out of step.
|
|
156
|
+
* - **`to` is optional and means "to the end of the line".**
|
|
157
|
+
*/
|
|
158
|
+
export function sliceColumns(text, from, to) {
|
|
159
|
+
const start = Math.max(0, Math.floor(from));
|
|
160
|
+
const end = to === undefined || !Number.isFinite(to) ? Number.POSITIVE_INFINITY : Math.floor(to);
|
|
161
|
+
if (end <= start)
|
|
162
|
+
return '';
|
|
163
|
+
let carried = '';
|
|
164
|
+
let out = '';
|
|
165
|
+
let col = 0;
|
|
166
|
+
let started = false;
|
|
167
|
+
const open = () => {
|
|
168
|
+
if (started)
|
|
169
|
+
return;
|
|
170
|
+
started = true;
|
|
171
|
+
out += carried;
|
|
172
|
+
};
|
|
173
|
+
for (const token of tokenize(text)) {
|
|
174
|
+
if (token.ansi) {
|
|
175
|
+
if (started)
|
|
176
|
+
out += token.text;
|
|
177
|
+
else {
|
|
178
|
+
if (SGR_RESET_RE.test(token.text))
|
|
179
|
+
carried = '';
|
|
180
|
+
carried += token.text;
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (col >= end)
|
|
185
|
+
break;
|
|
186
|
+
const w = clusterWidth(token.text);
|
|
187
|
+
if (w === 0) {
|
|
188
|
+
// A zero-width cluster attaches to the cell before it, so it belongs to the
|
|
189
|
+
// slice only when that cell is already inside.
|
|
190
|
+
if (started)
|
|
191
|
+
out += token.text;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const cellEnd = col + w;
|
|
195
|
+
if (cellEnd <= start) {
|
|
196
|
+
col = cellEnd;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
open();
|
|
200
|
+
if (col < start) {
|
|
201
|
+
// Straddles the LEFT boundary: only `cellEnd - start` columns are ours.
|
|
202
|
+
out += ' '.repeat(Math.min(cellEnd, end) - start);
|
|
203
|
+
}
|
|
204
|
+
else if (cellEnd > end) {
|
|
205
|
+
// Straddles the RIGHT boundary.
|
|
206
|
+
out += ' '.repeat(end - col);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
out += token.text;
|
|
210
|
+
}
|
|
211
|
+
col = cellEnd;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
/** Display width in columns, using the same oracle `sliceColumns` charges with. */
|
|
216
|
+
export function columnsOf(text) {
|
|
217
|
+
let total = 0;
|
|
218
|
+
for (const token of tokenize(text)) {
|
|
219
|
+
if (!token.ansi)
|
|
220
|
+
total += clusterWidth(token.text);
|
|
221
|
+
}
|
|
222
|
+
return total;
|
|
223
|
+
}
|
|
@@ -35,6 +35,39 @@ export interface FrameWriterHandle {
|
|
|
35
35
|
stdout: NodeJS.WriteStream;
|
|
36
36
|
/** Hand this to `setFrameStatsProvider`. */
|
|
37
37
|
stats(): FrameWriterStats;
|
|
38
|
+
/**
|
|
39
|
+
* Re-paint the last frame through the differ's `decorate` hook — how a
|
|
40
|
+
* selection drag reaches the screen without a React commit.
|
|
41
|
+
*
|
|
42
|
+
* Returns whether anything was written. `false` means the differ had nothing
|
|
43
|
+
* safe to do (no cache, or a geometry stand-down), which is the caller's cue to
|
|
44
|
+
* fall back to `App`'s `redrawNonce`.
|
|
45
|
+
*
|
|
46
|
+
* I-3 / I-13 — IT WRITES TO THE REAL STREAM, NEVER THROUGH THE PROXY. Through
|
|
47
|
+
* the proxy the payload would be handed to `transform`, which would not
|
|
48
|
+
* recognise it and would `invalidate()`, turning every drag frame into a full
|
|
49
|
+
* repaint — the flicker `tui-input-flicker-fix` removed. It is also not a
|
|
50
|
+
* fallback and not a frame, so it touches neither counter.
|
|
51
|
+
*/
|
|
52
|
+
repaint(): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Write bytes that are ours but are not a frame — today, the OSC 52 clipboard
|
|
55
|
+
* sequence (§4.4.5).
|
|
56
|
+
*
|
|
57
|
+
* The differ is invalidated first, because absolute addressing is only valid
|
|
58
|
+
* while nothing else writes to stdout, and one full repaint after a copy is the
|
|
59
|
+
* correct price.
|
|
60
|
+
*
|
|
61
|
+
* IT IS NOT A FALLBACK (P1-6 / I-13), and that is the whole reason this door
|
|
62
|
+
* exists rather than the caller reaching for `stdout.write`. `transform` cannot
|
|
63
|
+
* recognise an OSC 52 chunk, so through the proxy it would take
|
|
64
|
+
* `passThrough(true)` — which increments `fallbacks` and, on the first one,
|
|
65
|
+
* raises `onFirstFallback`, wired to `console.warn(FRAME_FALLBACK_NOTICE)`.
|
|
66
|
+
* The first copy of every session would print a diagnostic at a user who did
|
|
67
|
+
* nothing wrong, and `fallbacks` — documented to mean "something wrote to
|
|
68
|
+
* stdout behind Ink's back" — would stop being true for the rest of the run.
|
|
69
|
+
*/
|
|
70
|
+
writeForeign(text: string): void;
|
|
38
71
|
/** Removes the `'resize'` subscription of I-8. Idempotent. */
|
|
39
72
|
dispose(): void;
|
|
40
73
|
}
|
|
@@ -92,6 +92,21 @@ export function wrapStdoutForFrames(stdout, differ) {
|
|
|
92
92
|
return {
|
|
93
93
|
stdout: proxy,
|
|
94
94
|
stats: () => differ.stats(),
|
|
95
|
+
repaint() {
|
|
96
|
+
if (disposed)
|
|
97
|
+
return false;
|
|
98
|
+
const payload = differ.repaint();
|
|
99
|
+
if (payload.length === 0)
|
|
100
|
+
return false;
|
|
101
|
+
writeReal(payload);
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
writeForeign(text) {
|
|
105
|
+
differ.invalidate();
|
|
106
|
+
if (disposed || text.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
writeReal(text);
|
|
109
|
+
},
|
|
95
110
|
dispose() {
|
|
96
111
|
if (disposed)
|
|
97
112
|
return;
|
package/dist/ui/theme.d.ts
CHANGED
|
@@ -50,6 +50,17 @@ export interface Theme {
|
|
|
50
50
|
hintFg: Color;
|
|
51
51
|
/** Single-color wordmark fallback below ansi-256. */
|
|
52
52
|
logoShadow: Color;
|
|
53
|
+
/**
|
|
54
|
+
* Drag-selection highlight (tui-selection-and-scroll-follow §4.4.4).
|
|
55
|
+
*
|
|
56
|
+
* Degraded like every other colour here, which means that below ansi-256 these
|
|
57
|
+
* are chalk colour NAMES rather than hex triples. `highlight.ts` tests for a
|
|
58
|
+
* hex shape and falls back to plain reverse video otherwise — the fallback a
|
|
59
|
+
* terminal that shallow would render anyway, and what the emulator's own
|
|
60
|
+
* selection looks like.
|
|
61
|
+
*/
|
|
62
|
+
selectionBg: Color;
|
|
63
|
+
selectionFg: Color;
|
|
53
64
|
gradient: string[];
|
|
54
65
|
gauge: {
|
|
55
66
|
track: Color;
|
package/dist/ui/theme.js
CHANGED
|
@@ -101,6 +101,8 @@ export function getTheme(name, caps) {
|
|
|
101
101
|
idleBorder: d(palette.idleBorder),
|
|
102
102
|
hintFg: d(palette.hintFg),
|
|
103
103
|
logoShadow: d(palette.logoShadow),
|
|
104
|
+
selectionBg: d(palette.selectionBg),
|
|
105
|
+
selectionFg: d(palette.selectionFg),
|
|
104
106
|
gradient: level >= 2 ? [...palette.gradient] : [],
|
|
105
107
|
gauge: {
|
|
106
108
|
track: d(palette.gauge.track),
|