codsh-bundle 0.3.0 → 0.5.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 +880 -118
- package/lib/types/console.d.ts +32 -3
- package/lib/types/keys.d.ts +21 -0
- package/lib/types/prompt.d.ts +26 -1
- package/lib/types/screen.d.ts +111 -5
- package/lib/types/ship.d.ts +8 -0
- package/lib/types/theme.d.ts +17 -0
- package/lib/types/todos.d.ts +49 -0
- package/lib/types/transcript.d.ts +84 -0
- package/package.json +46 -46
package/lib/index.js
CHANGED
|
@@ -88,6 +88,7 @@ const SGR$1 = {
|
|
|
88
88
|
/** A theme that emits no sequences, used off a TTY and under `NO_COLOR`. */
|
|
89
89
|
const PLAIN = {
|
|
90
90
|
colored: false,
|
|
91
|
+
setLight: () => {},
|
|
91
92
|
dim: (text) => text,
|
|
92
93
|
bold: (text) => text,
|
|
93
94
|
error: (text) => text,
|
|
@@ -122,9 +123,13 @@ function createTheme(isTty, env) {
|
|
|
122
123
|
if (!isTty || env.NO_COLOR !== void 0) return PLAIN;
|
|
123
124
|
const palette = env.TERM?.includes("256color") === true || env.COLORTERM !== void 0;
|
|
124
125
|
const wrap = (code) => (text) => `${code}${text}${SGR$1.reset}`;
|
|
126
|
+
let gray = "\x1B[38;5;245m";
|
|
125
127
|
return {
|
|
126
128
|
colored: true,
|
|
127
|
-
|
|
129
|
+
setLight(light) {
|
|
130
|
+
gray = light ? "\x1B[38;5;242m" : "\x1B[38;5;245m";
|
|
131
|
+
},
|
|
132
|
+
dim: (text) => `${palette ? gray : SGR$1.dim}${text}${SGR$1.reset}`,
|
|
128
133
|
bold: wrap(SGR$1.bold),
|
|
129
134
|
error: wrap(SGR$1.red),
|
|
130
135
|
success: wrap(SGR$1.green),
|
|
@@ -141,6 +146,25 @@ function createTheme(isTty, env) {
|
|
|
141
146
|
};
|
|
142
147
|
}
|
|
143
148
|
/**
|
|
149
|
+
* Whether an OSC 10/11 color answer names a light color.
|
|
150
|
+
*
|
|
151
|
+
* Channels arrive as `rgb:RR/GG/BB` with one to four hex digits each; each is
|
|
152
|
+
* normalized by its own width before the relative-luminance weighting.
|
|
153
|
+
* @param payload - the reply payload, e.g. `rgb:ffff/ffff/ffff`.
|
|
154
|
+
* @returns true for light, false for dark, undefined when unparseable.
|
|
155
|
+
*/
|
|
156
|
+
function backgroundIsLight(payload) {
|
|
157
|
+
const match = /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(payload.trim());
|
|
158
|
+
if (match === null) return void 0;
|
|
159
|
+
const channel = (hex) => Number.parseInt(hex, 16) / (16 ** hex.length - 1);
|
|
160
|
+
const [red, green, blue] = [
|
|
161
|
+
channel(match[1] ?? "0"),
|
|
162
|
+
channel(match[2] ?? "0"),
|
|
163
|
+
channel(match[3] ?? "0")
|
|
164
|
+
];
|
|
165
|
+
return .2126 * red + .7152 * green + .0722 * blue > .5;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
144
168
|
* Display columns a string occupies once printed, ignoring styling sequences.
|
|
145
169
|
*
|
|
146
170
|
* Measured by `string-width` — the width authority cli-table3, ink, and every
|
|
@@ -562,9 +586,9 @@ function parseCommandFile(source) {
|
|
|
562
586
|
if (source.startsWith("---\n")) {
|
|
563
587
|
const end = source.indexOf("\n---\n", 4);
|
|
564
588
|
if (end >= 0) {
|
|
565
|
-
const header = source.slice(4, end);
|
|
589
|
+
const header$1 = source.slice(4, end);
|
|
566
590
|
return {
|
|
567
|
-
description: /^description:\s*(.+)$/m.exec(header)?.[1]?.trim() ?? "",
|
|
591
|
+
description: /^description:\s*(.+)$/m.exec(header$1)?.[1]?.trim() ?? "",
|
|
568
592
|
body: source.slice(end + 5).trim()
|
|
569
593
|
};
|
|
570
594
|
}
|
|
@@ -653,14 +677,56 @@ const PASTE_END = "\x1B[201~";
|
|
|
653
677
|
const WHEEL_LINES = 1;
|
|
654
678
|
/** An SGR mouse report: `ESC [ < button ; column ; row (M|m)`. */
|
|
655
679
|
const MOUSE = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
680
|
+
/**
|
|
681
|
+
* A kitty-keyboard-protocol report: `ESC [ code (:alternates) ; mods (:event) u`.
|
|
682
|
+
*
|
|
683
|
+
* The viewport pushes the protocol's disambiguate flag on entry (the same
|
|
684
|
+
* flag Claude Code pushes), so terminals that speak it report Esc and every
|
|
685
|
+
* modified key unambiguously — which is what makes Shift+Enter a key at all.
|
|
686
|
+
* Terminals that don't speak it ignore the push and keep sending the legacy
|
|
687
|
+
* sequences below.
|
|
688
|
+
*/
|
|
689
|
+
const KITTY = /^\u001B\[(\d+)(?::\d+)*(?:;(\d+)(?::(\d+))?)?u/;
|
|
690
|
+
/** Kitty modifier bits, after the encoded +1 offset is removed. */
|
|
691
|
+
const KITTY_SHIFT = 1;
|
|
692
|
+
/** The Alt bit. */
|
|
693
|
+
const KITTY_ALT = 2;
|
|
694
|
+
/** The Control bit. */
|
|
695
|
+
const KITTY_CTRL = 4;
|
|
656
696
|
/** Wheel-up button code in the SGR encoding; wheel-down is one higher. */
|
|
657
697
|
const WHEEL_UP = 64;
|
|
658
698
|
/** Modifier bits in an SGR button code: Shift, Meta, and Control. */
|
|
659
699
|
const MOUSE_MODIFIERS = 28;
|
|
660
|
-
/** The motion bit, set on
|
|
700
|
+
/** The motion bit, set on every report the pointer's movement produces. */
|
|
661
701
|
const MOUSE_MOTION = 32;
|
|
702
|
+
/**
|
|
703
|
+
* The button code any-motion tracking sends when no button is held.
|
|
704
|
+
*
|
|
705
|
+
* Motion with a button reports that button; motion with none reports 3, the
|
|
706
|
+
* same code a release carries — so a move is the motion bit over this, which
|
|
707
|
+
* is what tells the surface which block the pointer is merely resting on.
|
|
708
|
+
*/
|
|
709
|
+
const MOUSE_NO_BUTTON = 3;
|
|
710
|
+
/**
|
|
711
|
+
* An OSC reply from the terminal: `ESC ] code ; payload (BEL | ESC \)`.
|
|
712
|
+
*
|
|
713
|
+
* The viewport asks for the background color (OSC 11) on entry; the reply
|
|
714
|
+
* arrives on stdin and must be consumed here — leaked past the decoder it
|
|
715
|
+
* would be typed into the input box as text.
|
|
716
|
+
*/
|
|
717
|
+
const OSC_REPLY = /^\u001B\](\d+);([^\u0007\u001B]*)(?:\u0007|\u001B\\)/;
|
|
718
|
+
/** An OSC reply still arriving, its possible ST half included. */
|
|
719
|
+
const OSC_PARTIAL = /^\u001B\](?:\d*(?:;[^\u0007\u001B]*)?)?\u001B?$/;
|
|
662
720
|
/** Sequences that resolve to one key, longest first so a prefix never wins. */
|
|
663
721
|
const SEQUENCES = [
|
|
722
|
+
["\x1B[I", {
|
|
723
|
+
kind: "focus",
|
|
724
|
+
focused: true
|
|
725
|
+
}],
|
|
726
|
+
["\x1B[O", {
|
|
727
|
+
kind: "focus",
|
|
728
|
+
focused: false
|
|
729
|
+
}],
|
|
664
730
|
["\x1B[5~", {
|
|
665
731
|
kind: "page",
|
|
666
732
|
direction: -1
|
|
@@ -727,6 +793,7 @@ const CONTROLS = {
|
|
|
727
793
|
"\v": { kind: "kill-line" },
|
|
728
794
|
"\f": { kind: "clear-screen" },
|
|
729
795
|
"": { kind: "expand-output" },
|
|
796
|
+
"": { kind: "toggle-todos" },
|
|
730
797
|
"": { kind: "kill-input" },
|
|
731
798
|
"": { kind: "kill-word" }
|
|
732
799
|
};
|
|
@@ -797,6 +864,11 @@ var KeyDecoder = class {
|
|
|
797
864
|
}];
|
|
798
865
|
const column = Number(mouse[2]);
|
|
799
866
|
const row = Number(mouse[3]);
|
|
867
|
+
if ((button & ~MOUSE_MODIFIERS) === (MOUSE_MOTION | MOUSE_NO_BUTTON)) return [{
|
|
868
|
+
kind: "mouse-move",
|
|
869
|
+
row,
|
|
870
|
+
column
|
|
871
|
+
}];
|
|
800
872
|
if ((button & ~MOUSE_MOTION) === 0 && (button & MOUSE_MODIFIERS) === 0) {
|
|
801
873
|
if (mouse[4] === "m") return [{
|
|
802
874
|
kind: "mouse-up",
|
|
@@ -816,7 +888,23 @@ var KeyDecoder = class {
|
|
|
816
888
|
}
|
|
817
889
|
return [];
|
|
818
890
|
}
|
|
819
|
-
|
|
891
|
+
const kitty = KITTY.exec(this.held);
|
|
892
|
+
if (kitty !== null) {
|
|
893
|
+
this.held = this.held.slice(kitty[0].length);
|
|
894
|
+
return this.kittyKey(Number(kitty[1]), Number(kitty[2] ?? "1"), Number(kitty[3] ?? "1"));
|
|
895
|
+
}
|
|
896
|
+
const osc = OSC_REPLY.exec(this.held);
|
|
897
|
+
if (osc !== null) {
|
|
898
|
+
this.held = this.held.slice(osc[0].length);
|
|
899
|
+
const code = Number(osc[1]);
|
|
900
|
+
if (code === 10 || code === 11) return [{
|
|
901
|
+
kind: "osc-reply",
|
|
902
|
+
code,
|
|
903
|
+
payload: osc[2] ?? ""
|
|
904
|
+
}];
|
|
905
|
+
return [];
|
|
906
|
+
}
|
|
907
|
+
if (/^\u001B(\[[\d;:<]*)?$/.test(this.held) || OSC_PARTIAL.test(this.held)) return void 0;
|
|
820
908
|
for (const [sequence, key] of SEQUENCES) {
|
|
821
909
|
if (this.held.startsWith(sequence)) {
|
|
822
910
|
this.held = this.held.slice(sequence.length);
|
|
@@ -846,6 +934,38 @@ var KeyDecoder = class {
|
|
|
846
934
|
}];
|
|
847
935
|
}
|
|
848
936
|
/**
|
|
937
|
+
* Map one kitty-protocol report onto the same keys the legacy bytes make.
|
|
938
|
+
* @param code - the key's Unicode code point.
|
|
939
|
+
* @param mods - the encoded modifiers, offset by one.
|
|
940
|
+
* @param event - press (1), repeat (2), or release (3).
|
|
941
|
+
* @returns the keys produced; unknown chords are swallowed, never typed.
|
|
942
|
+
*/
|
|
943
|
+
kittyKey(code, mods, event) {
|
|
944
|
+
if (event === 3) return [];
|
|
945
|
+
const bits = Math.max(0, mods - 1);
|
|
946
|
+
const shift = (bits & KITTY_SHIFT) !== 0;
|
|
947
|
+
const alt = (bits & KITTY_ALT) !== 0;
|
|
948
|
+
const ctrl = (bits & KITTY_CTRL) !== 0;
|
|
949
|
+
if (code === 27) return [{ kind: "escape" }];
|
|
950
|
+
if (code === 13) return [shift || alt ? { kind: "newline" } : { kind: "enter" }];
|
|
951
|
+
if (code === 9) return [shift ? { kind: "shift-tab" } : { kind: "tab" }];
|
|
952
|
+
if (code === 127 || code === 8) return [alt || ctrl ? { kind: "kill-word" } : { kind: "backspace" }];
|
|
953
|
+
if (ctrl && code >= 97 && code <= 122) {
|
|
954
|
+
const control = CONTROLS[String.fromCharCode(code - 96)];
|
|
955
|
+
return control === void 0 ? [] : [control];
|
|
956
|
+
}
|
|
957
|
+
if (alt) {
|
|
958
|
+
if (code === 98) return [{ kind: "word-left" }];
|
|
959
|
+
if (code === 102) return [{ kind: "word-right" }];
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
if (!ctrl && code >= 32) return [{
|
|
963
|
+
kind: "text",
|
|
964
|
+
text: String.fromCodePoint(code)
|
|
965
|
+
}];
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
849
969
|
* Collect bracketed-paste content up to its end marker.
|
|
850
970
|
* @returns the paste key once complete, otherwise undefined.
|
|
851
971
|
*/
|
|
@@ -880,7 +1000,7 @@ const SGR = /^\u001B\[[0-9;]*m/;
|
|
|
880
1000
|
/** Any other escape sequence, also zero-width. */
|
|
881
1001
|
const ESCAPE = /^(?:\u001B\[[0-9;?]*[A-Za-z]|\u001B\][^\u0007]*\u0007|\u001B.)/;
|
|
882
1002
|
/** Closes every style a row opened, so a row never bleeds into the next. */
|
|
883
|
-
const RESET = "\x1B[0m";
|
|
1003
|
+
const RESET$1 = "\x1B[0m";
|
|
884
1004
|
/**
|
|
885
1005
|
* Break one styled line into rows no wider than `columns` display columns.
|
|
886
1006
|
*
|
|
@@ -899,7 +1019,7 @@ function wrapStyled(text, columns) {
|
|
|
899
1019
|
let width = 0;
|
|
900
1020
|
let rest = text;
|
|
901
1021
|
const flush = () => {
|
|
902
|
-
rows.push(active.length > 0 ? `${row}${RESET}` : row);
|
|
1022
|
+
rows.push(active.length > 0 ? `${row}${RESET$1}` : row);
|
|
903
1023
|
row = active.join("");
|
|
904
1024
|
width = 0;
|
|
905
1025
|
};
|
|
@@ -926,18 +1046,9 @@ function wrapStyled(text, columns) {
|
|
|
926
1046
|
width += cost;
|
|
927
1047
|
rest = rest.slice(character.length);
|
|
928
1048
|
}
|
|
929
|
-
rows.push(active.length > 0 ? `${row}${RESET}` : row);
|
|
1049
|
+
rows.push(active.length > 0 ? `${row}${RESET$1}` : row);
|
|
930
1050
|
return rows;
|
|
931
1051
|
}
|
|
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
1052
|
|
|
942
1053
|
//#endregion
|
|
943
1054
|
//#region src/screen.ts
|
|
@@ -948,16 +1059,37 @@ const ENTER_ALT = "\x1B[?1049h";
|
|
|
948
1059
|
/** Leave it, restoring both. */
|
|
949
1060
|
const LEAVE_ALT = "\x1B[?1049l";
|
|
950
1061
|
/**
|
|
951
|
-
* Report wheel and
|
|
1062
|
+
* Report wheel, button, and pointer-motion events, in the SGR encoding.
|
|
952
1063
|
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
1064
|
+
* Any-motion tracking (1003) is what lets the surface say which block the
|
|
1065
|
+
* pointer rests on — the blocks are clickable, and a target that gives no
|
|
1066
|
+
* feedback until it is hit is not an affordance. Button tracking (1002) is
|
|
1067
|
+
* pushed under it so terminals that implement only that one keep the drag
|
|
1068
|
+
* that selects. Motion is a report per cell crossed, so the surface repaints
|
|
1069
|
+
* only when the block under the pointer changes, not on every report. Most
|
|
1070
|
+
* terminals still hand a Shift-drag to their own selection either way.
|
|
957
1071
|
*/
|
|
958
|
-
const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1006h";
|
|
1072
|
+
const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1003h\x1B[?1006h";
|
|
959
1073
|
/** Stop reporting them. */
|
|
960
|
-
const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1002l";
|
|
1074
|
+
const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1003l\x1B[?1002l";
|
|
1075
|
+
/**
|
|
1076
|
+
* Push the kitty keyboard protocol's disambiguate flag — what Claude Code
|
|
1077
|
+
* pushes — so Shift+Enter, Esc, and control chords report unambiguously on
|
|
1078
|
+
* terminals that speak it. Others ignore the push and keep the legacy bytes.
|
|
1079
|
+
*/
|
|
1080
|
+
const ENABLE_KITTY_KEYS = "\x1B[>1u";
|
|
1081
|
+
/** Pop it, restoring whatever the shell had. */
|
|
1082
|
+
const DISABLE_KITTY_KEYS = "\x1B[<u";
|
|
1083
|
+
/** Report focus in/out (mode 1004), which the bell policy reads. */
|
|
1084
|
+
const ENABLE_FOCUS = "\x1B[?1004h";
|
|
1085
|
+
/** Stop reporting focus. */
|
|
1086
|
+
const DISABLE_FOCUS = "\x1B[?1004l";
|
|
1087
|
+
/**
|
|
1088
|
+
* Ask the terminal for its background color (OSC 11), the way opencode and
|
|
1089
|
+
* Codex do. The reply decides the light-background palette; a terminal that
|
|
1090
|
+
* never answers leaves the dark default standing.
|
|
1091
|
+
*/
|
|
1092
|
+
const QUERY_BACKGROUND = "\x1B]11;?\x07";
|
|
961
1093
|
/** Ask the terminal to paint a frame atomically, so no half-frame is shown. */
|
|
962
1094
|
const SYNC_BEGIN = "\x1B[?2026h";
|
|
963
1095
|
/** End the atomic frame. */
|
|
@@ -974,6 +1106,24 @@ const STYLES = /\u001B\[[0-9;]*m/gu;
|
|
|
974
1106
|
const INVERSE = "\x1B[7m";
|
|
975
1107
|
/** End reverse video only, leaving any other attributes alone. */
|
|
976
1108
|
const INVERSE_OFF = "\x1B[27m";
|
|
1109
|
+
/** Start underline, which is how the block under the pointer shows itself. */
|
|
1110
|
+
const UNDERLINE = "\x1B[4m";
|
|
1111
|
+
/** End underline only, leaving any other attributes alone. */
|
|
1112
|
+
const UNDERLINE_OFF = "\x1B[24m";
|
|
1113
|
+
/** A full SGR reset, which every styled span this surface prints ends with. */
|
|
1114
|
+
const RESET = "\x1B[0m";
|
|
1115
|
+
/**
|
|
1116
|
+
* Underline a whole rendered row.
|
|
1117
|
+
*
|
|
1118
|
+
* Every styled span this surface prints ends in a full reset, which would drop
|
|
1119
|
+
* the underline partway along the row — so the attribute is armed again after
|
|
1120
|
+
* each one, and turned off alone at the end so nothing else is disturbed.
|
|
1121
|
+
* @param row - the styled row.
|
|
1122
|
+
* @returns the row, underlined end to end.
|
|
1123
|
+
*/
|
|
1124
|
+
function underline(row) {
|
|
1125
|
+
return `${UNDERLINE}${row.replaceAll(RESET, `${RESET}${UNDERLINE}`)}${UNDERLINE_OFF}`;
|
|
1126
|
+
}
|
|
977
1127
|
/**
|
|
978
1128
|
* The string index where a display column begins.
|
|
979
1129
|
*
|
|
@@ -997,8 +1147,18 @@ function columnIndex(text, column) {
|
|
|
997
1147
|
var Screen = class {
|
|
998
1148
|
/** Logical transcript lines, unwrapped, oldest first. */
|
|
999
1149
|
logical = [];
|
|
1150
|
+
/**
|
|
1151
|
+
* The left rule each logical line carries, `''` for none.
|
|
1152
|
+
*
|
|
1153
|
+
* Kept beside the text rather than inside it: a rule has to repeat on every
|
|
1154
|
+
* row a line wraps to, and it must never reach the clipboard — it is a mark
|
|
1155
|
+
* the surface draws, not something the person wrote.
|
|
1156
|
+
*/
|
|
1157
|
+
rules = [];
|
|
1000
1158
|
/** The same lines wrapped to the current width — what the viewport slices. */
|
|
1001
1159
|
physical = [];
|
|
1160
|
+
/** Display columns the rule occupies on each physical row, for copy and hits. */
|
|
1161
|
+
ruleWidths = [];
|
|
1002
1162
|
/** The bottom rows: input box, menu, indicator, status. */
|
|
1003
1163
|
chrome = [];
|
|
1004
1164
|
chromeCursor = {
|
|
@@ -1015,6 +1175,18 @@ var Screen = class {
|
|
|
1015
1175
|
selection;
|
|
1016
1176
|
/** Collapsed blocks in the transcript, in order, with both of their forms. */
|
|
1017
1177
|
folds = [];
|
|
1178
|
+
/** The block the pointer rests on, or undefined when it rests on none. */
|
|
1179
|
+
hovered;
|
|
1180
|
+
/**
|
|
1181
|
+
* Physical row ranges the blocks occupy, or undefined when they need
|
|
1182
|
+
* measuring again.
|
|
1183
|
+
*
|
|
1184
|
+
* Motion arrives a report per cell crossed, and measuring a block's row from
|
|
1185
|
+
* the wrapped height of everything above it is a walk over the buffer — far
|
|
1186
|
+
* too much to redo per report. The walk happens once after the buffer
|
|
1187
|
+
* changes instead, and every report in between is a lookup.
|
|
1188
|
+
*/
|
|
1189
|
+
ranges;
|
|
1018
1190
|
/** Whether the folds currently show their full form. */
|
|
1019
1191
|
expanded = false;
|
|
1020
1192
|
/** The last painted frame, so a repaint only touches rows that changed. */
|
|
@@ -1037,7 +1209,7 @@ var Screen = class {
|
|
|
1037
1209
|
enter() {
|
|
1038
1210
|
if (this.active) return;
|
|
1039
1211
|
this.active = true;
|
|
1040
|
-
this.host.write(`${ENTER_ALT}${ENABLE_MOUSE}${HIDE_CURSOR}`);
|
|
1212
|
+
this.host.write(`${ENTER_ALT}${ENABLE_MOUSE}${ENABLE_KITTY_KEYS}${ENABLE_FOCUS}${QUERY_BACKGROUND}${HIDE_CURSOR}`);
|
|
1041
1213
|
this.painted = [];
|
|
1042
1214
|
this.render();
|
|
1043
1215
|
}
|
|
@@ -1050,7 +1222,7 @@ var Screen = class {
|
|
|
1050
1222
|
leave() {
|
|
1051
1223
|
if (!this.active) return;
|
|
1052
1224
|
this.active = false;
|
|
1053
|
-
this.host.write(`${DISABLE_MOUSE}${SHOW_CURSOR}${LEAVE_ALT}`);
|
|
1225
|
+
this.host.write(`${DISABLE_FOCUS}${DISABLE_KITTY_KEYS}${DISABLE_MOUSE}${SHOW_CURSOR}${LEAVE_ALT}`);
|
|
1054
1226
|
this.painted = [];
|
|
1055
1227
|
}
|
|
1056
1228
|
/**
|
|
@@ -1060,16 +1232,23 @@ var Screen = class {
|
|
|
1060
1232
|
* where they are, and the new rows accumulate below them.
|
|
1061
1233
|
* @param lines - the lines to keep, already styled.
|
|
1062
1234
|
*/
|
|
1063
|
-
append(lines) {
|
|
1235
|
+
append(lines, rule = "") {
|
|
1064
1236
|
if (lines.length === 0) return;
|
|
1065
1237
|
const columns = this.contentColumns();
|
|
1066
1238
|
for (const line of lines) {
|
|
1239
|
+
const own = line === "" ? "" : rule;
|
|
1067
1240
|
this.logical.push(line);
|
|
1068
|
-
this.
|
|
1241
|
+
this.rules.push(own);
|
|
1242
|
+
for (const row of this.wrapLine(line, own, columns)) {
|
|
1243
|
+
this.physical.push(row);
|
|
1244
|
+
this.ruleWidths.push(displayWidth(own));
|
|
1245
|
+
}
|
|
1069
1246
|
}
|
|
1247
|
+
this.ranges = void 0;
|
|
1070
1248
|
if (this.logical.length > MAX_SCROLLBACK) {
|
|
1071
1249
|
const dropped = this.logical.length - MAX_SCROLLBACK;
|
|
1072
1250
|
this.logical.splice(0, dropped);
|
|
1251
|
+
this.rules.splice(0, dropped);
|
|
1073
1252
|
this.folds = this.folds.flatMap((fold) => {
|
|
1074
1253
|
const at = fold.at - dropped;
|
|
1075
1254
|
return at >= 0 ? [{
|
|
@@ -1077,6 +1256,7 @@ var Screen = class {
|
|
|
1077
1256
|
at
|
|
1078
1257
|
}] : [];
|
|
1079
1258
|
});
|
|
1259
|
+
this.hovered = void 0;
|
|
1080
1260
|
this.rewrap();
|
|
1081
1261
|
}
|
|
1082
1262
|
this.render();
|
|
@@ -1089,17 +1269,21 @@ var Screen = class {
|
|
|
1089
1269
|
* place, exactly like a details/summary element.
|
|
1090
1270
|
* @param summary - the collapsed lines, already styled.
|
|
1091
1271
|
* @param full - the expanded lines, already styled.
|
|
1272
|
+
* @param rule - a styled left rule for the whole block, `''` for none.
|
|
1273
|
+
* @param label - what the block is, for the hover readout that names it.
|
|
1092
1274
|
*/
|
|
1093
|
-
appendFold(summary, full) {
|
|
1275
|
+
appendFold(summary, full, rule = "", label = "") {
|
|
1094
1276
|
const shown = this.expanded ? full : summary;
|
|
1095
1277
|
this.folds.push({
|
|
1096
1278
|
at: this.logical.length,
|
|
1097
1279
|
shownLength: shown.length,
|
|
1098
1280
|
summary: [...summary],
|
|
1099
1281
|
full: [...full],
|
|
1100
|
-
expanded: this.expanded
|
|
1282
|
+
expanded: this.expanded,
|
|
1283
|
+
rule,
|
|
1284
|
+
label
|
|
1101
1285
|
});
|
|
1102
|
-
this.append(shown);
|
|
1286
|
+
this.append(shown, rule);
|
|
1103
1287
|
}
|
|
1104
1288
|
/**
|
|
1105
1289
|
* Turn the last `count` appended lines into a collapsible block after the
|
|
@@ -1111,8 +1295,9 @@ var Screen = class {
|
|
|
1111
1295
|
* collapses with the rest when the conversation moves on.
|
|
1112
1296
|
* @param count - how many trailing lines the block owns.
|
|
1113
1297
|
* @param summary - the collapsed lines, already styled.
|
|
1298
|
+
* @param label - what the block is, for the hover readout that names it.
|
|
1114
1299
|
*/
|
|
1115
|
-
foldBack(count, summary) {
|
|
1300
|
+
foldBack(count, summary, label = "") {
|
|
1116
1301
|
const at = this.logical.length - count;
|
|
1117
1302
|
if (count <= 0 || at < 0) return;
|
|
1118
1303
|
const last = this.folds.at(-1);
|
|
@@ -1122,8 +1307,11 @@ var Screen = class {
|
|
|
1122
1307
|
shownLength: count,
|
|
1123
1308
|
summary: [...summary],
|
|
1124
1309
|
full: this.logical.slice(at),
|
|
1125
|
-
expanded: true
|
|
1310
|
+
expanded: true,
|
|
1311
|
+
rule: this.rules.slice(at).find((rule) => rule !== "") ?? "",
|
|
1312
|
+
label
|
|
1126
1313
|
});
|
|
1314
|
+
this.ranges = void 0;
|
|
1127
1315
|
}
|
|
1128
1316
|
/** Whether any collapsible block exists. */
|
|
1129
1317
|
get hasFolds() {
|
|
@@ -1135,11 +1323,15 @@ var Screen = class {
|
|
|
1135
1323
|
}
|
|
1136
1324
|
/**
|
|
1137
1325
|
* Swap every fold between its summary and its full form.
|
|
1326
|
+
*
|
|
1327
|
+
* What the blocks show decides the direction, not what the last Ctrl+O did:
|
|
1328
|
+
* clicking blocks open one at a time would otherwise leave the key pointing
|
|
1329
|
+
* the wrong way, and a press that visibly does nothing reads as broken.
|
|
1138
1330
|
* @returns false when there is nothing to toggle.
|
|
1139
1331
|
*/
|
|
1140
1332
|
toggleFolds() {
|
|
1141
1333
|
if (this.folds.length === 0) return false;
|
|
1142
|
-
this.setFolds(!this.expanded);
|
|
1334
|
+
this.setFolds(!this.folds.every((fold) => fold.expanded));
|
|
1143
1335
|
return true;
|
|
1144
1336
|
}
|
|
1145
1337
|
/** Return every fold to its summary, the way moving on reads as dismissal. */
|
|
@@ -1147,6 +1339,87 @@ var Screen = class {
|
|
|
1147
1339
|
if (this.folds.some((fold) => fold.expanded)) this.setFolds(false);
|
|
1148
1340
|
else this.expanded = false;
|
|
1149
1341
|
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Work the block a bare click landed on, the way a details element opens.
|
|
1344
|
+
*
|
|
1345
|
+
* The whole block is the target, in both forms: collapsed, the `+N lines`
|
|
1346
|
+
* line is what a person aims at, and open, anywhere inside the text folds it
|
|
1347
|
+
* back — hunting for a head row that has scrolled off the top is not an
|
|
1348
|
+
* affordance. Selecting text inside a block is a drag, which never reaches
|
|
1349
|
+
* here, so reading is unaffected.
|
|
1350
|
+
* @param row - physical buffer row the press anchored on.
|
|
1351
|
+
*/
|
|
1352
|
+
clickFold(row) {
|
|
1353
|
+
const fold = this.foldAt(row);
|
|
1354
|
+
if (fold === void 0) return;
|
|
1355
|
+
this.setFold(fold, !fold.expanded);
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Where each block sits in physical rows.
|
|
1359
|
+
*
|
|
1360
|
+
* Blocks are recorded in logical lines while the mouse reports physical
|
|
1361
|
+
* rows, so the wrapped height of everything above a block is what bridges
|
|
1362
|
+
* the two — measured under each line's own rule, which costs columns and so
|
|
1363
|
+
* changes the height.
|
|
1364
|
+
* @returns one range per block, in buffer order.
|
|
1365
|
+
*/
|
|
1366
|
+
foldRanges() {
|
|
1367
|
+
if (this.ranges !== void 0) return this.ranges;
|
|
1368
|
+
const columns = this.contentColumns();
|
|
1369
|
+
const height = (index$1) => this.wrapLine(this.logical[index$1] ?? "", this.rules[index$1] ?? "", columns).length;
|
|
1370
|
+
const ranges = [];
|
|
1371
|
+
let physical = 0;
|
|
1372
|
+
let index = 0;
|
|
1373
|
+
for (const fold of this.folds) {
|
|
1374
|
+
for (; index < fold.at; index += 1) physical += height(index);
|
|
1375
|
+
const from = physical;
|
|
1376
|
+
for (; index < fold.at + fold.shownLength; index += 1) physical += height(index);
|
|
1377
|
+
ranges.push({
|
|
1378
|
+
fold,
|
|
1379
|
+
from,
|
|
1380
|
+
to: physical - 1
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
this.ranges = ranges;
|
|
1384
|
+
return ranges;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* The block covering a physical row.
|
|
1388
|
+
* @param row - physical buffer row, 0-based.
|
|
1389
|
+
* @returns the block, or undefined when the row is not in one.
|
|
1390
|
+
*/
|
|
1391
|
+
foldAt(row) {
|
|
1392
|
+
return this.foldRanges().find((range) => row >= range.from && row <= range.to)?.fold;
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Swap one block, leaving the reader where they were.
|
|
1396
|
+
*
|
|
1397
|
+
* Someone who opened a block halfway up their history did not ask to be
|
|
1398
|
+
* moved to the tail: the rows above the block keep their screen positions,
|
|
1399
|
+
* and the transcript grows or shrinks below them. Following the tail there
|
|
1400
|
+
* is nothing to hold on to, so the frame keeps following it — which is what
|
|
1401
|
+
* Ctrl+O does for every block at once.
|
|
1402
|
+
* @param fold - the block to swap.
|
|
1403
|
+
* @param expanded - the form to put on screen.
|
|
1404
|
+
*/
|
|
1405
|
+
setFold(fold, expanded) {
|
|
1406
|
+
const shown = expanded ? fold.full : fold.summary;
|
|
1407
|
+
const delta = shown.length - fold.shownLength;
|
|
1408
|
+
this.logical.splice(fold.at, fold.shownLength, ...shown);
|
|
1409
|
+
this.rules.splice(fold.at, fold.shownLength, ...shown.map(() => fold.rule));
|
|
1410
|
+
fold.shownLength = shown.length;
|
|
1411
|
+
fold.expanded = expanded;
|
|
1412
|
+
for (const other of this.folds) if (other.at > fold.at) other.at += delta;
|
|
1413
|
+
const before = this.physical.length;
|
|
1414
|
+
const offset = this.offset;
|
|
1415
|
+
this.rewrap();
|
|
1416
|
+
if (offset > 0) {
|
|
1417
|
+
const limit = Math.max(0, this.physical.length - this.viewportHeight());
|
|
1418
|
+
this.offset = Math.min(limit, Math.max(0, offset + this.physical.length - before));
|
|
1419
|
+
}
|
|
1420
|
+
this.painted = [];
|
|
1421
|
+
this.render();
|
|
1422
|
+
}
|
|
1150
1423
|
/** Put every fold into one form, whatever mix of states they are in now. */
|
|
1151
1424
|
setFolds(expanded) {
|
|
1152
1425
|
this.expanded = expanded;
|
|
@@ -1158,6 +1431,7 @@ var Screen = class {
|
|
|
1158
1431
|
}
|
|
1159
1432
|
const shown = expanded ? fold.full : fold.summary;
|
|
1160
1433
|
this.logical.splice(fold.at, fold.shownLength, ...shown);
|
|
1434
|
+
this.rules.splice(fold.at, fold.shownLength, ...shown.map(() => fold.rule));
|
|
1161
1435
|
deltas.set(fold, shown.length - fold.shownLength);
|
|
1162
1436
|
fold.shownLength = shown.length;
|
|
1163
1437
|
fold.expanded = expanded;
|
|
@@ -1178,10 +1452,10 @@ var Screen = class {
|
|
|
1178
1452
|
* @param cursor - where the cursor belongs among them.
|
|
1179
1453
|
* @param focus - whether to show the cursor there.
|
|
1180
1454
|
*/
|
|
1181
|
-
setChrome(rows, cursor, focus) {
|
|
1455
|
+
setChrome(rows, cursor, focus$1) {
|
|
1182
1456
|
this.chrome = rows.map((row) => truncate(row, this.contentColumns()));
|
|
1183
1457
|
this.chromeCursor = { ...cursor };
|
|
1184
|
-
this.chromeFocus = focus;
|
|
1458
|
+
this.chromeFocus = focus$1;
|
|
1185
1459
|
this.render();
|
|
1186
1460
|
}
|
|
1187
1461
|
/**
|
|
@@ -1229,8 +1503,12 @@ var Screen = class {
|
|
|
1229
1503
|
*/
|
|
1230
1504
|
clearTranscript() {
|
|
1231
1505
|
this.logical = [];
|
|
1506
|
+
this.rules = [];
|
|
1232
1507
|
this.physical = [];
|
|
1508
|
+
this.ruleWidths = [];
|
|
1233
1509
|
this.folds = [];
|
|
1510
|
+
this.ranges = void 0;
|
|
1511
|
+
this.hovered = void 0;
|
|
1234
1512
|
this.expanded = false;
|
|
1235
1513
|
this.offset = 0;
|
|
1236
1514
|
this.painted = [];
|
|
@@ -1243,6 +1521,32 @@ var Screen = class {
|
|
|
1243
1521
|
this.render();
|
|
1244
1522
|
}
|
|
1245
1523
|
/**
|
|
1524
|
+
* Note where the pointer is resting, with nothing held down.
|
|
1525
|
+
*
|
|
1526
|
+
* A block is clickable, so it says so while the pointer is on it rather
|
|
1527
|
+
* than only once it is hit. Reports arrive a cell at a time, so the frame is
|
|
1528
|
+
* only touched when the block under the pointer actually changes — moving
|
|
1529
|
+
* along one block, or across the chrome, costs a lookup and nothing else.
|
|
1530
|
+
* @param row - terminal row, 1-based.
|
|
1531
|
+
* @param column - terminal column, 1-based.
|
|
1532
|
+
* @returns the block under the pointer, or undefined for none — reported
|
|
1533
|
+
* every time, so a caller need not track the changes itself.
|
|
1534
|
+
*/
|
|
1535
|
+
mouseMove(row, column) {
|
|
1536
|
+
const at = this.locate(row, column, false);
|
|
1537
|
+
const fold = at === void 0 ? void 0 : this.foldAt(at.row);
|
|
1538
|
+
if (fold !== this.hovered) {
|
|
1539
|
+
this.hovered = fold;
|
|
1540
|
+
this.render();
|
|
1541
|
+
}
|
|
1542
|
+
if (fold === void 0) return void 0;
|
|
1543
|
+
return {
|
|
1544
|
+
label: fold.label,
|
|
1545
|
+
lines: fold.full.length,
|
|
1546
|
+
expanded: fold.expanded
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1246
1550
|
* Anchor a selection where the left button went down.
|
|
1247
1551
|
*
|
|
1248
1552
|
* The terminal cannot select for us while mouse reporting is on, so the
|
|
@@ -1279,7 +1583,9 @@ var Screen = class {
|
|
|
1279
1583
|
* Finish the gesture.
|
|
1280
1584
|
*
|
|
1281
1585
|
* The highlight stays up — the copy already happened, and the marks show
|
|
1282
|
-
* what it took — until the next click or reflow dismisses it.
|
|
1586
|
+
* what it took — until the next click or reflow dismisses it. A press that
|
|
1587
|
+
* never moved is not a selection but a click, and a click on a collapsible
|
|
1588
|
+
* block works that one block: open it, or fold it back.
|
|
1283
1589
|
* @returns the selected text, or undefined for a bare click.
|
|
1284
1590
|
*/
|
|
1285
1591
|
mouseUp() {
|
|
@@ -1287,6 +1593,7 @@ var Screen = class {
|
|
|
1287
1593
|
if (selection === void 0) return void 0;
|
|
1288
1594
|
if (!selection.dragged) {
|
|
1289
1595
|
this.selection = void 0;
|
|
1596
|
+
this.clickFold(selection.anchor.row);
|
|
1290
1597
|
return;
|
|
1291
1598
|
}
|
|
1292
1599
|
const text = this.selectedText();
|
|
@@ -1301,8 +1608,8 @@ var Screen = class {
|
|
|
1301
1608
|
orderedSelection() {
|
|
1302
1609
|
const selection = this.selection;
|
|
1303
1610
|
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];
|
|
1611
|
+
const { anchor, focus: focus$1 } = selection;
|
|
1612
|
+
const [from, to] = focus$1.row < anchor.row || focus$1.row === anchor.row && focus$1.column < anchor.column ? [focus$1, anchor] : [anchor, focus$1];
|
|
1306
1613
|
return {
|
|
1307
1614
|
from,
|
|
1308
1615
|
to
|
|
@@ -1315,9 +1622,10 @@ var Screen = class {
|
|
|
1315
1622
|
const rows = [];
|
|
1316
1623
|
for (let index = bounds.from.row; index <= bounds.to.row; index += 1) {
|
|
1317
1624
|
const plain = (this.physical[index] ?? "").replaceAll(STYLES, "");
|
|
1318
|
-
const
|
|
1625
|
+
const rule = this.ruleWidths[index] ?? 0;
|
|
1626
|
+
const start = columnIndex(plain, index === bounds.from.row ? Math.max(bounds.from.column, rule) : rule);
|
|
1319
1627
|
const end = index === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
|
|
1320
|
-
rows.push(plain.slice(start, end));
|
|
1628
|
+
rows.push(plain.slice(start, Math.max(start, end)));
|
|
1321
1629
|
}
|
|
1322
1630
|
return rows.join("\n").replace(/^\n+|\n+$/gu, "") === "" ? "" : rows.join("\n");
|
|
1323
1631
|
}
|
|
@@ -1350,10 +1658,40 @@ var Screen = class {
|
|
|
1350
1658
|
contentColumns() {
|
|
1351
1659
|
return Math.max(1, this.host.columns() - 1);
|
|
1352
1660
|
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Wrap one logical line, repeating its rule on every row.
|
|
1663
|
+
*
|
|
1664
|
+
* The rule costs columns, so the text wraps inside what is left of the width;
|
|
1665
|
+
* a continuation row without the rule would break the block's left edge
|
|
1666
|
+
* exactly where a long line made it matter most.
|
|
1667
|
+
* @param line - the styled logical line.
|
|
1668
|
+
* @param rule - the styled left rule, `''` for none.
|
|
1669
|
+
* @param columns - display columns available for rule and text together.
|
|
1670
|
+
* @returns the physical rows, rule included.
|
|
1671
|
+
*/
|
|
1672
|
+
wrapLine(line, rule, columns) {
|
|
1673
|
+
if (rule === "") return wrapStyled(line, columns);
|
|
1674
|
+
return wrapStyled(line, Math.max(1, columns - displayWidth(rule))).map((row) => `${rule}${row}`);
|
|
1675
|
+
}
|
|
1676
|
+
/** Re-wrap every kept line at the current width, rules and all. */
|
|
1677
|
+
wrapBuffer() {
|
|
1678
|
+
const columns = this.contentColumns();
|
|
1679
|
+
this.ranges = void 0;
|
|
1680
|
+
this.physical = [];
|
|
1681
|
+
this.ruleWidths = [];
|
|
1682
|
+
for (const [at, line] of this.logical.entries()) {
|
|
1683
|
+
const rule = this.rules[at] ?? "";
|
|
1684
|
+
const width = displayWidth(rule);
|
|
1685
|
+
for (const row of this.wrapLine(line, rule, columns)) {
|
|
1686
|
+
this.physical.push(row);
|
|
1687
|
+
this.ruleWidths.push(width);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1353
1691
|
/** Re-wrap every kept line at the current width. */
|
|
1354
1692
|
rewrap() {
|
|
1355
1693
|
this.selection = void 0;
|
|
1356
|
-
this.
|
|
1694
|
+
this.wrapBuffer();
|
|
1357
1695
|
const limit = Math.max(0, this.physical.length - this.viewportHeight());
|
|
1358
1696
|
this.offset = Math.min(this.offset, limit);
|
|
1359
1697
|
}
|
|
@@ -1368,7 +1706,7 @@ var Screen = class {
|
|
|
1368
1706
|
if (!this.active) return;
|
|
1369
1707
|
const columns = this.host.columns();
|
|
1370
1708
|
if (columns !== this.paintedColumns) {
|
|
1371
|
-
this.
|
|
1709
|
+
this.wrapBuffer();
|
|
1372
1710
|
this.painted = [];
|
|
1373
1711
|
this.paintedColumns = columns;
|
|
1374
1712
|
}
|
|
@@ -1391,6 +1729,12 @@ var Screen = class {
|
|
|
1391
1729
|
visible[index] = `${plain.slice(0, start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(stop)}`;
|
|
1392
1730
|
}
|
|
1393
1731
|
}
|
|
1732
|
+
const hovered = this.hovered;
|
|
1733
|
+
if (hovered !== void 0) {
|
|
1734
|
+
const head = this.foldRanges().find((range) => range.fold === hovered)?.from;
|
|
1735
|
+
const index = head === void 0 ? -1 : head - Math.max(0, end - height);
|
|
1736
|
+
if (index >= 0 && index < visible.length) visible[index] = underline(visible[index] ?? "");
|
|
1737
|
+
}
|
|
1394
1738
|
const viewport = [...visible, ...padding];
|
|
1395
1739
|
if (this.offset > 0 && this.notice !== "" && viewport.length > 0) viewport[0] = truncate(this.notice, this.contentColumns());
|
|
1396
1740
|
const frame = [...viewport, ...this.chrome];
|
|
@@ -1442,6 +1786,11 @@ var TerminalConsole = class {
|
|
|
1442
1786
|
keyHandler;
|
|
1443
1787
|
/** Keys decoded before any handler registered — type-ahead is never dropped. */
|
|
1444
1788
|
earlyKeys = [];
|
|
1789
|
+
/** Whether the terminal window has focus; undefined until it reports. */
|
|
1790
|
+
focused;
|
|
1791
|
+
/** The terminal's OSC 11 background answer, kept for late listeners. */
|
|
1792
|
+
background;
|
|
1793
|
+
backgroundHandler;
|
|
1445
1794
|
escapeTimer;
|
|
1446
1795
|
ended = false;
|
|
1447
1796
|
/** The viewport this surface owns on a terminal; absent off one. */
|
|
@@ -1600,6 +1949,17 @@ var TerminalConsole = class {
|
|
|
1600
1949
|
}
|
|
1601
1950
|
/** Hand one key to the handler, or hold it until one registers. */
|
|
1602
1951
|
deliver(key) {
|
|
1952
|
+
if (key.kind === "focus") {
|
|
1953
|
+
this.focused = key.focused;
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (key.kind === "osc-reply") {
|
|
1957
|
+
if (key.code === 11) {
|
|
1958
|
+
this.background = key.payload;
|
|
1959
|
+
this.backgroundHandler?.(key.payload);
|
|
1960
|
+
}
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1603
1963
|
if (this.keyHandler === void 0) {
|
|
1604
1964
|
this.earlyKeys.push(key);
|
|
1605
1965
|
return;
|
|
@@ -1646,10 +2006,12 @@ var TerminalConsole = class {
|
|
|
1646
2006
|
* what lets the transcript scroll under a prompt that does not move. Off one
|
|
1647
2007
|
* it is written straight out, because a pipe's reader wants exactly that.
|
|
1648
2008
|
* @param line - the line, without its terminator.
|
|
2009
|
+
* @param rule - a styled left rule to draw down the line, `''` for none. Off
|
|
2010
|
+
* a terminal it is dropped: a pipe's reader wants the text, not the frame.
|
|
1649
2011
|
*/
|
|
1650
|
-
write(line) {
|
|
2012
|
+
write(line, rule = "") {
|
|
1651
2013
|
if (this.screen !== void 0) {
|
|
1652
|
-
this.screen.append([line]);
|
|
2014
|
+
this.screen.append([line], rule);
|
|
1653
2015
|
return;
|
|
1654
2016
|
}
|
|
1655
2017
|
this.output.write(`${line}\n`);
|
|
@@ -1665,8 +2027,8 @@ var TerminalConsole = class {
|
|
|
1665
2027
|
* @param focus - whether the rows hold input focus, which is when the cursor
|
|
1666
2028
|
* shows. Parked anywhere else it reads as content colliding with it.
|
|
1667
2029
|
*/
|
|
1668
|
-
setRegion(rows, cursor, focus = true) {
|
|
1669
|
-
this.screen?.setChrome(rows, cursor, focus);
|
|
2030
|
+
setRegion(rows, cursor, focus$1 = true) {
|
|
2031
|
+
this.screen?.setChrome(rows, cursor, focus$1);
|
|
1670
2032
|
}
|
|
1671
2033
|
/**
|
|
1672
2034
|
* Keep one collapsible block: summary now, full form behind the toggle.
|
|
@@ -1675,10 +2037,13 @@ var TerminalConsole = class {
|
|
|
1675
2037
|
* with, and scripts want the digest.
|
|
1676
2038
|
* @param summary - the collapsed lines.
|
|
1677
2039
|
* @param full - the expanded lines.
|
|
2040
|
+
* @param rule - a styled left rule for the whole block, `''` for none.
|
|
2041
|
+
* @param label - what the block is, for the readout naming what the pointer
|
|
2042
|
+
* is over.
|
|
1678
2043
|
*/
|
|
1679
|
-
appendFold(summary, full) {
|
|
2044
|
+
appendFold(summary, full, rule = "", label = "") {
|
|
1680
2045
|
if (this.screen !== void 0) {
|
|
1681
|
-
this.screen.appendFold(summary, full);
|
|
2046
|
+
this.screen.appendFold(summary, full, rule, label);
|
|
1682
2047
|
return;
|
|
1683
2048
|
}
|
|
1684
2049
|
for (const line of summary) this.output.write(`${line}\n`);
|
|
@@ -1704,6 +2069,16 @@ var TerminalConsole = class {
|
|
|
1704
2069
|
this.screen?.mouseDrag(row, column);
|
|
1705
2070
|
}
|
|
1706
2071
|
/**
|
|
2072
|
+
* Note where the pointer is resting, nothing held down.
|
|
2073
|
+
* @param row - terminal row, 1-based.
|
|
2074
|
+
* @param column - terminal column, 1-based.
|
|
2075
|
+
* @returns the block now under the pointer when it changed, undefined
|
|
2076
|
+
* otherwise.
|
|
2077
|
+
*/
|
|
2078
|
+
mouseMove(row, column) {
|
|
2079
|
+
return this.screen?.mouseMove(row, column);
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
1707
2082
|
* Finish the mouse selection.
|
|
1708
2083
|
* @returns the selected text, or undefined for a bare click.
|
|
1709
2084
|
*/
|
|
@@ -1750,9 +2125,11 @@ var TerminalConsole = class {
|
|
|
1750
2125
|
* summary would subtract from it.
|
|
1751
2126
|
* @param count - how many trailing lines the block owns.
|
|
1752
2127
|
* @param summary - the collapsed lines, already styled.
|
|
2128
|
+
* @param label - what the block is, for the readout naming what the pointer
|
|
2129
|
+
* is over.
|
|
1753
2130
|
*/
|
|
1754
|
-
foldRecent(count, summary) {
|
|
1755
|
-
this.screen?.foldBack(count, summary);
|
|
2131
|
+
foldRecent(count, summary, label = "") {
|
|
2132
|
+
this.screen?.foldBack(count, summary, label);
|
|
1756
2133
|
}
|
|
1757
2134
|
toggleFolds() {
|
|
1758
2135
|
return this.screen?.toggleFolds() ?? false;
|
|
@@ -1771,9 +2148,21 @@ var TerminalConsole = class {
|
|
|
1771
2148
|
/** Ring the terminal bell; a pipe gets nothing to beep with. */
|
|
1772
2149
|
bell() {
|
|
1773
2150
|
if (!this.isTty) return;
|
|
2151
|
+
if (this.focused === true) return;
|
|
1774
2152
|
this.output.write("\x07");
|
|
1775
2153
|
}
|
|
1776
2154
|
/**
|
|
2155
|
+
* Register for the terminal's background-color answer (OSC 11).
|
|
2156
|
+
*
|
|
2157
|
+
* The answer often lands before anyone is ready to hear it — the query goes
|
|
2158
|
+
* out with the first frame — so a buffered reply is delivered immediately.
|
|
2159
|
+
* @param handler - receives the raw payload, e.g. `rgb:1e1e/1e1e/2e2e`.
|
|
2160
|
+
*/
|
|
2161
|
+
onBackground(handler) {
|
|
2162
|
+
this.backgroundHandler = handler;
|
|
2163
|
+
if (this.background !== void 0) handler(this.background);
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
1777
2166
|
* Set the terminal window title.
|
|
1778
2167
|
* @param title - the title text; control bytes are the terminal's to reject.
|
|
1779
2168
|
*/
|
|
@@ -2465,10 +2854,131 @@ var Selector = class {
|
|
|
2465
2854
|
}
|
|
2466
2855
|
};
|
|
2467
2856
|
|
|
2857
|
+
//#endregion
|
|
2858
|
+
//#region src/todos.ts
|
|
2859
|
+
/**
|
|
2860
|
+
* Mark for one lifecycle state, styled by what the state means.
|
|
2861
|
+
*
|
|
2862
|
+
* The three marks are codsh's own (`✔`/`▶`/`○`), not the reference agent's
|
|
2863
|
+
* squares: the transcript has used them since todos first rendered, and one
|
|
2864
|
+
* surface speaking two alphabets for the same list is worse than differing from
|
|
2865
|
+
* the reference on a glyph.
|
|
2866
|
+
* @param status - the item's lifecycle state.
|
|
2867
|
+
* @param theme - styling for the mark.
|
|
2868
|
+
* @returns the styled mark.
|
|
2869
|
+
*/
|
|
2870
|
+
function mark(status, theme) {
|
|
2871
|
+
if (status === "completed") return theme.success("✔");
|
|
2872
|
+
if (status === "in_progress") return theme.pending("▶");
|
|
2873
|
+
return theme.dim("○");
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Count each lifecycle state once, so callers never fold the list twice.
|
|
2877
|
+
* @param todos - the list to count.
|
|
2878
|
+
* @returns done, active, and open counts alongside the total.
|
|
2879
|
+
*/
|
|
2880
|
+
function tally(todos) {
|
|
2881
|
+
let done = 0;
|
|
2882
|
+
let active = 0;
|
|
2883
|
+
for (const todo of todos) if (todo.status === "completed") done += 1;
|
|
2884
|
+
else if (todo.status === "in_progress") active += 1;
|
|
2885
|
+
return {
|
|
2886
|
+
done,
|
|
2887
|
+
active,
|
|
2888
|
+
open: todos.length - done - active,
|
|
2889
|
+
total: todos.length
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
2892
|
+
/**
|
|
2893
|
+
* The header both the card and the expanded list carry.
|
|
2894
|
+
*
|
|
2895
|
+
* Progress leads because it is the figure a glance wants; the state breakdown
|
|
2896
|
+
* follows, and a state with nothing in it is dropped rather than shown as zero —
|
|
2897
|
+
* the same rule the status line follows.
|
|
2898
|
+
* @param todos - the list to summarize.
|
|
2899
|
+
* @param theme - styling for the segments.
|
|
2900
|
+
* @param hint - a trailing note, e.g. the key that collapses the list.
|
|
2901
|
+
* @returns the header line.
|
|
2902
|
+
*/
|
|
2903
|
+
function header(todos, theme, hint) {
|
|
2904
|
+
const { done, active, open, total } = tally(todos);
|
|
2905
|
+
const segments = [
|
|
2906
|
+
theme.dim(`${done}/${total}`),
|
|
2907
|
+
...active === 0 ? [] : [theme.dim(`${active} in progress`)],
|
|
2908
|
+
...open === 0 ? [] : [theme.dim(`${open} open`)],
|
|
2909
|
+
...hint === void 0 ? [] : [theme.dim(hint)]
|
|
2910
|
+
];
|
|
2911
|
+
return `${theme.tool("todos")} ${segments.join(theme.dim(" · "))}`;
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* The item a person watching the run cares about: what is being worked now,
|
|
2915
|
+
* or, with nothing active, what comes next.
|
|
2916
|
+
* @param todos - the list to look through.
|
|
2917
|
+
* @returns the item, or undefined when every item is finished.
|
|
2918
|
+
*/
|
|
2919
|
+
function focus(todos) {
|
|
2920
|
+
return todos.find((todo) => todo.status === "in_progress") ?? todos.find((todo) => todo.status === "pending");
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Render the pinned row: one line naming the work in flight and the progress
|
|
2924
|
+
* around it.
|
|
2925
|
+
*
|
|
2926
|
+
* This row is the whole point of reading from a projection rather than from the
|
|
2927
|
+
* write event: the card that announced the list scrolls away, the row does not,
|
|
2928
|
+
* so the list stays answerable at a glance for the rest of the session.
|
|
2929
|
+
* @param todos - the current list.
|
|
2930
|
+
* @param theme - styling for the segments.
|
|
2931
|
+
* @param columns - display columns available; a longer row is cut, never wrapped.
|
|
2932
|
+
* @param hint - a trailing note, e.g. the key that expands the list.
|
|
2933
|
+
* @returns the row, or undefined when there is no list to report.
|
|
2934
|
+
*/
|
|
2935
|
+
function todoRow(todos, theme, columns, hint) {
|
|
2936
|
+
if (todos.length === 0) return void 0;
|
|
2937
|
+
const { done, total } = tally(todos);
|
|
2938
|
+
const next = focus(todos);
|
|
2939
|
+
const body = next === void 0 ? `${theme.success("✔")} ${theme.dim("all done")}` : `${mark(next.status, theme)} ${next.status === "in_progress" ? next.content : `next: ${next.content}`}`;
|
|
2940
|
+
return truncate([
|
|
2941
|
+
theme.tool("todos"),
|
|
2942
|
+
theme.dim(`${done}/${total}`),
|
|
2943
|
+
body,
|
|
2944
|
+
...hint === void 0 ? [] : [theme.dim(hint)]
|
|
2945
|
+
].join(theme.dim(" · ")), columns);
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Render the whole list: the header, then every item under it.
|
|
2949
|
+
*
|
|
2950
|
+
* A surface that cannot scroll passes `limit`, and the items past it are
|
|
2951
|
+
* counted rather than dropped in silence — a list that looks complete but is
|
|
2952
|
+
* not is worse than an honest tail.
|
|
2953
|
+
* @param todos - the current list.
|
|
2954
|
+
* @param theme - styling for the marks and text.
|
|
2955
|
+
* @param columns - display columns available; longer lines are cut.
|
|
2956
|
+
* @param options - header note and item cap.
|
|
2957
|
+
* @returns the lines, empty when there is no list.
|
|
2958
|
+
*/
|
|
2959
|
+
function todoReport(todos, theme, columns, options = {}) {
|
|
2960
|
+
if (todos.length === 0) return [];
|
|
2961
|
+
const { hint, limit } = options;
|
|
2962
|
+
const shown = limit === void 0 ? todos : todos.slice(0, Math.max(0, limit));
|
|
2963
|
+
const hidden = todos.length - shown.length;
|
|
2964
|
+
return [
|
|
2965
|
+
truncate(header(todos, theme, hint), columns),
|
|
2966
|
+
...shown.map((todo) => truncate(` ${mark(todo.status, theme)} ${todo.status === "completed" ? theme.dim(todo.content) : todo.content}`, columns)),
|
|
2967
|
+
...hidden === 0 ? [] : [theme.dim(truncate(` … +${hidden} more`, columns))]
|
|
2968
|
+
];
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2468
2971
|
//#endregion
|
|
2469
2972
|
//#region src/prompt.ts
|
|
2470
2973
|
/** How long a flash notice holds the hint row. */
|
|
2471
2974
|
const FLASH_MS = 1500;
|
|
2975
|
+
/**
|
|
2976
|
+
* Most todo items the expanded list may occupy.
|
|
2977
|
+
*
|
|
2978
|
+
* The chrome never scrolls, so an unbounded list would push the box off a short
|
|
2979
|
+
* terminal; the items past the cap are counted, never silently dropped.
|
|
2980
|
+
*/
|
|
2981
|
+
const TODO_ROWS = 10;
|
|
2472
2982
|
/** Drives the input box and answers reads and selections. */
|
|
2473
2983
|
var Prompt = class {
|
|
2474
2984
|
editor;
|
|
@@ -2485,9 +2995,18 @@ var Prompt = class {
|
|
|
2485
2995
|
hint;
|
|
2486
2996
|
/** A short-lived notice that borrows the hint row, e.g. the copy toast. */
|
|
2487
2997
|
flash;
|
|
2998
|
+
/** What the pointer is resting on, borrowing the hint row while it rests. */
|
|
2999
|
+
hover;
|
|
2488
3000
|
flashTimer;
|
|
2489
3001
|
/** The always-current session facts shown as the region's last row. */
|
|
2490
3002
|
status;
|
|
3003
|
+
/**
|
|
3004
|
+
* The agent's current todo list, kept in the chrome rather than only in the
|
|
3005
|
+
* transcript: the card that announced it scrolls away, this does not.
|
|
3006
|
+
*/
|
|
3007
|
+
todos = [];
|
|
3008
|
+
/** Whether the todo readout shows every item or only the one in flight. */
|
|
3009
|
+
todosExpanded = false;
|
|
2491
3010
|
/** The assistant line still arriving, shown above the box. */
|
|
2492
3011
|
streaming;
|
|
2493
3012
|
/** Frame styling for the current mode, e.g. plan mode's accent. */
|
|
@@ -2588,6 +3107,18 @@ var Prompt = class {
|
|
|
2588
3107
|
this.render();
|
|
2589
3108
|
}
|
|
2590
3109
|
/**
|
|
3110
|
+
* Set the todo readout to the list the session now holds.
|
|
3111
|
+
*
|
|
3112
|
+
* Compared by content, not identity: this is pushed on every session event,
|
|
3113
|
+
* and a repaint per event would flicker the chrome for nothing.
|
|
3114
|
+
* @param todos - the current list, empty to drop the readout.
|
|
3115
|
+
*/
|
|
3116
|
+
setTodos(todos) {
|
|
3117
|
+
if (todos.length === this.todos.length && todos.every((todo, at) => todo.content === this.todos[at]?.content && todo.status === this.todos[at]?.status)) return;
|
|
3118
|
+
this.todos = todos;
|
|
3119
|
+
this.render();
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
2591
3122
|
* Set the frame accent, which is how a mode shows on the box itself.
|
|
2592
3123
|
* @param accent - the styling, or undefined for the default frame.
|
|
2593
3124
|
*/
|
|
@@ -2606,9 +3137,10 @@ var Prompt = class {
|
|
|
2606
3137
|
/**
|
|
2607
3138
|
* Write one finished transcript line above the region.
|
|
2608
3139
|
* @param line - the line to keep.
|
|
3140
|
+
* @param rule - a styled left rule marking which block the line belongs to.
|
|
2609
3141
|
*/
|
|
2610
|
-
write(line) {
|
|
2611
|
-
this.console.write(line);
|
|
3142
|
+
write(line, rule = "") {
|
|
3143
|
+
this.console.write(line, rule);
|
|
2612
3144
|
}
|
|
2613
3145
|
/**
|
|
2614
3146
|
* Wait for the next submitted text.
|
|
@@ -2699,6 +3231,11 @@ var Prompt = class {
|
|
|
2699
3231
|
this.handlers.expandOutput?.();
|
|
2700
3232
|
return;
|
|
2701
3233
|
}
|
|
3234
|
+
if (key.kind === "toggle-todos") {
|
|
3235
|
+
this.todosExpanded = !this.todosExpanded;
|
|
3236
|
+
this.render();
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
2702
3239
|
if (key.kind === "page") {
|
|
2703
3240
|
this.console.scrollPage(key.direction);
|
|
2704
3241
|
this.render();
|
|
@@ -2733,6 +3270,14 @@ var Prompt = class {
|
|
|
2733
3270
|
this.console.mouseDrag(key.row, key.column);
|
|
2734
3271
|
return;
|
|
2735
3272
|
}
|
|
3273
|
+
if (key.kind === "mouse-move") {
|
|
3274
|
+
const block = this.console.mouseMove(key.row, key.column);
|
|
3275
|
+
const readout = block === void 0 ? void 0 : this.theme.dim(` ${block.label} · ${block.lines} lines · click to ${block.expanded ? "fold" : "expand"}`);
|
|
3276
|
+
if (readout === this.hover) return;
|
|
3277
|
+
this.hover = readout;
|
|
3278
|
+
this.render();
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
2736
3281
|
if (key.kind === "mouse-up") {
|
|
2737
3282
|
const text = this.console.mouseUp();
|
|
2738
3283
|
if (text !== void 0 && this.console.copyText(text)) {
|
|
@@ -2781,6 +3326,22 @@ var Prompt = class {
|
|
|
2781
3326
|
}
|
|
2782
3327
|
this.render();
|
|
2783
3328
|
}
|
|
3329
|
+
/**
|
|
3330
|
+
* The todo readout's rows: the one in flight, or the whole list once opened.
|
|
3331
|
+
* @param columns - display columns available.
|
|
3332
|
+
* @returns the rows, empty when no list is live.
|
|
3333
|
+
*/
|
|
3334
|
+
todoRows(columns) {
|
|
3335
|
+
if (this.todos.length === 0) return [];
|
|
3336
|
+
if (!this.todosExpanded) {
|
|
3337
|
+
const row = todoRow(this.todos, this.theme, columns, "Ctrl+T opens the list");
|
|
3338
|
+
return row === void 0 ? [] : [row];
|
|
3339
|
+
}
|
|
3340
|
+
return todoReport(this.todos, this.theme, columns, {
|
|
3341
|
+
hint: "Ctrl+T closes",
|
|
3342
|
+
limit: TODO_ROWS
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
2784
3345
|
/** Recompose and redraw the bottom region. */
|
|
2785
3346
|
render() {
|
|
2786
3347
|
if (!this.console.readsKeys) return;
|
|
@@ -2808,20 +3369,21 @@ var Prompt = class {
|
|
|
2808
3369
|
const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
|
|
2809
3370
|
rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
|
|
2810
3371
|
}
|
|
3372
|
+
rows.push(...this.todoRows(columns));
|
|
2811
3373
|
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;
|
|
3374
|
+
const notice = this.flash ?? this.hover ?? this.hint;
|
|
2813
3375
|
if (notice !== void 0) rows.push(notice);
|
|
2814
3376
|
if (this.status !== void 0) rows.push(this.status);
|
|
2815
3377
|
if (rows.length === 0) {
|
|
2816
3378
|
this.console.clearRegion();
|
|
2817
3379
|
return;
|
|
2818
3380
|
}
|
|
2819
|
-
const focus = this.select_ === void 0 && (this.engaged || this.reading);
|
|
2820
|
-
if (!focus) cursor = {
|
|
3381
|
+
const focus$1 = this.select_ === void 0 && (this.engaged || this.reading);
|
|
3382
|
+
if (!focus$1) cursor = {
|
|
2821
3383
|
row: rows.length - 1,
|
|
2822
3384
|
column: 0
|
|
2823
3385
|
};
|
|
2824
|
-
this.console.setRegion(rows, cursor, focus);
|
|
3386
|
+
this.console.setRegion(rows, cursor, focus$1);
|
|
2825
3387
|
}
|
|
2826
3388
|
};
|
|
2827
3389
|
|
|
@@ -3225,9 +3787,9 @@ function questionLines(question, theme) {
|
|
|
3225
3787
|
if (question.detail !== void 0) lines.push(...question.detail.split("\n"));
|
|
3226
3788
|
lines.push("", theme.bold(question.question));
|
|
3227
3789
|
(question.options ?? []).forEach((option, index) => {
|
|
3228
|
-
const mark = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
|
|
3790
|
+
const mark$1 = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
|
|
3229
3791
|
const description = option.description === void 0 ? "" : theme.dim(` — ${option.description}`);
|
|
3230
|
-
lines.push(` ${mark}. ${option.label}${description}`);
|
|
3792
|
+
lines.push(` ${mark$1}. ${option.label}${description}`);
|
|
3231
3793
|
});
|
|
3232
3794
|
lines.push(theme.dim(" (a number, or type your own answer)"));
|
|
3233
3795
|
return lines;
|
|
@@ -3319,6 +3881,35 @@ var TerminalQuestions = class {
|
|
|
3319
3881
|
}
|
|
3320
3882
|
};
|
|
3321
3883
|
|
|
3884
|
+
//#endregion
|
|
3885
|
+
//#region src/ship.ts
|
|
3886
|
+
/**
|
|
3887
|
+
* The `/ship` prompt: a canned workflow that takes a one-sentence requirement
|
|
3888
|
+
* from idea to shipped, verified code — a research-grounded interview, a
|
|
3889
|
+
* confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
|
|
3890
|
+
* until the spec's acceptance criteria pass.
|
|
3891
|
+
*/
|
|
3892
|
+
/** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
|
|
3893
|
+
const SHIP_PROMPT = `Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:
|
|
3894
|
+
|
|
3895
|
+
<idea>
|
|
3896
|
+
$ARGUMENTS
|
|
3897
|
+
</idea>
|
|
3898
|
+
|
|
3899
|
+
If the idea between the <idea> tags is empty, that is not an error: before anything else, ask for the one-sentence requirement with ask_user_question, and use the answer as the idea for the rest of this workflow.
|
|
3900
|
+
|
|
3901
|
+
Phase 1 — grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer — where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.
|
|
3902
|
+
|
|
3903
|
+
Phase 2 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion is objectively checkable — named tests, commands with expected output, observable behavior. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.
|
|
3904
|
+
|
|
3905
|
+
Phase 3 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
|
|
3906
|
+
|
|
3907
|
+
Phase 4 — landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, and fix until green before moving on. If it is larger — four or more substantially independent milestones, or work you expect to exceed what one session can hold — the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the durable source of truth, instructs each round to read the spec and plan from disk, pick up the next unfinished milestone, implement and test it, and record progress in the workspace, and defines completion as every acceptance criterion in the spec passing.
|
|
3908
|
+
|
|
3909
|
+
Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running the named tests and commands and reading their real output. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Close with a short honest report: what shipped, what was verified and how, and anything left open.
|
|
3910
|
+
|
|
3911
|
+
If the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.`;
|
|
3912
|
+
|
|
3322
3913
|
//#endregion
|
|
3323
3914
|
//#region src/spinner.ts
|
|
3324
3915
|
/** Braille frames, one cell wide each, so the line never changes width. */
|
|
@@ -3346,10 +3937,10 @@ const TICK_MS = 90;
|
|
|
3346
3937
|
*/
|
|
3347
3938
|
function spinnerText(frame, elapsedMs, label, theme) {
|
|
3348
3939
|
const seconds = (elapsedMs / 1e3).toFixed(elapsedMs < 1e4 ? 1 : 0);
|
|
3349
|
-
const mark = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
|
|
3940
|
+
const mark$1 = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
|
|
3350
3941
|
const extra = label.detail?.();
|
|
3351
3942
|
const detail = extra === void 0 || extra === "" ? "" : `${extra} · `;
|
|
3352
|
-
return `${theme.pending(mark)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
|
|
3943
|
+
return `${theme.pending(mark$1)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
|
|
3353
3944
|
}
|
|
3354
3945
|
/** Drives the working indicator for as long as the agent is busy. */
|
|
3355
3946
|
var Spinner = class {
|
|
@@ -3485,7 +4076,7 @@ const MAX_DIFF_LINES = 24;
|
|
|
3485
4076
|
* Result body lines printed for one completed call before the card collapses.
|
|
3486
4077
|
*
|
|
3487
4078
|
* 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.
|
|
4079
|
+
* the collapsed remainder is one click — or one Ctrl+O — away in full.
|
|
3489
4080
|
*/
|
|
3490
4081
|
const MAX_RESULT_LINES = 5;
|
|
3491
4082
|
/**
|
|
@@ -3497,6 +4088,85 @@ function visibleText(content) {
|
|
|
3497
4088
|
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
3498
4089
|
}
|
|
3499
4090
|
/**
|
|
4091
|
+
* The left rules the transcript draws down a block's edge, one per kind.
|
|
4092
|
+
*
|
|
4093
|
+
* A rule is how a segment shows where it starts and ends without a frame or a
|
|
4094
|
+
* background fill: the references converge on a left border (Claude Code's
|
|
4095
|
+
* `borderLeft`, opencode's `border: ["left"]`), and a border costs one column
|
|
4096
|
+
* where a fill costs the terminal's own background — which is the theme's to
|
|
4097
|
+
* decide, not this surface's (ADR-0001).
|
|
4098
|
+
*
|
|
4099
|
+
* The person's own words get the heavy mark; a tool block gets the light one,
|
|
4100
|
+
* in the error colour when the call failed. What a person actually reads — an
|
|
4101
|
+
* answer, a thinking summary — stays flush, so the rules mark the machinery
|
|
4102
|
+
* around it rather than everything equally.
|
|
4103
|
+
* @param theme - styling for the marks.
|
|
4104
|
+
* @returns the rule per block kind.
|
|
4105
|
+
*/
|
|
4106
|
+
function blockRules(theme) {
|
|
4107
|
+
return {
|
|
4108
|
+
user: theme.user("┃ "),
|
|
4109
|
+
tool: theme.tool("│ "),
|
|
4110
|
+
error: theme.error("│ ")
|
|
4111
|
+
};
|
|
4112
|
+
}
|
|
4113
|
+
/**
|
|
4114
|
+
* What the two nameless block kinds are called when a readout names them.
|
|
4115
|
+
*
|
|
4116
|
+
* A tool block answers with its card's own title, which is already on screen;
|
|
4117
|
+
* thinking and an answer have no title of their own, so these are theirs.
|
|
4118
|
+
*/
|
|
4119
|
+
const FOLD_LABELS = {
|
|
4120
|
+
thinking: "thinking",
|
|
4121
|
+
answer: "answer"
|
|
4122
|
+
};
|
|
4123
|
+
/** A finished answer longer than this many rendered lines becomes a fold. */
|
|
4124
|
+
const ANSWER_FOLD_LINES = 24;
|
|
4125
|
+
/** How many of its head lines a collapsed answer keeps visible. */
|
|
4126
|
+
const ANSWER_HEAD_LINES = 8;
|
|
4127
|
+
/**
|
|
4128
|
+
* The collapsed form of a finished answer, when it is long enough to fold.
|
|
4129
|
+
*
|
|
4130
|
+
* Shared by the live turn and by replay: a resumed session must offer the same
|
|
4131
|
+
* summary the turn itself left behind, or history would read as a different
|
|
4132
|
+
* conversation from the one that happened.
|
|
4133
|
+
* @param lines - the answer's rendered lines, without its trailing blank.
|
|
4134
|
+
* @param theme - styling for the count line.
|
|
4135
|
+
* @returns the collapsed lines, or undefined when the answer is short enough
|
|
4136
|
+
* to stand as it is.
|
|
4137
|
+
*/
|
|
4138
|
+
function answerSummary(lines, theme) {
|
|
4139
|
+
if (lines.length <= ANSWER_FOLD_LINES) return void 0;
|
|
4140
|
+
return [
|
|
4141
|
+
...lines.slice(0, ANSWER_HEAD_LINES),
|
|
4142
|
+
theme.dim(` … +${lines.length - ANSWER_HEAD_LINES} lines (click or Ctrl+O expands)`),
|
|
4143
|
+
""
|
|
4144
|
+
];
|
|
4145
|
+
}
|
|
4146
|
+
/**
|
|
4147
|
+
* The two forms of a thinking block: one dim line, and the deliberation behind
|
|
4148
|
+
* it.
|
|
4149
|
+
*
|
|
4150
|
+
* Pages of reasoning would bury the conversation, so the transcript keeps the
|
|
4151
|
+
* summary and hands the rest to Ctrl+O — live and on replay alike.
|
|
4152
|
+
* @param lines - the rendered thinking lines, already styled.
|
|
4153
|
+
* @param theme - styling for the header.
|
|
4154
|
+
* @param seconds - how long the thinking took, when the surface timed it; a
|
|
4155
|
+
* replayed log carries no clock, so the header simply says it thought.
|
|
4156
|
+
* @returns the collapsed and expanded forms.
|
|
4157
|
+
*/
|
|
4158
|
+
function thinkingFold(lines, theme, seconds) {
|
|
4159
|
+
const head = seconds === void 0 ? "✻ thought" : `✻ thought for ${seconds.toFixed(1)}s`;
|
|
4160
|
+
return {
|
|
4161
|
+
summary: [theme.dim(`${head} · +${lines.length} lines (click or Ctrl+O expands)`), ""],
|
|
4162
|
+
full: [
|
|
4163
|
+
theme.dim(head),
|
|
4164
|
+
...lines,
|
|
4165
|
+
""
|
|
4166
|
+
]
|
|
4167
|
+
};
|
|
4168
|
+
}
|
|
4169
|
+
/**
|
|
3500
4170
|
* Render one file's change as unified-diff body lines.
|
|
3501
4171
|
*
|
|
3502
4172
|
* A {@link FileDiff} carries one hunk's old and new blocks including their
|
|
@@ -3532,13 +4202,17 @@ function diffBody(diff, theme) {
|
|
|
3532
4202
|
*/
|
|
3533
4203
|
function cap(lines, limit, theme) {
|
|
3534
4204
|
if (lines.length <= limit) return lines;
|
|
3535
|
-
return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (Ctrl+O expands)`)];
|
|
4205
|
+
return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (click or Ctrl+O expands)`)];
|
|
3536
4206
|
}
|
|
3537
4207
|
/** Renders one session's appended events as terminal lines. */
|
|
3538
4208
|
var Transcript = class {
|
|
3539
4209
|
calls = /* @__PURE__ */ new Map();
|
|
3540
4210
|
/** The full form of the event just rendered, when its body was collapsed. */
|
|
3541
4211
|
fold;
|
|
4212
|
+
/** What the block {@link render} just returned is, for a hover readout. */
|
|
4213
|
+
label = "";
|
|
4214
|
+
/** The left rule the block {@link render} just returned belongs to. */
|
|
4215
|
+
rule = "";
|
|
3542
4216
|
constructor(options, presenters) {
|
|
3543
4217
|
this.options = options;
|
|
3544
4218
|
this.presenters = presenters;
|
|
@@ -3581,9 +4255,12 @@ var Transcript = class {
|
|
|
3581
4255
|
*/
|
|
3582
4256
|
render(event) {
|
|
3583
4257
|
const { theme } = this.options;
|
|
4258
|
+
const rules = blockRules(theme);
|
|
4259
|
+
this.rule = "";
|
|
3584
4260
|
switch (event.type) {
|
|
3585
4261
|
case "user/message": {
|
|
3586
4262
|
if (event.data.source.kind !== "user") return [];
|
|
4263
|
+
this.rule = rules.user;
|
|
3587
4264
|
const [first = "", ...rest] = visibleText(event.data.content).split("\n");
|
|
3588
4265
|
return [
|
|
3589
4266
|
`${theme.user("›")} ${first}`,
|
|
@@ -3595,24 +4272,22 @@ var Transcript = class {
|
|
|
3595
4272
|
const text = visibleText(event.data.message.content);
|
|
3596
4273
|
return text === "" ? [] : [...renderMarkdown(text, theme), ""];
|
|
3597
4274
|
}
|
|
3598
|
-
case "tool/call":
|
|
3599
|
-
|
|
4275
|
+
case "tool/call":
|
|
4276
|
+
this.rule = rules.tool;
|
|
4277
|
+
return this.renderCall(event.data.callId, event.data.name, event.data.arguments);
|
|
4278
|
+
case "tool/result":
|
|
4279
|
+
this.rule = rules.tool;
|
|
4280
|
+
return this.renderResult(event.data);
|
|
3600
4281
|
case "todo/write": {
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
return [
|
|
3605
|
-
`${theme.tool("todos")} ${theme.dim(`${done}/${todos.length}`)}`,
|
|
3606
|
-
...todos.map((todo) => {
|
|
3607
|
-
if (todo.status === "completed") return theme.dim(` ✔ ${todo.content}`);
|
|
3608
|
-
if (todo.status === "in_progress") return ` ${theme.pending("▶")} ${todo.content}`;
|
|
3609
|
-
return theme.dim(` ○ ${todo.content}`);
|
|
3610
|
-
}),
|
|
3611
|
-
""
|
|
3612
|
-
];
|
|
4282
|
+
this.rule = rules.tool;
|
|
4283
|
+
const lines = todoReport(event.data.todos, theme, this.options.columns);
|
|
4284
|
+
return lines.length === 0 ? [] : [...lines, ""];
|
|
3613
4285
|
}
|
|
3614
4286
|
case "plan/mode": return event.data.active ? [theme.pending("▲ plan mode — exploring only; no files will change until you approve a plan"), ""] : [theme.dim("▼ plan mode off"), ""];
|
|
3615
|
-
case "turn/end":
|
|
4287
|
+
case "turn/end":
|
|
4288
|
+
if (event.data.reason.kind !== "error") return [];
|
|
4289
|
+
this.rule = rules.error;
|
|
4290
|
+
return [theme.error(`✗ ${event.data.reason.error.code}: ${event.data.reason.error.message}`), ""];
|
|
3616
4291
|
default: return [];
|
|
3617
4292
|
}
|
|
3618
4293
|
}
|
|
@@ -3642,11 +4317,11 @@ var Transcript = class {
|
|
|
3642
4317
|
};
|
|
3643
4318
|
if (view === void 0) return record(name$1, [`${theme.pending("●")} ${theme.tool(name$1)}`]);
|
|
3644
4319
|
if (view.card === "terminal") {
|
|
3645
|
-
const header = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
|
|
4320
|
+
const header$1 = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
|
|
3646
4321
|
const description = view.description === void 0 ? [] : [theme.dim(` ${view.description}`)];
|
|
3647
4322
|
const command = this.relativizeIn(view.title);
|
|
3648
4323
|
return record(command, [
|
|
3649
|
-
`${theme.pending("●")} ${theme.tool(name$1)}${header}`,
|
|
4324
|
+
`${theme.pending("●")} ${theme.tool(name$1)}${header$1}`,
|
|
3650
4325
|
` $ ${truncate(command, columns - 4)}`,
|
|
3651
4326
|
...description
|
|
3652
4327
|
]);
|
|
@@ -3674,15 +4349,19 @@ var Transcript = class {
|
|
|
3674
4349
|
const pending = this.calls.get(callId);
|
|
3675
4350
|
this.calls.delete(callId);
|
|
3676
4351
|
const failed = error !== void 0 || block.isError === true;
|
|
4352
|
+
if (failed) this.rule = blockRules(theme).error;
|
|
3677
4353
|
const marker = failed ? theme.error("✗") : theme.success("●");
|
|
3678
4354
|
if (pending === void 0) {
|
|
3679
4355
|
const raw = this.resultText(block.content).split("\n");
|
|
3680
4356
|
const head$1 = `${marker} ${theme.dim("(result)")}`;
|
|
3681
|
-
if (raw.length > MAX_RESULT_LINES)
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
4357
|
+
if (raw.length > MAX_RESULT_LINES) {
|
|
4358
|
+
this.fold = [
|
|
4359
|
+
head$1,
|
|
4360
|
+
...raw,
|
|
4361
|
+
""
|
|
4362
|
+
];
|
|
4363
|
+
this.label = "tool result";
|
|
4364
|
+
}
|
|
3686
4365
|
return [
|
|
3687
4366
|
head$1,
|
|
3688
4367
|
...cap(raw, MAX_RESULT_LINES, theme),
|
|
@@ -3693,11 +4372,14 @@ var Transcript = class {
|
|
|
3693
4372
|
const title = view?.title === void 0 ? pending.title : this.relativizeIn(view.title);
|
|
3694
4373
|
const { suffix, body, full } = this.outcome(view, block);
|
|
3695
4374
|
const head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [];
|
|
3696
|
-
if (full !== void 0)
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
4375
|
+
if (full !== void 0) {
|
|
4376
|
+
this.fold = [
|
|
4377
|
+
...head,
|
|
4378
|
+
...full,
|
|
4379
|
+
""
|
|
4380
|
+
];
|
|
4381
|
+
this.label = title;
|
|
4382
|
+
}
|
|
3701
4383
|
return [
|
|
3702
4384
|
...head,
|
|
3703
4385
|
...body,
|
|
@@ -3718,6 +4400,31 @@ var Transcript = class {
|
|
|
3718
4400
|
return fold;
|
|
3719
4401
|
}
|
|
3720
4402
|
/**
|
|
4403
|
+
* What the block {@link takeFold} just described is called.
|
|
4404
|
+
*
|
|
4405
|
+
* The card's title, so the readout that names what the pointer rests on says
|
|
4406
|
+
* the same thing the block's own head line says.
|
|
4407
|
+
* @returns the label, or `''` when the block has no name of its own.
|
|
4408
|
+
*/
|
|
4409
|
+
takeLabel() {
|
|
4410
|
+
const label = this.label;
|
|
4411
|
+
this.label = "";
|
|
4412
|
+
return label;
|
|
4413
|
+
}
|
|
4414
|
+
/**
|
|
4415
|
+
* The left rule for the block {@link render} just returned, `''` when the
|
|
4416
|
+
* block stands flush.
|
|
4417
|
+
*
|
|
4418
|
+
* Paired with the lines rather than baked into them: the rule repeats on
|
|
4419
|
+
* every row the block wraps to, which only the buffer that wraps them knows.
|
|
4420
|
+
* @returns the styled rule, or `''`.
|
|
4421
|
+
*/
|
|
4422
|
+
takeRule() {
|
|
4423
|
+
const rule = this.rule;
|
|
4424
|
+
this.rule = "";
|
|
4425
|
+
return rule;
|
|
4426
|
+
}
|
|
4427
|
+
/**
|
|
3721
4428
|
* Render one completed call's status suffix and body from its declared view.
|
|
3722
4429
|
* @param view - the result view, absent when no presenter answered.
|
|
3723
4430
|
* @param block - the model-facing result block, used by the generic fallback.
|
|
@@ -3908,14 +4615,46 @@ function statusFacts(ctx, agent, cwd, selection, presetId, branch) {
|
|
|
3908
4615
|
};
|
|
3909
4616
|
}
|
|
3910
4617
|
/**
|
|
4618
|
+
* The agent's todo list as the chrome's readout wants it.
|
|
4619
|
+
*
|
|
4620
|
+
* Read from the projection rather than remembered from the write event, so a
|
|
4621
|
+
* resumed session shows the list it left off with and a `/clear` shows none.
|
|
4622
|
+
* @param ctx - plugin context carrying the projection service.
|
|
4623
|
+
* @param agent - the live agent.
|
|
4624
|
+
* @returns the current list, empty before any write.
|
|
4625
|
+
*/
|
|
4626
|
+
function todoList(ctx, agent) {
|
|
4627
|
+
return ctx.get("sessionProjections")?.snapshot(agent.session).values.todos ?? [];
|
|
4628
|
+
}
|
|
4629
|
+
/**
|
|
3911
4630
|
* Render every event a resumed session already holds, so the person sees the
|
|
3912
4631
|
* conversation they are continuing.
|
|
3913
4632
|
* @param session - the reconstructed session.
|
|
3914
4633
|
* @param transcript - the renderer, which also learns the pending call table.
|
|
3915
4634
|
* @param io - the terminal to write to.
|
|
3916
4635
|
*/
|
|
3917
|
-
function replay(session, transcript, io) {
|
|
3918
|
-
for (const event of session.events)
|
|
4636
|
+
function replay(session, transcript, io, theme) {
|
|
4637
|
+
for (const event of session.events) {
|
|
4638
|
+
if (event.type === "assistant/message") {
|
|
4639
|
+
const thought = event.data.message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
|
|
4640
|
+
if (thought !== "") {
|
|
4641
|
+
const { summary: summary$1, full: full$1 } = thinkingFold(thought.split("\n").map((line) => theme.dim(` ${line}`)), theme);
|
|
4642
|
+
io.console.appendFold(summary$1, full$1, "", FOLD_LABELS.thinking);
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
const lines = transcript.render(event);
|
|
4646
|
+
const full = transcript.takeFold();
|
|
4647
|
+
const rule = transcript.takeRule();
|
|
4648
|
+
const label = transcript.takeLabel();
|
|
4649
|
+
if (full !== void 0) {
|
|
4650
|
+
io.console.appendFold(lines, full, rule, label);
|
|
4651
|
+
continue;
|
|
4652
|
+
}
|
|
4653
|
+
for (const line of lines) io.console.write(line, rule);
|
|
4654
|
+
if (event.type !== "assistant/message") continue;
|
|
4655
|
+
const summary = answerSummary(lines.at(-1) === "" ? lines.slice(0, -1) : lines, theme);
|
|
4656
|
+
if (summary !== void 0) io.console.foldRecent(lines.length, summary, FOLD_LABELS.answer);
|
|
4657
|
+
}
|
|
3919
4658
|
}
|
|
3920
4659
|
/**
|
|
3921
4660
|
* Run one conversation turn and wait for the agent to go idle.
|
|
@@ -3962,7 +4701,7 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
|
3962
4701
|
io.console.write("");
|
|
3963
4702
|
return;
|
|
3964
4703
|
}
|
|
3965
|
-
const execution = await commands.execute(agent, line, signal);
|
|
4704
|
+
const execution = await commands.execute(agent, line, [], signal);
|
|
3966
4705
|
if (execution === void 0) {
|
|
3967
4706
|
io.console.write(theme.error(` unknown command: ${line}`));
|
|
3968
4707
|
return;
|
|
@@ -3976,10 +4715,6 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
|
3976
4715
|
const RECALL_WINDOW_MS = 1500;
|
|
3977
4716
|
/** Turns longer than this ring the bell on completion, when the bell is on. */
|
|
3978
4717
|
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;
|
|
3983
4718
|
/**
|
|
3984
4719
|
* Run one subprocess and capture everything it printed.
|
|
3985
4720
|
* @param file - the executable, or a shell when `shell` is given.
|
|
@@ -4131,6 +4866,10 @@ async function run(ctx, config, io) {
|
|
|
4131
4866
|
if (sessions === void 0) return;
|
|
4132
4867
|
const cwd = process.cwd();
|
|
4133
4868
|
const theme = createTheme(io.console.isTty, process.env);
|
|
4869
|
+
io.console.onBackground((payload) => {
|
|
4870
|
+
const light = backgroundIsLight(payload);
|
|
4871
|
+
if (light !== void 0) theme.setLight(light);
|
|
4872
|
+
});
|
|
4134
4873
|
const preset = await installPackagedPreset();
|
|
4135
4874
|
if (preset.installed) io.console.write(theme.dim(`installed preset into ${preset.path}`));
|
|
4136
4875
|
const composed = await compose(ctx, config, cwd);
|
|
@@ -4152,7 +4891,7 @@ async function run(ctx, config, io) {
|
|
|
4152
4891
|
const facts = (branch$1) => statusFacts(ctx, live.agent, cwd, selection, presetId, branch$1);
|
|
4153
4892
|
io.console.setTitle(`dsh code — ${basename(cwd)}`);
|
|
4154
4893
|
let branch = await gitBranch(cwd);
|
|
4155
|
-
if (config.resume !== "") replay(live.agent.session, live.transcript, io);
|
|
4894
|
+
if (config.resume !== "") replay(live.agent.session, live.transcript, io, theme);
|
|
4156
4895
|
for (const line of bannerLines({
|
|
4157
4896
|
model,
|
|
4158
4897
|
preset: presetId,
|
|
@@ -4209,6 +4948,7 @@ async function run(ctx, config, io) {
|
|
|
4209
4948
|
"quit",
|
|
4210
4949
|
"help",
|
|
4211
4950
|
"init",
|
|
4951
|
+
"ship",
|
|
4212
4952
|
"status",
|
|
4213
4953
|
"model",
|
|
4214
4954
|
"clear",
|
|
@@ -4223,6 +4963,10 @@ async function run(ctx, config, io) {
|
|
|
4223
4963
|
name: "init",
|
|
4224
4964
|
description: "analyze the repo and draft AGENTS.md"
|
|
4225
4965
|
},
|
|
4966
|
+
{
|
|
4967
|
+
name: "ship",
|
|
4968
|
+
description: "take a one-sentence idea to shipped code"
|
|
4969
|
+
},
|
|
4226
4970
|
...custom.commands.map((command) => ({
|
|
4227
4971
|
name: command.name,
|
|
4228
4972
|
description: command.description
|
|
@@ -4288,7 +5032,7 @@ async function run(ctx, config, io) {
|
|
|
4288
5032
|
},
|
|
4289
5033
|
shiftTab: () => {
|
|
4290
5034
|
const line = planModeFrom(live.agent.session.events) ? "/plan off" : "/plan";
|
|
4291
|
-
commands?.execute(live.agent, line, new AbortController().signal);
|
|
5035
|
+
commands?.execute(live.agent, line, [], new AbortController().signal);
|
|
4292
5036
|
},
|
|
4293
5037
|
expandOutput: () => {
|
|
4294
5038
|
if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
|
|
@@ -4334,6 +5078,20 @@ async function run(ctx, config, io) {
|
|
|
4334
5078
|
text: statusReport(facts(branch), live.agent.session.id)
|
|
4335
5079
|
})
|
|
4336
5080
|
}));
|
|
5081
|
+
disposers.push(commands.register({
|
|
5082
|
+
name: "todos",
|
|
5083
|
+
description: "print the agent's todo list as it now stands",
|
|
5084
|
+
handler: () => {
|
|
5085
|
+
const lines = todoReport(todoList(ctx, live.agent), theme, io.console.columns);
|
|
5086
|
+
return lines.length === 0 ? {
|
|
5087
|
+
kind: "success",
|
|
5088
|
+
text: "no todos yet"
|
|
5089
|
+
} : {
|
|
5090
|
+
kind: "success",
|
|
5091
|
+
text: lines.join("\n")
|
|
5092
|
+
};
|
|
5093
|
+
}
|
|
5094
|
+
}));
|
|
4337
5095
|
disposers.push(commands.register({
|
|
4338
5096
|
name: "clear",
|
|
4339
5097
|
description: "start a fresh session in place",
|
|
@@ -4470,14 +5228,14 @@ async function run(ctx, config, io) {
|
|
|
4470
5228
|
if (typed === "") {
|
|
4471
5229
|
await refreshModelCatalog();
|
|
4472
5230
|
const current = selection.current;
|
|
4473
|
-
const header = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
|
|
5231
|
+
const header$1 = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
|
|
4474
5232
|
if (modelCatalog.length === 0) return {
|
|
4475
5233
|
kind: "success",
|
|
4476
|
-
text: header
|
|
5234
|
+
text: header$1
|
|
4477
5235
|
};
|
|
4478
5236
|
if (!io.console.readsKeys) return {
|
|
4479
5237
|
kind: "success",
|
|
4480
|
-
text: `${header}\n${modelCatalog.map((entry) => {
|
|
5238
|
+
text: `${header$1}\n${modelCatalog.map((entry) => {
|
|
4481
5239
|
return `${entry.provider === current?.provider && entry.id === current.model ? "❯" : " "} ${entry.provider}/${entry.id} ${entry.name}`;
|
|
4482
5240
|
}).join("\n")}`
|
|
4483
5241
|
};
|
|
@@ -4527,11 +5285,8 @@ async function run(ctx, config, io) {
|
|
|
4527
5285
|
const tail = stream.flush();
|
|
4528
5286
|
emit([...tail, ""]);
|
|
4529
5287
|
answerLines.push(...tail);
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
theme.dim(` … +${answerLines.length - ANSWER_HEAD_LINES} lines (Ctrl+O expands)`),
|
|
4533
|
-
""
|
|
4534
|
-
]);
|
|
5288
|
+
const summary = answerSummary(answerLines, theme);
|
|
5289
|
+
if (summary !== void 0) io.console.foldRecent(answerLines.length + 1, summary, FOLD_LABELS.answer);
|
|
4535
5290
|
answerLines = [];
|
|
4536
5291
|
};
|
|
4537
5292
|
const thinking = new TextStream(theme, () => io.console.contentColumns, true);
|
|
@@ -4540,13 +5295,9 @@ async function run(ctx, config, io) {
|
|
|
4540
5295
|
const flushThinking = () => {
|
|
4541
5296
|
thinkingLines.push(...thinking.flush());
|
|
4542
5297
|
if (thinkingLines.length === 0) return;
|
|
4543
|
-
const seconds = ((performance.now() - thinkingStartedAt) / 1e3).toFixed(1);
|
|
4544
5298
|
prompt.setStreaming(void 0);
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
...thinkingLines,
|
|
4548
|
-
""
|
|
4549
|
-
]);
|
|
5299
|
+
const { summary, full } = thinkingFold(thinkingLines, theme, (performance.now() - thinkingStartedAt) / 1e3);
|
|
5300
|
+
io.console.appendFold(summary, full, "", FOLD_LABELS.thinking);
|
|
4550
5301
|
thinkingLines = [];
|
|
4551
5302
|
thinkingStartedAt = 0;
|
|
4552
5303
|
};
|
|
@@ -4555,15 +5306,16 @@ async function run(ctx, config, io) {
|
|
|
4555
5306
|
* @param lines - finished lines for the transcript.
|
|
4556
5307
|
* @param live - the in-progress line, or undefined to release the region.
|
|
4557
5308
|
*/
|
|
4558
|
-
const emit = (lines, live$1) => {
|
|
5309
|
+
const emit = (lines, live$1, rule = "") => {
|
|
4559
5310
|
if (lines.length > 0) prompt.setStreaming(void 0);
|
|
4560
|
-
for (const line of lines) prompt.write(line);
|
|
5311
|
+
for (const line of lines) prompt.write(line, rule);
|
|
4561
5312
|
prompt.setStreaming(live$1);
|
|
4562
5313
|
};
|
|
4563
5314
|
/** Push the always-current status row; the pipe shape prints it instead. */
|
|
4564
5315
|
const refreshStatus = () => {
|
|
4565
5316
|
if (!io.console.readsKeys) return;
|
|
4566
5317
|
prompt.setStatus(statusLine(facts(branch), theme, io.console.columns - 1));
|
|
5318
|
+
prompt.setTodos(todoList(ctx, live.agent));
|
|
4567
5319
|
};
|
|
4568
5320
|
if (planModeFrom(live.agent.session.events)) prompt.setAccent((text) => theme.pending(text));
|
|
4569
5321
|
refreshStatus();
|
|
@@ -4571,6 +5323,7 @@ async function run(ctx, config, io) {
|
|
|
4571
5323
|
if (session !== live.agent.session) return;
|
|
4572
5324
|
if (event.type === "plan/mode") prompt.setAccent(event.data.active ? (text) => theme.pending(text) : void 0);
|
|
4573
5325
|
refreshStatus();
|
|
5326
|
+
if (event.type === "todo/write") prompt.setTodos(event.data.todos);
|
|
4574
5327
|
if (config.print && event.type === "user/message") return;
|
|
4575
5328
|
if (event.type === "assistant/chunk") {
|
|
4576
5329
|
const { chunk } = event.data;
|
|
@@ -4598,12 +5351,14 @@ async function run(ctx, config, io) {
|
|
|
4598
5351
|
}
|
|
4599
5352
|
const lines = live.transcript.render(event);
|
|
4600
5353
|
const full = live.transcript.takeFold();
|
|
5354
|
+
const rule = live.transcript.takeRule();
|
|
5355
|
+
const label = live.transcript.takeLabel();
|
|
4601
5356
|
if (full === void 0) {
|
|
4602
|
-
emit(lines);
|
|
5357
|
+
emit(lines, void 0, rule);
|
|
4603
5358
|
return;
|
|
4604
5359
|
}
|
|
4605
5360
|
prompt.setStreaming(void 0);
|
|
4606
|
-
io.console.appendFold(lines, full);
|
|
5361
|
+
io.console.appendFold(lines, full, rule, label);
|
|
4607
5362
|
});
|
|
4608
5363
|
/** Pause the indicator around a decision, and resume it if work continues. */
|
|
4609
5364
|
const whileDeciding = async (decide) => {
|
|
@@ -4659,7 +5414,7 @@ async function run(ctx, config, io) {
|
|
|
4659
5414
|
approval.clear();
|
|
4660
5415
|
turnBaseTokens = 0;
|
|
4661
5416
|
prompt.setAccent(planModeFrom(next.agent.session.events) ? (text) => theme.pending(text) : void 0);
|
|
4662
|
-
if (replayLog) replay(next.agent.session, live.transcript, io);
|
|
5417
|
+
if (replayLog) replay(next.agent.session, live.transcript, io, theme);
|
|
4663
5418
|
refreshStatus();
|
|
4664
5419
|
};
|
|
4665
5420
|
const questions = ctx.get("userQuestions");
|
|
@@ -4845,6 +5600,13 @@ async function run(ctx, config, io) {
|
|
|
4845
5600
|
});
|
|
4846
5601
|
continue;
|
|
4847
5602
|
}
|
|
5603
|
+
if (name$1 === "ship") {
|
|
5604
|
+
await answer(expandTemplate(SHIP_PROMPT, rest.trim()), {
|
|
5605
|
+
kind: "plugin",
|
|
5606
|
+
plugin: "coding-cli"
|
|
5607
|
+
});
|
|
5608
|
+
continue;
|
|
5609
|
+
}
|
|
4848
5610
|
const canned = customByName.get(name$1);
|
|
4849
5611
|
if (canned !== void 0) {
|
|
4850
5612
|
await answer(expandTemplate(canned.template, rest.trim()), {
|