codsh-bundle 0.4.0 → 0.6.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 +1272 -146
- package/lib/types/clipboard-image.d.ts +44 -0
- package/lib/types/console.d.ts +19 -3
- package/lib/types/keys.d.ts +8 -0
- package/lib/types/prompt.d.ts +86 -1
- package/lib/types/screen.d.ts +111 -5
- package/lib/types/ship.d.ts +15 -0
- package/lib/types/todos.d.ts +49 -0
- package/lib/types/transcript.d.ts +84 -0
- package/lib/types/vision.d.ts +83 -0
- package/package.json +48 -46
package/lib/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, parse } from "node:path";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
7
|
+
import { admitEncodedImages, isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
|
|
7
8
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
9
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
11
|
import stringWidth from "string-width";
|
|
11
12
|
import { readdirSync } from "node:fs";
|
|
12
13
|
import { createInterface } from "node:readline";
|
|
@@ -586,9 +587,9 @@ function parseCommandFile(source) {
|
|
|
586
587
|
if (source.startsWith("---\n")) {
|
|
587
588
|
const end = source.indexOf("\n---\n", 4);
|
|
588
589
|
if (end >= 0) {
|
|
589
|
-
const header = source.slice(4, end);
|
|
590
|
+
const header$1 = source.slice(4, end);
|
|
590
591
|
return {
|
|
591
|
-
description: /^description:\s*(.+)$/m.exec(header)?.[1]?.trim() ?? "",
|
|
592
|
+
description: /^description:\s*(.+)$/m.exec(header$1)?.[1]?.trim() ?? "",
|
|
592
593
|
body: source.slice(end + 5).trim()
|
|
593
594
|
};
|
|
594
595
|
}
|
|
@@ -697,9 +698,17 @@ const KITTY_CTRL = 4;
|
|
|
697
698
|
const WHEEL_UP = 64;
|
|
698
699
|
/** Modifier bits in an SGR button code: Shift, Meta, and Control. */
|
|
699
700
|
const MOUSE_MODIFIERS = 28;
|
|
700
|
-
/** The motion bit, set on
|
|
701
|
+
/** The motion bit, set on every report the pointer's movement produces. */
|
|
701
702
|
const MOUSE_MOTION = 32;
|
|
702
703
|
/**
|
|
704
|
+
* The button code any-motion tracking sends when no button is held.
|
|
705
|
+
*
|
|
706
|
+
* Motion with a button reports that button; motion with none reports 3, the
|
|
707
|
+
* same code a release carries — so a move is the motion bit over this, which
|
|
708
|
+
* is what tells the surface which block the pointer is merely resting on.
|
|
709
|
+
*/
|
|
710
|
+
const MOUSE_NO_BUTTON = 3;
|
|
711
|
+
/**
|
|
703
712
|
* An OSC reply from the terminal: `ESC ] code ; payload (BEL | ESC \)`.
|
|
704
713
|
*
|
|
705
714
|
* The viewport asks for the background color (OSC 11) on entry; the reply
|
|
@@ -785,7 +794,9 @@ const CONTROLS = {
|
|
|
785
794
|
"\v": { kind: "kill-line" },
|
|
786
795
|
"\f": { kind: "clear-screen" },
|
|
787
796
|
"": { kind: "expand-output" },
|
|
797
|
+
"": { kind: "toggle-todos" },
|
|
788
798
|
"": { kind: "kill-input" },
|
|
799
|
+
"": { kind: "paste-image" },
|
|
789
800
|
"": { kind: "kill-word" }
|
|
790
801
|
};
|
|
791
802
|
/** Decodes terminal bytes into keys, holding partial sequences between reads. */
|
|
@@ -855,6 +866,11 @@ var KeyDecoder = class {
|
|
|
855
866
|
}];
|
|
856
867
|
const column = Number(mouse[2]);
|
|
857
868
|
const row = Number(mouse[3]);
|
|
869
|
+
if ((button & ~MOUSE_MODIFIERS) === (MOUSE_MOTION | MOUSE_NO_BUTTON)) return [{
|
|
870
|
+
kind: "mouse-move",
|
|
871
|
+
row,
|
|
872
|
+
column
|
|
873
|
+
}];
|
|
858
874
|
if ((button & ~MOUSE_MOTION) === 0 && (button & MOUSE_MODIFIERS) === 0) {
|
|
859
875
|
if (mouse[4] === "m") return [{
|
|
860
876
|
kind: "mouse-up",
|
|
@@ -986,7 +1002,7 @@ const SGR = /^\u001B\[[0-9;]*m/;
|
|
|
986
1002
|
/** Any other escape sequence, also zero-width. */
|
|
987
1003
|
const ESCAPE = /^(?:\u001B\[[0-9;?]*[A-Za-z]|\u001B\][^\u0007]*\u0007|\u001B.)/;
|
|
988
1004
|
/** Closes every style a row opened, so a row never bleeds into the next. */
|
|
989
|
-
const RESET = "\x1B[0m";
|
|
1005
|
+
const RESET$1 = "\x1B[0m";
|
|
990
1006
|
/**
|
|
991
1007
|
* Break one styled line into rows no wider than `columns` display columns.
|
|
992
1008
|
*
|
|
@@ -1005,7 +1021,7 @@ function wrapStyled(text, columns) {
|
|
|
1005
1021
|
let width = 0;
|
|
1006
1022
|
let rest = text;
|
|
1007
1023
|
const flush = () => {
|
|
1008
|
-
rows.push(active.length > 0 ? `${row}${RESET}` : row);
|
|
1024
|
+
rows.push(active.length > 0 ? `${row}${RESET$1}` : row);
|
|
1009
1025
|
row = active.join("");
|
|
1010
1026
|
width = 0;
|
|
1011
1027
|
};
|
|
@@ -1032,18 +1048,9 @@ function wrapStyled(text, columns) {
|
|
|
1032
1048
|
width += cost;
|
|
1033
1049
|
rest = rest.slice(character.length);
|
|
1034
1050
|
}
|
|
1035
|
-
rows.push(active.length > 0 ? `${row}${RESET}` : row);
|
|
1051
|
+
rows.push(active.length > 0 ? `${row}${RESET$1}` : row);
|
|
1036
1052
|
return rows;
|
|
1037
1053
|
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Wrap many lines, keeping their order.
|
|
1040
|
-
* @param lines - styled lines.
|
|
1041
|
-
* @param columns - display columns per row.
|
|
1042
|
-
* @returns the physical rows they occupy.
|
|
1043
|
-
*/
|
|
1044
|
-
function wrapAll(lines, columns) {
|
|
1045
|
-
return lines.flatMap((line) => wrapStyled(line, columns));
|
|
1046
|
-
}
|
|
1047
1054
|
|
|
1048
1055
|
//#endregion
|
|
1049
1056
|
//#region src/screen.ts
|
|
@@ -1054,16 +1061,19 @@ const ENTER_ALT = "\x1B[?1049h";
|
|
|
1054
1061
|
/** Leave it, restoring both. */
|
|
1055
1062
|
const LEAVE_ALT = "\x1B[?1049l";
|
|
1056
1063
|
/**
|
|
1057
|
-
* Report wheel and
|
|
1064
|
+
* Report wheel, button, and pointer-motion events, in the SGR encoding.
|
|
1058
1065
|
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1066
|
+
* Any-motion tracking (1003) is what lets the surface say which block the
|
|
1067
|
+
* pointer rests on — the blocks are clickable, and a target that gives no
|
|
1068
|
+
* feedback until it is hit is not an affordance. Button tracking (1002) is
|
|
1069
|
+
* pushed under it so terminals that implement only that one keep the drag
|
|
1070
|
+
* that selects. Motion is a report per cell crossed, so the surface repaints
|
|
1071
|
+
* only when the block under the pointer changes, not on every report. Most
|
|
1072
|
+
* terminals still hand a Shift-drag to their own selection either way.
|
|
1063
1073
|
*/
|
|
1064
|
-
const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1006h";
|
|
1074
|
+
const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1003h\x1B[?1006h";
|
|
1065
1075
|
/** Stop reporting them. */
|
|
1066
|
-
const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1002l";
|
|
1076
|
+
const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1003l\x1B[?1002l";
|
|
1067
1077
|
/**
|
|
1068
1078
|
* Push the kitty keyboard protocol's disambiguate flag — what Claude Code
|
|
1069
1079
|
* pushes — so Shift+Enter, Esc, and control chords report unambiguously on
|
|
@@ -1098,6 +1108,24 @@ const STYLES = /\u001B\[[0-9;]*m/gu;
|
|
|
1098
1108
|
const INVERSE = "\x1B[7m";
|
|
1099
1109
|
/** End reverse video only, leaving any other attributes alone. */
|
|
1100
1110
|
const INVERSE_OFF = "\x1B[27m";
|
|
1111
|
+
/** Start underline, which is how the block under the pointer shows itself. */
|
|
1112
|
+
const UNDERLINE = "\x1B[4m";
|
|
1113
|
+
/** End underline only, leaving any other attributes alone. */
|
|
1114
|
+
const UNDERLINE_OFF = "\x1B[24m";
|
|
1115
|
+
/** A full SGR reset, which every styled span this surface prints ends with. */
|
|
1116
|
+
const RESET = "\x1B[0m";
|
|
1117
|
+
/**
|
|
1118
|
+
* Underline a whole rendered row.
|
|
1119
|
+
*
|
|
1120
|
+
* Every styled span this surface prints ends in a full reset, which would drop
|
|
1121
|
+
* the underline partway along the row — so the attribute is armed again after
|
|
1122
|
+
* each one, and turned off alone at the end so nothing else is disturbed.
|
|
1123
|
+
* @param row - the styled row.
|
|
1124
|
+
* @returns the row, underlined end to end.
|
|
1125
|
+
*/
|
|
1126
|
+
function underline(row) {
|
|
1127
|
+
return `${UNDERLINE}${row.replaceAll(RESET, `${RESET}${UNDERLINE}`)}${UNDERLINE_OFF}`;
|
|
1128
|
+
}
|
|
1101
1129
|
/**
|
|
1102
1130
|
* The string index where a display column begins.
|
|
1103
1131
|
*
|
|
@@ -1121,8 +1149,18 @@ function columnIndex(text, column) {
|
|
|
1121
1149
|
var Screen = class {
|
|
1122
1150
|
/** Logical transcript lines, unwrapped, oldest first. */
|
|
1123
1151
|
logical = [];
|
|
1152
|
+
/**
|
|
1153
|
+
* The left rule each logical line carries, `''` for none.
|
|
1154
|
+
*
|
|
1155
|
+
* Kept beside the text rather than inside it: a rule has to repeat on every
|
|
1156
|
+
* row a line wraps to, and it must never reach the clipboard — it is a mark
|
|
1157
|
+
* the surface draws, not something the person wrote.
|
|
1158
|
+
*/
|
|
1159
|
+
rules = [];
|
|
1124
1160
|
/** The same lines wrapped to the current width — what the viewport slices. */
|
|
1125
1161
|
physical = [];
|
|
1162
|
+
/** Display columns the rule occupies on each physical row, for copy and hits. */
|
|
1163
|
+
ruleWidths = [];
|
|
1126
1164
|
/** The bottom rows: input box, menu, indicator, status. */
|
|
1127
1165
|
chrome = [];
|
|
1128
1166
|
chromeCursor = {
|
|
@@ -1139,6 +1177,18 @@ var Screen = class {
|
|
|
1139
1177
|
selection;
|
|
1140
1178
|
/** Collapsed blocks in the transcript, in order, with both of their forms. */
|
|
1141
1179
|
folds = [];
|
|
1180
|
+
/** The block the pointer rests on, or undefined when it rests on none. */
|
|
1181
|
+
hovered;
|
|
1182
|
+
/**
|
|
1183
|
+
* Physical row ranges the blocks occupy, or undefined when they need
|
|
1184
|
+
* measuring again.
|
|
1185
|
+
*
|
|
1186
|
+
* Motion arrives a report per cell crossed, and measuring a block's row from
|
|
1187
|
+
* the wrapped height of everything above it is a walk over the buffer — far
|
|
1188
|
+
* too much to redo per report. The walk happens once after the buffer
|
|
1189
|
+
* changes instead, and every report in between is a lookup.
|
|
1190
|
+
*/
|
|
1191
|
+
ranges;
|
|
1142
1192
|
/** Whether the folds currently show their full form. */
|
|
1143
1193
|
expanded = false;
|
|
1144
1194
|
/** The last painted frame, so a repaint only touches rows that changed. */
|
|
@@ -1184,16 +1234,23 @@ var Screen = class {
|
|
|
1184
1234
|
* where they are, and the new rows accumulate below them.
|
|
1185
1235
|
* @param lines - the lines to keep, already styled.
|
|
1186
1236
|
*/
|
|
1187
|
-
append(lines) {
|
|
1237
|
+
append(lines, rule = "") {
|
|
1188
1238
|
if (lines.length === 0) return;
|
|
1189
1239
|
const columns = this.contentColumns();
|
|
1190
1240
|
for (const line of lines) {
|
|
1241
|
+
const own = line === "" ? "" : rule;
|
|
1191
1242
|
this.logical.push(line);
|
|
1192
|
-
this.
|
|
1243
|
+
this.rules.push(own);
|
|
1244
|
+
for (const row of this.wrapLine(line, own, columns)) {
|
|
1245
|
+
this.physical.push(row);
|
|
1246
|
+
this.ruleWidths.push(displayWidth(own));
|
|
1247
|
+
}
|
|
1193
1248
|
}
|
|
1249
|
+
this.ranges = void 0;
|
|
1194
1250
|
if (this.logical.length > MAX_SCROLLBACK) {
|
|
1195
1251
|
const dropped = this.logical.length - MAX_SCROLLBACK;
|
|
1196
1252
|
this.logical.splice(0, dropped);
|
|
1253
|
+
this.rules.splice(0, dropped);
|
|
1197
1254
|
this.folds = this.folds.flatMap((fold) => {
|
|
1198
1255
|
const at = fold.at - dropped;
|
|
1199
1256
|
return at >= 0 ? [{
|
|
@@ -1201,6 +1258,7 @@ var Screen = class {
|
|
|
1201
1258
|
at
|
|
1202
1259
|
}] : [];
|
|
1203
1260
|
});
|
|
1261
|
+
this.hovered = void 0;
|
|
1204
1262
|
this.rewrap();
|
|
1205
1263
|
}
|
|
1206
1264
|
this.render();
|
|
@@ -1213,17 +1271,21 @@ var Screen = class {
|
|
|
1213
1271
|
* place, exactly like a details/summary element.
|
|
1214
1272
|
* @param summary - the collapsed lines, already styled.
|
|
1215
1273
|
* @param full - the expanded lines, already styled.
|
|
1274
|
+
* @param rule - a styled left rule for the whole block, `''` for none.
|
|
1275
|
+
* @param label - what the block is, for the hover readout that names it.
|
|
1216
1276
|
*/
|
|
1217
|
-
appendFold(summary, full) {
|
|
1277
|
+
appendFold(summary, full, rule = "", label = "") {
|
|
1218
1278
|
const shown = this.expanded ? full : summary;
|
|
1219
1279
|
this.folds.push({
|
|
1220
1280
|
at: this.logical.length,
|
|
1221
1281
|
shownLength: shown.length,
|
|
1222
1282
|
summary: [...summary],
|
|
1223
1283
|
full: [...full],
|
|
1224
|
-
expanded: this.expanded
|
|
1284
|
+
expanded: this.expanded,
|
|
1285
|
+
rule,
|
|
1286
|
+
label
|
|
1225
1287
|
});
|
|
1226
|
-
this.append(shown);
|
|
1288
|
+
this.append(shown, rule);
|
|
1227
1289
|
}
|
|
1228
1290
|
/**
|
|
1229
1291
|
* Turn the last `count` appended lines into a collapsible block after the
|
|
@@ -1235,8 +1297,9 @@ var Screen = class {
|
|
|
1235
1297
|
* collapses with the rest when the conversation moves on.
|
|
1236
1298
|
* @param count - how many trailing lines the block owns.
|
|
1237
1299
|
* @param summary - the collapsed lines, already styled.
|
|
1300
|
+
* @param label - what the block is, for the hover readout that names it.
|
|
1238
1301
|
*/
|
|
1239
|
-
foldBack(count, summary) {
|
|
1302
|
+
foldBack(count, summary, label = "") {
|
|
1240
1303
|
const at = this.logical.length - count;
|
|
1241
1304
|
if (count <= 0 || at < 0) return;
|
|
1242
1305
|
const last = this.folds.at(-1);
|
|
@@ -1246,8 +1309,11 @@ var Screen = class {
|
|
|
1246
1309
|
shownLength: count,
|
|
1247
1310
|
summary: [...summary],
|
|
1248
1311
|
full: this.logical.slice(at),
|
|
1249
|
-
expanded: true
|
|
1312
|
+
expanded: true,
|
|
1313
|
+
rule: this.rules.slice(at).find((rule) => rule !== "") ?? "",
|
|
1314
|
+
label
|
|
1250
1315
|
});
|
|
1316
|
+
this.ranges = void 0;
|
|
1251
1317
|
}
|
|
1252
1318
|
/** Whether any collapsible block exists. */
|
|
1253
1319
|
get hasFolds() {
|
|
@@ -1259,11 +1325,15 @@ var Screen = class {
|
|
|
1259
1325
|
}
|
|
1260
1326
|
/**
|
|
1261
1327
|
* Swap every fold between its summary and its full form.
|
|
1328
|
+
*
|
|
1329
|
+
* What the blocks show decides the direction, not what the last Ctrl+O did:
|
|
1330
|
+
* clicking blocks open one at a time would otherwise leave the key pointing
|
|
1331
|
+
* the wrong way, and a press that visibly does nothing reads as broken.
|
|
1262
1332
|
* @returns false when there is nothing to toggle.
|
|
1263
1333
|
*/
|
|
1264
1334
|
toggleFolds() {
|
|
1265
1335
|
if (this.folds.length === 0) return false;
|
|
1266
|
-
this.setFolds(!this.expanded);
|
|
1336
|
+
this.setFolds(!this.folds.every((fold) => fold.expanded));
|
|
1267
1337
|
return true;
|
|
1268
1338
|
}
|
|
1269
1339
|
/** Return every fold to its summary, the way moving on reads as dismissal. */
|
|
@@ -1271,6 +1341,87 @@ var Screen = class {
|
|
|
1271
1341
|
if (this.folds.some((fold) => fold.expanded)) this.setFolds(false);
|
|
1272
1342
|
else this.expanded = false;
|
|
1273
1343
|
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Work the block a bare click landed on, the way a details element opens.
|
|
1346
|
+
*
|
|
1347
|
+
* The whole block is the target, in both forms: collapsed, the `+N lines`
|
|
1348
|
+
* line is what a person aims at, and open, anywhere inside the text folds it
|
|
1349
|
+
* back — hunting for a head row that has scrolled off the top is not an
|
|
1350
|
+
* affordance. Selecting text inside a block is a drag, which never reaches
|
|
1351
|
+
* here, so reading is unaffected.
|
|
1352
|
+
* @param row - physical buffer row the press anchored on.
|
|
1353
|
+
*/
|
|
1354
|
+
clickFold(row) {
|
|
1355
|
+
const fold = this.foldAt(row);
|
|
1356
|
+
if (fold === void 0) return;
|
|
1357
|
+
this.setFold(fold, !fold.expanded);
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Where each block sits in physical rows.
|
|
1361
|
+
*
|
|
1362
|
+
* Blocks are recorded in logical lines while the mouse reports physical
|
|
1363
|
+
* rows, so the wrapped height of everything above a block is what bridges
|
|
1364
|
+
* the two — measured under each line's own rule, which costs columns and so
|
|
1365
|
+
* changes the height.
|
|
1366
|
+
* @returns one range per block, in buffer order.
|
|
1367
|
+
*/
|
|
1368
|
+
foldRanges() {
|
|
1369
|
+
if (this.ranges !== void 0) return this.ranges;
|
|
1370
|
+
const columns = this.contentColumns();
|
|
1371
|
+
const height = (index$1) => this.wrapLine(this.logical[index$1] ?? "", this.rules[index$1] ?? "", columns).length;
|
|
1372
|
+
const ranges = [];
|
|
1373
|
+
let physical = 0;
|
|
1374
|
+
let index = 0;
|
|
1375
|
+
for (const fold of this.folds) {
|
|
1376
|
+
for (; index < fold.at; index += 1) physical += height(index);
|
|
1377
|
+
const from = physical;
|
|
1378
|
+
for (; index < fold.at + fold.shownLength; index += 1) physical += height(index);
|
|
1379
|
+
ranges.push({
|
|
1380
|
+
fold,
|
|
1381
|
+
from,
|
|
1382
|
+
to: physical - 1
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
this.ranges = ranges;
|
|
1386
|
+
return ranges;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* The block covering a physical row.
|
|
1390
|
+
* @param row - physical buffer row, 0-based.
|
|
1391
|
+
* @returns the block, or undefined when the row is not in one.
|
|
1392
|
+
*/
|
|
1393
|
+
foldAt(row) {
|
|
1394
|
+
return this.foldRanges().find((range) => row >= range.from && row <= range.to)?.fold;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Swap one block, leaving the reader where they were.
|
|
1398
|
+
*
|
|
1399
|
+
* Someone who opened a block halfway up their history did not ask to be
|
|
1400
|
+
* moved to the tail: the rows above the block keep their screen positions,
|
|
1401
|
+
* and the transcript grows or shrinks below them. Following the tail there
|
|
1402
|
+
* is nothing to hold on to, so the frame keeps following it — which is what
|
|
1403
|
+
* Ctrl+O does for every block at once.
|
|
1404
|
+
* @param fold - the block to swap.
|
|
1405
|
+
* @param expanded - the form to put on screen.
|
|
1406
|
+
*/
|
|
1407
|
+
setFold(fold, expanded) {
|
|
1408
|
+
const shown = expanded ? fold.full : fold.summary;
|
|
1409
|
+
const delta = shown.length - fold.shownLength;
|
|
1410
|
+
this.logical.splice(fold.at, fold.shownLength, ...shown);
|
|
1411
|
+
this.rules.splice(fold.at, fold.shownLength, ...shown.map(() => fold.rule));
|
|
1412
|
+
fold.shownLength = shown.length;
|
|
1413
|
+
fold.expanded = expanded;
|
|
1414
|
+
for (const other of this.folds) if (other.at > fold.at) other.at += delta;
|
|
1415
|
+
const before = this.physical.length;
|
|
1416
|
+
const offset = this.offset;
|
|
1417
|
+
this.rewrap();
|
|
1418
|
+
if (offset > 0) {
|
|
1419
|
+
const limit = Math.max(0, this.physical.length - this.viewportHeight());
|
|
1420
|
+
this.offset = Math.min(limit, Math.max(0, offset + this.physical.length - before));
|
|
1421
|
+
}
|
|
1422
|
+
this.painted = [];
|
|
1423
|
+
this.render();
|
|
1424
|
+
}
|
|
1274
1425
|
/** Put every fold into one form, whatever mix of states they are in now. */
|
|
1275
1426
|
setFolds(expanded) {
|
|
1276
1427
|
this.expanded = expanded;
|
|
@@ -1282,6 +1433,7 @@ var Screen = class {
|
|
|
1282
1433
|
}
|
|
1283
1434
|
const shown = expanded ? fold.full : fold.summary;
|
|
1284
1435
|
this.logical.splice(fold.at, fold.shownLength, ...shown);
|
|
1436
|
+
this.rules.splice(fold.at, fold.shownLength, ...shown.map(() => fold.rule));
|
|
1285
1437
|
deltas.set(fold, shown.length - fold.shownLength);
|
|
1286
1438
|
fold.shownLength = shown.length;
|
|
1287
1439
|
fold.expanded = expanded;
|
|
@@ -1302,10 +1454,10 @@ var Screen = class {
|
|
|
1302
1454
|
* @param cursor - where the cursor belongs among them.
|
|
1303
1455
|
* @param focus - whether to show the cursor there.
|
|
1304
1456
|
*/
|
|
1305
|
-
setChrome(rows, cursor, focus) {
|
|
1457
|
+
setChrome(rows, cursor, focus$1) {
|
|
1306
1458
|
this.chrome = rows.map((row) => truncate(row, this.contentColumns()));
|
|
1307
1459
|
this.chromeCursor = { ...cursor };
|
|
1308
|
-
this.chromeFocus = focus;
|
|
1460
|
+
this.chromeFocus = focus$1;
|
|
1309
1461
|
this.render();
|
|
1310
1462
|
}
|
|
1311
1463
|
/**
|
|
@@ -1353,8 +1505,12 @@ var Screen = class {
|
|
|
1353
1505
|
*/
|
|
1354
1506
|
clearTranscript() {
|
|
1355
1507
|
this.logical = [];
|
|
1508
|
+
this.rules = [];
|
|
1356
1509
|
this.physical = [];
|
|
1510
|
+
this.ruleWidths = [];
|
|
1357
1511
|
this.folds = [];
|
|
1512
|
+
this.ranges = void 0;
|
|
1513
|
+
this.hovered = void 0;
|
|
1358
1514
|
this.expanded = false;
|
|
1359
1515
|
this.offset = 0;
|
|
1360
1516
|
this.painted = [];
|
|
@@ -1367,6 +1523,32 @@ var Screen = class {
|
|
|
1367
1523
|
this.render();
|
|
1368
1524
|
}
|
|
1369
1525
|
/**
|
|
1526
|
+
* Note where the pointer is resting, with nothing held down.
|
|
1527
|
+
*
|
|
1528
|
+
* A block is clickable, so it says so while the pointer is on it rather
|
|
1529
|
+
* than only once it is hit. Reports arrive a cell at a time, so the frame is
|
|
1530
|
+
* only touched when the block under the pointer actually changes — moving
|
|
1531
|
+
* along one block, or across the chrome, costs a lookup and nothing else.
|
|
1532
|
+
* @param row - terminal row, 1-based.
|
|
1533
|
+
* @param column - terminal column, 1-based.
|
|
1534
|
+
* @returns the block under the pointer, or undefined for none — reported
|
|
1535
|
+
* every time, so a caller need not track the changes itself.
|
|
1536
|
+
*/
|
|
1537
|
+
mouseMove(row, column) {
|
|
1538
|
+
const at = this.locate(row, column, false);
|
|
1539
|
+
const fold = at === void 0 ? void 0 : this.foldAt(at.row);
|
|
1540
|
+
if (fold !== this.hovered) {
|
|
1541
|
+
this.hovered = fold;
|
|
1542
|
+
this.render();
|
|
1543
|
+
}
|
|
1544
|
+
if (fold === void 0) return void 0;
|
|
1545
|
+
return {
|
|
1546
|
+
label: fold.label,
|
|
1547
|
+
lines: fold.full.length,
|
|
1548
|
+
expanded: fold.expanded
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1370
1552
|
* Anchor a selection where the left button went down.
|
|
1371
1553
|
*
|
|
1372
1554
|
* The terminal cannot select for us while mouse reporting is on, so the
|
|
@@ -1403,7 +1585,9 @@ var Screen = class {
|
|
|
1403
1585
|
* Finish the gesture.
|
|
1404
1586
|
*
|
|
1405
1587
|
* The highlight stays up — the copy already happened, and the marks show
|
|
1406
|
-
* what it took — until the next click or reflow dismisses it.
|
|
1588
|
+
* what it took — until the next click or reflow dismisses it. A press that
|
|
1589
|
+
* never moved is not a selection but a click, and a click on a collapsible
|
|
1590
|
+
* block works that one block: open it, or fold it back.
|
|
1407
1591
|
* @returns the selected text, or undefined for a bare click.
|
|
1408
1592
|
*/
|
|
1409
1593
|
mouseUp() {
|
|
@@ -1411,6 +1595,7 @@ var Screen = class {
|
|
|
1411
1595
|
if (selection === void 0) return void 0;
|
|
1412
1596
|
if (!selection.dragged) {
|
|
1413
1597
|
this.selection = void 0;
|
|
1598
|
+
this.clickFold(selection.anchor.row);
|
|
1414
1599
|
return;
|
|
1415
1600
|
}
|
|
1416
1601
|
const text = this.selectedText();
|
|
@@ -1425,8 +1610,8 @@ var Screen = class {
|
|
|
1425
1610
|
orderedSelection() {
|
|
1426
1611
|
const selection = this.selection;
|
|
1427
1612
|
if (selection === void 0 || !selection.dragged) return void 0;
|
|
1428
|
-
const { anchor, focus } = selection;
|
|
1429
|
-
const [from, to] = focus.row < anchor.row || focus.row === anchor.row && focus.column < anchor.column ? [focus, anchor] : [anchor, focus];
|
|
1613
|
+
const { anchor, focus: focus$1 } = selection;
|
|
1614
|
+
const [from, to] = focus$1.row < anchor.row || focus$1.row === anchor.row && focus$1.column < anchor.column ? [focus$1, anchor] : [anchor, focus$1];
|
|
1430
1615
|
return {
|
|
1431
1616
|
from,
|
|
1432
1617
|
to
|
|
@@ -1439,9 +1624,10 @@ var Screen = class {
|
|
|
1439
1624
|
const rows = [];
|
|
1440
1625
|
for (let index = bounds.from.row; index <= bounds.to.row; index += 1) {
|
|
1441
1626
|
const plain = (this.physical[index] ?? "").replaceAll(STYLES, "");
|
|
1442
|
-
const
|
|
1627
|
+
const rule = this.ruleWidths[index] ?? 0;
|
|
1628
|
+
const start = columnIndex(plain, index === bounds.from.row ? Math.max(bounds.from.column, rule) : rule);
|
|
1443
1629
|
const end = index === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
|
|
1444
|
-
rows.push(plain.slice(start, end));
|
|
1630
|
+
rows.push(plain.slice(start, Math.max(start, end)));
|
|
1445
1631
|
}
|
|
1446
1632
|
return rows.join("\n").replace(/^\n+|\n+$/gu, "") === "" ? "" : rows.join("\n");
|
|
1447
1633
|
}
|
|
@@ -1474,10 +1660,40 @@ var Screen = class {
|
|
|
1474
1660
|
contentColumns() {
|
|
1475
1661
|
return Math.max(1, this.host.columns() - 1);
|
|
1476
1662
|
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Wrap one logical line, repeating its rule on every row.
|
|
1665
|
+
*
|
|
1666
|
+
* The rule costs columns, so the text wraps inside what is left of the width;
|
|
1667
|
+
* a continuation row without the rule would break the block's left edge
|
|
1668
|
+
* exactly where a long line made it matter most.
|
|
1669
|
+
* @param line - the styled logical line.
|
|
1670
|
+
* @param rule - the styled left rule, `''` for none.
|
|
1671
|
+
* @param columns - display columns available for rule and text together.
|
|
1672
|
+
* @returns the physical rows, rule included.
|
|
1673
|
+
*/
|
|
1674
|
+
wrapLine(line, rule, columns) {
|
|
1675
|
+
if (rule === "") return wrapStyled(line, columns);
|
|
1676
|
+
return wrapStyled(line, Math.max(1, columns - displayWidth(rule))).map((row) => `${rule}${row}`);
|
|
1677
|
+
}
|
|
1678
|
+
/** Re-wrap every kept line at the current width, rules and all. */
|
|
1679
|
+
wrapBuffer() {
|
|
1680
|
+
const columns = this.contentColumns();
|
|
1681
|
+
this.ranges = void 0;
|
|
1682
|
+
this.physical = [];
|
|
1683
|
+
this.ruleWidths = [];
|
|
1684
|
+
for (const [at, line] of this.logical.entries()) {
|
|
1685
|
+
const rule = this.rules[at] ?? "";
|
|
1686
|
+
const width = displayWidth(rule);
|
|
1687
|
+
for (const row of this.wrapLine(line, rule, columns)) {
|
|
1688
|
+
this.physical.push(row);
|
|
1689
|
+
this.ruleWidths.push(width);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1477
1693
|
/** Re-wrap every kept line at the current width. */
|
|
1478
1694
|
rewrap() {
|
|
1479
1695
|
this.selection = void 0;
|
|
1480
|
-
this.
|
|
1696
|
+
this.wrapBuffer();
|
|
1481
1697
|
const limit = Math.max(0, this.physical.length - this.viewportHeight());
|
|
1482
1698
|
this.offset = Math.min(this.offset, limit);
|
|
1483
1699
|
}
|
|
@@ -1492,7 +1708,7 @@ var Screen = class {
|
|
|
1492
1708
|
if (!this.active) return;
|
|
1493
1709
|
const columns = this.host.columns();
|
|
1494
1710
|
if (columns !== this.paintedColumns) {
|
|
1495
|
-
this.
|
|
1711
|
+
this.wrapBuffer();
|
|
1496
1712
|
this.painted = [];
|
|
1497
1713
|
this.paintedColumns = columns;
|
|
1498
1714
|
}
|
|
@@ -1515,6 +1731,12 @@ var Screen = class {
|
|
|
1515
1731
|
visible[index] = `${plain.slice(0, start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(stop)}`;
|
|
1516
1732
|
}
|
|
1517
1733
|
}
|
|
1734
|
+
const hovered = this.hovered;
|
|
1735
|
+
if (hovered !== void 0) {
|
|
1736
|
+
const head = this.foldRanges().find((range) => range.fold === hovered)?.from;
|
|
1737
|
+
const index = head === void 0 ? -1 : head - Math.max(0, end - height);
|
|
1738
|
+
if (index >= 0 && index < visible.length) visible[index] = underline(visible[index] ?? "");
|
|
1739
|
+
}
|
|
1518
1740
|
const viewport = [...visible, ...padding];
|
|
1519
1741
|
if (this.offset > 0 && this.notice !== "" && viewport.length > 0) viewport[0] = truncate(this.notice, this.contentColumns());
|
|
1520
1742
|
const frame = [...viewport, ...this.chrome];
|
|
@@ -1786,10 +2008,12 @@ var TerminalConsole = class {
|
|
|
1786
2008
|
* what lets the transcript scroll under a prompt that does not move. Off one
|
|
1787
2009
|
* it is written straight out, because a pipe's reader wants exactly that.
|
|
1788
2010
|
* @param line - the line, without its terminator.
|
|
2011
|
+
* @param rule - a styled left rule to draw down the line, `''` for none. Off
|
|
2012
|
+
* a terminal it is dropped: a pipe's reader wants the text, not the frame.
|
|
1789
2013
|
*/
|
|
1790
|
-
write(line) {
|
|
2014
|
+
write(line, rule = "") {
|
|
1791
2015
|
if (this.screen !== void 0) {
|
|
1792
|
-
this.screen.append([line]);
|
|
2016
|
+
this.screen.append([line], rule);
|
|
1793
2017
|
return;
|
|
1794
2018
|
}
|
|
1795
2019
|
this.output.write(`${line}\n`);
|
|
@@ -1805,8 +2029,8 @@ var TerminalConsole = class {
|
|
|
1805
2029
|
* @param focus - whether the rows hold input focus, which is when the cursor
|
|
1806
2030
|
* shows. Parked anywhere else it reads as content colliding with it.
|
|
1807
2031
|
*/
|
|
1808
|
-
setRegion(rows, cursor, focus = true) {
|
|
1809
|
-
this.screen?.setChrome(rows, cursor, focus);
|
|
2032
|
+
setRegion(rows, cursor, focus$1 = true) {
|
|
2033
|
+
this.screen?.setChrome(rows, cursor, focus$1);
|
|
1810
2034
|
}
|
|
1811
2035
|
/**
|
|
1812
2036
|
* Keep one collapsible block: summary now, full form behind the toggle.
|
|
@@ -1815,10 +2039,13 @@ var TerminalConsole = class {
|
|
|
1815
2039
|
* with, and scripts want the digest.
|
|
1816
2040
|
* @param summary - the collapsed lines.
|
|
1817
2041
|
* @param full - the expanded lines.
|
|
2042
|
+
* @param rule - a styled left rule for the whole block, `''` for none.
|
|
2043
|
+
* @param label - what the block is, for the readout naming what the pointer
|
|
2044
|
+
* is over.
|
|
1818
2045
|
*/
|
|
1819
|
-
appendFold(summary, full) {
|
|
2046
|
+
appendFold(summary, full, rule = "", label = "") {
|
|
1820
2047
|
if (this.screen !== void 0) {
|
|
1821
|
-
this.screen.appendFold(summary, full);
|
|
2048
|
+
this.screen.appendFold(summary, full, rule, label);
|
|
1822
2049
|
return;
|
|
1823
2050
|
}
|
|
1824
2051
|
for (const line of summary) this.output.write(`${line}\n`);
|
|
@@ -1844,6 +2071,16 @@ var TerminalConsole = class {
|
|
|
1844
2071
|
this.screen?.mouseDrag(row, column);
|
|
1845
2072
|
}
|
|
1846
2073
|
/**
|
|
2074
|
+
* Note where the pointer is resting, nothing held down.
|
|
2075
|
+
* @param row - terminal row, 1-based.
|
|
2076
|
+
* @param column - terminal column, 1-based.
|
|
2077
|
+
* @returns the block now under the pointer when it changed, undefined
|
|
2078
|
+
* otherwise.
|
|
2079
|
+
*/
|
|
2080
|
+
mouseMove(row, column) {
|
|
2081
|
+
return this.screen?.mouseMove(row, column);
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
1847
2084
|
* Finish the mouse selection.
|
|
1848
2085
|
* @returns the selected text, or undefined for a bare click.
|
|
1849
2086
|
*/
|
|
@@ -1890,9 +2127,11 @@ var TerminalConsole = class {
|
|
|
1890
2127
|
* summary would subtract from it.
|
|
1891
2128
|
* @param count - how many trailing lines the block owns.
|
|
1892
2129
|
* @param summary - the collapsed lines, already styled.
|
|
2130
|
+
* @param label - what the block is, for the readout naming what the pointer
|
|
2131
|
+
* is over.
|
|
1893
2132
|
*/
|
|
1894
|
-
foldRecent(count, summary) {
|
|
1895
|
-
this.screen?.foldBack(count, summary);
|
|
2133
|
+
foldRecent(count, summary, label = "") {
|
|
2134
|
+
this.screen?.foldBack(count, summary, label);
|
|
1896
2135
|
}
|
|
1897
2136
|
toggleFolds() {
|
|
1898
2137
|
return this.screen?.toggleFolds() ?? false;
|
|
@@ -1988,6 +2227,158 @@ var TerminalConsole = class {
|
|
|
1988
2227
|
}
|
|
1989
2228
|
};
|
|
1990
2229
|
|
|
2230
|
+
//#endregion
|
|
2231
|
+
//#region src/clipboard-image.ts
|
|
2232
|
+
/** The most a clipboard image may be; beyond this the read reports none. */
|
|
2233
|
+
const MAX_CLIPBOARD_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
2234
|
+
/**
|
|
2235
|
+
* Run one command, capturing binary stdout.
|
|
2236
|
+
* @param command - the executable.
|
|
2237
|
+
* @param args - its arguments.
|
|
2238
|
+
* @returns stdout as bytes, or undefined on any failure or empty output.
|
|
2239
|
+
*/
|
|
2240
|
+
function run$1(command, args) {
|
|
2241
|
+
return new Promise((resolve) => {
|
|
2242
|
+
execFile(command, [...args], {
|
|
2243
|
+
encoding: "buffer",
|
|
2244
|
+
maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
|
|
2245
|
+
timeout: 1e4
|
|
2246
|
+
}, (error, stdout) => {
|
|
2247
|
+
if (error !== null || stdout.length === 0) resolve(void 0);
|
|
2248
|
+
else resolve(stdout);
|
|
2249
|
+
});
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* What image type these bytes are, from their magic numbers.
|
|
2254
|
+
*
|
|
2255
|
+
* Sniffed rather than taken from the reader's word: the store verifies the
|
|
2256
|
+
* declared type against the decoded bytes and refuses a mismatch, so lying
|
|
2257
|
+
* here would only defer the failure to a worse moment.
|
|
2258
|
+
* @param data - the bytes.
|
|
2259
|
+
* @returns the media type, or undefined for anything that is not an image.
|
|
2260
|
+
*/
|
|
2261
|
+
function sniffImageType(data) {
|
|
2262
|
+
if (data.length < 12) return void 0;
|
|
2263
|
+
if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png";
|
|
2264
|
+
if (data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
|
|
2265
|
+
if (data[0] === 71 && data[1] === 73 && data[2] === 70 && data[3] === 56) return "image/gif";
|
|
2266
|
+
if (data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
|
|
2267
|
+
}
|
|
2268
|
+
/** The clipboard's image on macOS: AppleScript writes the PNG to a file. */
|
|
2269
|
+
async function readDarwin() {
|
|
2270
|
+
const dir = await mkdtemp(join(tmpdir(), "codsh-clip-"));
|
|
2271
|
+
const file = join(dir, "clipboard.png");
|
|
2272
|
+
try {
|
|
2273
|
+
const script = [
|
|
2274
|
+
"-e",
|
|
2275
|
+
"set png_data to (the clipboard as «class PNGf»)",
|
|
2276
|
+
"-e",
|
|
2277
|
+
`set fp to open for access POSIX file "${file}" with write permission`,
|
|
2278
|
+
"-e",
|
|
2279
|
+
"write png_data to fp",
|
|
2280
|
+
"-e",
|
|
2281
|
+
"close access fp"
|
|
2282
|
+
];
|
|
2283
|
+
if (!await new Promise((resolve) => {
|
|
2284
|
+
execFile("osascript", script, { timeout: 1e4 }, (error) => void resolve(error === null));
|
|
2285
|
+
})) return void 0;
|
|
2286
|
+
return await readFile(file).catch(() => void 0);
|
|
2287
|
+
} finally {
|
|
2288
|
+
await rm(dir, {
|
|
2289
|
+
recursive: true,
|
|
2290
|
+
force: true
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/** The clipboard's image on Linux, Wayland first, X11 as the fallback. */
|
|
2295
|
+
async function readLinux(env) {
|
|
2296
|
+
if (env.WAYLAND_DISPLAY !== void 0) {
|
|
2297
|
+
const offered$1 = (await run$1("wl-paste", ["-l"]))?.toString("utf8").match(/image\/(?:png|jpeg|webp|gif)/u)?.[0];
|
|
2298
|
+
if (offered$1 === void 0) return void 0;
|
|
2299
|
+
return run$1("wl-paste", ["-t", offered$1]);
|
|
2300
|
+
}
|
|
2301
|
+
const offered = (await run$1("xclip", [
|
|
2302
|
+
"-selection",
|
|
2303
|
+
"clipboard",
|
|
2304
|
+
"-t",
|
|
2305
|
+
"TARGETS",
|
|
2306
|
+
"-o"
|
|
2307
|
+
]))?.toString("utf8").match(/image\/(?:png|jpeg|webp|gif)/u)?.[0];
|
|
2308
|
+
if (offered === void 0) return void 0;
|
|
2309
|
+
return run$1("xclip", [
|
|
2310
|
+
"-selection",
|
|
2311
|
+
"clipboard",
|
|
2312
|
+
"-t",
|
|
2313
|
+
offered,
|
|
2314
|
+
"-o"
|
|
2315
|
+
]);
|
|
2316
|
+
}
|
|
2317
|
+
/** The clipboard's image on Windows: PowerShell saves it as a PNG file. */
|
|
2318
|
+
async function readWin32() {
|
|
2319
|
+
const dir = await mkdtemp(join(tmpdir(), "codsh-clip-"));
|
|
2320
|
+
const file = join(dir, "clipboard.png");
|
|
2321
|
+
try {
|
|
2322
|
+
const script = `$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${file.replaceAll("\\", "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png) }`;
|
|
2323
|
+
if (!await new Promise((resolve) => {
|
|
2324
|
+
execFile("powershell", [
|
|
2325
|
+
"-NoProfile",
|
|
2326
|
+
"-Command",
|
|
2327
|
+
script
|
|
2328
|
+
], { timeout: 1e4 }, (error) => void resolve(error === null));
|
|
2329
|
+
})) return void 0;
|
|
2330
|
+
return await readFile(file).catch(() => void 0);
|
|
2331
|
+
} finally {
|
|
2332
|
+
await rm(dir, {
|
|
2333
|
+
recursive: true,
|
|
2334
|
+
force: true
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Pixel dimensions from the image header, best effort.
|
|
2340
|
+
*
|
|
2341
|
+
* sharp decodes properly, but it is a native module and the flash line does
|
|
2342
|
+
* not justify failing a paste over it — an unreadable header simply reports
|
|
2343
|
+
* no dimensions.
|
|
2344
|
+
* @param data - the image bytes.
|
|
2345
|
+
* @returns width and height, or undefined.
|
|
2346
|
+
*/
|
|
2347
|
+
async function probeDimensions(data) {
|
|
2348
|
+
try {
|
|
2349
|
+
const { default: sharp } = await import("sharp");
|
|
2350
|
+
const meta = await sharp(data).metadata();
|
|
2351
|
+
if (typeof meta.width === "number" && typeof meta.height === "number") return {
|
|
2352
|
+
width: meta.width,
|
|
2353
|
+
height: meta.height
|
|
2354
|
+
};
|
|
2355
|
+
return;
|
|
2356
|
+
} catch {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* The image on the system clipboard, or undefined when it holds none.
|
|
2362
|
+
*
|
|
2363
|
+
* `CODSH_CLIPBOARD_IMAGE_CMD` overrides the platform reader with a shell
|
|
2364
|
+
* command whose stdout is the image bytes — the seam the tests use, exactly
|
|
2365
|
+
* as `CODSH_CLIPBOARD=osc52` keeps the write path off the real clipboard.
|
|
2366
|
+
* @param env - the environment, for the override and the display probes.
|
|
2367
|
+
* @returns the image with its sniffed type and dimensions, or undefined.
|
|
2368
|
+
*/
|
|
2369
|
+
async function readClipboardImage(env) {
|
|
2370
|
+
const override = env.CODSH_CLIPBOARD_IMAGE_CMD;
|
|
2371
|
+
const data = override !== void 0 && override !== "" ? await run$1(env.SHELL ?? "/bin/sh", ["-c", override]) : process.platform === "darwin" ? await readDarwin() : process.platform === "win32" ? await readWin32() : await readLinux(env);
|
|
2372
|
+
if (data === void 0) return void 0;
|
|
2373
|
+
const mediaType = sniffImageType(data);
|
|
2374
|
+
if (mediaType === void 0) return void 0;
|
|
2375
|
+
return {
|
|
2376
|
+
data,
|
|
2377
|
+
mediaType,
|
|
2378
|
+
...await probeDimensions(data)
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
|
|
1991
2382
|
//#endregion
|
|
1992
2383
|
//#region src/editor.ts
|
|
1993
2384
|
/** Longest run of history the editor keeps for one session. */
|
|
@@ -2214,9 +2605,11 @@ var Editor = class {
|
|
|
2214
2605
|
backspace() {
|
|
2215
2606
|
if (this.column > 0) {
|
|
2216
2607
|
const cells = points(this.line());
|
|
2217
|
-
cells.
|
|
2608
|
+
const token = /\[Image #\d+\]$/u.exec(cells.slice(0, this.column).join(""));
|
|
2609
|
+
const width = token === null ? 1 : points(token[0]).length;
|
|
2610
|
+
cells.splice(this.column - width, width);
|
|
2218
2611
|
this.setLine(cells.join(""));
|
|
2219
|
-
this.column -=
|
|
2612
|
+
this.column -= width;
|
|
2220
2613
|
} else if (this.row > 0) {
|
|
2221
2614
|
const previous = this.lines[this.row - 1] ?? "";
|
|
2222
2615
|
const current = this.line();
|
|
@@ -2617,10 +3010,131 @@ var Selector = class {
|
|
|
2617
3010
|
}
|
|
2618
3011
|
};
|
|
2619
3012
|
|
|
3013
|
+
//#endregion
|
|
3014
|
+
//#region src/todos.ts
|
|
3015
|
+
/**
|
|
3016
|
+
* Mark for one lifecycle state, styled by what the state means.
|
|
3017
|
+
*
|
|
3018
|
+
* The three marks are codsh's own (`✔`/`▶`/`○`), not the reference agent's
|
|
3019
|
+
* squares: the transcript has used them since todos first rendered, and one
|
|
3020
|
+
* surface speaking two alphabets for the same list is worse than differing from
|
|
3021
|
+
* the reference on a glyph.
|
|
3022
|
+
* @param status - the item's lifecycle state.
|
|
3023
|
+
* @param theme - styling for the mark.
|
|
3024
|
+
* @returns the styled mark.
|
|
3025
|
+
*/
|
|
3026
|
+
function mark(status, theme) {
|
|
3027
|
+
if (status === "completed") return theme.success("✔");
|
|
3028
|
+
if (status === "in_progress") return theme.pending("▶");
|
|
3029
|
+
return theme.dim("○");
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* Count each lifecycle state once, so callers never fold the list twice.
|
|
3033
|
+
* @param todos - the list to count.
|
|
3034
|
+
* @returns done, active, and open counts alongside the total.
|
|
3035
|
+
*/
|
|
3036
|
+
function tally(todos) {
|
|
3037
|
+
let done = 0;
|
|
3038
|
+
let active = 0;
|
|
3039
|
+
for (const todo of todos) if (todo.status === "completed") done += 1;
|
|
3040
|
+
else if (todo.status === "in_progress") active += 1;
|
|
3041
|
+
return {
|
|
3042
|
+
done,
|
|
3043
|
+
active,
|
|
3044
|
+
open: todos.length - done - active,
|
|
3045
|
+
total: todos.length
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* The header both the card and the expanded list carry.
|
|
3050
|
+
*
|
|
3051
|
+
* Progress leads because it is the figure a glance wants; the state breakdown
|
|
3052
|
+
* follows, and a state with nothing in it is dropped rather than shown as zero —
|
|
3053
|
+
* the same rule the status line follows.
|
|
3054
|
+
* @param todos - the list to summarize.
|
|
3055
|
+
* @param theme - styling for the segments.
|
|
3056
|
+
* @param hint - a trailing note, e.g. the key that collapses the list.
|
|
3057
|
+
* @returns the header line.
|
|
3058
|
+
*/
|
|
3059
|
+
function header(todos, theme, hint) {
|
|
3060
|
+
const { done, active, open, total } = tally(todos);
|
|
3061
|
+
const segments = [
|
|
3062
|
+
theme.dim(`${done}/${total}`),
|
|
3063
|
+
...active === 0 ? [] : [theme.dim(`${active} in progress`)],
|
|
3064
|
+
...open === 0 ? [] : [theme.dim(`${open} open`)],
|
|
3065
|
+
...hint === void 0 ? [] : [theme.dim(hint)]
|
|
3066
|
+
];
|
|
3067
|
+
return `${theme.tool("todos")} ${segments.join(theme.dim(" · "))}`;
|
|
3068
|
+
}
|
|
3069
|
+
/**
|
|
3070
|
+
* The item a person watching the run cares about: what is being worked now,
|
|
3071
|
+
* or, with nothing active, what comes next.
|
|
3072
|
+
* @param todos - the list to look through.
|
|
3073
|
+
* @returns the item, or undefined when every item is finished.
|
|
3074
|
+
*/
|
|
3075
|
+
function focus(todos) {
|
|
3076
|
+
return todos.find((todo) => todo.status === "in_progress") ?? todos.find((todo) => todo.status === "pending");
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Render the pinned row: one line naming the work in flight and the progress
|
|
3080
|
+
* around it.
|
|
3081
|
+
*
|
|
3082
|
+
* This row is the whole point of reading from a projection rather than from the
|
|
3083
|
+
* write event: the card that announced the list scrolls away, the row does not,
|
|
3084
|
+
* so the list stays answerable at a glance for the rest of the session.
|
|
3085
|
+
* @param todos - the current list.
|
|
3086
|
+
* @param theme - styling for the segments.
|
|
3087
|
+
* @param columns - display columns available; a longer row is cut, never wrapped.
|
|
3088
|
+
* @param hint - a trailing note, e.g. the key that expands the list.
|
|
3089
|
+
* @returns the row, or undefined when there is no list to report.
|
|
3090
|
+
*/
|
|
3091
|
+
function todoRow(todos, theme, columns, hint) {
|
|
3092
|
+
if (todos.length === 0) return void 0;
|
|
3093
|
+
const { done, total } = tally(todos);
|
|
3094
|
+
const next = focus(todos);
|
|
3095
|
+
const body = next === void 0 ? `${theme.success("✔")} ${theme.dim("all done")}` : `${mark(next.status, theme)} ${next.status === "in_progress" ? next.content : `next: ${next.content}`}`;
|
|
3096
|
+
return truncate([
|
|
3097
|
+
theme.tool("todos"),
|
|
3098
|
+
theme.dim(`${done}/${total}`),
|
|
3099
|
+
body,
|
|
3100
|
+
...hint === void 0 ? [] : [theme.dim(hint)]
|
|
3101
|
+
].join(theme.dim(" · ")), columns);
|
|
3102
|
+
}
|
|
3103
|
+
/**
|
|
3104
|
+
* Render the whole list: the header, then every item under it.
|
|
3105
|
+
*
|
|
3106
|
+
* A surface that cannot scroll passes `limit`, and the items past it are
|
|
3107
|
+
* counted rather than dropped in silence — a list that looks complete but is
|
|
3108
|
+
* not is worse than an honest tail.
|
|
3109
|
+
* @param todos - the current list.
|
|
3110
|
+
* @param theme - styling for the marks and text.
|
|
3111
|
+
* @param columns - display columns available; longer lines are cut.
|
|
3112
|
+
* @param options - header note and item cap.
|
|
3113
|
+
* @returns the lines, empty when there is no list.
|
|
3114
|
+
*/
|
|
3115
|
+
function todoReport(todos, theme, columns, options = {}) {
|
|
3116
|
+
if (todos.length === 0) return [];
|
|
3117
|
+
const { hint, limit } = options;
|
|
3118
|
+
const shown = limit === void 0 ? todos : todos.slice(0, Math.max(0, limit));
|
|
3119
|
+
const hidden = todos.length - shown.length;
|
|
3120
|
+
return [
|
|
3121
|
+
truncate(header(todos, theme, hint), columns),
|
|
3122
|
+
...shown.map((todo) => truncate(` ${mark(todo.status, theme)} ${todo.status === "completed" ? theme.dim(todo.content) : todo.content}`, columns)),
|
|
3123
|
+
...hidden === 0 ? [] : [theme.dim(truncate(` … +${hidden} more`, columns))]
|
|
3124
|
+
];
|
|
3125
|
+
}
|
|
3126
|
+
|
|
2620
3127
|
//#endregion
|
|
2621
3128
|
//#region src/prompt.ts
|
|
2622
3129
|
/** How long a flash notice holds the hint row. */
|
|
2623
3130
|
const FLASH_MS = 1500;
|
|
3131
|
+
/**
|
|
3132
|
+
* Most todo items the expanded list may occupy.
|
|
3133
|
+
*
|
|
3134
|
+
* The chrome never scrolls, so an unbounded list would push the box off a short
|
|
3135
|
+
* terminal; the items past the cap are counted, never silently dropped.
|
|
3136
|
+
*/
|
|
3137
|
+
const TODO_ROWS = 10;
|
|
2624
3138
|
/** Drives the input box and answers reads and selections. */
|
|
2625
3139
|
var Prompt = class {
|
|
2626
3140
|
editor;
|
|
@@ -2633,13 +3147,31 @@ var Prompt = class {
|
|
|
2633
3147
|
* not be lost; the queue is what a line reader provides for free.
|
|
2634
3148
|
*/
|
|
2635
3149
|
queued = [];
|
|
3150
|
+
/** Images pasted into the box, by the number their `[Image #N]` token wears. */
|
|
3151
|
+
pendingImages = /* @__PURE__ */ new Map();
|
|
3152
|
+
/** Numbers are never reused within a session: a recalled token must not
|
|
3153
|
+
* silently pick up a different image. */
|
|
3154
|
+
imageCounter = 0;
|
|
3155
|
+
/** The images belonging to the line the last read handed out. */
|
|
3156
|
+
submittedImages = [];
|
|
3157
|
+
/** Whether a clipboard read is already in flight; a second Ctrl+V waits. */
|
|
3158
|
+
pastingImage = false;
|
|
2636
3159
|
/** The working indicator shown under the box. */
|
|
2637
3160
|
hint;
|
|
2638
3161
|
/** A short-lived notice that borrows the hint row, e.g. the copy toast. */
|
|
2639
3162
|
flash;
|
|
3163
|
+
/** What the pointer is resting on, borrowing the hint row while it rests. */
|
|
3164
|
+
hover;
|
|
2640
3165
|
flashTimer;
|
|
2641
3166
|
/** The always-current session facts shown as the region's last row. */
|
|
2642
3167
|
status;
|
|
3168
|
+
/**
|
|
3169
|
+
* The agent's current todo list, kept in the chrome rather than only in the
|
|
3170
|
+
* transcript: the card that announced it scrolls away, this does not.
|
|
3171
|
+
*/
|
|
3172
|
+
todos = [];
|
|
3173
|
+
/** Whether the todo readout shows every item or only the one in flight. */
|
|
3174
|
+
todosExpanded = false;
|
|
2643
3175
|
/** The assistant line still arriving, shown above the box. */
|
|
2644
3176
|
streaming;
|
|
2645
3177
|
/** Frame styling for the current mode, e.g. plan mode's accent. */
|
|
@@ -2740,6 +3272,18 @@ var Prompt = class {
|
|
|
2740
3272
|
this.render();
|
|
2741
3273
|
}
|
|
2742
3274
|
/**
|
|
3275
|
+
* Set the todo readout to the list the session now holds.
|
|
3276
|
+
*
|
|
3277
|
+
* Compared by content, not identity: this is pushed on every session event,
|
|
3278
|
+
* and a repaint per event would flicker the chrome for nothing.
|
|
3279
|
+
* @param todos - the current list, empty to drop the readout.
|
|
3280
|
+
*/
|
|
3281
|
+
setTodos(todos) {
|
|
3282
|
+
if (todos.length === this.todos.length && todos.every((todo, at) => todo.content === this.todos[at]?.content && todo.status === this.todos[at]?.status)) return;
|
|
3283
|
+
this.todos = todos;
|
|
3284
|
+
this.render();
|
|
3285
|
+
}
|
|
3286
|
+
/**
|
|
2743
3287
|
* Set the frame accent, which is how a mode shows on the box itself.
|
|
2744
3288
|
* @param accent - the styling, or undefined for the default frame.
|
|
2745
3289
|
*/
|
|
@@ -2758,9 +3302,10 @@ var Prompt = class {
|
|
|
2758
3302
|
/**
|
|
2759
3303
|
* Write one finished transcript line above the region.
|
|
2760
3304
|
* @param line - the line to keep.
|
|
3305
|
+
* @param rule - a styled left rule marking which block the line belongs to.
|
|
2761
3306
|
*/
|
|
2762
|
-
write(line) {
|
|
2763
|
-
this.console.write(line);
|
|
3307
|
+
write(line, rule = "") {
|
|
3308
|
+
this.console.write(line, rule);
|
|
2764
3309
|
}
|
|
2765
3310
|
/**
|
|
2766
3311
|
* Wait for the next submitted text.
|
|
@@ -2770,7 +3315,10 @@ var Prompt = class {
|
|
|
2770
3315
|
read(signal) {
|
|
2771
3316
|
if (!this.console.readsKeys) return this.console.readLine(signal);
|
|
2772
3317
|
const typedAhead = this.queued.shift();
|
|
2773
|
-
if (typedAhead !== void 0)
|
|
3318
|
+
if (typedAhead !== void 0) {
|
|
3319
|
+
this.submittedImages = typedAhead.images;
|
|
3320
|
+
return Promise.resolve(typedAhead.text);
|
|
3321
|
+
}
|
|
2774
3322
|
if (this.console.finished) return Promise.resolve(void 0);
|
|
2775
3323
|
this.reading = true;
|
|
2776
3324
|
this.render();
|
|
@@ -2778,6 +3326,7 @@ var Prompt = class {
|
|
|
2778
3326
|
const settle = (text) => {
|
|
2779
3327
|
this.pending = void 0;
|
|
2780
3328
|
this.reading = false;
|
|
3329
|
+
this.submittedImages = text === void 0 ? [] : this.claimImages(text);
|
|
2781
3330
|
resolve(text);
|
|
2782
3331
|
};
|
|
2783
3332
|
const onAbort = () => {
|
|
@@ -2851,6 +3400,11 @@ var Prompt = class {
|
|
|
2851
3400
|
this.handlers.expandOutput?.();
|
|
2852
3401
|
return;
|
|
2853
3402
|
}
|
|
3403
|
+
if (key.kind === "toggle-todos") {
|
|
3404
|
+
this.todosExpanded = !this.todosExpanded;
|
|
3405
|
+
this.render();
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
2854
3408
|
if (key.kind === "page") {
|
|
2855
3409
|
this.console.scrollPage(key.direction);
|
|
2856
3410
|
this.render();
|
|
@@ -2885,6 +3439,14 @@ var Prompt = class {
|
|
|
2885
3439
|
this.console.mouseDrag(key.row, key.column);
|
|
2886
3440
|
return;
|
|
2887
3441
|
}
|
|
3442
|
+
if (key.kind === "mouse-move") {
|
|
3443
|
+
const block = this.console.mouseMove(key.row, key.column);
|
|
3444
|
+
const readout = block === void 0 ? void 0 : this.theme.dim(` ${block.label} · ${block.lines} lines · click to ${block.expanded ? "fold" : "expand"}`);
|
|
3445
|
+
if (readout === this.hover) return;
|
|
3446
|
+
this.hover = readout;
|
|
3447
|
+
this.render();
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
2888
3450
|
if (key.kind === "mouse-up") {
|
|
2889
3451
|
const text = this.console.mouseUp();
|
|
2890
3452
|
if (text !== void 0 && this.console.copyText(text)) {
|
|
@@ -2904,12 +3466,19 @@ var Prompt = class {
|
|
|
2904
3466
|
this.render();
|
|
2905
3467
|
return;
|
|
2906
3468
|
}
|
|
3469
|
+
if (key.kind === "paste-image") {
|
|
3470
|
+
this.pasteImage();
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
2907
3473
|
const action = this.editor.handle(key);
|
|
2908
3474
|
switch (action.kind) {
|
|
2909
3475
|
case "submit": {
|
|
2910
3476
|
const waiting = this.pending;
|
|
2911
3477
|
if (waiting === void 0) {
|
|
2912
|
-
this.queued.push(
|
|
3478
|
+
this.queued.push({
|
|
3479
|
+
text: action.text,
|
|
3480
|
+
images: this.claimImages(action.text)
|
|
3481
|
+
});
|
|
2913
3482
|
break;
|
|
2914
3483
|
}
|
|
2915
3484
|
waiting.dispose();
|
|
@@ -2933,6 +3502,97 @@ var Prompt = class {
|
|
|
2933
3502
|
}
|
|
2934
3503
|
this.render();
|
|
2935
3504
|
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Read the clipboard and attach its image behind an `[Image #N]` token.
|
|
3507
|
+
*
|
|
3508
|
+
* The read shells out and takes real time, so it runs off the key handler;
|
|
3509
|
+
* a second Ctrl+V during it is dropped rather than raced. Numbers count up
|
|
3510
|
+
* for the whole session — a token in a recalled line must never quietly
|
|
3511
|
+
* name a different image than the one it was minted for.
|
|
3512
|
+
*/
|
|
3513
|
+
async pasteImage() {
|
|
3514
|
+
const read = this.handlers.readClipboardImage;
|
|
3515
|
+
if (read === void 0 || this.pastingImage) return;
|
|
3516
|
+
this.pastingImage = true;
|
|
3517
|
+
try {
|
|
3518
|
+
const found = await read();
|
|
3519
|
+
if (found === void 0) {
|
|
3520
|
+
this.setFlash(this.theme.dim(" no image in the clipboard"));
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
this.imageCounter += 1;
|
|
3524
|
+
const id = this.imageCounter;
|
|
3525
|
+
const pending = {
|
|
3526
|
+
id,
|
|
3527
|
+
image: {
|
|
3528
|
+
mediaType: found.mediaType,
|
|
3529
|
+
data: found.data.toString("base64"),
|
|
3530
|
+
name: `Pasted image #${id}`
|
|
3531
|
+
}
|
|
3532
|
+
};
|
|
3533
|
+
if (found.width !== void 0) pending.width = found.width;
|
|
3534
|
+
if (found.height !== void 0) pending.height = found.height;
|
|
3535
|
+
this.pendingImages.set(id, pending);
|
|
3536
|
+
this.editor.handle({
|
|
3537
|
+
kind: "paste",
|
|
3538
|
+
text: `[Image #${id}]`
|
|
3539
|
+
});
|
|
3540
|
+
const size = found.width !== void 0 && found.height !== void 0 ? ` (${found.width}×${found.height} ${found.mediaType.slice(6)})` : "";
|
|
3541
|
+
this.setFlash(this.theme.dim(` ✓ image #${id} attached${size}`));
|
|
3542
|
+
} finally {
|
|
3543
|
+
this.pastingImage = false;
|
|
3544
|
+
this.render();
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* The images a submitted line actually references, in token order.
|
|
3549
|
+
*
|
|
3550
|
+
* The tokens are the source of truth: a token the person deleted drops its
|
|
3551
|
+
* image, a token duplicated by editing still names one attachment once.
|
|
3552
|
+
* Claimed images leave the pending pool, so a token recalled from history
|
|
3553
|
+
* later submits as plain text rather than resurrecting consumed bytes.
|
|
3554
|
+
* @param text - the submitted line.
|
|
3555
|
+
* @returns the referenced images, ready to ride the submission.
|
|
3556
|
+
*/
|
|
3557
|
+
claimImages(text) {
|
|
3558
|
+
const images = [];
|
|
3559
|
+
for (const match of text.matchAll(/\[Image #(\d+)\]/gu)) {
|
|
3560
|
+
const id = Number(match[1]);
|
|
3561
|
+
const pending = this.pendingImages.get(id);
|
|
3562
|
+
if (pending === void 0) continue;
|
|
3563
|
+
this.pendingImages.delete(id);
|
|
3564
|
+
images.push(pending);
|
|
3565
|
+
}
|
|
3566
|
+
return images;
|
|
3567
|
+
}
|
|
3568
|
+
/**
|
|
3569
|
+
* The images belonging to the line the last read returned.
|
|
3570
|
+
*
|
|
3571
|
+
* A drain, in the transcript's `take*` idiom: the caller reads the line,
|
|
3572
|
+
* then takes its images exactly once.
|
|
3573
|
+
* @returns the images in token order, empty for a plain line.
|
|
3574
|
+
*/
|
|
3575
|
+
takeAttachments() {
|
|
3576
|
+
const images = this.submittedImages;
|
|
3577
|
+
this.submittedImages = [];
|
|
3578
|
+
return images;
|
|
3579
|
+
}
|
|
3580
|
+
/**
|
|
3581
|
+
* The todo readout's rows: the one in flight, or the whole list once opened.
|
|
3582
|
+
* @param columns - display columns available.
|
|
3583
|
+
* @returns the rows, empty when no list is live.
|
|
3584
|
+
*/
|
|
3585
|
+
todoRows(columns) {
|
|
3586
|
+
if (this.todos.length === 0) return [];
|
|
3587
|
+
if (!this.todosExpanded) {
|
|
3588
|
+
const row = todoRow(this.todos, this.theme, columns, "Ctrl+T opens the list");
|
|
3589
|
+
return row === void 0 ? [] : [row];
|
|
3590
|
+
}
|
|
3591
|
+
return todoReport(this.todos, this.theme, columns, {
|
|
3592
|
+
hint: "Ctrl+T closes",
|
|
3593
|
+
limit: TODO_ROWS
|
|
3594
|
+
});
|
|
3595
|
+
}
|
|
2936
3596
|
/** Recompose and redraw the bottom region. */
|
|
2937
3597
|
render() {
|
|
2938
3598
|
if (!this.console.readsKeys) return;
|
|
@@ -2956,24 +3616,25 @@ var Prompt = class {
|
|
|
2956
3616
|
rows.push(...box.rows);
|
|
2957
3617
|
}
|
|
2958
3618
|
if (this.queued.length > 0) {
|
|
2959
|
-
const preview = this.queued[0] ?? "";
|
|
3619
|
+
const preview = this.queued[0]?.text ?? "";
|
|
2960
3620
|
const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
|
|
2961
3621
|
rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
|
|
2962
3622
|
}
|
|
3623
|
+
rows.push(...this.todoRows(columns));
|
|
2963
3624
|
this.console.setScrollNotice(this.console.scrolledBy > 0 ? this.theme.dim(truncate(` ↑ ${this.console.scrolledBy} rows above · PgDn returns to the latest`, columns)) : "");
|
|
2964
|
-
const notice = this.flash ?? this.hint;
|
|
3625
|
+
const notice = this.flash ?? this.hover ?? this.hint;
|
|
2965
3626
|
if (notice !== void 0) rows.push(notice);
|
|
2966
3627
|
if (this.status !== void 0) rows.push(this.status);
|
|
2967
3628
|
if (rows.length === 0) {
|
|
2968
3629
|
this.console.clearRegion();
|
|
2969
3630
|
return;
|
|
2970
3631
|
}
|
|
2971
|
-
const focus = this.select_ === void 0 && (this.engaged || this.reading);
|
|
2972
|
-
if (!focus) cursor = {
|
|
3632
|
+
const focus$1 = this.select_ === void 0 && (this.engaged || this.reading);
|
|
3633
|
+
if (!focus$1) cursor = {
|
|
2973
3634
|
row: rows.length - 1,
|
|
2974
3635
|
column: 0
|
|
2975
3636
|
};
|
|
2976
|
-
this.console.setRegion(rows, cursor, focus);
|
|
3637
|
+
this.console.setRegion(rows, cursor, focus$1);
|
|
2977
3638
|
}
|
|
2978
3639
|
};
|
|
2979
3640
|
|
|
@@ -3377,9 +4038,9 @@ function questionLines(question, theme) {
|
|
|
3377
4038
|
if (question.detail !== void 0) lines.push(...question.detail.split("\n"));
|
|
3378
4039
|
lines.push("", theme.bold(question.question));
|
|
3379
4040
|
(question.options ?? []).forEach((option, index) => {
|
|
3380
|
-
const mark = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
|
|
4041
|
+
const mark$1 = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
|
|
3381
4042
|
const description = option.description === void 0 ? "" : theme.dim(` — ${option.description}`);
|
|
3382
|
-
lines.push(` ${mark}. ${option.label}${description}`);
|
|
4043
|
+
lines.push(` ${mark$1}. ${option.label}${description}`);
|
|
3383
4044
|
});
|
|
3384
4045
|
lines.push(theme.dim(" (a number, or type your own answer)"));
|
|
3385
4046
|
return lines;
|
|
@@ -3471,6 +4132,42 @@ var TerminalQuestions = class {
|
|
|
3471
4132
|
}
|
|
3472
4133
|
};
|
|
3473
4134
|
|
|
4135
|
+
//#endregion
|
|
4136
|
+
//#region src/ship.ts
|
|
4137
|
+
/**
|
|
4138
|
+
* The `/ship` prompt: a canned workflow that takes a one-sentence requirement
|
|
4139
|
+
* from idea to shipped, verified code — a research-grounded interview, a
|
|
4140
|
+
* confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
|
|
4141
|
+
* until the spec's acceptance criteria pass.
|
|
4142
|
+
*
|
|
4143
|
+
* The spec FILE is the workflow's memory, not the conversation: the approved
|
|
4144
|
+
* plan is written into it, its Status line names the phase, its checkboxes
|
|
4145
|
+
* are the progress, and a bare /ship offers to resume whatever it finds
|
|
4146
|
+
* unfinished. Conversations get interrupted, compacted, and cleared; the file
|
|
4147
|
+
* survives all three, which is what makes the landing reliable rather than
|
|
4148
|
+
* merely well-intentioned.
|
|
4149
|
+
*/
|
|
4150
|
+
/** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
|
|
4151
|
+
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:
|
|
4152
|
+
|
|
4153
|
+
<idea>
|
|
4154
|
+
$ARGUMENTS
|
|
4155
|
+
</idea>
|
|
4156
|
+
|
|
4157
|
+
If the idea between the <idea> tags is empty, that is not an error. First look for unfinished work: scan the repository's spec directory (docs/specs/, or the repo's own design-document convention) for a spec whose Status line is not \`shipped\` — a bare /ship most likely means "carry on", so offer through ask_user_question to resume that spec from the phase its Status names, with everything below applying from that phase onward. Only when there is nothing to resume, ask for the one-sentence requirement with ask_user_question and use the answer as the idea. Images accompanying the command — [Image #N] tokens, <pasted-image> context, attached image blocks — are part of the requirement: a mockup or a screenshot is requirements material, so read it and cite what it shows in the interview.
|
|
4158
|
+
|
|
4159
|
+
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.
|
|
4160
|
+
|
|
4161
|
+
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 names the exact command that proves it and the output that counts as passing — the final phase runs those commands verbatim, so a criterion without a command is not finished. Give the file a \`Status:\` line (interviewing, confirmed, planned, landing, shipped) and keep it current at every phase change: it is what lets an interrupted /ship resume instead of starting over. 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.
|
|
4162
|
+
|
|
4163
|
+
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. Once approved, write the plan into the spec file as a \`## Plan\` section with one checkbox per milestone — an approved plan lives on disk, not in a conversation that can be compacted or lost. Then, still before any implementation code, establish the ground: check the working tree is clean (uncommitted unrelated changes are the user's to decide about — ask), and run the plan's proof commands once, recording the baseline in the spec. A baseline that is already red changes what "green" will mean, so surface it here rather than discovering it under your own diff. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
|
|
4164
|
+
|
|
4165
|
+
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. Either way the spec file — not this conversation — is the working memory: re-read it before starting each milestone, tick the milestone's checkbox and update Status as you go, and commit after each milestone turns green — small commits are the progress that survives a crash and the history a reviewer can walk. 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, fix until green, then commit 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 single source of truth, instructs each round to read the spec from disk (plan, checkboxes, baseline), pick the first unchecked milestone, implement and test it, then commit and tick its checkbox, and defines completion as every acceptance criterion in the spec passing. Bound the loop: budget about three rounds per milestone, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.
|
|
4166
|
+
|
|
4167
|
+
Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running its named command and reading the real output. After a Ralph loop returns, run every proof command again yourself — the loop's word is a report, not a verification. 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. Set Status to shipped only after that final run, and close with an honest report listing each criterion, the command that proved it, and what it printed — plus anything left open.
|
|
4168
|
+
|
|
4169
|
+
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.`;
|
|
4170
|
+
|
|
3474
4171
|
//#endregion
|
|
3475
4172
|
//#region src/spinner.ts
|
|
3476
4173
|
/** Braille frames, one cell wide each, so the line never changes width. */
|
|
@@ -3498,10 +4195,10 @@ const TICK_MS = 90;
|
|
|
3498
4195
|
*/
|
|
3499
4196
|
function spinnerText(frame, elapsedMs, label, theme) {
|
|
3500
4197
|
const seconds = (elapsedMs / 1e3).toFixed(elapsedMs < 1e4 ? 1 : 0);
|
|
3501
|
-
const mark = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
|
|
4198
|
+
const mark$1 = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
|
|
3502
4199
|
const extra = label.detail?.();
|
|
3503
4200
|
const detail = extra === void 0 || extra === "" ? "" : `${extra} · `;
|
|
3504
|
-
return `${theme.pending(mark)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
|
|
4201
|
+
return `${theme.pending(mark$1)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
|
|
3505
4202
|
}
|
|
3506
4203
|
/** Drives the working indicator for as long as the agent is busy. */
|
|
3507
4204
|
var Spinner = class {
|
|
@@ -3555,6 +4252,166 @@ var Spinner = class {
|
|
|
3555
4252
|
}
|
|
3556
4253
|
};
|
|
3557
4254
|
|
|
4255
|
+
//#endregion
|
|
4256
|
+
//#region src/vision.ts
|
|
4257
|
+
/** How long one description may take before the paste falls back to file-only. */
|
|
4258
|
+
const VISION_TIMEOUT_MS = 3e4;
|
|
4259
|
+
/**
|
|
4260
|
+
* The one instruction the sidecar gets.
|
|
4261
|
+
*
|
|
4262
|
+
* It is the eyes for a model that has none, so completeness beats brevity and
|
|
4263
|
+
* verbatim beats summary: a truncated error message or a paraphrased line of
|
|
4264
|
+
* code is exactly the part the coding agent needed.
|
|
4265
|
+
*/
|
|
4266
|
+
const VISION_PROMPT = "You are the eyes for a text-only coding agent. Describe this image precisely and completely. Transcribe ALL visible text, code, commands, error messages, numbers and labels verbatim. When it shows a UI, terminal, diagram or chart, describe its structure and layout so the agent can reason about it. Do not speculate beyond what is visible.";
|
|
4267
|
+
/**
|
|
4268
|
+
* The sidecar from the environment, or undefined when none is configured.
|
|
4269
|
+
* @param env - the process environment.
|
|
4270
|
+
* @returns the config when both the base URL and the model are set.
|
|
4271
|
+
*/
|
|
4272
|
+
function visionConfigFromEnv(env) {
|
|
4273
|
+
const baseUrl = env.CODSH_VISION_BASE_URL;
|
|
4274
|
+
const model = env.CODSH_VISION_MODEL;
|
|
4275
|
+
if (baseUrl === void 0 || baseUrl === "" || model === void 0 || model === "") return void 0;
|
|
4276
|
+
const config = {
|
|
4277
|
+
baseUrl: baseUrl.replace(/\/$/u, ""),
|
|
4278
|
+
model
|
|
4279
|
+
};
|
|
4280
|
+
const key = env.CODSH_VISION_API_KEY;
|
|
4281
|
+
if (key !== void 0 && key !== "") config.apiKey = key;
|
|
4282
|
+
return config;
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Ask the sidecar what an image shows.
|
|
4286
|
+
* @param image - the encoded image.
|
|
4287
|
+
* @param config - which endpoint and model to ask.
|
|
4288
|
+
* @param signal - cancels the request, on top of the built-in timeout.
|
|
4289
|
+
* @returns the description.
|
|
4290
|
+
* @throws on timeout, a non-2xx answer, or an answer with no text.
|
|
4291
|
+
*/
|
|
4292
|
+
async function describeImage(image, config, signal) {
|
|
4293
|
+
const timeout = AbortSignal.timeout(VISION_TIMEOUT_MS);
|
|
4294
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
4295
|
+
method: "POST",
|
|
4296
|
+
headers: {
|
|
4297
|
+
"content-type": "application/json",
|
|
4298
|
+
...config.apiKey === void 0 ? {} : { authorization: `Bearer ${config.apiKey}` }
|
|
4299
|
+
},
|
|
4300
|
+
body: JSON.stringify({
|
|
4301
|
+
model: config.model,
|
|
4302
|
+
messages: [{
|
|
4303
|
+
role: "user",
|
|
4304
|
+
content: [{
|
|
4305
|
+
type: "image_url",
|
|
4306
|
+
image_url: { url: `data:${image.mediaType};base64,${image.data}` }
|
|
4307
|
+
}, {
|
|
4308
|
+
type: "text",
|
|
4309
|
+
text: VISION_PROMPT
|
|
4310
|
+
}]
|
|
4311
|
+
}]
|
|
4312
|
+
}),
|
|
4313
|
+
signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout])
|
|
4314
|
+
});
|
|
4315
|
+
if (!response.ok) throw new Error(`vision endpoint answered ${response.status}`);
|
|
4316
|
+
const text = (await response.json()).choices?.[0]?.message?.content?.trim();
|
|
4317
|
+
if (text === void 0 || text === "") throw new Error("vision endpoint answered without text");
|
|
4318
|
+
return text;
|
|
4319
|
+
}
|
|
4320
|
+
/**
|
|
4321
|
+
* The upstream store's default admission limits, for use when no store is
|
|
4322
|
+
* mounted: the sidecar payload is bounded by the same line either way.
|
|
4323
|
+
*/
|
|
4324
|
+
const DEFAULT_IMAGE_LIMITS = {
|
|
4325
|
+
maxImageBytes: 3.5 * 1024 * 1024,
|
|
4326
|
+
maxImagesPerMessage: 20,
|
|
4327
|
+
maxMessageImageBytes: 100 * 1024 * 1024,
|
|
4328
|
+
maxImagePixels: 4e7,
|
|
4329
|
+
maxImageDimension: 2e3,
|
|
4330
|
+
mediaTypes: [
|
|
4331
|
+
"image/png",
|
|
4332
|
+
"image/jpeg",
|
|
4333
|
+
"image/webp",
|
|
4334
|
+
"image/gif"
|
|
4335
|
+
]
|
|
4336
|
+
};
|
|
4337
|
+
/** File extension per media type, for the saved copy's name. */
|
|
4338
|
+
const EXTENSIONS = {
|
|
4339
|
+
"image/png": "png",
|
|
4340
|
+
"image/jpeg": "jpg",
|
|
4341
|
+
"image/webp": "webp",
|
|
4342
|
+
"image/gif": "gif"
|
|
4343
|
+
};
|
|
4344
|
+
/**
|
|
4345
|
+
* Save a pasted image where the agent's tools can reach it.
|
|
4346
|
+
*
|
|
4347
|
+
* The original bytes, never a downscaled copy — the person may want the asset
|
|
4348
|
+
* itself committed. Content-addressed under the dsh home so a repeated paste
|
|
4349
|
+
* dedupes, nothing lands in the workspace uninvited, and the path stays valid
|
|
4350
|
+
* for `--resume`.
|
|
4351
|
+
* @param image - the encoded image.
|
|
4352
|
+
* @returns the absolute path of the saved file.
|
|
4353
|
+
*/
|
|
4354
|
+
async function savePastedImage(image) {
|
|
4355
|
+
const data = Buffer.from(image.data, "base64");
|
|
4356
|
+
const digest = createHash("sha256").update(data).digest("hex").slice(0, 12);
|
|
4357
|
+
const dir = dshHomePath("attachments", "pasted");
|
|
4358
|
+
await mkdir(dir, { recursive: true });
|
|
4359
|
+
const path = join(dir, `${digest}.${EXTENSIONS[image.mediaType]}`);
|
|
4360
|
+
await writeFile(path, data);
|
|
4361
|
+
return path;
|
|
4362
|
+
}
|
|
4363
|
+
/**
|
|
4364
|
+
* The context block a pasted image contributes on a text-only route.
|
|
4365
|
+
*
|
|
4366
|
+
* The same XMLish convention the `!` passthrough uses: the model reads the
|
|
4367
|
+
* path (its tools can open the file), the dimensions, and — when the sidecar
|
|
4368
|
+
* ran — the description standing in for sight.
|
|
4369
|
+
* @param id - the `[Image #N]` number the person's text references.
|
|
4370
|
+
* @param image - the encoded image.
|
|
4371
|
+
* @param at - where the file was saved and what is known about it.
|
|
4372
|
+
* @returns the block text.
|
|
4373
|
+
*/
|
|
4374
|
+
function pastedImageBlock(id, image, at) {
|
|
4375
|
+
const size = at.width !== void 0 && at.height !== void 0 ? ` dimensions="${at.width}x${at.height}"` : "";
|
|
4376
|
+
const body = at.description === void 0 ? "" : `\n<description>\n${at.description}\n</description>`;
|
|
4377
|
+
return `<pasted-image id="${id}" media="${image.mediaType}"${size} path="${at.path}">${body}\n</pasted-image>`;
|
|
4378
|
+
}
|
|
4379
|
+
/**
|
|
4380
|
+
* Shrink an image until the attachment store will admit it.
|
|
4381
|
+
*
|
|
4382
|
+
* Retina screenshots exceed the deployed routes' 2000-pixel side limit as a
|
|
4383
|
+
* matter of course, and refusing them would make the feature useless on the
|
|
4384
|
+
* machines most likely to use it. The downscale re-encodes as PNG; a copy
|
|
4385
|
+
* still over the byte limit falls back to JPEG, which is what a photograph
|
|
4386
|
+
* that big actually is.
|
|
4387
|
+
* @param image - the encoded image.
|
|
4388
|
+
* @param limits - the store's admission limits.
|
|
4389
|
+
* @returns the image, downscaled only when it had to be.
|
|
4390
|
+
*/
|
|
4391
|
+
async function fitWithinLimits(image, limits) {
|
|
4392
|
+
const data = Buffer.from(image.data, "base64");
|
|
4393
|
+
const { default: sharp } = await import("sharp");
|
|
4394
|
+
const meta = await sharp(data).metadata();
|
|
4395
|
+
if (!(Math.max(meta.width ?? 0, meta.height ?? 0) > limits.maxImageDimension || data.length > limits.maxImageBytes || (meta.width ?? 0) * (meta.height ?? 0) > limits.maxImagePixels)) return image;
|
|
4396
|
+
const png = await sharp(data).resize({
|
|
4397
|
+
width: limits.maxImageDimension,
|
|
4398
|
+
height: limits.maxImageDimension,
|
|
4399
|
+
fit: "inside",
|
|
4400
|
+
withoutEnlargement: true
|
|
4401
|
+
}).png().toBuffer();
|
|
4402
|
+
if (png.length <= limits.maxImageBytes) return {
|
|
4403
|
+
...image,
|
|
4404
|
+
mediaType: "image/png",
|
|
4405
|
+
data: png.toString("base64")
|
|
4406
|
+
};
|
|
4407
|
+
const jpeg = await sharp(png).jpeg({ quality: 80 }).toBuffer();
|
|
4408
|
+
return {
|
|
4409
|
+
...image,
|
|
4410
|
+
mediaType: "image/jpeg",
|
|
4411
|
+
data: jpeg.toString("base64")
|
|
4412
|
+
};
|
|
4413
|
+
}
|
|
4414
|
+
|
|
3558
4415
|
//#endregion
|
|
3559
4416
|
//#region src/streaming.ts
|
|
3560
4417
|
/** Accumulates assistant text deltas into rendered lines. */
|
|
@@ -3637,7 +4494,7 @@ const MAX_DIFF_LINES = 24;
|
|
|
3637
4494
|
* Result body lines printed for one completed call before the card collapses.
|
|
3638
4495
|
*
|
|
3639
4496
|
* Small on purpose: a long output in the transcript is skimmed, not read, and
|
|
3640
|
-
* the collapsed remainder is one Ctrl+O away in full.
|
|
4497
|
+
* the collapsed remainder is one click — or one Ctrl+O — away in full.
|
|
3641
4498
|
*/
|
|
3642
4499
|
const MAX_RESULT_LINES = 5;
|
|
3643
4500
|
/**
|
|
@@ -3649,6 +4506,111 @@ function visibleText(content) {
|
|
|
3649
4506
|
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
3650
4507
|
}
|
|
3651
4508
|
/**
|
|
4509
|
+
* One dim line per image a user message carried, in place of pixels.
|
|
4510
|
+
*
|
|
4511
|
+
* An image block's bytes cannot render here, and a `<pasted-image>` context
|
|
4512
|
+
* block is the pipeline talking to the model — pages of description would
|
|
4513
|
+
* bury the words the person typed. Either becomes a line saying what rode
|
|
4514
|
+
* along and what became of it.
|
|
4515
|
+
* @param content - the message's blocks.
|
|
4516
|
+
* @param theme - styling for the meta lines.
|
|
4517
|
+
* @returns the lines, empty for a text-only message.
|
|
4518
|
+
*/
|
|
4519
|
+
function imageMetaLines(content, theme) {
|
|
4520
|
+
const lines = [];
|
|
4521
|
+
for (const block of content) if (block.type === "image") {
|
|
4522
|
+
const { width, height, mediaType } = block.attachment;
|
|
4523
|
+
lines.push(theme.dim(` [image · ${width}×${height} ${mediaType.slice(6)} · sent to the model]`));
|
|
4524
|
+
} else if (block.type === "text" && block.text.startsWith("<pasted-image ")) {
|
|
4525
|
+
const id = /id="(\d+)"/u.exec(block.text)?.[1] ?? "?";
|
|
4526
|
+
const dims = /dimensions="(\d+)x(\d+)"/u.exec(block.text);
|
|
4527
|
+
const media = /media="image\/(\w+)"/u.exec(block.text)?.[1] ?? "image";
|
|
4528
|
+
const size = dims === null ? "" : ` · ${dims[1]}×${dims[2]}`;
|
|
4529
|
+
const fate = block.text.includes("<description>") ? "described" : "saved to file";
|
|
4530
|
+
lines.push(theme.dim(` [image #${id}${size} ${media} · ${fate}]`));
|
|
4531
|
+
}
|
|
4532
|
+
return lines;
|
|
4533
|
+
}
|
|
4534
|
+
/**
|
|
4535
|
+
* The left rules the transcript draws down a block's edge, one per kind.
|
|
4536
|
+
*
|
|
4537
|
+
* A rule is how a segment shows where it starts and ends without a frame or a
|
|
4538
|
+
* background fill: the references converge on a left border (Claude Code's
|
|
4539
|
+
* `borderLeft`, opencode's `border: ["left"]`), and a border costs one column
|
|
4540
|
+
* where a fill costs the terminal's own background — which is the theme's to
|
|
4541
|
+
* decide, not this surface's (ADR-0001).
|
|
4542
|
+
*
|
|
4543
|
+
* The person's own words get the heavy mark; a tool block gets the light one,
|
|
4544
|
+
* in the error colour when the call failed. What a person actually reads — an
|
|
4545
|
+
* answer, a thinking summary — stays flush, so the rules mark the machinery
|
|
4546
|
+
* around it rather than everything equally.
|
|
4547
|
+
* @param theme - styling for the marks.
|
|
4548
|
+
* @returns the rule per block kind.
|
|
4549
|
+
*/
|
|
4550
|
+
function blockRules(theme) {
|
|
4551
|
+
return {
|
|
4552
|
+
user: theme.user("┃ "),
|
|
4553
|
+
tool: theme.tool("│ "),
|
|
4554
|
+
error: theme.error("│ ")
|
|
4555
|
+
};
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* What the two nameless block kinds are called when a readout names them.
|
|
4559
|
+
*
|
|
4560
|
+
* A tool block answers with its card's own title, which is already on screen;
|
|
4561
|
+
* thinking and an answer have no title of their own, so these are theirs.
|
|
4562
|
+
*/
|
|
4563
|
+
const FOLD_LABELS = {
|
|
4564
|
+
thinking: "thinking",
|
|
4565
|
+
answer: "answer"
|
|
4566
|
+
};
|
|
4567
|
+
/** A finished answer longer than this many rendered lines becomes a fold. */
|
|
4568
|
+
const ANSWER_FOLD_LINES = 24;
|
|
4569
|
+
/** How many of its head lines a collapsed answer keeps visible. */
|
|
4570
|
+
const ANSWER_HEAD_LINES = 8;
|
|
4571
|
+
/**
|
|
4572
|
+
* The collapsed form of a finished answer, when it is long enough to fold.
|
|
4573
|
+
*
|
|
4574
|
+
* Shared by the live turn and by replay: a resumed session must offer the same
|
|
4575
|
+
* summary the turn itself left behind, or history would read as a different
|
|
4576
|
+
* conversation from the one that happened.
|
|
4577
|
+
* @param lines - the answer's rendered lines, without its trailing blank.
|
|
4578
|
+
* @param theme - styling for the count line.
|
|
4579
|
+
* @returns the collapsed lines, or undefined when the answer is short enough
|
|
4580
|
+
* to stand as it is.
|
|
4581
|
+
*/
|
|
4582
|
+
function answerSummary(lines, theme) {
|
|
4583
|
+
if (lines.length <= ANSWER_FOLD_LINES) return void 0;
|
|
4584
|
+
return [
|
|
4585
|
+
...lines.slice(0, ANSWER_HEAD_LINES),
|
|
4586
|
+
theme.dim(` … +${lines.length - ANSWER_HEAD_LINES} lines (click or Ctrl+O expands)`),
|
|
4587
|
+
""
|
|
4588
|
+
];
|
|
4589
|
+
}
|
|
4590
|
+
/**
|
|
4591
|
+
* The two forms of a thinking block: one dim line, and the deliberation behind
|
|
4592
|
+
* it.
|
|
4593
|
+
*
|
|
4594
|
+
* Pages of reasoning would bury the conversation, so the transcript keeps the
|
|
4595
|
+
* summary and hands the rest to Ctrl+O — live and on replay alike.
|
|
4596
|
+
* @param lines - the rendered thinking lines, already styled.
|
|
4597
|
+
* @param theme - styling for the header.
|
|
4598
|
+
* @param seconds - how long the thinking took, when the surface timed it; a
|
|
4599
|
+
* replayed log carries no clock, so the header simply says it thought.
|
|
4600
|
+
* @returns the collapsed and expanded forms.
|
|
4601
|
+
*/
|
|
4602
|
+
function thinkingFold(lines, theme, seconds) {
|
|
4603
|
+
const head = seconds === void 0 ? "✻ thought" : `✻ thought for ${seconds.toFixed(1)}s`;
|
|
4604
|
+
return {
|
|
4605
|
+
summary: [theme.dim(`${head} · +${lines.length} lines (click or Ctrl+O expands)`), ""],
|
|
4606
|
+
full: [
|
|
4607
|
+
theme.dim(head),
|
|
4608
|
+
...lines,
|
|
4609
|
+
""
|
|
4610
|
+
]
|
|
4611
|
+
};
|
|
4612
|
+
}
|
|
4613
|
+
/**
|
|
3652
4614
|
* Render one file's change as unified-diff body lines.
|
|
3653
4615
|
*
|
|
3654
4616
|
* A {@link FileDiff} carries one hunk's old and new blocks including their
|
|
@@ -3684,13 +4646,17 @@ function diffBody(diff, theme) {
|
|
|
3684
4646
|
*/
|
|
3685
4647
|
function cap(lines, limit, theme) {
|
|
3686
4648
|
if (lines.length <= limit) return lines;
|
|
3687
|
-
return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (Ctrl+O expands)`)];
|
|
4649
|
+
return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (click or Ctrl+O expands)`)];
|
|
3688
4650
|
}
|
|
3689
4651
|
/** Renders one session's appended events as terminal lines. */
|
|
3690
4652
|
var Transcript = class {
|
|
3691
4653
|
calls = /* @__PURE__ */ new Map();
|
|
3692
4654
|
/** The full form of the event just rendered, when its body was collapsed. */
|
|
3693
4655
|
fold;
|
|
4656
|
+
/** What the block {@link render} just returned is, for a hover readout. */
|
|
4657
|
+
label = "";
|
|
4658
|
+
/** The left rule the block {@link render} just returned belongs to. */
|
|
4659
|
+
rule = "";
|
|
3694
4660
|
constructor(options, presenters) {
|
|
3695
4661
|
this.options = options;
|
|
3696
4662
|
this.presenters = presenters;
|
|
@@ -3733,13 +4699,18 @@ var Transcript = class {
|
|
|
3733
4699
|
*/
|
|
3734
4700
|
render(event) {
|
|
3735
4701
|
const { theme } = this.options;
|
|
4702
|
+
const rules = blockRules(theme);
|
|
4703
|
+
this.rule = "";
|
|
3736
4704
|
switch (event.type) {
|
|
3737
4705
|
case "user/message": {
|
|
3738
4706
|
if (event.data.source.kind !== "user") return [];
|
|
3739
|
-
|
|
4707
|
+
this.rule = rules.user;
|
|
4708
|
+
const [first = "", ...rest] = event.data.content.filter((block) => block.type === "text").filter((block) => !block.text.startsWith("<pasted-image ")).map((block) => block.text).join("").split("\n");
|
|
4709
|
+
const meta = imageMetaLines(event.data.content, theme);
|
|
3740
4710
|
return [
|
|
3741
4711
|
`${theme.user("›")} ${first}`,
|
|
3742
4712
|
...rest.map((line) => ` ${line}`),
|
|
4713
|
+
...meta,
|
|
3743
4714
|
""
|
|
3744
4715
|
];
|
|
3745
4716
|
}
|
|
@@ -3747,24 +4718,22 @@ var Transcript = class {
|
|
|
3747
4718
|
const text = visibleText(event.data.message.content);
|
|
3748
4719
|
return text === "" ? [] : [...renderMarkdown(text, theme), ""];
|
|
3749
4720
|
}
|
|
3750
|
-
case "tool/call":
|
|
3751
|
-
|
|
4721
|
+
case "tool/call":
|
|
4722
|
+
this.rule = rules.tool;
|
|
4723
|
+
return this.renderCall(event.data.callId, event.data.name, event.data.arguments);
|
|
4724
|
+
case "tool/result":
|
|
4725
|
+
this.rule = rules.tool;
|
|
4726
|
+
return this.renderResult(event.data);
|
|
3752
4727
|
case "todo/write": {
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
return [
|
|
3757
|
-
`${theme.tool("todos")} ${theme.dim(`${done}/${todos.length}`)}`,
|
|
3758
|
-
...todos.map((todo) => {
|
|
3759
|
-
if (todo.status === "completed") return theme.dim(` ✔ ${todo.content}`);
|
|
3760
|
-
if (todo.status === "in_progress") return ` ${theme.pending("▶")} ${todo.content}`;
|
|
3761
|
-
return theme.dim(` ○ ${todo.content}`);
|
|
3762
|
-
}),
|
|
3763
|
-
""
|
|
3764
|
-
];
|
|
4728
|
+
this.rule = rules.tool;
|
|
4729
|
+
const lines = todoReport(event.data.todos, theme, this.options.columns);
|
|
4730
|
+
return lines.length === 0 ? [] : [...lines, ""];
|
|
3765
4731
|
}
|
|
3766
4732
|
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"), ""];
|
|
3767
|
-
case "turn/end":
|
|
4733
|
+
case "turn/end":
|
|
4734
|
+
if (event.data.reason.kind !== "error") return [];
|
|
4735
|
+
this.rule = rules.error;
|
|
4736
|
+
return [theme.error(`✗ ${event.data.reason.error.code}: ${event.data.reason.error.message}`), ""];
|
|
3768
4737
|
default: return [];
|
|
3769
4738
|
}
|
|
3770
4739
|
}
|
|
@@ -3794,11 +4763,11 @@ var Transcript = class {
|
|
|
3794
4763
|
};
|
|
3795
4764
|
if (view === void 0) return record(name$1, [`${theme.pending("●")} ${theme.tool(name$1)}`]);
|
|
3796
4765
|
if (view.card === "terminal") {
|
|
3797
|
-
const header = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
|
|
4766
|
+
const header$1 = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
|
|
3798
4767
|
const description = view.description === void 0 ? [] : [theme.dim(` ${view.description}`)];
|
|
3799
4768
|
const command = this.relativizeIn(view.title);
|
|
3800
4769
|
return record(command, [
|
|
3801
|
-
`${theme.pending("●")} ${theme.tool(name$1)}${header}`,
|
|
4770
|
+
`${theme.pending("●")} ${theme.tool(name$1)}${header$1}`,
|
|
3802
4771
|
` $ ${truncate(command, columns - 4)}`,
|
|
3803
4772
|
...description
|
|
3804
4773
|
]);
|
|
@@ -3826,15 +4795,19 @@ var Transcript = class {
|
|
|
3826
4795
|
const pending = this.calls.get(callId);
|
|
3827
4796
|
this.calls.delete(callId);
|
|
3828
4797
|
const failed = error !== void 0 || block.isError === true;
|
|
4798
|
+
if (failed) this.rule = blockRules(theme).error;
|
|
3829
4799
|
const marker = failed ? theme.error("✗") : theme.success("●");
|
|
3830
4800
|
if (pending === void 0) {
|
|
3831
4801
|
const raw = this.resultText(block.content).split("\n");
|
|
3832
4802
|
const head$1 = `${marker} ${theme.dim("(result)")}`;
|
|
3833
|
-
if (raw.length > MAX_RESULT_LINES)
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
4803
|
+
if (raw.length > MAX_RESULT_LINES) {
|
|
4804
|
+
this.fold = [
|
|
4805
|
+
head$1,
|
|
4806
|
+
...raw,
|
|
4807
|
+
""
|
|
4808
|
+
];
|
|
4809
|
+
this.label = "tool result";
|
|
4810
|
+
}
|
|
3838
4811
|
return [
|
|
3839
4812
|
head$1,
|
|
3840
4813
|
...cap(raw, MAX_RESULT_LINES, theme),
|
|
@@ -3845,11 +4818,14 @@ var Transcript = class {
|
|
|
3845
4818
|
const title = view?.title === void 0 ? pending.title : this.relativizeIn(view.title);
|
|
3846
4819
|
const { suffix, body, full } = this.outcome(view, block);
|
|
3847
4820
|
const head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [];
|
|
3848
|
-
if (full !== void 0)
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
4821
|
+
if (full !== void 0) {
|
|
4822
|
+
this.fold = [
|
|
4823
|
+
...head,
|
|
4824
|
+
...full,
|
|
4825
|
+
""
|
|
4826
|
+
];
|
|
4827
|
+
this.label = title;
|
|
4828
|
+
}
|
|
3853
4829
|
return [
|
|
3854
4830
|
...head,
|
|
3855
4831
|
...body,
|
|
@@ -3870,6 +4846,31 @@ var Transcript = class {
|
|
|
3870
4846
|
return fold;
|
|
3871
4847
|
}
|
|
3872
4848
|
/**
|
|
4849
|
+
* What the block {@link takeFold} just described is called.
|
|
4850
|
+
*
|
|
4851
|
+
* The card's title, so the readout that names what the pointer rests on says
|
|
4852
|
+
* the same thing the block's own head line says.
|
|
4853
|
+
* @returns the label, or `''` when the block has no name of its own.
|
|
4854
|
+
*/
|
|
4855
|
+
takeLabel() {
|
|
4856
|
+
const label = this.label;
|
|
4857
|
+
this.label = "";
|
|
4858
|
+
return label;
|
|
4859
|
+
}
|
|
4860
|
+
/**
|
|
4861
|
+
* The left rule for the block {@link render} just returned, `''` when the
|
|
4862
|
+
* block stands flush.
|
|
4863
|
+
*
|
|
4864
|
+
* Paired with the lines rather than baked into them: the rule repeats on
|
|
4865
|
+
* every row the block wraps to, which only the buffer that wraps them knows.
|
|
4866
|
+
* @returns the styled rule, or `''`.
|
|
4867
|
+
*/
|
|
4868
|
+
takeRule() {
|
|
4869
|
+
const rule = this.rule;
|
|
4870
|
+
this.rule = "";
|
|
4871
|
+
return rule;
|
|
4872
|
+
}
|
|
4873
|
+
/**
|
|
3873
4874
|
* Render one completed call's status suffix and body from its declared view.
|
|
3874
4875
|
* @param view - the result view, absent when no presenter answered.
|
|
3875
4876
|
* @param block - the model-facing result block, used by the generic fallback.
|
|
@@ -4060,29 +5061,57 @@ function statusFacts(ctx, agent, cwd, selection, presetId, branch) {
|
|
|
4060
5061
|
};
|
|
4061
5062
|
}
|
|
4062
5063
|
/**
|
|
5064
|
+
* The agent's todo list as the chrome's readout wants it.
|
|
5065
|
+
*
|
|
5066
|
+
* Read from the projection rather than remembered from the write event, so a
|
|
5067
|
+
* resumed session shows the list it left off with and a `/clear` shows none.
|
|
5068
|
+
* @param ctx - plugin context carrying the projection service.
|
|
5069
|
+
* @param agent - the live agent.
|
|
5070
|
+
* @returns the current list, empty before any write.
|
|
5071
|
+
*/
|
|
5072
|
+
function todoList(ctx, agent) {
|
|
5073
|
+
return ctx.get("sessionProjections")?.snapshot(agent.session).values.todos ?? [];
|
|
5074
|
+
}
|
|
5075
|
+
/**
|
|
4063
5076
|
* Render every event a resumed session already holds, so the person sees the
|
|
4064
5077
|
* conversation they are continuing.
|
|
4065
5078
|
* @param session - the reconstructed session.
|
|
4066
5079
|
* @param transcript - the renderer, which also learns the pending call table.
|
|
4067
5080
|
* @param io - the terminal to write to.
|
|
4068
5081
|
*/
|
|
4069
|
-
function replay(session, transcript, io) {
|
|
4070
|
-
for (const event of session.events)
|
|
5082
|
+
function replay(session, transcript, io, theme) {
|
|
5083
|
+
for (const event of session.events) {
|
|
5084
|
+
if (event.type === "assistant/message") {
|
|
5085
|
+
const thought = event.data.message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
|
|
5086
|
+
if (thought !== "") {
|
|
5087
|
+
const { summary: summary$1, full: full$1 } = thinkingFold(thought.split("\n").map((line) => theme.dim(` ${line}`)), theme);
|
|
5088
|
+
io.console.appendFold(summary$1, full$1, "", FOLD_LABELS.thinking);
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
const lines = transcript.render(event);
|
|
5092
|
+
const full = transcript.takeFold();
|
|
5093
|
+
const rule = transcript.takeRule();
|
|
5094
|
+
const label = transcript.takeLabel();
|
|
5095
|
+
if (full !== void 0) {
|
|
5096
|
+
io.console.appendFold(lines, full, rule, label);
|
|
5097
|
+
continue;
|
|
5098
|
+
}
|
|
5099
|
+
for (const line of lines) io.console.write(line, rule);
|
|
5100
|
+
if (event.type !== "assistant/message") continue;
|
|
5101
|
+
const summary = answerSummary(lines.at(-1) === "" ? lines.slice(0, -1) : lines, theme);
|
|
5102
|
+
if (summary !== void 0) io.console.foldRecent(lines.length, summary, FOLD_LABELS.answer);
|
|
5103
|
+
}
|
|
4071
5104
|
}
|
|
4072
|
-
|
|
4073
|
-
* Run one conversation turn and wait for the agent to go idle.
|
|
4074
|
-
* @param agent - the live agent.
|
|
4075
|
-
* @param text - the person's message.
|
|
4076
|
-
* @param working - the indicator to run while the turn does.
|
|
4077
|
-
* @param source - the message source; a canned prompt is plugin-sourced so the
|
|
4078
|
-
* transcript echoes the command that ran it, not its whole body.
|
|
4079
|
-
*/
|
|
4080
|
-
async function turn(agent, text, working, source = { kind: "user" }) {
|
|
5105
|
+
async function turn(agent, text, working, source = { kind: "user" }, extra) {
|
|
4081
5106
|
agent.followup(createUserMessage({
|
|
4082
|
-
content: [
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
5107
|
+
content: [
|
|
5108
|
+
...extra?.leading ?? [],
|
|
5109
|
+
{
|
|
5110
|
+
type: "text",
|
|
5111
|
+
text
|
|
5112
|
+
},
|
|
5113
|
+
...extra?.trailing ?? []
|
|
5114
|
+
],
|
|
4086
5115
|
source
|
|
4087
5116
|
}));
|
|
4088
5117
|
working?.start();
|
|
@@ -4101,7 +5130,7 @@ async function turn(agent, text, working, source = { kind: "user" }) {
|
|
|
4101
5130
|
* @param theme - styling for the command's report.
|
|
4102
5131
|
* @param signal - cancels the command when the person interrupts.
|
|
4103
5132
|
*/
|
|
4104
|
-
async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
5133
|
+
async function runCommand(ctx, agent, line, io, theme, signal, images = []) {
|
|
4105
5134
|
const commands = ctx.get("commands");
|
|
4106
5135
|
if (commands === void 0) {
|
|
4107
5136
|
io.console.write(theme.error(" commands are unavailable in this composition"));
|
|
@@ -4114,7 +5143,7 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
|
4114
5143
|
io.console.write("");
|
|
4115
5144
|
return;
|
|
4116
5145
|
}
|
|
4117
|
-
const execution = await commands.execute(agent, line, signal);
|
|
5146
|
+
const execution = await commands.execute(agent, line, images, signal);
|
|
4118
5147
|
if (execution === void 0) {
|
|
4119
5148
|
io.console.write(theme.error(` unknown command: ${line}`));
|
|
4120
5149
|
return;
|
|
@@ -4128,10 +5157,6 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
|
|
|
4128
5157
|
const RECALL_WINDOW_MS = 1500;
|
|
4129
5158
|
/** Turns longer than this ring the bell on completion, when the bell is on. */
|
|
4130
5159
|
const BELL_TURN_MS = 1e4;
|
|
4131
|
-
/** A finished answer longer than this many rendered lines becomes a fold. */
|
|
4132
|
-
const ANSWER_FOLD_LINES = 24;
|
|
4133
|
-
/** How many of its head lines a collapsed answer keeps visible. */
|
|
4134
|
-
const ANSWER_HEAD_LINES = 8;
|
|
4135
5160
|
/**
|
|
4136
5161
|
* Run one subprocess and capture everything it printed.
|
|
4137
5162
|
* @param file - the executable, or a shell when `shell` is given.
|
|
@@ -4308,7 +5333,7 @@ async function run(ctx, config, io) {
|
|
|
4308
5333
|
const facts = (branch$1) => statusFacts(ctx, live.agent, cwd, selection, presetId, branch$1);
|
|
4309
5334
|
io.console.setTitle(`dsh code — ${basename(cwd)}`);
|
|
4310
5335
|
let branch = await gitBranch(cwd);
|
|
4311
|
-
if (config.resume !== "") replay(live.agent.session, live.transcript, io);
|
|
5336
|
+
if (config.resume !== "") replay(live.agent.session, live.transcript, io, theme);
|
|
4312
5337
|
for (const line of bannerLines({
|
|
4313
5338
|
model,
|
|
4314
5339
|
preset: presetId,
|
|
@@ -4335,11 +5360,24 @@ async function run(ctx, config, io) {
|
|
|
4335
5360
|
}))).flat().map((entry) => ({
|
|
4336
5361
|
provider: entry.provider,
|
|
4337
5362
|
id: entry.id,
|
|
4338
|
-
name: entry.name
|
|
5363
|
+
name: entry.name,
|
|
5364
|
+
...entry.inputModalities === void 0 ? {} : { inputModalities: entry.inputModalities }
|
|
4339
5365
|
}));
|
|
4340
5366
|
};
|
|
4341
5367
|
refreshModelCatalog();
|
|
4342
5368
|
/**
|
|
5369
|
+
* Whether the current route explicitly accepts image input.
|
|
5370
|
+
*
|
|
5371
|
+
* The same explicit-true test the adapter applies before throwing
|
|
5372
|
+
* UNSUPPORTED_CONTENT: absent modalities mean unknown, and unknown gets the
|
|
5373
|
+
* text fallback — which degrades, where the block path would crash the turn.
|
|
5374
|
+
*/
|
|
5375
|
+
const routeAcceptsImages = () => {
|
|
5376
|
+
const current = selection.current;
|
|
5377
|
+
if (current === void 0) return false;
|
|
5378
|
+
return modelCatalog.find((model$1) => model$1.provider === current.provider && model$1.id === current.model)?.inputModalities?.includes("image") === true;
|
|
5379
|
+
};
|
|
5380
|
+
/**
|
|
4343
5381
|
* Resolve a /model argument to a selection.
|
|
4344
5382
|
* @param typed - a bare model id, or an explicit `provider/model`.
|
|
4345
5383
|
* @returns the selection, or an error message naming what is available.
|
|
@@ -4365,6 +5403,7 @@ async function run(ctx, config, io) {
|
|
|
4365
5403
|
"quit",
|
|
4366
5404
|
"help",
|
|
4367
5405
|
"init",
|
|
5406
|
+
"ship",
|
|
4368
5407
|
"status",
|
|
4369
5408
|
"model",
|
|
4370
5409
|
"clear",
|
|
@@ -4379,6 +5418,10 @@ async function run(ctx, config, io) {
|
|
|
4379
5418
|
name: "init",
|
|
4380
5419
|
description: "analyze the repo and draft AGENTS.md"
|
|
4381
5420
|
},
|
|
5421
|
+
{
|
|
5422
|
+
name: "ship",
|
|
5423
|
+
description: "take a one-sentence idea to shipped code"
|
|
5424
|
+
},
|
|
4382
5425
|
...custom.commands.map((command) => ({
|
|
4383
5426
|
name: command.name,
|
|
4384
5427
|
description: command.description
|
|
@@ -4444,11 +5487,12 @@ async function run(ctx, config, io) {
|
|
|
4444
5487
|
},
|
|
4445
5488
|
shiftTab: () => {
|
|
4446
5489
|
const line = planModeFrom(live.agent.session.events) ? "/plan off" : "/plan";
|
|
4447
|
-
commands?.execute(live.agent, line, new AbortController().signal);
|
|
5490
|
+
commands?.execute(live.agent, line, [], new AbortController().signal);
|
|
4448
5491
|
},
|
|
4449
5492
|
expandOutput: () => {
|
|
4450
5493
|
if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
|
|
4451
|
-
}
|
|
5494
|
+
},
|
|
5495
|
+
readClipboardImage: () => readClipboardImage(process.env)
|
|
4452
5496
|
}, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
|
|
4453
5497
|
let turnBaseTokens = 0;
|
|
4454
5498
|
const spinner = new Spinner({
|
|
@@ -4490,6 +5534,20 @@ async function run(ctx, config, io) {
|
|
|
4490
5534
|
text: statusReport(facts(branch), live.agent.session.id)
|
|
4491
5535
|
})
|
|
4492
5536
|
}));
|
|
5537
|
+
disposers.push(commands.register({
|
|
5538
|
+
name: "todos",
|
|
5539
|
+
description: "print the agent's todo list as it now stands",
|
|
5540
|
+
handler: () => {
|
|
5541
|
+
const lines = todoReport(todoList(ctx, live.agent), theme, io.console.columns);
|
|
5542
|
+
return lines.length === 0 ? {
|
|
5543
|
+
kind: "success",
|
|
5544
|
+
text: "no todos yet"
|
|
5545
|
+
} : {
|
|
5546
|
+
kind: "success",
|
|
5547
|
+
text: lines.join("\n")
|
|
5548
|
+
};
|
|
5549
|
+
}
|
|
5550
|
+
}));
|
|
4493
5551
|
disposers.push(commands.register({
|
|
4494
5552
|
name: "clear",
|
|
4495
5553
|
description: "start a fresh session in place",
|
|
@@ -4626,14 +5684,14 @@ async function run(ctx, config, io) {
|
|
|
4626
5684
|
if (typed === "") {
|
|
4627
5685
|
await refreshModelCatalog();
|
|
4628
5686
|
const current = selection.current;
|
|
4629
|
-
const header = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
|
|
5687
|
+
const header$1 = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
|
|
4630
5688
|
if (modelCatalog.length === 0) return {
|
|
4631
5689
|
kind: "success",
|
|
4632
|
-
text: header
|
|
5690
|
+
text: header$1
|
|
4633
5691
|
};
|
|
4634
5692
|
if (!io.console.readsKeys) return {
|
|
4635
5693
|
kind: "success",
|
|
4636
|
-
text: `${header}\n${modelCatalog.map((entry) => {
|
|
5694
|
+
text: `${header$1}\n${modelCatalog.map((entry) => {
|
|
4637
5695
|
return `${entry.provider === current?.provider && entry.id === current.model ? "❯" : " "} ${entry.provider}/${entry.id} ${entry.name}`;
|
|
4638
5696
|
}).join("\n")}`
|
|
4639
5697
|
};
|
|
@@ -4683,11 +5741,8 @@ async function run(ctx, config, io) {
|
|
|
4683
5741
|
const tail = stream.flush();
|
|
4684
5742
|
emit([...tail, ""]);
|
|
4685
5743
|
answerLines.push(...tail);
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
theme.dim(` … +${answerLines.length - ANSWER_HEAD_LINES} lines (Ctrl+O expands)`),
|
|
4689
|
-
""
|
|
4690
|
-
]);
|
|
5744
|
+
const summary = answerSummary(answerLines, theme);
|
|
5745
|
+
if (summary !== void 0) io.console.foldRecent(answerLines.length + 1, summary, FOLD_LABELS.answer);
|
|
4691
5746
|
answerLines = [];
|
|
4692
5747
|
};
|
|
4693
5748
|
const thinking = new TextStream(theme, () => io.console.contentColumns, true);
|
|
@@ -4696,13 +5751,9 @@ async function run(ctx, config, io) {
|
|
|
4696
5751
|
const flushThinking = () => {
|
|
4697
5752
|
thinkingLines.push(...thinking.flush());
|
|
4698
5753
|
if (thinkingLines.length === 0) return;
|
|
4699
|
-
const seconds = ((performance.now() - thinkingStartedAt) / 1e3).toFixed(1);
|
|
4700
5754
|
prompt.setStreaming(void 0);
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
...thinkingLines,
|
|
4704
|
-
""
|
|
4705
|
-
]);
|
|
5755
|
+
const { summary, full } = thinkingFold(thinkingLines, theme, (performance.now() - thinkingStartedAt) / 1e3);
|
|
5756
|
+
io.console.appendFold(summary, full, "", FOLD_LABELS.thinking);
|
|
4706
5757
|
thinkingLines = [];
|
|
4707
5758
|
thinkingStartedAt = 0;
|
|
4708
5759
|
};
|
|
@@ -4711,15 +5762,16 @@ async function run(ctx, config, io) {
|
|
|
4711
5762
|
* @param lines - finished lines for the transcript.
|
|
4712
5763
|
* @param live - the in-progress line, or undefined to release the region.
|
|
4713
5764
|
*/
|
|
4714
|
-
const emit = (lines, live$1) => {
|
|
5765
|
+
const emit = (lines, live$1, rule = "") => {
|
|
4715
5766
|
if (lines.length > 0) prompt.setStreaming(void 0);
|
|
4716
|
-
for (const line of lines) prompt.write(line);
|
|
5767
|
+
for (const line of lines) prompt.write(line, rule);
|
|
4717
5768
|
prompt.setStreaming(live$1);
|
|
4718
5769
|
};
|
|
4719
5770
|
/** Push the always-current status row; the pipe shape prints it instead. */
|
|
4720
5771
|
const refreshStatus = () => {
|
|
4721
5772
|
if (!io.console.readsKeys) return;
|
|
4722
5773
|
prompt.setStatus(statusLine(facts(branch), theme, io.console.columns - 1));
|
|
5774
|
+
prompt.setTodos(todoList(ctx, live.agent));
|
|
4723
5775
|
};
|
|
4724
5776
|
if (planModeFrom(live.agent.session.events)) prompt.setAccent((text) => theme.pending(text));
|
|
4725
5777
|
refreshStatus();
|
|
@@ -4727,6 +5779,7 @@ async function run(ctx, config, io) {
|
|
|
4727
5779
|
if (session !== live.agent.session) return;
|
|
4728
5780
|
if (event.type === "plan/mode") prompt.setAccent(event.data.active ? (text) => theme.pending(text) : void 0);
|
|
4729
5781
|
refreshStatus();
|
|
5782
|
+
if (event.type === "todo/write") prompt.setTodos(event.data.todos);
|
|
4730
5783
|
if (config.print && event.type === "user/message") return;
|
|
4731
5784
|
if (event.type === "assistant/chunk") {
|
|
4732
5785
|
const { chunk } = event.data;
|
|
@@ -4754,12 +5807,14 @@ async function run(ctx, config, io) {
|
|
|
4754
5807
|
}
|
|
4755
5808
|
const lines = live.transcript.render(event);
|
|
4756
5809
|
const full = live.transcript.takeFold();
|
|
5810
|
+
const rule = live.transcript.takeRule();
|
|
5811
|
+
const label = live.transcript.takeLabel();
|
|
4757
5812
|
if (full === void 0) {
|
|
4758
|
-
emit(lines);
|
|
5813
|
+
emit(lines, void 0, rule);
|
|
4759
5814
|
return;
|
|
4760
5815
|
}
|
|
4761
5816
|
prompt.setStreaming(void 0);
|
|
4762
|
-
io.console.appendFold(lines, full);
|
|
5817
|
+
io.console.appendFold(lines, full, rule, label);
|
|
4763
5818
|
});
|
|
4764
5819
|
/** Pause the indicator around a decision, and resume it if work continues. */
|
|
4765
5820
|
const whileDeciding = async (decide) => {
|
|
@@ -4815,7 +5870,7 @@ async function run(ctx, config, io) {
|
|
|
4815
5870
|
approval.clear();
|
|
4816
5871
|
turnBaseTokens = 0;
|
|
4817
5872
|
prompt.setAccent(planModeFrom(next.agent.session.events) ? (text) => theme.pending(text) : void 0);
|
|
4818
|
-
if (replayLog) replay(next.agent.session, live.transcript, io);
|
|
5873
|
+
if (replayLog) replay(next.agent.session, live.transcript, io, theme);
|
|
4819
5874
|
refreshStatus();
|
|
4820
5875
|
};
|
|
4821
5876
|
const questions = ctx.get("userQuestions");
|
|
@@ -4898,13 +5953,70 @@ async function run(ctx, config, io) {
|
|
|
4898
5953
|
* than only in the status line.
|
|
4899
5954
|
* @param text - the person's message.
|
|
4900
5955
|
*/
|
|
4901
|
-
|
|
5956
|
+
/**
|
|
5957
|
+
* Turn pasted images into what this turn's message can carry.
|
|
5958
|
+
*
|
|
5959
|
+
* Three exits, decided by the route. An image-capable model gets the images
|
|
5960
|
+
* as first-class blocks through the durable store — the runtime's own path.
|
|
5961
|
+
* A text-only model gets each image saved as a file plus, when the vision
|
|
5962
|
+
* sidecar is configured, a description standing in for sight; both ride the
|
|
5963
|
+
* same message so they persist for `--resume`. A failure never loses the
|
|
5964
|
+
* turn: it flashes, and the text still goes.
|
|
5965
|
+
* @param images - the submission's pasted images, in token order.
|
|
5966
|
+
* @returns blocks around the text, or undefined when there is nothing extra.
|
|
5967
|
+
*/
|
|
5968
|
+
const prepareImages = async (images) => {
|
|
5969
|
+
if (images.length === 0) return void 0;
|
|
5970
|
+
const store = ctx.get("attachments");
|
|
5971
|
+
const limits = store?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
|
|
5972
|
+
if (routeAcceptsImages() && store !== void 0) try {
|
|
5973
|
+
return {
|
|
5974
|
+
leading: (await admitEncodedImages(store, await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits))))).map((attachment) => ({
|
|
5975
|
+
type: "image",
|
|
5976
|
+
attachment
|
|
5977
|
+
})),
|
|
5978
|
+
trailing: []
|
|
5979
|
+
};
|
|
5980
|
+
} catch (error) {
|
|
5981
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
5982
|
+
prompt.setFlash(theme.error(truncate(` image dropped: ${reason}`, io.console.columns)));
|
|
5983
|
+
if (!isImageAdmissionError(error)) return void 0;
|
|
5984
|
+
}
|
|
5985
|
+
const vision = visionConfigFromEnv(process.env);
|
|
5986
|
+
const trailing = [];
|
|
5987
|
+
for (const pending of images) {
|
|
5988
|
+
const at = { path: await savePastedImage(pending.image) };
|
|
5989
|
+
if (pending.width !== void 0) at.width = pending.width;
|
|
5990
|
+
if (pending.height !== void 0) at.height = pending.height;
|
|
5991
|
+
if (vision !== void 0) {
|
|
5992
|
+
prompt.setHint(theme.dim(` ✻ describing image #${pending.id} with ${vision.model}…`));
|
|
5993
|
+
try {
|
|
5994
|
+
at.description = await describeImage(await fitWithinLimits(pending.image, limits), vision);
|
|
5995
|
+
} catch (error) {
|
|
5996
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
5997
|
+
prompt.setFlash(theme.error(truncate(` image #${pending.id}: description failed (${reason}) — attached as file only`, io.console.columns)));
|
|
5998
|
+
} finally {
|
|
5999
|
+
prompt.setHint(void 0);
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
trailing.push({
|
|
6003
|
+
type: "text",
|
|
6004
|
+
text: pastedImageBlock(pending.id, pending.image, at)
|
|
6005
|
+
});
|
|
6006
|
+
}
|
|
6007
|
+
return {
|
|
6008
|
+
leading: [],
|
|
6009
|
+
trailing
|
|
6010
|
+
};
|
|
6011
|
+
};
|
|
6012
|
+
const answer = async (text, source, images = []) => {
|
|
6013
|
+
const extra = await prepareImages(images);
|
|
4902
6014
|
const before = totalTokens(facts(branch).usage) ?? 0;
|
|
4903
6015
|
turnBaseTokens = before;
|
|
4904
6016
|
const started = performance.now();
|
|
4905
6017
|
io.console.setTitle(`⚡ dsh code — ${basename(cwd)}`);
|
|
4906
6018
|
try {
|
|
4907
|
-
await turn(live.agent, text, spinner, source);
|
|
6019
|
+
await turn(live.agent, text, spinner, source, extra);
|
|
4908
6020
|
} finally {
|
|
4909
6021
|
io.console.setTitle(`dsh code — ${basename(cwd)}`);
|
|
4910
6022
|
}
|
|
@@ -4982,11 +6094,13 @@ async function run(ctx, config, io) {
|
|
|
4982
6094
|
}
|
|
4983
6095
|
const line = await prompt.read();
|
|
4984
6096
|
if (line === void 0) break;
|
|
6097
|
+
const images = prompt.takeAttachments();
|
|
4985
6098
|
io.console.collapseFolds();
|
|
4986
6099
|
const trimmed = line.trim();
|
|
4987
6100
|
if (trimmed === "") continue;
|
|
4988
6101
|
if (trimmed === "/exit" || trimmed === "/quit") break;
|
|
4989
6102
|
if (trimmed.startsWith("!")) {
|
|
6103
|
+
if (images.length > 0) prompt.setFlash(theme.dim(" images do not ride ! commands — send them with a prompt"));
|
|
4990
6104
|
const command = trimmed.slice(1).trim();
|
|
4991
6105
|
if (command !== "") await passthrough(command);
|
|
4992
6106
|
continue;
|
|
@@ -4998,7 +6112,14 @@ async function run(ctx, config, io) {
|
|
|
4998
6112
|
await answer(INIT_PROMPT, {
|
|
4999
6113
|
kind: "plugin",
|
|
5000
6114
|
plugin: "coding-cli"
|
|
5001
|
-
});
|
|
6115
|
+
}, images);
|
|
6116
|
+
continue;
|
|
6117
|
+
}
|
|
6118
|
+
if (name$1 === "ship") {
|
|
6119
|
+
await answer(expandTemplate(SHIP_PROMPT, rest.trim()), {
|
|
6120
|
+
kind: "plugin",
|
|
6121
|
+
plugin: "coding-cli"
|
|
6122
|
+
}, images);
|
|
5002
6123
|
continue;
|
|
5003
6124
|
}
|
|
5004
6125
|
const canned = customByName.get(name$1);
|
|
@@ -5006,18 +6127,23 @@ async function run(ctx, config, io) {
|
|
|
5006
6127
|
await answer(expandTemplate(canned.template, rest.trim()), {
|
|
5007
6128
|
kind: "plugin",
|
|
5008
6129
|
plugin: "coding-cli"
|
|
5009
|
-
});
|
|
6130
|
+
}, images);
|
|
5010
6131
|
continue;
|
|
5011
6132
|
}
|
|
6133
|
+
let batch = [];
|
|
6134
|
+
if (images.length > 0) if (routeAcceptsImages()) {
|
|
6135
|
+
const limits = ctx.get("attachments")?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
|
|
6136
|
+
batch = await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits)));
|
|
6137
|
+
} else prompt.setFlash(theme.error(" this model does not accept images with commands — they were dropped"));
|
|
5012
6138
|
running = new AbortController();
|
|
5013
6139
|
try {
|
|
5014
|
-
await runCommand(ctx, live.agent, trimmed, io, theme, running.signal);
|
|
6140
|
+
await runCommand(ctx, live.agent, trimmed, io, theme, running.signal, batch);
|
|
5015
6141
|
} finally {
|
|
5016
6142
|
running = void 0;
|
|
5017
6143
|
}
|
|
5018
6144
|
continue;
|
|
5019
6145
|
}
|
|
5020
|
-
await answer(trimmed);
|
|
6146
|
+
await answer(trimmed, void 0, images);
|
|
5021
6147
|
}
|
|
5022
6148
|
await sessions.flush(live.agent.session);
|
|
5023
6149
|
try {
|