codsh-cli 0.1.0 → 0.2.0
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 +14 -2
- package/README.zh.md +12 -1
- package/lib/index.js +1154 -251
- package/lib/types/console.d.ts +121 -33
- package/lib/types/keys.d.ts +20 -0
- package/lib/types/prompt.d.ts +15 -0
- package/lib/types/screen.d.ts +204 -0
- package/lib/types/selector.d.ts +0 -1
- package/lib/types/spinner.d.ts +7 -1
- package/lib/types/theme.d.ts +6 -5
- package/lib/types/transcript.d.ts +9 -9
- package/lib/types/wrap.d.ts +27 -0
- package/package.json +4 -2
package/lib/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
|
7
7
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
8
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
+
import stringWidth from "string-width";
|
|
10
11
|
import { readdirSync } from "node:fs";
|
|
11
12
|
import { createInterface } from "node:readline";
|
|
12
13
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
@@ -72,13 +73,8 @@ function answerForKey(key) {
|
|
|
72
73
|
|
|
73
74
|
//#endregion
|
|
74
75
|
//#region src/theme.ts
|
|
75
|
-
/**
|
|
76
|
-
* Terminal styling and display metrics: SGR sequences that degrade to plain
|
|
77
|
-
* text off a TTY, and the display-column width a rendered string occupies.
|
|
78
|
-
* @module codsh-cli/src/theme
|
|
79
|
-
*/
|
|
80
76
|
/** SGR codes applied by {@link Theme}, by role. */
|
|
81
|
-
const SGR = {
|
|
77
|
+
const SGR$1 = {
|
|
82
78
|
reset: "\x1B[0m",
|
|
83
79
|
dim: "\x1B[2m",
|
|
84
80
|
bold: "\x1B[1m",
|
|
@@ -125,80 +121,49 @@ const PLAIN = {
|
|
|
125
121
|
function createTheme(isTty, env) {
|
|
126
122
|
if (!isTty || env.NO_COLOR !== void 0) return PLAIN;
|
|
127
123
|
const palette = env.TERM?.includes("256color") === true || env.COLORTERM !== void 0;
|
|
128
|
-
const wrap = (code) => (text) => `${code}${text}${SGR.reset}`;
|
|
124
|
+
const wrap = (code) => (text) => `${code}${text}${SGR$1.reset}`;
|
|
129
125
|
return {
|
|
130
126
|
colored: true,
|
|
131
|
-
dim: wrap(palette ? "\x1B[38;5;245m" : SGR.dim),
|
|
132
|
-
bold: wrap(SGR.bold),
|
|
133
|
-
error: wrap(SGR.red),
|
|
134
|
-
success: wrap(SGR.green),
|
|
135
|
-
pending: wrap(SGR.yellow),
|
|
136
|
-
tool: wrap(SGR.cyan),
|
|
137
|
-
path: wrap(SGR.blue),
|
|
138
|
-
user: wrap(SGR.magenta),
|
|
127
|
+
dim: wrap(palette ? "\x1B[38;5;245m" : SGR$1.dim),
|
|
128
|
+
bold: wrap(SGR$1.bold),
|
|
129
|
+
error: wrap(SGR$1.red),
|
|
130
|
+
success: wrap(SGR$1.green),
|
|
131
|
+
pending: wrap(SGR$1.yellow),
|
|
132
|
+
tool: wrap(SGR$1.cyan),
|
|
133
|
+
path: wrap(SGR$1.blue),
|
|
134
|
+
user: wrap(SGR$1.magenta),
|
|
139
135
|
syntax: {
|
|
140
|
-
keyword: wrap(SGR.magenta),
|
|
141
|
-
string: wrap(SGR.green),
|
|
142
|
-
number: wrap(SGR.cyan),
|
|
143
|
-
comment: wrap(SGR.dim)
|
|
136
|
+
keyword: wrap(SGR$1.magenta),
|
|
137
|
+
string: wrap(SGR$1.green),
|
|
138
|
+
number: wrap(SGR$1.cyan),
|
|
139
|
+
comment: wrap(SGR$1.dim)
|
|
144
140
|
}
|
|
145
141
|
};
|
|
146
142
|
}
|
|
147
143
|
/**
|
|
148
|
-
* Inclusive code-point ranges rendered two columns wide by a terminal: the
|
|
149
|
-
* East Asian Wide and Fullwidth classes of Unicode TR11, which cover the CJK
|
|
150
|
-
* blocks, Hangul, the fullwidth forms, and the emoji planes this surface
|
|
151
|
-
* prints.
|
|
152
|
-
*/
|
|
153
|
-
const WIDE_RANGES = [
|
|
154
|
-
[4352, 4447],
|
|
155
|
-
[11904, 12350],
|
|
156
|
-
[12353, 13311],
|
|
157
|
-
[13312, 19903],
|
|
158
|
-
[19968, 40959],
|
|
159
|
-
[40960, 42191],
|
|
160
|
-
[43360, 43391],
|
|
161
|
-
[44032, 55203],
|
|
162
|
-
[63744, 64255],
|
|
163
|
-
[65040, 65049],
|
|
164
|
-
[65072, 65135],
|
|
165
|
-
[65280, 65376],
|
|
166
|
-
[65504, 65510],
|
|
167
|
-
[127744, 128591],
|
|
168
|
-
[129280, 129535],
|
|
169
|
-
[131072, 196605],
|
|
170
|
-
[196608, 262141]
|
|
171
|
-
];
|
|
172
|
-
/** Matches one SGR sequence, which occupies no display column. */
|
|
173
|
-
const SGR_PATTERN = /\u001B\[[0-9;]*m/gu;
|
|
174
|
-
/**
|
|
175
|
-
* Whether one code point occupies two display columns.
|
|
176
|
-
* @param code - the code point to classify.
|
|
177
|
-
* @returns true when the terminal renders it double-width.
|
|
178
|
-
*/
|
|
179
|
-
function isWide(code) {
|
|
180
|
-
return WIDE_RANGES.some(([low, high]) => code >= low && code <= high);
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
144
|
* Display columns a string occupies once printed, ignoring styling sequences.
|
|
184
145
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
146
|
+
* Measured by `string-width` — the width authority cli-table3, ink, and every
|
|
147
|
+
* maintained terminal renderer sit on — so East Asian Wide, emoji presentation
|
|
148
|
+
* (`⚡` included), combining marks, and ZWJ sequences all match what a
|
|
149
|
+
* terminal's cursor actually does. A hand-kept range table here mis-sized `⚡`
|
|
150
|
+
* and sheared a real table's columns; widths are exactly the kind of data
|
|
151
|
+
* nobody should maintain by hand.
|
|
190
152
|
* @param text - the string to measure, possibly carrying SGR sequences.
|
|
191
153
|
* @returns the number of display columns.
|
|
192
154
|
*/
|
|
193
155
|
function displayWidth(text) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (code
|
|
199
|
-
|
|
156
|
+
if (text === "") return 0;
|
|
157
|
+
let ascii = true;
|
|
158
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
159
|
+
const code = text.charCodeAt(index);
|
|
160
|
+
if (code < 32 || code > 126) {
|
|
161
|
+
ascii = false;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
200
164
|
}
|
|
201
|
-
return
|
|
165
|
+
if (ascii) return text.length;
|
|
166
|
+
return stringWidth(text);
|
|
202
167
|
}
|
|
203
168
|
/** Matches one SGR sequence at the start of a string. */
|
|
204
169
|
const SGR_AT_START = /^\u001B\[[0-9;]*m/u;
|
|
@@ -231,7 +196,7 @@ function truncate(text, columns) {
|
|
|
231
196
|
}
|
|
232
197
|
const code = text.codePointAt(at) ?? 0;
|
|
233
198
|
const cell = String.fromCodePoint(code);
|
|
234
|
-
const step =
|
|
199
|
+
const step = displayWidth(cell);
|
|
235
200
|
if (width + step > columns - 1) break;
|
|
236
201
|
width += step;
|
|
237
202
|
out += cell;
|
|
@@ -380,6 +345,22 @@ function statusReport(facts, session) {
|
|
|
380
345
|
/** The product name, shown as the framed headline. */
|
|
381
346
|
const NAME$1 = "dsh code";
|
|
382
347
|
/**
|
|
348
|
+
* The lettermark, drawn in half-block glyphs.
|
|
349
|
+
*
|
|
350
|
+
* Forty columns wide: it fits an eighty-column terminal with room to spare,
|
|
351
|
+
* and anything narrower falls back to the plain headline anyway.
|
|
352
|
+
*/
|
|
353
|
+
const LOGO = [
|
|
354
|
+
" ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗",
|
|
355
|
+
"██╔════╝██╔═══██╗██╔══██╗██╔════╝██║ ██║",
|
|
356
|
+
"██║ ██║ ██║██║ ██║███████╗███████║",
|
|
357
|
+
"██║ ██║ ██║██║ ██║╚════██║██╔══██║",
|
|
358
|
+
"╚██████╗╚██████╔╝██████╔╝███████║██║ ██║",
|
|
359
|
+
" ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝"
|
|
360
|
+
];
|
|
361
|
+
/** Display width of the widest logo row. */
|
|
362
|
+
const LOGO_WIDTH = 41;
|
|
363
|
+
/**
|
|
383
364
|
* Frame one headline in a rounded box sized to its content.
|
|
384
365
|
*
|
|
385
366
|
* Drawn from the measured display width rather than the character count, so a
|
|
@@ -410,14 +391,22 @@ function bannerLines(facts, theme, columns) {
|
|
|
410
391
|
const interrupt = facts.readsKeys ? "ESC" : "Ctrl-C";
|
|
411
392
|
const plain = `${NAME$1} · ${composition}`;
|
|
412
393
|
const headline = `${theme.bold(NAME$1)}${theme.dim(` · ${composition}`)}`;
|
|
413
|
-
|
|
414
|
-
...displayWidth(plain) + 4 <= columns ? framed(headline, displayWidth(plain), theme) : [truncate(plain, columns)],
|
|
394
|
+
const details = [
|
|
415
395
|
theme.dim(` ${truncate(where, columns - 2)}`),
|
|
416
396
|
theme.dim(` session ${facts.session}${facts.resumed ? " (resumed)" : ""}`),
|
|
417
397
|
"",
|
|
418
398
|
theme.dim(truncate(` /help for commands · Tab completes · ⇧Tab plan mode · ${interrupt} interrupts · /exit leaves`, columns)),
|
|
419
399
|
""
|
|
420
400
|
];
|
|
401
|
+
if (facts.readsKeys && !facts.resumed && columns >= LOGO_WIDTH + 2) return [
|
|
402
|
+
"",
|
|
403
|
+
...LOGO.map((row) => theme.user(` ${row}`)),
|
|
404
|
+
"",
|
|
405
|
+
` ${theme.bold("✻ Welcome to codsh")}${theme.dim(` · ${composition}`)}`,
|
|
406
|
+
"",
|
|
407
|
+
...details
|
|
408
|
+
];
|
|
409
|
+
return [...displayWidth(plain) + 4 <= columns ? framed(headline, displayWidth(plain), theme) : [truncate(plain, columns)], ...details];
|
|
421
410
|
}
|
|
422
411
|
|
|
423
412
|
//#endregion
|
|
@@ -654,8 +643,41 @@ async function loadCustomCommands(roots, taken) {
|
|
|
654
643
|
const PASTE_START = "\x1B[200~";
|
|
655
644
|
/** Bracketed paste end. */
|
|
656
645
|
const PASTE_END = "\x1B[201~";
|
|
646
|
+
/**
|
|
647
|
+
* Rows one wheel event moves.
|
|
648
|
+
*
|
|
649
|
+
* One, not three: trackpads and hi-res wheels emit a stream of events per
|
|
650
|
+
* gesture, so a multiplier here compounds into overshoot. The smoothness comes
|
|
651
|
+
* from coalescing the stream into one repaint, not from bigger steps.
|
|
652
|
+
*/
|
|
653
|
+
const WHEEL_LINES = 1;
|
|
654
|
+
/** An SGR mouse report: `ESC [ < button ; column ; row (M|m)`. */
|
|
655
|
+
const MOUSE = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
656
|
+
/** Wheel-up button code in the SGR encoding; wheel-down is one higher. */
|
|
657
|
+
const WHEEL_UP = 64;
|
|
658
|
+
/** Modifier bits in an SGR button code: Shift, Meta, and Control. */
|
|
659
|
+
const MOUSE_MODIFIERS = 28;
|
|
660
|
+
/** The motion bit, set on reports sent while a button is held. */
|
|
661
|
+
const MOUSE_MOTION = 32;
|
|
657
662
|
/** Sequences that resolve to one key, longest first so a prefix never wins. */
|
|
658
663
|
const SEQUENCES = [
|
|
664
|
+
["\x1B[5~", {
|
|
665
|
+
kind: "page",
|
|
666
|
+
direction: -1
|
|
667
|
+
}],
|
|
668
|
+
["\x1B[6~", {
|
|
669
|
+
kind: "page",
|
|
670
|
+
direction: 1
|
|
671
|
+
}],
|
|
672
|
+
["\x1B[1;2A", {
|
|
673
|
+
kind: "scroll",
|
|
674
|
+
lines: -1
|
|
675
|
+
}],
|
|
676
|
+
["\x1B[1;2B", {
|
|
677
|
+
kind: "scroll",
|
|
678
|
+
lines: 1
|
|
679
|
+
}],
|
|
680
|
+
["\x1B[1;5F", { kind: "scroll-end" }],
|
|
659
681
|
["\x1B[1;5D", { kind: "word-left" }],
|
|
660
682
|
["\x1B[1;5C", { kind: "word-right" }],
|
|
661
683
|
["\x1B[1;3D", { kind: "word-left" }],
|
|
@@ -761,6 +783,40 @@ var KeyDecoder = class {
|
|
|
761
783
|
return [];
|
|
762
784
|
}
|
|
763
785
|
if (PASTE_START.startsWith(this.held)) return void 0;
|
|
786
|
+
const mouse = MOUSE.exec(this.held);
|
|
787
|
+
if (mouse !== null) {
|
|
788
|
+
this.held = this.held.slice(mouse[0].length);
|
|
789
|
+
const button = Number(mouse[1]);
|
|
790
|
+
if (button === WHEEL_UP) return [{
|
|
791
|
+
kind: "scroll",
|
|
792
|
+
lines: -WHEEL_LINES
|
|
793
|
+
}];
|
|
794
|
+
if (button === WHEEL_UP + 1) return [{
|
|
795
|
+
kind: "scroll",
|
|
796
|
+
lines: WHEEL_LINES
|
|
797
|
+
}];
|
|
798
|
+
const column = Number(mouse[2]);
|
|
799
|
+
const row = Number(mouse[3]);
|
|
800
|
+
if ((button & ~MOUSE_MOTION) === 0 && (button & MOUSE_MODIFIERS) === 0) {
|
|
801
|
+
if (mouse[4] === "m") return [{
|
|
802
|
+
kind: "mouse-up",
|
|
803
|
+
row,
|
|
804
|
+
column
|
|
805
|
+
}];
|
|
806
|
+
if ((button & MOUSE_MOTION) !== 0) return [{
|
|
807
|
+
kind: "mouse-drag",
|
|
808
|
+
row,
|
|
809
|
+
column
|
|
810
|
+
}];
|
|
811
|
+
return [{
|
|
812
|
+
kind: "mouse-down",
|
|
813
|
+
row,
|
|
814
|
+
column
|
|
815
|
+
}];
|
|
816
|
+
}
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
if (/^\u001B(\[(<\d*(;\d*){0,2})?)?$/.test(this.held)) return void 0;
|
|
764
820
|
for (const [sequence, key] of SEQUENCES) {
|
|
765
821
|
if (this.held.startsWith(sequence)) {
|
|
766
822
|
this.held = this.held.slice(sequence.length);
|
|
@@ -817,6 +873,543 @@ const ENABLE_PASTE_MARKERS = "\x1B[?2004h";
|
|
|
817
873
|
/** Stop the terminal wrapping pasted text, restoring what it did before. */
|
|
818
874
|
const DISABLE_PASTE_MARKERS = "\x1B[?2004l";
|
|
819
875
|
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/wrap.ts
|
|
878
|
+
/** One SGR sequence, which occupies no display columns. */
|
|
879
|
+
const SGR = /^\u001B\[[0-9;]*m/;
|
|
880
|
+
/** Any other escape sequence, also zero-width. */
|
|
881
|
+
const ESCAPE = /^(?:\u001B\[[0-9;?]*[A-Za-z]|\u001B\][^\u0007]*\u0007|\u001B.)/;
|
|
882
|
+
/** Closes every style a row opened, so a row never bleeds into the next. */
|
|
883
|
+
const RESET = "\x1B[0m";
|
|
884
|
+
/**
|
|
885
|
+
* Break one styled line into rows no wider than `columns` display columns.
|
|
886
|
+
*
|
|
887
|
+
* Styles carry across the break: each continuation row re-opens whatever was
|
|
888
|
+
* active where the cut fell, and every row that opened a style closes it.
|
|
889
|
+
* @param text - the styled line, without a terminator.
|
|
890
|
+
* @param columns - display columns available per row.
|
|
891
|
+
* @returns the rows, at least one (an empty line yields one empty row).
|
|
892
|
+
*/
|
|
893
|
+
function wrapStyled(text, columns) {
|
|
894
|
+
if (columns <= 0) return [text];
|
|
895
|
+
const rows = [];
|
|
896
|
+
/** SGR sequences active at the cursor, in the order they were applied. */
|
|
897
|
+
let active = [];
|
|
898
|
+
let row = "";
|
|
899
|
+
let width = 0;
|
|
900
|
+
let rest = text;
|
|
901
|
+
const flush = () => {
|
|
902
|
+
rows.push(active.length > 0 ? `${row}${RESET}` : row);
|
|
903
|
+
row = active.join("");
|
|
904
|
+
width = 0;
|
|
905
|
+
};
|
|
906
|
+
while (rest !== "") {
|
|
907
|
+
const sgr = SGR.exec(rest);
|
|
908
|
+
if (sgr !== null) {
|
|
909
|
+
const sequence = sgr[0];
|
|
910
|
+
if (sequence === "\x1B[0m" || sequence === "\x1B[m") active = [];
|
|
911
|
+
else active.push(sequence);
|
|
912
|
+
row += sequence;
|
|
913
|
+
rest = rest.slice(sequence.length);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const other = ESCAPE.exec(rest);
|
|
917
|
+
if (other !== null) {
|
|
918
|
+
row += other[0];
|
|
919
|
+
rest = rest.slice(other[0].length);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const character = [...rest][0] ?? "";
|
|
923
|
+
const cost = displayWidth(character);
|
|
924
|
+
if (width + cost > columns && width > 0) flush();
|
|
925
|
+
row += character;
|
|
926
|
+
width += cost;
|
|
927
|
+
rest = rest.slice(character.length);
|
|
928
|
+
}
|
|
929
|
+
rows.push(active.length > 0 ? `${row}${RESET}` : row);
|
|
930
|
+
return rows;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Wrap many lines, keeping their order.
|
|
934
|
+
* @param lines - styled lines.
|
|
935
|
+
* @param columns - display columns per row.
|
|
936
|
+
* @returns the physical rows they occupy.
|
|
937
|
+
*/
|
|
938
|
+
function wrapAll(lines, columns) {
|
|
939
|
+
return lines.flatMap((line) => wrapStyled(line, columns));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
//#endregion
|
|
943
|
+
//#region src/screen.ts
|
|
944
|
+
/** Logical transcript lines kept before the oldest are dropped. */
|
|
945
|
+
const MAX_SCROLLBACK = 5e3;
|
|
946
|
+
/** Enter the alternate screen, saving the cursor and the current buffer. */
|
|
947
|
+
const ENTER_ALT = "\x1B[?1049h";
|
|
948
|
+
/** Leave it, restoring both. */
|
|
949
|
+
const LEAVE_ALT = "\x1B[?1049l";
|
|
950
|
+
/**
|
|
951
|
+
* Report wheel and button events, in the SGR encoding.
|
|
952
|
+
*
|
|
953
|
+
* Button tracking (1002) rather than any-motion (1003): the wheel and clicks
|
|
954
|
+
* are all this surface reads, and motion reporting floods the input for
|
|
955
|
+
* nothing. Most terminals still hand a Shift-drag to their own selection, so
|
|
956
|
+
* copying text keeps working.
|
|
957
|
+
*/
|
|
958
|
+
const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1006h";
|
|
959
|
+
/** Stop reporting them. */
|
|
960
|
+
const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1002l";
|
|
961
|
+
/** Ask the terminal to paint a frame atomically, so no half-frame is shown. */
|
|
962
|
+
const SYNC_BEGIN = "\x1B[?2026h";
|
|
963
|
+
/** End the atomic frame. */
|
|
964
|
+
const SYNC_END = "\x1B[?2026l";
|
|
965
|
+
/** Erase the row from the cursor rightwards. */
|
|
966
|
+
const CLEAR_LINE = "\x1B[K";
|
|
967
|
+
/** Hide the cursor while a frame is painted. */
|
|
968
|
+
const HIDE_CURSOR = "\x1B[?25l";
|
|
969
|
+
/** Show it again. */
|
|
970
|
+
const SHOW_CURSOR = "\x1B[?25h";
|
|
971
|
+
/** Styling escapes, removed before a row is measured or copied. */
|
|
972
|
+
const STYLES = /\u001B\[[0-9;]*m/gu;
|
|
973
|
+
/** Start reverse video, which is how the selection shows itself. */
|
|
974
|
+
const INVERSE = "\x1B[7m";
|
|
975
|
+
/** End reverse video only, leaving any other attributes alone. */
|
|
976
|
+
const INVERSE_OFF = "\x1B[27m";
|
|
977
|
+
/**
|
|
978
|
+
* The string index where a display column begins.
|
|
979
|
+
*
|
|
980
|
+
* Columns are what the mouse reports and characters are what strings hold;
|
|
981
|
+
* this is the bridge. A column landing inside a wide character snaps past it.
|
|
982
|
+
* @param text - plain text, no escapes.
|
|
983
|
+
* @param column - display column, 0-based.
|
|
984
|
+
* @returns the index of the first character at or beyond that column.
|
|
985
|
+
*/
|
|
986
|
+
function columnIndex(text, column) {
|
|
987
|
+
let width = 0;
|
|
988
|
+
let index = 0;
|
|
989
|
+
for (const character of text) {
|
|
990
|
+
if (width >= column) return index;
|
|
991
|
+
width += displayWidth(character);
|
|
992
|
+
index += character.length;
|
|
993
|
+
}
|
|
994
|
+
return text.length;
|
|
995
|
+
}
|
|
996
|
+
/** An alternate-screen viewport over a scrollback buffer this surface owns. */
|
|
997
|
+
var Screen = class {
|
|
998
|
+
/** Logical transcript lines, unwrapped, oldest first. */
|
|
999
|
+
logical = [];
|
|
1000
|
+
/** The same lines wrapped to the current width — what the viewport slices. */
|
|
1001
|
+
physical = [];
|
|
1002
|
+
/** The bottom rows: input box, menu, indicator, status. */
|
|
1003
|
+
chrome = [];
|
|
1004
|
+
chromeCursor = {
|
|
1005
|
+
row: 0,
|
|
1006
|
+
column: 0
|
|
1007
|
+
};
|
|
1008
|
+
/** Whether the chrome holds input focus, which is when the cursor shows. */
|
|
1009
|
+
chromeFocus = true;
|
|
1010
|
+
/** Physical rows hidden below the viewport; zero means following the tail. */
|
|
1011
|
+
offset = 0;
|
|
1012
|
+
/** What to show while scrolled back, drawn over the viewport's top row. */
|
|
1013
|
+
notice = "";
|
|
1014
|
+
/** A mouse selection over the transcript, in physical-row coordinates. */
|
|
1015
|
+
selection;
|
|
1016
|
+
/** Collapsed blocks in the transcript, in order, with both of their forms. */
|
|
1017
|
+
folds = [];
|
|
1018
|
+
/** Whether the folds currently show their full form. */
|
|
1019
|
+
expanded = false;
|
|
1020
|
+
/** The last painted frame, so a repaint only touches rows that changed. */
|
|
1021
|
+
painted = [];
|
|
1022
|
+
/** Width the current frame was painted at, to detect a resize. */
|
|
1023
|
+
paintedColumns = 0;
|
|
1024
|
+
active = false;
|
|
1025
|
+
constructor(host) {
|
|
1026
|
+
this.host = host;
|
|
1027
|
+
}
|
|
1028
|
+
/** Whether the alternate screen is currently held. */
|
|
1029
|
+
get entered() {
|
|
1030
|
+
return this.active;
|
|
1031
|
+
}
|
|
1032
|
+
/** Physical rows scrolled up out of view; zero means the tail is showing. */
|
|
1033
|
+
get scrolledBy() {
|
|
1034
|
+
return this.offset;
|
|
1035
|
+
}
|
|
1036
|
+
/** Take the alternate screen and start reporting the mouse. */
|
|
1037
|
+
enter() {
|
|
1038
|
+
if (this.active) return;
|
|
1039
|
+
this.active = true;
|
|
1040
|
+
this.host.write(`${ENTER_ALT}${ENABLE_MOUSE}${HIDE_CURSOR}`);
|
|
1041
|
+
this.painted = [];
|
|
1042
|
+
this.render();
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Give the terminal back exactly as it was.
|
|
1046
|
+
*
|
|
1047
|
+
* Idempotent, because every exit path calls it — a normal quit, an
|
|
1048
|
+
* interrupt, and a crash handler all have to leave the terminal usable.
|
|
1049
|
+
*/
|
|
1050
|
+
leave() {
|
|
1051
|
+
if (!this.active) return;
|
|
1052
|
+
this.active = false;
|
|
1053
|
+
this.host.write(`${DISABLE_MOUSE}${SHOW_CURSOR}${LEAVE_ALT}`);
|
|
1054
|
+
this.painted = [];
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Append finished transcript lines.
|
|
1058
|
+
*
|
|
1059
|
+
* Following the tail is the default; a person who has scrolled up stays
|
|
1060
|
+
* where they are, and the new rows accumulate below them.
|
|
1061
|
+
* @param lines - the lines to keep, already styled.
|
|
1062
|
+
*/
|
|
1063
|
+
append(lines) {
|
|
1064
|
+
if (lines.length === 0) return;
|
|
1065
|
+
const columns = this.contentColumns();
|
|
1066
|
+
for (const line of lines) {
|
|
1067
|
+
this.logical.push(line);
|
|
1068
|
+
this.physical.push(...wrapStyled(line, columns));
|
|
1069
|
+
}
|
|
1070
|
+
if (this.logical.length > MAX_SCROLLBACK) {
|
|
1071
|
+
const dropped = this.logical.length - MAX_SCROLLBACK;
|
|
1072
|
+
this.logical.splice(0, dropped);
|
|
1073
|
+
this.folds = this.folds.flatMap((fold) => {
|
|
1074
|
+
const at = fold.at - dropped;
|
|
1075
|
+
return at >= 0 ? [{
|
|
1076
|
+
...fold,
|
|
1077
|
+
at
|
|
1078
|
+
}] : [];
|
|
1079
|
+
});
|
|
1080
|
+
this.rewrap();
|
|
1081
|
+
}
|
|
1082
|
+
this.render();
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Append one collapsible block: its summary now, its full form on demand.
|
|
1086
|
+
*
|
|
1087
|
+
* This is what makes every long block — not merely the latest — expandable:
|
|
1088
|
+
* the buffer keeps both forms, and toggling rebuilds the transcript in
|
|
1089
|
+
* place, exactly like a details/summary element.
|
|
1090
|
+
* @param summary - the collapsed lines, already styled.
|
|
1091
|
+
* @param full - the expanded lines, already styled.
|
|
1092
|
+
*/
|
|
1093
|
+
appendFold(summary, full) {
|
|
1094
|
+
const shown = this.expanded ? full : summary;
|
|
1095
|
+
this.folds.push({
|
|
1096
|
+
at: this.logical.length,
|
|
1097
|
+
shownLength: shown.length,
|
|
1098
|
+
summary: [...summary],
|
|
1099
|
+
full: [...full],
|
|
1100
|
+
expanded: this.expanded
|
|
1101
|
+
});
|
|
1102
|
+
this.append(shown);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Turn the last `count` appended lines into a collapsible block after the
|
|
1106
|
+
* fact.
|
|
1107
|
+
*
|
|
1108
|
+
* This is how a finished answer becomes foldable without ever having been
|
|
1109
|
+
* withheld: it streamed in the open, and only once complete does it grow a
|
|
1110
|
+
* summary form. The block starts expanded — the person is reading it — and
|
|
1111
|
+
* collapses with the rest when the conversation moves on.
|
|
1112
|
+
* @param count - how many trailing lines the block owns.
|
|
1113
|
+
* @param summary - the collapsed lines, already styled.
|
|
1114
|
+
*/
|
|
1115
|
+
foldBack(count, summary) {
|
|
1116
|
+
const at = this.logical.length - count;
|
|
1117
|
+
if (count <= 0 || at < 0) return;
|
|
1118
|
+
const last = this.folds.at(-1);
|
|
1119
|
+
if (last !== void 0 && at < last.at + last.shownLength) return;
|
|
1120
|
+
this.folds.push({
|
|
1121
|
+
at,
|
|
1122
|
+
shownLength: count,
|
|
1123
|
+
summary: [...summary],
|
|
1124
|
+
full: this.logical.slice(at),
|
|
1125
|
+
expanded: true
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
/** Whether any collapsible block exists. */
|
|
1129
|
+
get hasFolds() {
|
|
1130
|
+
return this.folds.length > 0;
|
|
1131
|
+
}
|
|
1132
|
+
/** Whether the folds currently show their full form. */
|
|
1133
|
+
get foldsExpanded() {
|
|
1134
|
+
return this.expanded;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Swap every fold between its summary and its full form.
|
|
1138
|
+
* @returns false when there is nothing to toggle.
|
|
1139
|
+
*/
|
|
1140
|
+
toggleFolds() {
|
|
1141
|
+
if (this.folds.length === 0) return false;
|
|
1142
|
+
this.setFolds(!this.expanded);
|
|
1143
|
+
return true;
|
|
1144
|
+
}
|
|
1145
|
+
/** Return every fold to its summary, the way moving on reads as dismissal. */
|
|
1146
|
+
collapseFolds() {
|
|
1147
|
+
if (this.folds.some((fold) => fold.expanded)) this.setFolds(false);
|
|
1148
|
+
else this.expanded = false;
|
|
1149
|
+
}
|
|
1150
|
+
/** Put every fold into one form, whatever mix of states they are in now. */
|
|
1151
|
+
setFolds(expanded) {
|
|
1152
|
+
this.expanded = expanded;
|
|
1153
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
1154
|
+
for (const fold of [...this.folds].reverse()) {
|
|
1155
|
+
if (fold.expanded === expanded) {
|
|
1156
|
+
deltas.set(fold, 0);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const shown = expanded ? fold.full : fold.summary;
|
|
1160
|
+
this.logical.splice(fold.at, fold.shownLength, ...shown);
|
|
1161
|
+
deltas.set(fold, shown.length - fold.shownLength);
|
|
1162
|
+
fold.shownLength = shown.length;
|
|
1163
|
+
fold.expanded = expanded;
|
|
1164
|
+
}
|
|
1165
|
+
let shift = 0;
|
|
1166
|
+
for (const fold of this.folds) {
|
|
1167
|
+
fold.at += shift;
|
|
1168
|
+
shift += deltas.get(fold) ?? 0;
|
|
1169
|
+
}
|
|
1170
|
+
this.rewrap();
|
|
1171
|
+
this.offset = 0;
|
|
1172
|
+
this.painted = [];
|
|
1173
|
+
this.render();
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Replace the bottom rows.
|
|
1177
|
+
* @param rows - the chrome, top to bottom.
|
|
1178
|
+
* @param cursor - where the cursor belongs among them.
|
|
1179
|
+
* @param focus - whether to show the cursor there.
|
|
1180
|
+
*/
|
|
1181
|
+
setChrome(rows, cursor, focus) {
|
|
1182
|
+
this.chrome = rows.map((row) => truncate(row, this.contentColumns()));
|
|
1183
|
+
this.chromeCursor = { ...cursor };
|
|
1184
|
+
this.chromeFocus = focus;
|
|
1185
|
+
this.render();
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Set the line shown while the reader is away from the tail.
|
|
1189
|
+
*
|
|
1190
|
+
* Drawn OVER the viewport's top row rather than added to the chrome: a notice
|
|
1191
|
+
* that changed the chrome's height would move the input box as a side effect
|
|
1192
|
+
* of scrolling, and would make a page up and a page down different sizes.
|
|
1193
|
+
* @param text - the styled notice, already fitted.
|
|
1194
|
+
*/
|
|
1195
|
+
setScrollNotice(text) {
|
|
1196
|
+
if (text === this.notice) return;
|
|
1197
|
+
this.notice = text;
|
|
1198
|
+
if (this.offset > 0) this.render();
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Scroll the transcript.
|
|
1202
|
+
* @param delta - rows to move; negative scrolls back into history.
|
|
1203
|
+
*/
|
|
1204
|
+
scrollBy(delta) {
|
|
1205
|
+
const limit = Math.max(0, this.physical.length - this.viewportHeight());
|
|
1206
|
+
const next = Math.min(limit, Math.max(0, this.offset - delta));
|
|
1207
|
+
if (next === this.offset) return;
|
|
1208
|
+
this.offset = next;
|
|
1209
|
+
this.render();
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Scroll by a whole viewport, which is what the page keys mean.
|
|
1213
|
+
* @param direction - -1 for back into history, 1 towards the tail.
|
|
1214
|
+
*/
|
|
1215
|
+
scrollPage(direction) {
|
|
1216
|
+
this.scrollBy(direction * Math.max(1, this.viewportHeight() - 1));
|
|
1217
|
+
}
|
|
1218
|
+
/** Jump back to the tail, which is also what a new submission does. */
|
|
1219
|
+
scrollToBottom() {
|
|
1220
|
+
if (this.offset === 0) return;
|
|
1221
|
+
this.offset = 0;
|
|
1222
|
+
this.render();
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Drop the transcript, keeping the chrome.
|
|
1226
|
+
*
|
|
1227
|
+
* Ctrl-L on a shared terminal clears a viewport the person may want back; on
|
|
1228
|
+
* our own screen the buffer IS the session's history, so this empties it.
|
|
1229
|
+
*/
|
|
1230
|
+
clearTranscript() {
|
|
1231
|
+
this.logical = [];
|
|
1232
|
+
this.physical = [];
|
|
1233
|
+
this.folds = [];
|
|
1234
|
+
this.expanded = false;
|
|
1235
|
+
this.offset = 0;
|
|
1236
|
+
this.painted = [];
|
|
1237
|
+
this.render();
|
|
1238
|
+
}
|
|
1239
|
+
/** Re-wrap and repaint after the terminal changed size. */
|
|
1240
|
+
resize() {
|
|
1241
|
+
this.rewrap();
|
|
1242
|
+
this.painted = [];
|
|
1243
|
+
this.render();
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Anchor a selection where the left button went down.
|
|
1247
|
+
*
|
|
1248
|
+
* The terminal cannot select for us while mouse reporting is on, so the
|
|
1249
|
+
* viewport does it: press anchors, motion extends, release copies — the
|
|
1250
|
+
* shape opencode and Claude give the same gesture.
|
|
1251
|
+
* @param row - terminal row, 1-based.
|
|
1252
|
+
* @param column - terminal column, 1-based.
|
|
1253
|
+
*/
|
|
1254
|
+
mouseDown(row, column) {
|
|
1255
|
+
const had = this.selection !== void 0;
|
|
1256
|
+
this.selection = void 0;
|
|
1257
|
+
const at = this.locate(row, column, false);
|
|
1258
|
+
if (at !== void 0) this.selection = {
|
|
1259
|
+
anchor: at,
|
|
1260
|
+
focus: at,
|
|
1261
|
+
dragged: false
|
|
1262
|
+
};
|
|
1263
|
+
if (had) this.render();
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Extend the selection to where the pointer moved.
|
|
1267
|
+
* @param row - terminal row, 1-based.
|
|
1268
|
+
* @param column - terminal column, 1-based.
|
|
1269
|
+
*/
|
|
1270
|
+
mouseDrag(row, column) {
|
|
1271
|
+
if (this.selection === void 0) return;
|
|
1272
|
+
const at = this.locate(row, column, true);
|
|
1273
|
+
if (at === void 0) return;
|
|
1274
|
+
this.selection.focus = at;
|
|
1275
|
+
this.selection.dragged = true;
|
|
1276
|
+
this.render();
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Finish the gesture.
|
|
1280
|
+
*
|
|
1281
|
+
* The highlight stays up — the copy already happened, and the marks show
|
|
1282
|
+
* what it took — until the next click or reflow dismisses it.
|
|
1283
|
+
* @returns the selected text, or undefined for a bare click.
|
|
1284
|
+
*/
|
|
1285
|
+
mouseUp() {
|
|
1286
|
+
const selection = this.selection;
|
|
1287
|
+
if (selection === void 0) return void 0;
|
|
1288
|
+
if (!selection.dragged) {
|
|
1289
|
+
this.selection = void 0;
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const text = this.selectedText();
|
|
1293
|
+
if (text === "") {
|
|
1294
|
+
this.selection = void 0;
|
|
1295
|
+
this.render();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
return text;
|
|
1299
|
+
}
|
|
1300
|
+
/** The selection's bounds in order, top-left first. */
|
|
1301
|
+
orderedSelection() {
|
|
1302
|
+
const selection = this.selection;
|
|
1303
|
+
if (selection === void 0 || !selection.dragged) return void 0;
|
|
1304
|
+
const { anchor, focus } = selection;
|
|
1305
|
+
const [from, to] = focus.row < anchor.row || focus.row === anchor.row && focus.column < anchor.column ? [focus, anchor] : [anchor, focus];
|
|
1306
|
+
return {
|
|
1307
|
+
from,
|
|
1308
|
+
to
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
/** The plain text under the selection, visual rows joined by newlines. */
|
|
1312
|
+
selectedText() {
|
|
1313
|
+
const bounds = this.orderedSelection();
|
|
1314
|
+
if (bounds === void 0) return "";
|
|
1315
|
+
const rows = [];
|
|
1316
|
+
for (let index = bounds.from.row; index <= bounds.to.row; index += 1) {
|
|
1317
|
+
const plain = (this.physical[index] ?? "").replaceAll(STYLES, "");
|
|
1318
|
+
const start = index === bounds.from.row ? columnIndex(plain, bounds.from.column) : 0;
|
|
1319
|
+
const end = index === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
|
|
1320
|
+
rows.push(plain.slice(start, end));
|
|
1321
|
+
}
|
|
1322
|
+
return rows.join("\n").replace(/^\n+|\n+$/gu, "") === "" ? "" : rows.join("\n");
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Map a terminal position to a physical buffer position.
|
|
1326
|
+
* @param row - terminal row, 1-based.
|
|
1327
|
+
* @param column - terminal column, 1-based.
|
|
1328
|
+
* @param clamp - pull an outside position to the nearest content row, the
|
|
1329
|
+
* way dragging past an edge keeps selecting, instead of refusing it.
|
|
1330
|
+
* @returns the position, or undefined when it misses the content.
|
|
1331
|
+
*/
|
|
1332
|
+
locate(row, column, clamp) {
|
|
1333
|
+
if (this.physical.length === 0) return void 0;
|
|
1334
|
+
const height = this.viewportHeight();
|
|
1335
|
+
const end = this.physical.length - this.offset;
|
|
1336
|
+
const start = Math.max(0, end - height);
|
|
1337
|
+
let index = start + row - 1;
|
|
1338
|
+
if (!clamp && (row - 1 >= height || index >= end || index < start)) return void 0;
|
|
1339
|
+
index = Math.min(Math.max(index, start), end - 1);
|
|
1340
|
+
return {
|
|
1341
|
+
row: index,
|
|
1342
|
+
column: Math.max(0, column - 1)
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
/** Rows the transcript viewport occupies. */
|
|
1346
|
+
viewportHeight() {
|
|
1347
|
+
return Math.max(1, this.host.rows() - this.chrome.length);
|
|
1348
|
+
}
|
|
1349
|
+
/** Columns content is laid out for, one short of the width so no row wraps. */
|
|
1350
|
+
contentColumns() {
|
|
1351
|
+
return Math.max(1, this.host.columns() - 1);
|
|
1352
|
+
}
|
|
1353
|
+
/** Re-wrap every kept line at the current width. */
|
|
1354
|
+
rewrap() {
|
|
1355
|
+
this.selection = void 0;
|
|
1356
|
+
this.physical = wrapAll(this.logical, this.contentColumns());
|
|
1357
|
+
const limit = Math.max(0, this.physical.length - this.viewportHeight());
|
|
1358
|
+
this.offset = Math.min(this.offset, limit);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Compose and paint the frame.
|
|
1362
|
+
*
|
|
1363
|
+
* The viewport is padded at the top when the transcript is shorter than the
|
|
1364
|
+
* screen, which is what puts the chrome at the bottom from the first frame
|
|
1365
|
+
* rather than wherever output happened to reach.
|
|
1366
|
+
*/
|
|
1367
|
+
render() {
|
|
1368
|
+
if (!this.active) return;
|
|
1369
|
+
const columns = this.host.columns();
|
|
1370
|
+
if (columns !== this.paintedColumns) {
|
|
1371
|
+
this.physical = wrapAll(this.logical, this.contentColumns());
|
|
1372
|
+
this.painted = [];
|
|
1373
|
+
this.paintedColumns = columns;
|
|
1374
|
+
}
|
|
1375
|
+
const height = this.viewportHeight();
|
|
1376
|
+
const end = this.physical.length - this.offset;
|
|
1377
|
+
const visible = this.physical.slice(Math.max(0, end - height), Math.max(0, end));
|
|
1378
|
+
const padding = Array.from({ length: Math.max(0, height - visible.length) }, () => "");
|
|
1379
|
+
const bounds = this.orderedSelection();
|
|
1380
|
+
if (bounds !== void 0) {
|
|
1381
|
+
const first = Math.max(0, end - height);
|
|
1382
|
+
for (let index = 0; index < visible.length; index += 1) {
|
|
1383
|
+
const at = first + index;
|
|
1384
|
+
if (at < bounds.from.row || at > bounds.to.row) continue;
|
|
1385
|
+
const plain = (visible[index] ?? "").replaceAll(STYLES, "");
|
|
1386
|
+
const start = at === bounds.from.row ? columnIndex(plain, bounds.from.column) : 0;
|
|
1387
|
+
const stop = at === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
|
|
1388
|
+
let marked = plain.slice(start, stop);
|
|
1389
|
+
if (marked === "" && at > bounds.from.row && at < bounds.to.row) marked = " ";
|
|
1390
|
+
if (marked === "" && start >= stop) continue;
|
|
1391
|
+
visible[index] = `${plain.slice(0, start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(stop)}`;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
const viewport = [...visible, ...padding];
|
|
1395
|
+
if (this.offset > 0 && this.notice !== "" && viewport.length > 0) viewport[0] = truncate(this.notice, this.contentColumns());
|
|
1396
|
+
const frame = [...viewport, ...this.chrome];
|
|
1397
|
+
let out = SYNC_BEGIN + HIDE_CURSOR;
|
|
1398
|
+
frame.forEach((row, index) => {
|
|
1399
|
+
if (this.painted[index] === row) return;
|
|
1400
|
+
out += `\u001B[${index + 1};1H${CLEAR_LINE}${row}`;
|
|
1401
|
+
});
|
|
1402
|
+
for (let index = frame.length; index < this.painted.length; index += 1) out += `\u001B[${index + 1};1H${CLEAR_LINE}`;
|
|
1403
|
+
if (this.chromeFocus) {
|
|
1404
|
+
const row = frame.length - this.chrome.length + this.chromeCursor.row + 1;
|
|
1405
|
+
out += `\u001B[${row};${this.chromeCursor.column + 1}H${SHOW_CURSOR}`;
|
|
1406
|
+
}
|
|
1407
|
+
out += SYNC_END;
|
|
1408
|
+
this.host.write(out);
|
|
1409
|
+
this.painted = frame;
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
|
|
820
1413
|
//#endregion
|
|
821
1414
|
//#region src/console.ts
|
|
822
1415
|
/** Columns assumed when the output stream reports none (a pipe). */
|
|
@@ -829,14 +1422,8 @@ const FALLBACK_COLUMNS = 80;
|
|
|
829
1422
|
* this floor the lines overflow instead, which stays readable.
|
|
830
1423
|
*/
|
|
831
1424
|
const MIN_COLUMNS = 20;
|
|
832
|
-
/**
|
|
833
|
-
const
|
|
834
|
-
/** Erase from the cursor to the end of the screen. */
|
|
835
|
-
const CLEAR_BELOW = "\x1B[0J";
|
|
836
|
-
/** Hide the cursor while a region is redrawn, so it does not visibly jump. */
|
|
837
|
-
const HIDE_CURSOR = "\x1B[?25l";
|
|
838
|
-
/** Show the cursor again. */
|
|
839
|
-
const SHOW_CURSOR = "\x1B[?25h";
|
|
1425
|
+
/** Rows assumed when the output stream reports none. */
|
|
1426
|
+
const FALLBACK_ROWS = 24;
|
|
840
1427
|
/**
|
|
841
1428
|
* How long a held Escape waits for a successor before it counts as the key.
|
|
842
1429
|
*
|
|
@@ -857,21 +1444,22 @@ var TerminalConsole = class {
|
|
|
857
1444
|
earlyKeys = [];
|
|
858
1445
|
escapeTimer;
|
|
859
1446
|
ended = false;
|
|
860
|
-
/**
|
|
861
|
-
|
|
862
|
-
/** Where among those rows the cursor was left. */
|
|
863
|
-
regionCursor = {
|
|
864
|
-
row: 0,
|
|
865
|
-
column: 0
|
|
866
|
-
};
|
|
867
|
-
/** Whether the region currently holds input focus, which shows the cursor. */
|
|
868
|
-
regionFocus = true;
|
|
1447
|
+
/** The viewport this surface owns on a terminal; absent off one. */
|
|
1448
|
+
screen;
|
|
869
1449
|
constructor(input, output) {
|
|
870
1450
|
this.input = input;
|
|
871
1451
|
this.output = output;
|
|
872
1452
|
if (this.readsKeys) {
|
|
873
1453
|
input.setRawMode?.(true);
|
|
874
1454
|
this.output.write(ENABLE_PASTE_MARKERS);
|
|
1455
|
+
this.screen = new Screen({
|
|
1456
|
+
write: (data) => void this.output.write(data),
|
|
1457
|
+
columns: () => this.columns,
|
|
1458
|
+
rows: () => Math.max(2, this.output.rows ?? FALLBACK_ROWS)
|
|
1459
|
+
});
|
|
1460
|
+
this.output.on("resize", () => {
|
|
1461
|
+
this.screen?.resize();
|
|
1462
|
+
});
|
|
875
1463
|
input.on("data", (chunk) => {
|
|
876
1464
|
this.onBytes(chunk);
|
|
877
1465
|
});
|
|
@@ -897,6 +1485,15 @@ var TerminalConsole = class {
|
|
|
897
1485
|
get columns() {
|
|
898
1486
|
return Math.max(this.output.columns ?? FALLBACK_COLUMNS, MIN_COLUMNS);
|
|
899
1487
|
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Columns content may be laid out for: one less than the width, because the
|
|
1490
|
+
* viewport wraps at that boundary. Markdown layout MUST use this figure — a
|
|
1491
|
+
* table laid out one column wider is refolded by the viewport, and its rows
|
|
1492
|
+
* shear apart.
|
|
1493
|
+
*/
|
|
1494
|
+
get contentColumns() {
|
|
1495
|
+
return Math.max(1, this.columns - 1);
|
|
1496
|
+
}
|
|
900
1497
|
/** Whether the output stream is a terminal. */
|
|
901
1498
|
get isTty() {
|
|
902
1499
|
return this.output.isTTY === true;
|
|
@@ -926,19 +1523,59 @@ var TerminalConsole = class {
|
|
|
926
1523
|
return () => void this.output.off("resize", handler);
|
|
927
1524
|
}
|
|
928
1525
|
/**
|
|
929
|
-
*
|
|
1526
|
+
* Take the viewport: the transcript and the prompt live on their own screen
|
|
1527
|
+
* from here, and the terminal keeps the buffer the person had.
|
|
1528
|
+
*/
|
|
1529
|
+
enterScreen() {
|
|
1530
|
+
this.screen?.enter();
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Give the terminal back. Idempotent: every exit path calls it.
|
|
1534
|
+
*/
|
|
1535
|
+
leaveScreen() {
|
|
1536
|
+
this.screen?.leave();
|
|
1537
|
+
}
|
|
1538
|
+
/** Whether this surface currently holds its own screen. */
|
|
1539
|
+
get owningScreen() {
|
|
1540
|
+
return this.screen?.entered === true;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Scroll the transcript inside the viewport.
|
|
1544
|
+
* @param delta - rows to move; negative goes back into history.
|
|
1545
|
+
*/
|
|
1546
|
+
scrollBy(delta) {
|
|
1547
|
+
this.screen?.scrollBy(delta);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Set the notice shown while the transcript is scrolled back.
|
|
1551
|
+
* @param text - the styled line, or the empty string for none.
|
|
1552
|
+
*/
|
|
1553
|
+
setScrollNotice(text) {
|
|
1554
|
+
this.screen?.setScrollNotice(text);
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Scroll the transcript by a whole viewport.
|
|
1558
|
+
* @param direction - -1 for back into history, 1 towards the tail.
|
|
1559
|
+
*/
|
|
1560
|
+
scrollPage(direction) {
|
|
1561
|
+
this.screen?.scrollPage(direction);
|
|
1562
|
+
}
|
|
1563
|
+
/** Return to the tail of the transcript. */
|
|
1564
|
+
scrollToBottom() {
|
|
1565
|
+
this.screen?.scrollToBottom();
|
|
1566
|
+
}
|
|
1567
|
+
/** Physical rows currently scrolled out of view; zero means at the tail. */
|
|
1568
|
+
get scrolledBy() {
|
|
1569
|
+
return this.screen?.scrolledBy ?? 0;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Clear the transcript this session accumulated.
|
|
930
1573
|
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1574
|
+
* On its own screen there is no shell scrollback to preserve, so this empties
|
|
1575
|
+
* the buffer the viewport shows rather than wiping a shared terminal.
|
|
933
1576
|
*/
|
|
934
1577
|
clearScreen() {
|
|
935
|
-
|
|
936
|
-
this.output.write("\x1B[2J\x1B[H");
|
|
937
|
-
this.regionRows = [];
|
|
938
|
-
this.regionCursor = {
|
|
939
|
-
row: 0,
|
|
940
|
-
column: 0
|
|
941
|
-
};
|
|
1578
|
+
this.screen?.clearTranscript();
|
|
942
1579
|
}
|
|
943
1580
|
/**
|
|
944
1581
|
* Route decoded keys to a handler.
|
|
@@ -1003,77 +1640,133 @@ var TerminalConsole = class {
|
|
|
1003
1640
|
}
|
|
1004
1641
|
}
|
|
1005
1642
|
/**
|
|
1006
|
-
*
|
|
1643
|
+
* Keep one finished transcript line.
|
|
1007
1644
|
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1645
|
+
* On its own screen the line goes into the viewport's scrollback, which is
|
|
1646
|
+
* what lets the transcript scroll under a prompt that does not move. Off one
|
|
1647
|
+
* it is written straight out, because a pipe's reader wants exactly that.
|
|
1010
1648
|
* @param line - the line, without its terminator.
|
|
1011
1649
|
*/
|
|
1012
1650
|
write(line) {
|
|
1013
|
-
if (this.
|
|
1014
|
-
this.
|
|
1651
|
+
if (this.screen !== void 0) {
|
|
1652
|
+
this.screen.append([line]);
|
|
1015
1653
|
return;
|
|
1016
1654
|
}
|
|
1017
|
-
const rows = this.regionRows;
|
|
1018
|
-
const cursor = this.regionCursor;
|
|
1019
|
-
this.eraseRegion();
|
|
1020
1655
|
this.output.write(`${line}\n`);
|
|
1021
|
-
this.drawRegion(rows, cursor, this.regionFocus);
|
|
1022
1656
|
}
|
|
1023
1657
|
/**
|
|
1024
|
-
* Replace the
|
|
1658
|
+
* Replace the rows pinned below the transcript.
|
|
1025
1659
|
*
|
|
1026
1660
|
* This is the whole live area: an input box, a completion menu, a working
|
|
1027
|
-
* indicator.
|
|
1028
|
-
*
|
|
1029
|
-
* terminal the call is ignored — a redirected transcript must not collect
|
|
1030
|
-
* frames of a box nobody can see.
|
|
1661
|
+
* indicator, the status row. Off a terminal the call is ignored — a
|
|
1662
|
+
* redirected transcript must not collect frames of a box nobody can see.
|
|
1031
1663
|
* @param rows - the rows to display, top to bottom.
|
|
1032
1664
|
* @param cursor - where to leave the terminal cursor among them.
|
|
1033
|
-
* @param focus - whether the
|
|
1034
|
-
*
|
|
1035
|
-
* a streaming line) reads as content colliding with it.
|
|
1665
|
+
* @param focus - whether the rows hold input focus, which is when the cursor
|
|
1666
|
+
* shows. Parked anywhere else it reads as content colliding with it.
|
|
1036
1667
|
*/
|
|
1037
1668
|
setRegion(rows, cursor, focus = true) {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if (
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1669
|
+
this.screen?.setChrome(rows, cursor, focus);
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Keep one collapsible block: summary now, full form behind the toggle.
|
|
1673
|
+
*
|
|
1674
|
+
* Off a terminal only the summary is written — a pipe has no keys to toggle
|
|
1675
|
+
* with, and scripts want the digest.
|
|
1676
|
+
* @param summary - the collapsed lines.
|
|
1677
|
+
* @param full - the expanded lines.
|
|
1678
|
+
*/
|
|
1679
|
+
appendFold(summary, full) {
|
|
1680
|
+
if (this.screen !== void 0) {
|
|
1681
|
+
this.screen.appendFold(summary, full);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
for (const line of summary) this.output.write(`${line}\n`);
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Swap every collapsible block between summary and full form.
|
|
1688
|
+
* @returns false when there is nothing to toggle.
|
|
1689
|
+
*/
|
|
1690
|
+
/**
|
|
1691
|
+
* Anchor a mouse selection at a terminal position.
|
|
1692
|
+
* @param row - terminal row, 1-based.
|
|
1693
|
+
* @param column - terminal column, 1-based.
|
|
1694
|
+
*/
|
|
1695
|
+
mouseDown(row, column) {
|
|
1696
|
+
this.screen?.mouseDown(row, column);
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Extend the mouse selection to a terminal position.
|
|
1700
|
+
* @param row - terminal row, 1-based.
|
|
1701
|
+
* @param column - terminal column, 1-based.
|
|
1702
|
+
*/
|
|
1703
|
+
mouseDrag(row, column) {
|
|
1704
|
+
this.screen?.mouseDrag(row, column);
|
|
1056
1705
|
}
|
|
1057
|
-
/**
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1706
|
+
/**
|
|
1707
|
+
* Finish the mouse selection.
|
|
1708
|
+
* @returns the selected text, or undefined for a bare click.
|
|
1709
|
+
*/
|
|
1710
|
+
mouseUp() {
|
|
1711
|
+
return this.screen?.mouseUp();
|
|
1062
1712
|
}
|
|
1063
1713
|
/**
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1714
|
+
* Put text on the clipboard.
|
|
1715
|
+
*
|
|
1716
|
+
* Two channels, because neither is universal: OSC 52 reaches through SSH and
|
|
1717
|
+
* works wherever the terminal permits it, and the platform helper covers the
|
|
1718
|
+
* terminals that refuse the escape. `CODSH_CLIPBOARD` narrows it to `osc52`,
|
|
1719
|
+
* `system`, or `off` — tests use `osc52` so a run never touches the real
|
|
1720
|
+
* clipboard.
|
|
1721
|
+
* @param text - the plain text to copy.
|
|
1722
|
+
* @returns whether a copy was attempted at all.
|
|
1068
1723
|
*/
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1724
|
+
copyText(text) {
|
|
1725
|
+
const mode = process.env["CODSH_CLIPBOARD"] ?? "both";
|
|
1726
|
+
if (mode === "off" || text === "") return false;
|
|
1727
|
+
if (mode !== "system") this.output.write(`\u001B]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
|
|
1728
|
+
if (mode !== "osc52") {
|
|
1729
|
+
const command = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : process.env["WAYLAND_DISPLAY"] === void 0 ? [
|
|
1730
|
+
"xclip",
|
|
1731
|
+
"-selection",
|
|
1732
|
+
"clipboard"
|
|
1733
|
+
] : ["wl-copy"];
|
|
1734
|
+
try {
|
|
1735
|
+
const child = spawn(command[0] ?? "", command.slice(1), { stdio: [
|
|
1736
|
+
"pipe",
|
|
1737
|
+
"ignore",
|
|
1738
|
+
"ignore"
|
|
1739
|
+
] });
|
|
1740
|
+
child.on("error", () => {});
|
|
1741
|
+
child.stdin.end(text);
|
|
1742
|
+
} catch {}
|
|
1743
|
+
}
|
|
1744
|
+
return true;
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Make the last `count` written lines a collapsible block after the fact.
|
|
1748
|
+
*
|
|
1749
|
+
* Screen-only on purpose: a pipe already carries the full text, and a
|
|
1750
|
+
* summary would subtract from it.
|
|
1751
|
+
* @param count - how many trailing lines the block owns.
|
|
1752
|
+
* @param summary - the collapsed lines, already styled.
|
|
1753
|
+
*/
|
|
1754
|
+
foldRecent(count, summary) {
|
|
1755
|
+
this.screen?.foldBack(count, summary);
|
|
1756
|
+
}
|
|
1757
|
+
toggleFolds() {
|
|
1758
|
+
return this.screen?.toggleFolds() ?? false;
|
|
1759
|
+
}
|
|
1760
|
+
/** Return every block to its summary. */
|
|
1761
|
+
collapseFolds() {
|
|
1762
|
+
this.screen?.collapseFolds();
|
|
1763
|
+
}
|
|
1764
|
+
/** Take the pinned rows down, leaving the transcript alone. */
|
|
1765
|
+
clearRegion() {
|
|
1766
|
+
this.screen?.setChrome([], {
|
|
1767
|
+
row: 0,
|
|
1768
|
+
column: 0
|
|
1769
|
+
}, false);
|
|
1077
1770
|
}
|
|
1078
1771
|
/** Ring the terminal bell; a pipe gets nothing to beep with. */
|
|
1079
1772
|
bell() {
|
|
@@ -1120,10 +1813,9 @@ var TerminalConsole = class {
|
|
|
1120
1813
|
}
|
|
1121
1814
|
/** Restore the terminal and stop reading. */
|
|
1122
1815
|
close() {
|
|
1123
|
-
this.clearRegion();
|
|
1124
1816
|
if (this.escapeTimer !== void 0) clearTimeout(this.escapeTimer);
|
|
1125
1817
|
if (this.readsKeys) {
|
|
1126
|
-
this.
|
|
1818
|
+
this.screen?.leave();
|
|
1127
1819
|
this.output.write(DISABLE_PASTE_MARKERS);
|
|
1128
1820
|
this.input.setRawMode?.(false);
|
|
1129
1821
|
this.input.pause();
|
|
@@ -1131,6 +1823,17 @@ var TerminalConsole = class {
|
|
|
1131
1823
|
this.rl?.close();
|
|
1132
1824
|
this.end();
|
|
1133
1825
|
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Write one line to the terminal the person keeps, not to our viewport.
|
|
1828
|
+
*
|
|
1829
|
+
* The exit summary is what needs this: the session's own screen disappears
|
|
1830
|
+
* with it, so the few facts worth keeping — the session id, what it cost —
|
|
1831
|
+
* have to land in the buffer that survives.
|
|
1832
|
+
* @param line - the line, without its terminator.
|
|
1833
|
+
*/
|
|
1834
|
+
writeAfterScreen(line) {
|
|
1835
|
+
this.output.write(`${line}\n`);
|
|
1836
|
+
}
|
|
1134
1837
|
};
|
|
1135
1838
|
|
|
1136
1839
|
//#endregion
|
|
@@ -1593,19 +2296,22 @@ function inputBox(view, theme, columns, options = {}) {
|
|
|
1593
2296
|
});
|
|
1594
2297
|
rows.push(accent(`╰${rule}╯`));
|
|
1595
2298
|
if (view.candidates.length > 0) {
|
|
1596
|
-
const
|
|
2299
|
+
const first = Math.min(Math.max(0, view.selected - MENU_LIMIT + 1), Math.max(0, view.candidates.length - MENU_LIMIT));
|
|
2300
|
+
const menu = view.candidates.slice(first, first + MENU_LIMIT);
|
|
1597
2301
|
const width = Math.max(...menu.map((candidate) => displayWidth(candidate.value)));
|
|
2302
|
+
if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
|
|
1598
2303
|
menu.forEach((candidate, index) => {
|
|
1599
|
-
const chosen = index === view.selected;
|
|
2304
|
+
const chosen = first + index === view.selected;
|
|
1600
2305
|
const matched = view.token !== "" && candidate.value.startsWith(view.token) ? view.token.length : 0;
|
|
1601
|
-
const head =
|
|
2306
|
+
const head = candidate.value.slice(0, matched);
|
|
1602
2307
|
const tail = candidate.value.slice(matched);
|
|
1603
|
-
const label =
|
|
2308
|
+
const label = chosen ? theme.bold(theme.tool(candidate.value)) : `${theme.tool(head)}${tail}`;
|
|
1604
2309
|
const pad = " ".repeat(Math.max(0, width - displayWidth(candidate.value)));
|
|
1605
2310
|
const detail = candidate.detail === "" ? "" : ` ${candidate.detail}`;
|
|
1606
2311
|
rows.push(truncate(`${chosen ? theme.user("❯") : " "} ${label}${pad}${theme.dim(detail)}`, columns));
|
|
1607
2312
|
});
|
|
1608
|
-
|
|
2313
|
+
const below = view.candidates.length - first - menu.length;
|
|
2314
|
+
if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
|
|
1609
2315
|
} else if (options.hint !== void 0) rows.push(theme.dim(truncate(options.hint, columns)));
|
|
1610
2316
|
const inCursorRow = visual[cursorAt];
|
|
1611
2317
|
const before = inCursorRow === void 0 ? "" : Array.from(view.lines[view.row] ?? "").slice(inCursorRow.start, view.column).join("");
|
|
@@ -1619,6 +2325,8 @@ function inputBox(view, theme, columns, options = {}) {
|
|
|
1619
2325
|
//#endregion
|
|
1620
2326
|
//#region src/selector.ts
|
|
1621
2327
|
/** An in-progress selection. */
|
|
2328
|
+
/** Option rows shown at once; the window follows the marked row. */
|
|
2329
|
+
const VISIBLE_ROWS = 10;
|
|
1622
2330
|
var Selector = class {
|
|
1623
2331
|
selected = 0;
|
|
1624
2332
|
checked = /* @__PURE__ */ new Set();
|
|
@@ -1714,10 +2422,16 @@ var Selector = class {
|
|
|
1714
2422
|
*/
|
|
1715
2423
|
view(theme, columns) {
|
|
1716
2424
|
const rows = [theme.bold(truncate(this.spec.title, columns))];
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
});
|
|
1720
|
-
|
|
2425
|
+
const total = this.count;
|
|
2426
|
+
const first = Math.min(Math.max(0, this.selected - VISIBLE_ROWS + 1), Math.max(0, total - VISIBLE_ROWS));
|
|
2427
|
+
if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
|
|
2428
|
+
for (let index = first; index < Math.min(total, first + VISIBLE_ROWS); index += 1) {
|
|
2429
|
+
const option = this.spec.options[index];
|
|
2430
|
+
if (option !== void 0) rows.push(this.row(index, this.label(option, theme), option.detail, theme, columns));
|
|
2431
|
+
else if (this.spec.custom !== void 0) rows.push(this.row(index, theme.dim(this.spec.custom), void 0, theme, columns));
|
|
2432
|
+
}
|
|
2433
|
+
const below = total - first - VISIBLE_ROWS;
|
|
2434
|
+
if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
|
|
1721
2435
|
const how = this.spec.multi === true ? "Space toggles · Enter confirms · Esc cancels" : "↑↓ move · Enter accepts · Esc cancels";
|
|
1722
2436
|
rows.push(theme.dim(truncate(` ${how}`, columns)));
|
|
1723
2437
|
return rows;
|
|
@@ -1747,12 +2461,14 @@ var Selector = class {
|
|
|
1747
2461
|
const box = this.spec.multi === true && !this.isCustom(index) ? this.checked.has(index) ? theme.success("◉ ") : theme.dim("○ ") : "";
|
|
1748
2462
|
const number = theme.dim(`${index + 1}.`);
|
|
1749
2463
|
const trail = detail === void 0 || detail === "" ? "" : theme.dim(` ${detail}`);
|
|
1750
|
-
return truncate(`${marker} ${number} ${box}${marked ? theme.bold(label) : label}${trail}`, columns);
|
|
2464
|
+
return truncate(`${marker} ${number} ${box}${marked ? theme.bold(theme.tool(label)) : label}${trail}`, columns);
|
|
1751
2465
|
}
|
|
1752
2466
|
};
|
|
1753
2467
|
|
|
1754
2468
|
//#endregion
|
|
1755
2469
|
//#region src/prompt.ts
|
|
2470
|
+
/** How long a flash notice holds the hint row. */
|
|
2471
|
+
const FLASH_MS = 1500;
|
|
1756
2472
|
/** Drives the input box and answers reads and selections. */
|
|
1757
2473
|
var Prompt = class {
|
|
1758
2474
|
editor;
|
|
@@ -1767,6 +2483,9 @@ var Prompt = class {
|
|
|
1767
2483
|
queued = [];
|
|
1768
2484
|
/** The working indicator shown under the box. */
|
|
1769
2485
|
hint;
|
|
2486
|
+
/** A short-lived notice that borrows the hint row, e.g. the copy toast. */
|
|
2487
|
+
flash;
|
|
2488
|
+
flashTimer;
|
|
1770
2489
|
/** The always-current session facts shown as the region's last row. */
|
|
1771
2490
|
status;
|
|
1772
2491
|
/** The assistant line still arriving, shown above the box. */
|
|
@@ -1775,6 +2494,9 @@ var Prompt = class {
|
|
|
1775
2494
|
accent;
|
|
1776
2495
|
/** Whether a read is outstanding, which decides where a submission goes. */
|
|
1777
2496
|
reading = false;
|
|
2497
|
+
/** Wheel rows accumulated this tick, painted once — scrolling per event janks. */
|
|
2498
|
+
pendingScroll = 0;
|
|
2499
|
+
scrollFlushQueued = false;
|
|
1778
2500
|
/**
|
|
1779
2501
|
* Whether the interactive session is running, which is when the box is worth
|
|
1780
2502
|
* drawing. The box stays up while the agent works — typing ahead must be
|
|
@@ -1833,10 +2555,30 @@ var Prompt = class {
|
|
|
1833
2555
|
* @param text - the text, or undefined to drop the row.
|
|
1834
2556
|
*/
|
|
1835
2557
|
setHint(text) {
|
|
2558
|
+
if (text === this.hint) return;
|
|
1836
2559
|
this.hint = text;
|
|
1837
2560
|
this.render();
|
|
1838
2561
|
}
|
|
1839
2562
|
/**
|
|
2563
|
+
* Show a notice on the hint row briefly, then give the row back.
|
|
2564
|
+
*
|
|
2565
|
+
* The hint row belongs to the working indicator, which repaints itself
|
|
2566
|
+
* continuously — a notice written through setHint would last one tick. The
|
|
2567
|
+
* flash outranks the hint until its moment passes.
|
|
2568
|
+
* @param text - the styled notice.
|
|
2569
|
+
*/
|
|
2570
|
+
setFlash(text) {
|
|
2571
|
+
this.flash = text;
|
|
2572
|
+
if (this.flashTimer !== void 0) clearTimeout(this.flashTimer);
|
|
2573
|
+
this.flashTimer = setTimeout(() => {
|
|
2574
|
+
this.flash = void 0;
|
|
2575
|
+
this.flashTimer = void 0;
|
|
2576
|
+
this.render();
|
|
2577
|
+
}, FLASH_MS);
|
|
2578
|
+
this.flashTimer.unref();
|
|
2579
|
+
this.render();
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
1840
2582
|
* Set the status row, the region's always-current last line.
|
|
1841
2583
|
* @param text - the styled row, or undefined to drop it.
|
|
1842
2584
|
*/
|
|
@@ -1957,6 +2699,48 @@ var Prompt = class {
|
|
|
1957
2699
|
this.handlers.expandOutput?.();
|
|
1958
2700
|
return;
|
|
1959
2701
|
}
|
|
2702
|
+
if (key.kind === "page") {
|
|
2703
|
+
this.console.scrollPage(key.direction);
|
|
2704
|
+
this.render();
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
if (key.kind === "scroll") {
|
|
2708
|
+
this.pendingScroll += key.lines;
|
|
2709
|
+
if (!this.scrollFlushQueued) {
|
|
2710
|
+
this.scrollFlushQueued = true;
|
|
2711
|
+
queueMicrotask(() => {
|
|
2712
|
+
this.scrollFlushQueued = false;
|
|
2713
|
+
const delta = this.pendingScroll;
|
|
2714
|
+
this.pendingScroll = 0;
|
|
2715
|
+
if (delta !== 0) {
|
|
2716
|
+
this.console.scrollBy(delta);
|
|
2717
|
+
this.render();
|
|
2718
|
+
}
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
if (key.kind === "scroll-end") {
|
|
2724
|
+
this.console.scrollToBottom();
|
|
2725
|
+
this.render();
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
if (key.kind === "mouse-down") {
|
|
2729
|
+
this.console.mouseDown(key.row, key.column);
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
if (key.kind === "mouse-drag") {
|
|
2733
|
+
this.console.mouseDrag(key.row, key.column);
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
if (key.kind === "mouse-up") {
|
|
2737
|
+
const text = this.console.mouseUp();
|
|
2738
|
+
if (text !== void 0 && this.console.copyText(text)) {
|
|
2739
|
+
const rows = text.split("\n").length;
|
|
2740
|
+
this.setFlash(this.theme.dim(rows > 1 ? ` ✓ copied ${rows} lines` : " ✓ copied"));
|
|
2741
|
+
}
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
1960
2744
|
const selecting = this.select_;
|
|
1961
2745
|
if (selecting !== void 0) {
|
|
1962
2746
|
const step = selecting.selector.handle(key);
|
|
@@ -1978,6 +2762,7 @@ var Prompt = class {
|
|
|
1978
2762
|
}
|
|
1979
2763
|
waiting.dispose();
|
|
1980
2764
|
waiting.resolve(action.text);
|
|
2765
|
+
this.console.scrollToBottom();
|
|
1981
2766
|
break;
|
|
1982
2767
|
}
|
|
1983
2768
|
case "escape":
|
|
@@ -2023,7 +2808,9 @@ var Prompt = class {
|
|
|
2023
2808
|
const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
|
|
2024
2809
|
rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
|
|
2025
2810
|
}
|
|
2026
|
-
|
|
2811
|
+
this.console.setScrollNotice(this.console.scrolledBy > 0 ? this.theme.dim(truncate(` ↑ ${this.console.scrolledBy} rows above · PgDn returns to the latest`, columns)) : "");
|
|
2812
|
+
const notice = this.flash ?? this.hint;
|
|
2813
|
+
if (notice !== void 0) rows.push(notice);
|
|
2027
2814
|
if (this.status !== void 0) rows.push(this.status);
|
|
2028
2815
|
if (rows.length === 0) {
|
|
2029
2816
|
this.console.clearRegion();
|
|
@@ -2213,18 +3000,19 @@ function highlightCode(line, syntax) {
|
|
|
2213
3000
|
* @returns the styled line.
|
|
2214
3001
|
*/
|
|
2215
3002
|
function renderInline(text, theme) {
|
|
3003
|
+
const emphasized = (inner) => inner.split(/(`[^`]+`)/u).map((segment) => segment.startsWith("`") && segment.endsWith("`") && segment.length > 1 ? theme.bold(theme.tool(segment.slice(1, -1))) : segment === "" ? "" : theme.bold(segment)).join("");
|
|
2216
3004
|
return text.replace(INLINE, (match, code, starBold, underBold, link, starEm, underEm) => {
|
|
2217
3005
|
if (code !== void 0) return theme.tool(code.slice(1, -1));
|
|
2218
|
-
if (starBold !== void 0) return
|
|
2219
|
-
if (underBold !== void 0) return
|
|
3006
|
+
if (starBold !== void 0) return emphasized(starBold.slice(2, -2));
|
|
3007
|
+
if (underBold !== void 0) return emphasized(underBold.slice(2, -2));
|
|
2220
3008
|
if (link !== void 0) {
|
|
2221
3009
|
const parts = /^\[([^\]]*)\]\(([^)]*)\)$/.exec(link);
|
|
2222
3010
|
if (parts === null) return match;
|
|
2223
3011
|
const [, label, target] = parts;
|
|
2224
3012
|
return `${theme.bold(label ?? "")} ${theme.dim(`(${target ?? ""})`)}`;
|
|
2225
3013
|
}
|
|
2226
|
-
if (starEm !== void 0) return
|
|
2227
|
-
if (underEm !== void 0) return
|
|
3014
|
+
if (starEm !== void 0) return emphasized(starEm.slice(1, -1));
|
|
3015
|
+
if (underEm !== void 0) return emphasized(underEm.slice(1, -1));
|
|
2228
3016
|
return match;
|
|
2229
3017
|
});
|
|
2230
3018
|
}
|
|
@@ -2263,34 +3051,70 @@ function createMarkdownStream(theme, columns) {
|
|
|
2263
3051
|
};
|
|
2264
3052
|
}
|
|
2265
3053
|
/**
|
|
2266
|
-
* Lay out one buffered table
|
|
3054
|
+
* Lay out one buffered table as a bordered grid.
|
|
2267
3055
|
*
|
|
2268
|
-
*
|
|
2269
|
-
*
|
|
2270
|
-
*
|
|
3056
|
+
* Outer borders, one space of padding inside every cell, and a rule between
|
|
3057
|
+
* every pair of rows: the padding keeps text off the rules, and the row rules
|
|
3058
|
+
* are unconditional because a wrapped cell's continuation must never blur into
|
|
3059
|
+
* the record below it. Cells render their inline constructs; every width is of
|
|
3060
|
+
* the VISIBLE text. A table wider than the terminal wraps inside its cells,
|
|
3061
|
+
* shrinking the widest columns toward an even share; only a terminal too
|
|
3062
|
+
* narrow to hold the columns at all falls back to the source lines.
|
|
2271
3063
|
* @param rows - the raw `|`-delimited lines, in order.
|
|
2272
|
-
* @param theme - styling for the
|
|
2273
|
-
* @param budget - display columns available
|
|
3064
|
+
* @param theme - styling for the borders and the header.
|
|
3065
|
+
* @param budget - display columns available.
|
|
2274
3066
|
* @returns the rendered table, or the source lines styled as prose.
|
|
2275
3067
|
*/
|
|
2276
3068
|
function layoutTable(rows, theme, budget) {
|
|
2277
3069
|
const asSource = () => rows.map((row) => renderInline(row, theme));
|
|
2278
|
-
const
|
|
2279
|
-
const delimiter =
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
const
|
|
2284
|
-
|
|
2285
|
-
const
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
3070
|
+
const raw = rows.map((row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()));
|
|
3071
|
+
const delimiter = raw[1];
|
|
3072
|
+
const marks = (delimiter ?? []).filter((cell) => cell !== "");
|
|
3073
|
+
if (raw.length < 2 || delimiter === void 0 || marks.length === 0 || !marks.every((cell) => TABLE_DELIMITER.test(cell))) return asSource();
|
|
3074
|
+
let count = Math.max(1, (raw[0] ?? []).length);
|
|
3075
|
+
const content = [raw[0] ?? [], ...raw.slice(2)];
|
|
3076
|
+
while (count > 1 && content.every((row) => (row[count - 1] ?? "") === "")) count -= 1;
|
|
3077
|
+
const styled = content.map((row) => row.slice(0, count).map((cell) => renderInline(cell, theme)));
|
|
3078
|
+
const visible = (cell) => displayWidth(cell.replaceAll(/\u001B\[[0-9;]*m/gu, ""));
|
|
3079
|
+
const natural = Array.from({ length: count }, (_, column) => Math.max(1, ...styled.map((row) => visible(row[column] ?? ""))));
|
|
3080
|
+
const framing = 3 * count + 1;
|
|
3081
|
+
let widths = [...natural];
|
|
3082
|
+
if (natural.reduce((sum, width) => sum + width, 0) + framing > budget) {
|
|
3083
|
+
const available = budget - framing;
|
|
3084
|
+
if (available < count * 2) return asSource();
|
|
3085
|
+
const fair = Math.floor(available / count);
|
|
3086
|
+
const kept = natural.map((width) => Math.min(width, fair));
|
|
3087
|
+
let spare = available - kept.reduce((sum, width) => sum + width, 0);
|
|
3088
|
+
widths = natural.map((width, column) => {
|
|
3089
|
+
const base = kept[column] ?? 0;
|
|
3090
|
+
if (width <= base) return width;
|
|
3091
|
+
const extra = Math.min(width - base, spare);
|
|
3092
|
+
spare -= extra;
|
|
3093
|
+
return base + extra;
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
const edge = (left, junction, right) => theme.dim(`${left}${widths.map((width) => "─".repeat(width + 2)).join(junction)}${right}`);
|
|
3097
|
+
const bar = theme.dim("│");
|
|
3098
|
+
const line = (row, style) => {
|
|
3099
|
+
const wrapped = Array.from({ length: count }, (_, column) => wrapStyled(row[column] ?? "", widths[column] ?? 1));
|
|
3100
|
+
const height = Math.max(...wrapped.map((cell) => cell.length));
|
|
3101
|
+
return Array.from({ length: height }, (_, index) => {
|
|
3102
|
+
return `${bar}${wrapped.map((cell, column) => {
|
|
3103
|
+
const piece = cell[index] ?? "";
|
|
3104
|
+
const alignRight = /^:?-+:$/.test(delimiter[column] ?? "") && !/^:-+:$/.test(delimiter[column] ?? "");
|
|
3105
|
+
const pad = " ".repeat(Math.max(0, (widths[column] ?? 0) - visible(piece)));
|
|
3106
|
+
return alignRight ? ` ${pad}${style(piece)} ` : ` ${style(piece)}${pad} `;
|
|
3107
|
+
}).join(bar)}${bar}`;
|
|
3108
|
+
});
|
|
3109
|
+
};
|
|
3110
|
+
const separator = edge("├", "┼", "┤");
|
|
3111
|
+
const body = styled.slice(1).flatMap((row, index) => index > 0 ? [separator, ...line(row, (text) => text)] : line(row, (text) => text));
|
|
2290
3112
|
return [
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
3113
|
+
edge("╭", "┬", "╮"),
|
|
3114
|
+
...line(styled[0] ?? [], (text) => theme.bold(text)),
|
|
3115
|
+
separator,
|
|
3116
|
+
...body,
|
|
3117
|
+
edge("╰", "┴", "╯")
|
|
2294
3118
|
];
|
|
2295
3119
|
}
|
|
2296
3120
|
/**
|
|
@@ -2545,11 +3369,14 @@ var Spinner = class {
|
|
|
2545
3369
|
/**
|
|
2546
3370
|
* Start the indicator, or do nothing when it is already running or the
|
|
2547
3371
|
* surface has no cursor to rewrite.
|
|
3372
|
+
*
|
|
3373
|
+
* The clock keeps running across a pause: the elapsed figure is the whole
|
|
3374
|
+
* turn's, and an indicator that restarted from zero at every tool call would
|
|
3375
|
+
* report each step instead.
|
|
2548
3376
|
*/
|
|
2549
3377
|
start() {
|
|
2550
3378
|
if (this.timer !== void 0 || !this.surface.isTty) return;
|
|
2551
|
-
this.startedAt = this.now();
|
|
2552
|
-
this.frame = 0;
|
|
3379
|
+
if (this.startedAt === 0) this.startedAt = this.now();
|
|
2553
3380
|
this.draw();
|
|
2554
3381
|
this.timer = setInterval(() => {
|
|
2555
3382
|
this.frame += 1;
|
|
@@ -2557,13 +3384,19 @@ var Spinner = class {
|
|
|
2557
3384
|
}, TICK_MS);
|
|
2558
3385
|
this.timer.unref();
|
|
2559
3386
|
}
|
|
2560
|
-
/**
|
|
2561
|
-
|
|
3387
|
+
/** Hide the indicator without forgetting when the turn began. */
|
|
3388
|
+
pause() {
|
|
2562
3389
|
if (this.timer === void 0) return;
|
|
2563
3390
|
clearInterval(this.timer);
|
|
2564
3391
|
this.timer = void 0;
|
|
2565
3392
|
this.surface.setLive(void 0);
|
|
2566
3393
|
}
|
|
3394
|
+
/** Stop the indicator: the turn is over, and the next one starts at zero. */
|
|
3395
|
+
stop() {
|
|
3396
|
+
this.pause();
|
|
3397
|
+
this.startedAt = 0;
|
|
3398
|
+
this.frame = 0;
|
|
3399
|
+
}
|
|
2567
3400
|
/** Paint the current frame. */
|
|
2568
3401
|
draw() {
|
|
2569
3402
|
this.surface.setLive(spinnerText(this.frame, this.now() - this.startedAt, this.label, this.theme));
|
|
@@ -2646,10 +3479,15 @@ var TextStream = class {
|
|
|
2646
3479
|
//#region src/transcript.ts
|
|
2647
3480
|
/** Context lines kept on each side of a rendered hunk. */
|
|
2648
3481
|
const DIFF_CONTEXT = 3;
|
|
2649
|
-
/** Diff body lines printed for one file before the card
|
|
2650
|
-
const MAX_DIFF_LINES =
|
|
2651
|
-
/**
|
|
2652
|
-
|
|
3482
|
+
/** Diff body lines printed for one file before the card collapses the rest. */
|
|
3483
|
+
const MAX_DIFF_LINES = 24;
|
|
3484
|
+
/**
|
|
3485
|
+
* Result body lines printed for one completed call before the card collapses.
|
|
3486
|
+
*
|
|
3487
|
+
* Small on purpose: a long output in the transcript is skimmed, not read, and
|
|
3488
|
+
* the collapsed remainder is one Ctrl+O away in full.
|
|
3489
|
+
*/
|
|
3490
|
+
const MAX_RESULT_LINES = 5;
|
|
2653
3491
|
/**
|
|
2654
3492
|
* Concatenate a message's text blocks, dropping reasoning and non-text content.
|
|
2655
3493
|
* @param content - the message content blocks.
|
|
@@ -2694,13 +3532,13 @@ function diffBody(diff, theme) {
|
|
|
2694
3532
|
*/
|
|
2695
3533
|
function cap(lines, limit, theme) {
|
|
2696
3534
|
if (lines.length <= limit) return lines;
|
|
2697
|
-
return [...lines.slice(0, limit), theme.dim(` …
|
|
3535
|
+
return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (Ctrl+O expands)`)];
|
|
2698
3536
|
}
|
|
2699
3537
|
/** Renders one session's appended events as terminal lines. */
|
|
2700
3538
|
var Transcript = class {
|
|
2701
3539
|
calls = /* @__PURE__ */ new Map();
|
|
2702
|
-
/** The
|
|
2703
|
-
|
|
3540
|
+
/** The full form of the event just rendered, when its body was collapsed. */
|
|
3541
|
+
fold;
|
|
2704
3542
|
constructor(options, presenters) {
|
|
2705
3543
|
this.options = options;
|
|
2706
3544
|
this.presenters = presenters;
|
|
@@ -2839,12 +3677,14 @@ var Transcript = class {
|
|
|
2839
3677
|
const marker = failed ? theme.error("✗") : theme.success("●");
|
|
2840
3678
|
if (pending === void 0) {
|
|
2841
3679
|
const raw = this.resultText(block.content).split("\n");
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
3680
|
+
const head$1 = `${marker} ${theme.dim("(result)")}`;
|
|
3681
|
+
if (raw.length > MAX_RESULT_LINES) this.fold = [
|
|
3682
|
+
head$1,
|
|
3683
|
+
...raw,
|
|
3684
|
+
""
|
|
3685
|
+
];
|
|
2846
3686
|
return [
|
|
2847
|
-
|
|
3687
|
+
head$1,
|
|
2848
3688
|
...cap(raw, MAX_RESULT_LINES, theme),
|
|
2849
3689
|
""
|
|
2850
3690
|
];
|
|
@@ -2852,32 +3692,30 @@ var Transcript = class {
|
|
|
2852
3692
|
const view = this.safeResult(pending, block.content, failed, meta);
|
|
2853
3693
|
const title = view?.title === void 0 ? pending.title : this.relativizeIn(view.title);
|
|
2854
3694
|
const { suffix, body, full } = this.outcome(view, block);
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
3695
|
+
const head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [];
|
|
3696
|
+
if (full !== void 0) this.fold = [
|
|
3697
|
+
...head,
|
|
3698
|
+
...full,
|
|
3699
|
+
""
|
|
3700
|
+
];
|
|
2859
3701
|
return [
|
|
2860
|
-
...
|
|
3702
|
+
...head,
|
|
2861
3703
|
...body,
|
|
2862
3704
|
""
|
|
2863
3705
|
];
|
|
2864
3706
|
}
|
|
2865
3707
|
/**
|
|
2866
|
-
* The
|
|
2867
|
-
*
|
|
2868
|
-
*
|
|
2869
|
-
*
|
|
2870
|
-
*
|
|
2871
|
-
* @returns the
|
|
3708
|
+
* The expanded form of the lines {@link render} just returned, when that
|
|
3709
|
+
* event's body was collapsed — the whole event re-rendered without its cap,
|
|
3710
|
+
* because a fold swaps entire blocks, not just the clipped tail. The full
|
|
3711
|
+
* body is kept from the render itself: what a tool truncated before
|
|
3712
|
+
* returning is upstream of the log and unrecoverable everywhere.
|
|
3713
|
+
* @returns the full lines, or undefined when nothing was collapsed.
|
|
2872
3714
|
*/
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
return
|
|
2877
|
-
`${theme.dim("—")} ${theme.tool(this.clipped.title)} ${theme.dim("— full output —")}`,
|
|
2878
|
-
...this.clipped.lines,
|
|
2879
|
-
""
|
|
2880
|
-
];
|
|
3715
|
+
takeFold() {
|
|
3716
|
+
const fold = this.fold;
|
|
3717
|
+
this.fold = void 0;
|
|
3718
|
+
return fold;
|
|
2881
3719
|
}
|
|
2882
3720
|
/**
|
|
2883
3721
|
* Render one completed call's status suffix and body from its declared view.
|
|
@@ -3138,6 +3976,10 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
|
3138
3976
|
const RECALL_WINDOW_MS = 1500;
|
|
3139
3977
|
/** Turns longer than this ring the bell on completion, when the bell is on. */
|
|
3140
3978
|
const BELL_TURN_MS = 1e4;
|
|
3979
|
+
/** A finished answer longer than this many rendered lines becomes a fold. */
|
|
3980
|
+
const ANSWER_FOLD_LINES = 24;
|
|
3981
|
+
/** How many of its head lines a collapsed answer keeps visible. */
|
|
3982
|
+
const ANSWER_HEAD_LINES = 8;
|
|
3141
3983
|
/**
|
|
3142
3984
|
* Run one subprocess and capture everything it printed.
|
|
3143
3985
|
* @param file - the executable, or a shell when `shell` is given.
|
|
@@ -3449,12 +4291,7 @@ async function run(ctx, config, io) {
|
|
|
3449
4291
|
commands?.execute(live.agent, line, new AbortController().signal);
|
|
3450
4292
|
},
|
|
3451
4293
|
expandOutput: () => {
|
|
3452
|
-
|
|
3453
|
-
if (full === void 0) {
|
|
3454
|
-
prompt.write(theme.dim(" no clipped output to expand"));
|
|
3455
|
-
return;
|
|
3456
|
-
}
|
|
3457
|
-
for (const line of full) prompt.write(line);
|
|
4294
|
+
if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
|
|
3458
4295
|
}
|
|
3459
4296
|
}, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
|
|
3460
4297
|
let turnBaseTokens = 0;
|
|
@@ -3502,6 +4339,15 @@ async function run(ctx, config, io) {
|
|
|
3502
4339
|
description: "start a fresh session in place",
|
|
3503
4340
|
handler: async () => {
|
|
3504
4341
|
await switchTo(await composed.createAnother(), false);
|
|
4342
|
+
for (const line of bannerLines({
|
|
4343
|
+
model,
|
|
4344
|
+
preset: presetId,
|
|
4345
|
+
cwd,
|
|
4346
|
+
branch,
|
|
4347
|
+
session: live.agent.session.id,
|
|
4348
|
+
readsKeys: io.console.readsKeys,
|
|
4349
|
+
resumed: false
|
|
4350
|
+
}, theme, io.console.columns)) prompt.write(line);
|
|
3505
4351
|
return {
|
|
3506
4352
|
kind: "success",
|
|
3507
4353
|
text: `new session ${live.agent.session.id}`
|
|
@@ -3674,8 +4520,36 @@ async function run(ctx, config, io) {
|
|
|
3674
4520
|
}
|
|
3675
4521
|
}));
|
|
3676
4522
|
}
|
|
3677
|
-
const stream = new TextStream(theme, () => io.console.
|
|
3678
|
-
|
|
4523
|
+
const stream = new TextStream(theme, () => io.console.contentColumns);
|
|
4524
|
+
let answerLines = [];
|
|
4525
|
+
const finishAnswer = () => {
|
|
4526
|
+
if (!stream.streamed) return;
|
|
4527
|
+
const tail = stream.flush();
|
|
4528
|
+
emit([...tail, ""]);
|
|
4529
|
+
answerLines.push(...tail);
|
|
4530
|
+
if (answerLines.length > ANSWER_FOLD_LINES) io.console.foldRecent(answerLines.length + 1, [
|
|
4531
|
+
...answerLines.slice(0, ANSWER_HEAD_LINES),
|
|
4532
|
+
theme.dim(` … +${answerLines.length - ANSWER_HEAD_LINES} lines (Ctrl+O expands)`),
|
|
4533
|
+
""
|
|
4534
|
+
]);
|
|
4535
|
+
answerLines = [];
|
|
4536
|
+
};
|
|
4537
|
+
const thinking = new TextStream(theme, () => io.console.contentColumns, true);
|
|
4538
|
+
let thinkingLines = [];
|
|
4539
|
+
let thinkingStartedAt = 0;
|
|
4540
|
+
const flushThinking = () => {
|
|
4541
|
+
thinkingLines.push(...thinking.flush());
|
|
4542
|
+
if (thinkingLines.length === 0) return;
|
|
4543
|
+
const seconds = ((performance.now() - thinkingStartedAt) / 1e3).toFixed(1);
|
|
4544
|
+
prompt.setStreaming(void 0);
|
|
4545
|
+
io.console.appendFold([theme.dim(`✻ thought for ${seconds}s · +${thinkingLines.length} lines (Ctrl+O expands)`), ""], [
|
|
4546
|
+
theme.dim(`✻ thought for ${seconds}s`),
|
|
4547
|
+
...thinkingLines,
|
|
4548
|
+
""
|
|
4549
|
+
]);
|
|
4550
|
+
thinkingLines = [];
|
|
4551
|
+
thinkingStartedAt = 0;
|
|
4552
|
+
};
|
|
3679
4553
|
/**
|
|
3680
4554
|
* Append the lines an event produced, and show the line still being typed.
|
|
3681
4555
|
* @param lines - finished lines for the transcript.
|
|
@@ -3702,32 +4576,38 @@ async function run(ctx, config, io) {
|
|
|
3702
4576
|
const { chunk } = event.data;
|
|
3703
4577
|
if (chunk.type === "reasoning-delta") {
|
|
3704
4578
|
if (chunk.text === "") return;
|
|
3705
|
-
|
|
3706
|
-
if (!thinking.streamed) emit([theme.dim("✻ thinking")]);
|
|
4579
|
+
if (thinkingStartedAt === 0) thinkingStartedAt = performance.now();
|
|
3707
4580
|
const step$1 = thinking.push(chunk.text);
|
|
3708
|
-
|
|
4581
|
+
thinkingLines.push(...step$1.lines);
|
|
4582
|
+
prompt.setStreaming(step$1.live ?? thinkingLines.at(-1) ?? theme.dim("✻ thinking"));
|
|
3709
4583
|
return;
|
|
3710
4584
|
}
|
|
3711
4585
|
if (chunk.type !== "text-delta") return;
|
|
3712
|
-
|
|
3713
|
-
if (thinking.streamed) emit([...thinking.flush(), ""]);
|
|
4586
|
+
flushThinking();
|
|
3714
4587
|
const step = stream.push(chunk.text);
|
|
3715
4588
|
emit(step.lines, step.live);
|
|
4589
|
+
answerLines.push(...step.lines);
|
|
3716
4590
|
return;
|
|
3717
4591
|
}
|
|
3718
4592
|
if (event.type === "assistant/message") {
|
|
3719
|
-
|
|
4593
|
+
flushThinking();
|
|
3720
4594
|
if (stream.streamed) {
|
|
3721
|
-
|
|
3722
|
-
if (live.agent.status === "running") spinner.start();
|
|
4595
|
+
finishAnswer();
|
|
3723
4596
|
return;
|
|
3724
4597
|
}
|
|
3725
4598
|
}
|
|
3726
|
-
|
|
4599
|
+
const lines = live.transcript.render(event);
|
|
4600
|
+
const full = live.transcript.takeFold();
|
|
4601
|
+
if (full === void 0) {
|
|
4602
|
+
emit(lines);
|
|
4603
|
+
return;
|
|
4604
|
+
}
|
|
4605
|
+
prompt.setStreaming(void 0);
|
|
4606
|
+
io.console.appendFold(lines, full);
|
|
3727
4607
|
});
|
|
3728
4608
|
/** Pause the indicator around a decision, and resume it if work continues. */
|
|
3729
4609
|
const whileDeciding = async (decide) => {
|
|
3730
|
-
spinner.
|
|
4610
|
+
spinner.pause();
|
|
3731
4611
|
if (config.bell) io.console.bell();
|
|
3732
4612
|
try {
|
|
3733
4613
|
return await decide();
|
|
@@ -3775,6 +4655,7 @@ async function run(ctx, config, io) {
|
|
|
3775
4655
|
columns: io.console.columns,
|
|
3776
4656
|
cwd
|
|
3777
4657
|
}, presentersFor(ctx, next.agent));
|
|
4658
|
+
io.console.clearScreen();
|
|
3778
4659
|
approval.clear();
|
|
3779
4660
|
turnBaseTokens = 0;
|
|
3780
4661
|
prompt.setAccent(planModeFrom(next.agent.session.events) ? (text) => theme.pending(text) : void 0);
|
|
@@ -3800,11 +4681,28 @@ async function run(ctx, config, io) {
|
|
|
3800
4681
|
spinner.stop();
|
|
3801
4682
|
running?.abort();
|
|
3802
4683
|
live.agent.cancel({ kind: "user" });
|
|
3803
|
-
|
|
3804
|
-
|
|
4684
|
+
flushThinking();
|
|
4685
|
+
finishAnswer();
|
|
3805
4686
|
if (busy) prompt.write(theme.dim(" interrupted"));
|
|
3806
4687
|
return busy;
|
|
3807
4688
|
};
|
|
4689
|
+
/**
|
|
4690
|
+
* Hand the terminal back and leave.
|
|
4691
|
+
*
|
|
4692
|
+
* The session's own screen disappears with it, so the few facts a person
|
|
4693
|
+
* still needs — which session this was, what it cost, how to reopen it — are
|
|
4694
|
+
* written to the buffer that survives instead.
|
|
4695
|
+
* @param code - the exit status to request.
|
|
4696
|
+
*/
|
|
4697
|
+
const leave = (code) => {
|
|
4698
|
+
prompt.clear();
|
|
4699
|
+
io.console.close();
|
|
4700
|
+
const usage = totalTokens(facts(branch).usage);
|
|
4701
|
+
const spent = usage === void 0 || usage === 0 ? "" : ` · ${formatTokens(usage)} tokens`;
|
|
4702
|
+
io.console.writeAfterScreen(theme.dim(`codsh session ${live.agent.session.id}${spent}`));
|
|
4703
|
+
io.console.writeAfterScreen(theme.dim(` reopen with: codsh --resume ${live.agent.session.id}`));
|
|
4704
|
+
io.exit(code);
|
|
4705
|
+
};
|
|
3808
4706
|
let lastInterrupt = 0;
|
|
3809
4707
|
let recallArmed;
|
|
3810
4708
|
onEscapeKey = () => {
|
|
@@ -3834,9 +4732,7 @@ async function run(ctx, config, io) {
|
|
|
3834
4732
|
prompt.write(theme.dim(" Ctrl-C again to exit"));
|
|
3835
4733
|
return;
|
|
3836
4734
|
}
|
|
3837
|
-
|
|
3838
|
-
io.console.close();
|
|
3839
|
-
io.exit(130);
|
|
4735
|
+
leave(130);
|
|
3840
4736
|
};
|
|
3841
4737
|
/**
|
|
3842
4738
|
* Run one turn and report what it cost.
|
|
@@ -3865,8 +4761,8 @@ async function run(ctx, config, io) {
|
|
|
3865
4761
|
};
|
|
3866
4762
|
if (config.print) {
|
|
3867
4763
|
await turn(live.agent, config.task, spinner);
|
|
3868
|
-
|
|
3869
|
-
|
|
4764
|
+
flushThinking();
|
|
4765
|
+
finishAnswer();
|
|
3870
4766
|
await sessions.flush(live.agent.session);
|
|
3871
4767
|
prompt.clear();
|
|
3872
4768
|
io.console.close();
|
|
@@ -3914,6 +4810,7 @@ async function run(ctx, config, io) {
|
|
|
3914
4810
|
running = void 0;
|
|
3915
4811
|
}
|
|
3916
4812
|
};
|
|
4813
|
+
io.console.enterScreen();
|
|
3917
4814
|
prompt.setEngaged(true);
|
|
3918
4815
|
if (config.task !== "") await answer(config.task);
|
|
3919
4816
|
let shownStatus;
|
|
@@ -3929,6 +4826,7 @@ async function run(ctx, config, io) {
|
|
|
3929
4826
|
}
|
|
3930
4827
|
const line = await prompt.read();
|
|
3931
4828
|
if (line === void 0) break;
|
|
4829
|
+
io.console.collapseFolds();
|
|
3932
4830
|
const trimmed = line.trim();
|
|
3933
4831
|
if (trimmed === "") continue;
|
|
3934
4832
|
if (trimmed === "/exit" || trimmed === "/quit") break;
|
|
@@ -3972,10 +4870,7 @@ async function run(ctx, config, io) {
|
|
|
3972
4870
|
} catch {}
|
|
3973
4871
|
for (const dispose of disposers.splice(0)) dispose();
|
|
3974
4872
|
prompt.setEngaged(false);
|
|
3975
|
-
|
|
3976
|
-
io.console.write(theme.dim(`session ${live.agent.session.id}`));
|
|
3977
|
-
io.console.close();
|
|
3978
|
-
io.exit(0);
|
|
4873
|
+
leave(0);
|
|
3979
4874
|
}
|
|
3980
4875
|
/**
|
|
3981
4876
|
* Report an unexpected surface failure and request a failing exit.
|
|
@@ -3983,8 +4878,8 @@ async function run(ctx, config, io) {
|
|
|
3983
4878
|
* @param error - the failure.
|
|
3984
4879
|
*/
|
|
3985
4880
|
function fail(io, error) {
|
|
3986
|
-
io.console.write(`dsh: ${error instanceof Error ? error.message : String(error)}`);
|
|
3987
4881
|
io.console.close();
|
|
4882
|
+
io.console.writeAfterScreen(`codsh: ${error instanceof Error ? error.message : String(error)}`);
|
|
3988
4883
|
io.exit(1);
|
|
3989
4884
|
}
|
|
3990
4885
|
/**
|
|
@@ -3999,6 +4894,14 @@ function apply(ctx, config) {
|
|
|
3999
4894
|
console: new TerminalConsole(internals.input, internals.output),
|
|
4000
4895
|
exit
|
|
4001
4896
|
};
|
|
4897
|
+
const restore = () => {
|
|
4898
|
+
io.console.leaveScreen();
|
|
4899
|
+
};
|
|
4900
|
+
process.once("exit", restore);
|
|
4901
|
+
for (const signal of ["SIGTERM", "SIGHUP"]) process.once(signal, () => {
|
|
4902
|
+
restore();
|
|
4903
|
+
process.exit(signal === "SIGTERM" ? 143 : 129);
|
|
4904
|
+
});
|
|
4002
4905
|
run(ctx, config, io).catch((error) => {
|
|
4003
4906
|
fail(io, error);
|
|
4004
4907
|
});
|