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/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
- * Combining marks are counted as zero and East Asian Wide/Fullwidth code
186
- * points as two, which is what a terminal's own cursor arithmetic does. This
187
- * covers the alignment and wrapping this surface needs; it is not a complete
188
- * grapheme segmenter, so a ZWJ emoji sequence still counts each joined code
189
- * point ({@link ../README.md | Known Limitations}).
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
- let width = 0;
195
- for (const character of text.replace(SGR_PATTERN, "")) {
196
- const code = character.codePointAt(0);
197
- if (code === void 0) continue;
198
- if (code >= 768 && code <= 879) continue;
199
- width += isWide(code) ? 2 : 1;
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 width;
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 = code >= 768 && code <= 879 ? 0 : isWide(code) ? 2 : 1;
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
- return [
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
- /** Erase the current line from the cursor rightwards. */
833
- const CLEAR_LINE = "\x1B[K";
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
- /** Rows currently drawn in the bottom region. */
861
- regionRows = [];
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
- * Clear the visible screen.
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
- * The scrollback survives this wipes the viewport the way a shell's clear
932
- * does. The managed region is forgotten with it, so the caller redraws.
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
- if (!this.readsKeys) return;
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
- * Write one finished line above the managed region.
1643
+ * Keep one finished transcript line.
1007
1644
  *
1008
- * The region is erased first and redrawn after, so the transcript stays
1009
- * append-only while the input box keeps its place at the bottom.
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.regionRows.length === 0) {
1014
- this.output.write(`${line}\n`);
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 managed region at the bottom of the screen.
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. Everything the transcript keeps goes through {@link write}
1028
- * instead, because a terminal cannot revise a row that has scrolled. Off a
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 region holds input focus. Without it the cursor
1034
- * stays hidden: a block cursor parked on a display row (the status line,
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
- if (!this.readsKeys) return;
1039
- this.eraseRegion();
1040
- this.drawRegion(rows, cursor, focus);
1041
- this.regionRows = [...rows];
1042
- this.regionCursor = { ...cursor };
1043
- this.regionFocus = focus;
1044
- }
1045
- /** Remove the region, leaving the cursor where the next write will land. */
1046
- clearRegion() {
1047
- if (this.regionRows.length === 0) return;
1048
- this.eraseRegion();
1049
- if (!this.regionFocus) this.output.write(SHOW_CURSOR);
1050
- this.regionRows = [];
1051
- this.regionCursor = {
1052
- row: 0,
1053
- column: 0
1054
- };
1055
- this.regionFocus = true;
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
- /** Move to the region's first row and erase everything from there down. */
1058
- eraseRegion() {
1059
- if (this.regionRows.length === 0) return;
1060
- const up = this.regionCursor.row > 0 ? `\u001B[${this.regionCursor.row}A` : "";
1061
- this.output.write(`${HIDE_CURSOR}${up}\r${CLEAR_BELOW}`);
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
- * Draw rows from the cursor down and place the cursor among them.
1065
- * @param rows - the rows to draw.
1066
- * @param cursor - the target position.
1067
- * @param focus - whether to show the cursor at that position afterwards.
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
- drawRegion(rows, cursor, focus = true) {
1070
- if (rows.length === 0) return;
1071
- const fitted = rows.map((row) => truncate(row, this.columns - 1));
1072
- const body = fitted.map((row) => `${CLEAR_LINE}${row}`).join("\n");
1073
- const back = fitted.length - 1 - cursor.row;
1074
- const up = back > 0 ? `\u001B[${back}A` : "";
1075
- const right = cursor.column > 0 ? `\u001B[${cursor.column}C` : "";
1076
- this.output.write(`${HIDE_CURSOR}${body}${up}\r${right}${focus ? SHOW_CURSOR : ""}`);
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.output.write(SHOW_CURSOR);
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 menu = view.candidates.slice(0, MENU_LIMIT);
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 = theme.tool(candidate.value.slice(0, matched));
2306
+ const head = candidate.value.slice(0, matched);
1602
2307
  const tail = candidate.value.slice(matched);
1603
- const label = `${head}${chosen ? theme.bold(tail) : tail}`;
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
- if (view.candidates.length > menu.length) rows.push(theme.dim(` … ${view.candidates.length - menu.length} more`));
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
- this.spec.options.forEach((option, index) => {
1718
- rows.push(this.row(index, this.label(option, theme), option.detail, theme, columns));
1719
- });
1720
- if (this.spec.custom !== void 0) rows.push(this.row(this.spec.options.length, theme.dim(this.spec.custom), void 0, theme, columns));
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
- if (this.hint !== void 0) rows.push(this.hint);
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 theme.bold(starBold.slice(2, -2));
2219
- if (underBold !== void 0) return theme.bold(underBold.slice(2, -2));
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 theme.bold(starEm.slice(1, -1));
2227
- if (underEm !== void 0) return theme.bold(underEm.slice(1, -1));
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, or fall back to its source lines.
3054
+ * Lay out one buffered table as a bordered grid.
2267
3055
  *
2268
- * Cells print verbatim padding is by display width, and styled text would
2269
- * make the two disagree. Only the frame carries styling: the header is bold and
2270
- * the rule under it dim.
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 frame.
2273
- * @param budget - display columns available; a wider table degrades to source.
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 cells = rows.map((row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()));
2279
- const delimiter = cells[1];
2280
- if (cells.length < 2 || delimiter === void 0 || !delimiter.every((cell) => TABLE_DELIMITER.test(cell))) return asSource();
2281
- const body = [cells[0] ?? [], ...cells.slice(2)];
2282
- const count = Math.max(...body.map((row) => row.length));
2283
- const widths = Array.from({ length: count }, (_, column) => Math.max(...body.map((row) => displayWidth(row[column] ?? ""))));
2284
- if (widths.reduce((sum, width) => sum + width, 0) + 2 * (count - 1) > budget) return asSource();
2285
- const aligned = (row) => Array.from({ length: count }, (_, column) => {
2286
- const cell = row[column] ?? "";
2287
- const pad = " ".repeat(Math.max(0, (widths[column] ?? 0) - displayWidth(cell)));
2288
- return /^:?-+:$/.test(delimiter[column] ?? "") && !/^:-+:$/.test(delimiter[column] ?? "") ? `${pad}${cell}` : `${cell}${pad}`;
2289
- }).join(" ").trimEnd();
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
- theme.bold(aligned(cells[0] ?? [])),
2292
- theme.dim(widths.map((width) => "─".repeat(width)).join(" ")),
2293
- ...cells.slice(2).map((row) => aligned(row))
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
- /** Stop the indicator and clear its line. */
2561
- stop() {
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 summarizes the rest. */
2650
- const MAX_DIFF_LINES = 40;
2651
- /** Result body lines printed for one completed call before the card summarizes the rest. */
2652
- const MAX_RESULT_LINES = 16;
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(` … ${lines.length - limit} more lines`)];
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 most recent result whose body the cap clipped, kept in full. */
2703
- clipped;
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
- if (raw.length > MAX_RESULT_LINES) this.clipped = {
2843
- title: "(result)",
2844
- lines: raw
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
- `${marker} ${theme.dim("(result)")}`,
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
- if (full !== void 0) this.clipped = {
2856
- title,
2857
- lines: full
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
- ...failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [],
3702
+ ...head,
2861
3703
  ...body,
2862
3704
  ""
2863
3705
  ];
2864
3706
  }
2865
3707
  /**
2866
- * The last clipped result, rendered without its cap.
2867
- *
2868
- * Ctrl-O's answer. The full body is kept from the render itself because a
2869
- * tool's own output limits are upstream of the log this is everything the
2870
- * model saw, which is everything recoverable.
2871
- * @returns the header and full body, or undefined when nothing was clipped.
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
- expandLast() {
2874
- if (this.clipped === void 0) return void 0;
2875
- const { theme } = this.options;
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
- const full = live.transcript.expandLast();
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.columns);
3678
- const thinking = new TextStream(theme, () => io.console.columns, true);
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
- spinner.stop();
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
- emit(step$1.lines, step$1.live);
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
- spinner.stop();
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
- if (thinking.streamed) emit([...thinking.flush(), ""]);
4593
+ flushThinking();
3720
4594
  if (stream.streamed) {
3721
- emit([...stream.flush(), ""]);
3722
- if (live.agent.status === "running") spinner.start();
4595
+ finishAnswer();
3723
4596
  return;
3724
4597
  }
3725
4598
  }
3726
- emit(live.transcript.render(event));
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.stop();
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
- if (thinking.streamed) emit([...thinking.flush(), ""]);
3804
- if (stream.streamed) emit([...stream.flush(), ""]);
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
- prompt.clear();
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
- if (thinking.streamed) emit([...thinking.flush(), ""]);
3869
- if (stream.streamed) emit([...stream.flush(), ""]);
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
- prompt.clear();
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
  });