codsh-bundle 0.6.0 → 0.7.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 +1057 -166
- package/lib/types/bang.d.ts +6 -0
- package/lib/types/completion.d.ts +20 -0
- package/lib/types/console.d.ts +59 -5
- package/lib/types/editor.d.ts +58 -0
- package/lib/types/inputbox.d.ts +13 -1
- package/lib/types/keys.d.ts +4 -0
- package/lib/types/prompt.d.ts +17 -1
- package/lib/types/screen.d.ts +67 -1
- package/lib/types/selector.d.ts +14 -3
- package/lib/types/ship.d.ts +6 -5
- package/lib/types/spinner.d.ts +7 -0
- package/lib/types/status.d.ts +5 -4
- package/lib/types/transcript.d.ts +21 -0
- package/lib/types/vision.d.ts +6 -5
- package/package.json +48 -47
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node
|
|
|
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 { isUserInvocable } from "@deepseek-ai/dsh-skill";
|
|
7
8
|
import { admitEncodedImages, isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
|
|
8
9
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
9
10
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
@@ -314,14 +315,15 @@ async function gitBranch(cwd) {
|
|
|
314
315
|
* a fresh session reads as short rather than as broken.
|
|
315
316
|
* @param facts - what to report.
|
|
316
317
|
* @param theme - styling for the segments.
|
|
317
|
-
* @param columns - display columns available; a longer line is cut, never
|
|
318
|
+
* @param columns - display columns available; a longer line is cut, never
|
|
319
|
+
* wrapped. Omit to keep the full line, so a later paint can re-fit it.
|
|
318
320
|
* @returns the line, unstyled when the theme is plain.
|
|
319
321
|
*/
|
|
320
322
|
function statusLine(facts, theme, columns) {
|
|
321
323
|
const left = contextLeftPercent(facts.context);
|
|
322
324
|
const total = totalTokens(facts.usage);
|
|
323
325
|
const headroom = left === void 0 ? [] : [left <= 10 ? theme.error(`${left}% context left`) : left <= 25 ? theme.pending(`${left}% context left`) : theme.dim(`${left}% context left`)];
|
|
324
|
-
|
|
326
|
+
const line = [
|
|
325
327
|
theme.tool(facts.model),
|
|
326
328
|
...facts.preset === void 0 ? [] : [theme.dim(facts.preset)],
|
|
327
329
|
...facts.permission === void 0 ? [] : [theme.dim(facts.permission)],
|
|
@@ -329,7 +331,8 @@ function statusLine(facts, theme, columns) {
|
|
|
329
331
|
...total === void 0 ? [] : [theme.dim(`${formatTokens(total)} tokens`)],
|
|
330
332
|
...headroom,
|
|
331
333
|
theme.dim(facts.branch === void 0 ? displayPath(facts.cwd) : `${displayPath(facts.cwd)} (${facts.branch})`)
|
|
332
|
-
].join(theme.dim(" · "))
|
|
334
|
+
].join(theme.dim(" · "));
|
|
335
|
+
return columns === void 0 ? line : truncate(line, columns);
|
|
333
336
|
}
|
|
334
337
|
/**
|
|
335
338
|
* Render the fuller readout `/status` answers with.
|
|
@@ -370,21 +373,59 @@ function statusReport(facts, session) {
|
|
|
370
373
|
/** The product name, shown as the framed headline. */
|
|
371
374
|
const NAME$1 = "dsh code";
|
|
372
375
|
/**
|
|
373
|
-
*
|
|
376
|
+
* Half-block sprite of `assets/logo.svg`: a › chevron, a hull, a wave, and a
|
|
377
|
+
* whale tail. Each character is two vertical pixels (`▀` `▄` `█`).
|
|
374
378
|
*
|
|
375
|
-
*
|
|
376
|
-
* and anything narrower falls back to the plain headline anyway.
|
|
379
|
+
* `.` empty, `c` chevron, `h` hull, `w` water and fluke.
|
|
377
380
|
*/
|
|
378
|
-
const
|
|
379
|
-
"
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
"
|
|
384
|
-
"
|
|
381
|
+
const SPRITE = [
|
|
382
|
+
"........c...........",
|
|
383
|
+
"........ccc.........",
|
|
384
|
+
"........c.ccc.......",
|
|
385
|
+
"........c..cc.......",
|
|
386
|
+
"........c.ccc.......",
|
|
387
|
+
"........ccc.........",
|
|
388
|
+
"......hhhhhhhh......",
|
|
389
|
+
".......hhhhhh.......",
|
|
390
|
+
"....w.ww..ww.ww.....",
|
|
391
|
+
"....wwwwwwwwwwww....",
|
|
392
|
+
".....w...ww...w.....",
|
|
393
|
+
"......ww.ww.ww......",
|
|
394
|
+
".......ww..ww.......",
|
|
395
|
+
"........wwww........"
|
|
385
396
|
];
|
|
386
|
-
/** Display width of the
|
|
387
|
-
const LOGO_WIDTH =
|
|
397
|
+
/** Display width of the sprite, plus the leading pad. */
|
|
398
|
+
const LOGO_WIDTH = SPRITE[0].length + 1;
|
|
399
|
+
/** Truecolor fills matching `assets/logo.svg`, used only on a coloured TTY. */
|
|
400
|
+
const FILL = {
|
|
401
|
+
c: "\x1B[38;2;126;150;245m",
|
|
402
|
+
h: "\x1B[38;2;238;241;251m",
|
|
403
|
+
w: "\x1B[38;2;61;86;214m"
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* Paint one half-block cell from a pair of pixels.
|
|
407
|
+
*/
|
|
408
|
+
function paintCell(upper, lower, theme) {
|
|
409
|
+
const ink = (role, ch) => theme.colored ? `${FILL[role]}${ch}\u001B[0m` : ch;
|
|
410
|
+
if (upper === ".") return lower === "." ? " " : ink(lower, "▄");
|
|
411
|
+
if (lower === ".") return ink(upper, "▀");
|
|
412
|
+
if (upper === lower) return ink(upper, "█");
|
|
413
|
+
return ink(upper, "▀");
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Paint the first-screen mark as half-block rows.
|
|
417
|
+
*/
|
|
418
|
+
function logoLines(theme) {
|
|
419
|
+
const rows = [];
|
|
420
|
+
for (let y = 0; y < SPRITE.length; y += 2) {
|
|
421
|
+
const top = SPRITE[y] ?? "";
|
|
422
|
+
const bot = SPRITE[y + 1] ?? "";
|
|
423
|
+
let line = " ";
|
|
424
|
+
for (let x = 0; x < top.length; x += 1) line += paintCell(top[x] ?? ".", bot[x] ?? ".", theme);
|
|
425
|
+
rows.push(line);
|
|
426
|
+
}
|
|
427
|
+
return rows;
|
|
428
|
+
}
|
|
388
429
|
/**
|
|
389
430
|
* Frame one headline in a rounded box sized to its content.
|
|
390
431
|
*
|
|
@@ -423,11 +464,11 @@ function bannerLines(facts, theme, columns) {
|
|
|
423
464
|
theme.dim(truncate(` /help for commands · Tab completes · ⇧Tab plan mode · ${interrupt} interrupts · /exit leaves`, columns)),
|
|
424
465
|
""
|
|
425
466
|
];
|
|
426
|
-
if (facts.readsKeys && !facts.resumed && columns >= LOGO_WIDTH + 2) return [
|
|
467
|
+
if (facts.readsKeys && !facts.resumed && columns >= Math.max(LOGO_WIDTH + 2, 40)) return [
|
|
427
468
|
"",
|
|
428
|
-
...
|
|
469
|
+
...logoLines(theme),
|
|
429
470
|
"",
|
|
430
|
-
` ${theme.bold("✻ Welcome to codsh")}${theme.dim(` · ${composition}`)}`,
|
|
471
|
+
truncate(` ${theme.bold("✻ Welcome to codsh")}${theme.dim(` · ${composition}`)}`, columns),
|
|
431
472
|
"",
|
|
432
473
|
...details
|
|
433
474
|
];
|
|
@@ -479,6 +520,33 @@ function fuzzyScore(needle, hay) {
|
|
|
479
520
|
}
|
|
480
521
|
return score * 100 - have.length;
|
|
481
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Rank candidates that contain `typed`, prefix hits first.
|
|
525
|
+
*
|
|
526
|
+
* A fragment anywhere in the name is enough to offer it; an exact prefix still
|
|
527
|
+
* outranks a buried hit, so `/p` keeps `plan` above `compact`.
|
|
528
|
+
* @param items - the candidates.
|
|
529
|
+
* @param typed - what was typed, without a leading `/` `$` `@`.
|
|
530
|
+
* @param nameOf - the name to match against.
|
|
531
|
+
* @returns matching items, prefix then substring.
|
|
532
|
+
*/
|
|
533
|
+
function rankContains(items, typed, nameOf) {
|
|
534
|
+
if (typed === "") return [...items];
|
|
535
|
+
const needle = typed.toLowerCase();
|
|
536
|
+
const prefixed = [];
|
|
537
|
+
const contained = [];
|
|
538
|
+
for (const item of items) {
|
|
539
|
+
const have = nameOf(item).toLowerCase();
|
|
540
|
+
if (have.startsWith(needle)) prefixed.push(item);
|
|
541
|
+
else if (have.includes(needle)) contained.push(item);
|
|
542
|
+
}
|
|
543
|
+
contained.sort((left, right) => {
|
|
544
|
+
const a = nameOf(left).toLowerCase();
|
|
545
|
+
const b = nameOf(right).toLowerCase();
|
|
546
|
+
return a.indexOf(needle) - b.indexOf(needle) || a.length - b.length;
|
|
547
|
+
});
|
|
548
|
+
return [...prefixed, ...contained];
|
|
549
|
+
}
|
|
482
550
|
let cached;
|
|
483
551
|
/**
|
|
484
552
|
* Walk the workspace into a flat list of relative paths.
|
|
@@ -558,20 +626,20 @@ function createCompleter(commands, cwd) {
|
|
|
558
626
|
if (token.startsWith("@")) return [completePath(token, cwd), token];
|
|
559
627
|
if (!line.startsWith("/") || line.includes(" ")) return [[], token];
|
|
560
628
|
const typed = line.slice(1);
|
|
561
|
-
|
|
562
|
-
const prefixed = names.filter((name$1) => name$1.startsWith(`/${typed}`));
|
|
563
|
-
const fuzzy = names.map((name$1) => ({
|
|
564
|
-
name: name$1,
|
|
565
|
-
score: fuzzyScore(typed, name$1.slice(1))
|
|
566
|
-
})).filter((hit) => hit.score !== void 0).sort((a, b) => b.score - a.score).map((hit) => hit.name);
|
|
567
|
-
const ranked = [];
|
|
568
|
-
for (const name$1 of [...prefixed, ...fuzzy]) {
|
|
569
|
-
if (!ranked.includes(name$1)) ranked.push(name$1);
|
|
570
|
-
if (ranked.length >= LIMIT) break;
|
|
571
|
-
}
|
|
572
|
-
return [ranked, line];
|
|
629
|
+
return [rankContains(commands(), typed, (command) => command.name).map((command) => `/${command.name}`).slice(0, LIMIT), line];
|
|
573
630
|
};
|
|
574
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Turn `$name` skill gestures into the `/name` tokens dsh injects.
|
|
634
|
+
*
|
|
635
|
+
* Only names in `known` are rewritten, so an ordinary `$amount` stays prose.
|
|
636
|
+
* @param text - the submitted line.
|
|
637
|
+
* @param known - user-invocable skill names.
|
|
638
|
+
* @returns the line with known `$name` tokens rewritten as `/name`.
|
|
639
|
+
*/
|
|
640
|
+
function expandSkillGestures(text, known) {
|
|
641
|
+
return text.replace(/(^|\s)\$([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g, (match, bound, name$1) => known.has(name$1) ? `${bound}/${name$1}` : match);
|
|
642
|
+
}
|
|
575
643
|
|
|
576
644
|
//#endregion
|
|
577
645
|
//#region src/custom-commands.ts
|
|
@@ -795,6 +863,8 @@ const CONTROLS = {
|
|
|
795
863
|
"\f": { kind: "clear-screen" },
|
|
796
864
|
"": { kind: "expand-output" },
|
|
797
865
|
"": { kind: "toggle-todos" },
|
|
866
|
+
"": { kind: "history-search" },
|
|
867
|
+
"": { kind: "transcript-search" },
|
|
798
868
|
"": { kind: "kill-input" },
|
|
799
869
|
"": { kind: "paste-image" },
|
|
800
870
|
"": { kind: "kill-word" }
|
|
@@ -1056,6 +1126,8 @@ function wrapStyled(text, columns) {
|
|
|
1056
1126
|
//#region src/screen.ts
|
|
1057
1127
|
/** Logical transcript lines kept before the oldest are dropped. */
|
|
1058
1128
|
const MAX_SCROLLBACK = 5e3;
|
|
1129
|
+
/** Blank columns to the left of every painted row, so text is not flush to the window. */
|
|
1130
|
+
const GUTTER = 2;
|
|
1059
1131
|
/** Enter the alternate screen, saving the cursor and the current buffer. */
|
|
1060
1132
|
const ENTER_ALT = "\x1B[?1049h";
|
|
1061
1133
|
/** Leave it, restoring both. */
|
|
@@ -1108,23 +1180,30 @@ const STYLES = /\u001B\[[0-9;]*m/gu;
|
|
|
1108
1180
|
const INVERSE = "\x1B[7m";
|
|
1109
1181
|
/** End reverse video only, leaving any other attributes alone. */
|
|
1110
1182
|
const INVERSE_OFF = "\x1B[27m";
|
|
1111
|
-
/**
|
|
1112
|
-
const
|
|
1113
|
-
/**
|
|
1114
|
-
const
|
|
1183
|
+
/** Dark-background hover fill, a slight lift off the default black. */
|
|
1184
|
+
const FILL_DARK = "\x1B[48;5;236m";
|
|
1185
|
+
/** Light-background hover fill, a slight drop off the default white. */
|
|
1186
|
+
const FILL_LIGHT = "\x1B[48;5;253m";
|
|
1187
|
+
/** Restore the terminal's default background, leaving other attributes. */
|
|
1188
|
+
const FILL_OFF = "\x1B[49m";
|
|
1115
1189
|
/** A full SGR reset, which every styled span this surface prints ends with. */
|
|
1116
1190
|
const RESET = "\x1B[0m";
|
|
1117
1191
|
/**
|
|
1118
|
-
*
|
|
1192
|
+
* Fill a row with the hover panel colour, padded to the content width.
|
|
1119
1193
|
*
|
|
1120
1194
|
* Every styled span this surface prints ends in a full reset, which would drop
|
|
1121
|
-
* the
|
|
1122
|
-
*
|
|
1195
|
+
* the fill partway along the row — so the attribute is armed again after each
|
|
1196
|
+
* one, and turned off alone at the end. Spaces pad to the viewport width so
|
|
1197
|
+
* the block reads as a panel, the way opencode fills `backgroundElement`.
|
|
1123
1198
|
* @param row - the styled row.
|
|
1124
|
-
* @
|
|
1199
|
+
* @param columns - display columns the panel should occupy.
|
|
1200
|
+
* @param light - whether the terminal background is light.
|
|
1201
|
+
* @returns the row, filled end to end.
|
|
1125
1202
|
*/
|
|
1126
|
-
function
|
|
1127
|
-
|
|
1203
|
+
function fill(row, columns, light) {
|
|
1204
|
+
const bg = light ? FILL_LIGHT : FILL_DARK;
|
|
1205
|
+
const pad = Math.max(0, columns - displayWidth(row));
|
|
1206
|
+
return `${bg}${`${row}${" ".repeat(pad)}`.replaceAll(RESET, `${RESET}${bg}`)}${FILL_OFF}`;
|
|
1128
1207
|
}
|
|
1129
1208
|
/**
|
|
1130
1209
|
* The string index where a display column begins.
|
|
@@ -1173,12 +1252,16 @@ var Screen = class {
|
|
|
1173
1252
|
offset = 0;
|
|
1174
1253
|
/** What to show while scrolled back, drawn over the viewport's top row. */
|
|
1175
1254
|
notice = "";
|
|
1255
|
+
/** Completion menu painted over the viewport, just above the chrome. */
|
|
1256
|
+
overlay = [];
|
|
1176
1257
|
/** A mouse selection over the transcript, in physical-row coordinates. */
|
|
1177
1258
|
selection;
|
|
1178
1259
|
/** Collapsed blocks in the transcript, in order, with both of their forms. */
|
|
1179
1260
|
folds = [];
|
|
1180
1261
|
/** The block the pointer rests on, or undefined when it rests on none. */
|
|
1181
1262
|
hovered;
|
|
1263
|
+
/** Whether OSC 11 named a light background; the hover fill picks a shade. */
|
|
1264
|
+
light = false;
|
|
1182
1265
|
/**
|
|
1183
1266
|
* Physical row ranges the blocks occupy, or undefined when they need
|
|
1184
1267
|
* measuring again.
|
|
@@ -1191,22 +1274,51 @@ var Screen = class {
|
|
|
1191
1274
|
ranges;
|
|
1192
1275
|
/** Whether the folds currently show their full form. */
|
|
1193
1276
|
expanded = false;
|
|
1277
|
+
/** Incremental find over the owned scrollback, absent when find is closed. */
|
|
1278
|
+
find;
|
|
1194
1279
|
/** The last painted frame, so a repaint only touches rows that changed. */
|
|
1195
1280
|
painted = [];
|
|
1196
1281
|
/** Width the current frame was painted at, to detect a resize. */
|
|
1197
1282
|
paintedColumns = 0;
|
|
1198
1283
|
active = false;
|
|
1284
|
+
/** Opens a child session when a view-card is clicked. */
|
|
1285
|
+
enterHandler;
|
|
1199
1286
|
constructor(host) {
|
|
1200
1287
|
this.host = host;
|
|
1201
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* What a click on a view-card does.
|
|
1291
|
+
* @param handler - receives the child session id; omit to restore folding.
|
|
1292
|
+
*/
|
|
1293
|
+
setEnter(handler) {
|
|
1294
|
+
this.enterHandler = handler;
|
|
1295
|
+
}
|
|
1202
1296
|
/** Whether the alternate screen is currently held. */
|
|
1203
1297
|
get entered() {
|
|
1204
1298
|
return this.active;
|
|
1205
1299
|
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Adopt the light- or dark-background hover fill.
|
|
1302
|
+
* @param light - true when OSC 11 named a light color.
|
|
1303
|
+
*/
|
|
1304
|
+
setLight(light) {
|
|
1305
|
+
if (this.light === light) return;
|
|
1306
|
+
this.light = light;
|
|
1307
|
+
if (this.hovered !== void 0) this.render();
|
|
1308
|
+
}
|
|
1206
1309
|
/** Physical rows scrolled up out of view; zero means the tail is showing. */
|
|
1207
1310
|
get scrolledBy() {
|
|
1208
1311
|
return this.offset;
|
|
1209
1312
|
}
|
|
1313
|
+
/** Incremental find over the scrollback, absent when find is closed. */
|
|
1314
|
+
get transcriptSearch() {
|
|
1315
|
+
if (this.find === void 0) return void 0;
|
|
1316
|
+
return {
|
|
1317
|
+
query: this.find.query,
|
|
1318
|
+
hits: this.find.hits.length,
|
|
1319
|
+
index: this.find.index
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1210
1322
|
/** Take the alternate screen and start reporting the mouse. */
|
|
1211
1323
|
enter() {
|
|
1212
1324
|
if (this.active) return;
|
|
@@ -1273,8 +1385,9 @@ var Screen = class {
|
|
|
1273
1385
|
* @param full - the expanded lines, already styled.
|
|
1274
1386
|
* @param rule - a styled left rule for the whole block, `''` for none.
|
|
1275
1387
|
* @param label - what the block is, for the hover readout that names it.
|
|
1388
|
+
* @param enter - child session a click opens instead of folding, when set.
|
|
1276
1389
|
*/
|
|
1277
|
-
appendFold(summary, full, rule = "", label = "") {
|
|
1390
|
+
appendFold(summary, full, rule = "", label = "", enter) {
|
|
1278
1391
|
const shown = this.expanded ? full : summary;
|
|
1279
1392
|
this.folds.push({
|
|
1280
1393
|
at: this.logical.length,
|
|
@@ -1283,7 +1396,8 @@ var Screen = class {
|
|
|
1283
1396
|
full: [...full],
|
|
1284
1397
|
expanded: this.expanded,
|
|
1285
1398
|
rule,
|
|
1286
|
-
label
|
|
1399
|
+
label,
|
|
1400
|
+
...enter === void 0 ? {} : { enter }
|
|
1287
1401
|
});
|
|
1288
1402
|
this.append(shown, rule);
|
|
1289
1403
|
}
|
|
@@ -1354,6 +1468,10 @@ var Screen = class {
|
|
|
1354
1468
|
clickFold(row) {
|
|
1355
1469
|
const fold = this.foldAt(row);
|
|
1356
1470
|
if (fold === void 0) return;
|
|
1471
|
+
if (fold.enter !== void 0 && this.enterHandler !== void 0) {
|
|
1472
|
+
this.enterHandler(fold.enter);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1357
1475
|
this.setFold(fold, !fold.expanded);
|
|
1358
1476
|
}
|
|
1359
1477
|
/**
|
|
@@ -1474,6 +1592,18 @@ var Screen = class {
|
|
|
1474
1592
|
if (this.offset > 0) this.render();
|
|
1475
1593
|
}
|
|
1476
1594
|
/**
|
|
1595
|
+
* Float rows over the viewport just above the chrome.
|
|
1596
|
+
*
|
|
1597
|
+
* The chrome's height does not change, so opening a completion menu cannot
|
|
1598
|
+
* shake the transcript. Empty clears the layer.
|
|
1599
|
+
* @param rows - the overlay, top to bottom.
|
|
1600
|
+
*/
|
|
1601
|
+
setOverlay(rows) {
|
|
1602
|
+
if (rows.length === this.overlay.length && rows.every((row, index) => row === this.overlay[index])) return;
|
|
1603
|
+
this.overlay = [...rows];
|
|
1604
|
+
this.render();
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1477
1607
|
* Scroll the transcript.
|
|
1478
1608
|
* @param delta - rows to move; negative scrolls back into history.
|
|
1479
1609
|
*/
|
|
@@ -1498,6 +1628,78 @@ var Screen = class {
|
|
|
1498
1628
|
this.render();
|
|
1499
1629
|
}
|
|
1500
1630
|
/**
|
|
1631
|
+
* Search the owned scrollback.
|
|
1632
|
+
*
|
|
1633
|
+
* Hits are physical rows, case-insensitive. A new query starts on the
|
|
1634
|
+
* newest hit so recent output is what find lands on first.
|
|
1635
|
+
* @param query - the needle; empty means no hits yet.
|
|
1636
|
+
* @returns the current find state.
|
|
1637
|
+
*/
|
|
1638
|
+
searchTranscript(query) {
|
|
1639
|
+
const hits = [];
|
|
1640
|
+
const needle = query.toLowerCase();
|
|
1641
|
+
if (needle !== "") for (const [row, line] of this.physical.entries()) {
|
|
1642
|
+
const lower = line.replaceAll(STYLES, "").toLowerCase();
|
|
1643
|
+
let from = 0;
|
|
1644
|
+
for (;;) {
|
|
1645
|
+
const at = lower.indexOf(needle, from);
|
|
1646
|
+
if (at < 0) break;
|
|
1647
|
+
hits.push({
|
|
1648
|
+
row,
|
|
1649
|
+
start: at,
|
|
1650
|
+
end: at + needle.length
|
|
1651
|
+
});
|
|
1652
|
+
from = at + needle.length;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const index = hits.length === 0 ? 0 : hits.length - 1;
|
|
1656
|
+
this.find = {
|
|
1657
|
+
query,
|
|
1658
|
+
hits,
|
|
1659
|
+
index
|
|
1660
|
+
};
|
|
1661
|
+
this.revealFindHit();
|
|
1662
|
+
return {
|
|
1663
|
+
query,
|
|
1664
|
+
hits: hits.length,
|
|
1665
|
+
index
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Step to another hit of the current query.
|
|
1670
|
+
* @param direction - 1 towards the tail, -1 towards the head.
|
|
1671
|
+
* @returns the current find state, or undefined when find is closed.
|
|
1672
|
+
*/
|
|
1673
|
+
nextTranscriptHit(direction) {
|
|
1674
|
+
if (this.find === void 0 || this.find.hits.length === 0) return this.transcriptSearch;
|
|
1675
|
+
const count = this.find.hits.length;
|
|
1676
|
+
this.find.index = (this.find.index + direction + count) % count;
|
|
1677
|
+
this.revealFindHit();
|
|
1678
|
+
return this.transcriptSearch;
|
|
1679
|
+
}
|
|
1680
|
+
/** Close find. Transcript content is untouched. */
|
|
1681
|
+
clearTranscriptSearch() {
|
|
1682
|
+
if (this.find === void 0) return;
|
|
1683
|
+
this.find = void 0;
|
|
1684
|
+
this.render();
|
|
1685
|
+
}
|
|
1686
|
+
/** Scroll so the current hit is in the viewport, then paint. */
|
|
1687
|
+
revealFindHit() {
|
|
1688
|
+
const hit = this.find?.hits[this.find.index];
|
|
1689
|
+
if (hit === void 0) {
|
|
1690
|
+
this.render();
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
const height = this.viewportHeight();
|
|
1694
|
+
const end = this.physical.length - this.offset;
|
|
1695
|
+
const start = Math.max(0, end - height);
|
|
1696
|
+
if (hit.row < start || hit.row >= end) {
|
|
1697
|
+
const limit = Math.max(0, this.physical.length - height);
|
|
1698
|
+
this.offset = Math.min(limit, Math.max(0, this.physical.length - hit.row - 1));
|
|
1699
|
+
}
|
|
1700
|
+
this.render();
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1501
1703
|
* Drop the transcript, keeping the chrome.
|
|
1502
1704
|
*
|
|
1503
1705
|
* Ctrl-L on a shared terminal clears a viewport the person may want back; on
|
|
@@ -1511,6 +1713,7 @@ var Screen = class {
|
|
|
1511
1713
|
this.folds = [];
|
|
1512
1714
|
this.ranges = void 0;
|
|
1513
1715
|
this.hovered = void 0;
|
|
1716
|
+
this.find = void 0;
|
|
1514
1717
|
this.expanded = false;
|
|
1515
1718
|
this.offset = 0;
|
|
1516
1719
|
this.painted = [];
|
|
@@ -1535,6 +1738,13 @@ var Screen = class {
|
|
|
1535
1738
|
* every time, so a caller need not track the changes itself.
|
|
1536
1739
|
*/
|
|
1537
1740
|
mouseMove(row, column) {
|
|
1741
|
+
if (this.coversOverlay(row)) {
|
|
1742
|
+
if (this.hovered !== void 0) {
|
|
1743
|
+
this.hovered = void 0;
|
|
1744
|
+
this.render();
|
|
1745
|
+
}
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1538
1748
|
const at = this.locate(row, column, false);
|
|
1539
1749
|
const fold = at === void 0 ? void 0 : this.foldAt(at.row);
|
|
1540
1750
|
if (fold !== this.hovered) {
|
|
@@ -1545,7 +1755,8 @@ var Screen = class {
|
|
|
1545
1755
|
return {
|
|
1546
1756
|
label: fold.label,
|
|
1547
1757
|
lines: fold.full.length,
|
|
1548
|
-
expanded: fold.expanded
|
|
1758
|
+
expanded: fold.expanded,
|
|
1759
|
+
...fold.enter === void 0 ? {} : { enter: true }
|
|
1549
1760
|
};
|
|
1550
1761
|
}
|
|
1551
1762
|
/**
|
|
@@ -1560,6 +1771,10 @@ var Screen = class {
|
|
|
1560
1771
|
mouseDown(row, column) {
|
|
1561
1772
|
const had = this.selection !== void 0;
|
|
1562
1773
|
this.selection = void 0;
|
|
1774
|
+
if (this.coversOverlay(row)) {
|
|
1775
|
+
if (had) this.render();
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1563
1778
|
const at = this.locate(row, column, false);
|
|
1564
1779
|
if (at !== void 0) this.selection = {
|
|
1565
1780
|
anchor: at,
|
|
@@ -1639,6 +1854,13 @@ var Screen = class {
|
|
|
1639
1854
|
* way dragging past an edge keeps selecting, instead of refusing it.
|
|
1640
1855
|
* @returns the position, or undefined when it misses the content.
|
|
1641
1856
|
*/
|
|
1857
|
+
/** Whether a terminal row sits on the floating completion layer. */
|
|
1858
|
+
coversOverlay(row) {
|
|
1859
|
+
if (this.overlay.length === 0) return false;
|
|
1860
|
+
const chromeStart = this.host.rows() - this.chrome.length;
|
|
1861
|
+
const overlayStart = chromeStart - this.overlay.length;
|
|
1862
|
+
return row - 1 >= overlayStart && row - 1 < chromeStart;
|
|
1863
|
+
}
|
|
1642
1864
|
locate(row, column, clamp) {
|
|
1643
1865
|
if (this.physical.length === 0) return void 0;
|
|
1644
1866
|
const height = this.viewportHeight();
|
|
@@ -1649,7 +1871,7 @@ var Screen = class {
|
|
|
1649
1871
|
index = Math.min(Math.max(index, start), end - 1);
|
|
1650
1872
|
return {
|
|
1651
1873
|
row: index,
|
|
1652
|
-
column: Math.max(0, column - 1)
|
|
1874
|
+
column: Math.max(0, column - 1 - GUTTER)
|
|
1653
1875
|
};
|
|
1654
1876
|
}
|
|
1655
1877
|
/** Rows the transcript viewport occupies. */
|
|
@@ -1658,7 +1880,7 @@ var Screen = class {
|
|
|
1658
1880
|
}
|
|
1659
1881
|
/** Columns content is laid out for, one short of the width so no row wraps. */
|
|
1660
1882
|
contentColumns() {
|
|
1661
|
-
return Math.max(1, this.host.columns() - 1);
|
|
1883
|
+
return Math.max(1, this.host.columns() - 1 - GUTTER);
|
|
1662
1884
|
}
|
|
1663
1885
|
/**
|
|
1664
1886
|
* Wrap one logical line, repeating its rule on every row.
|
|
@@ -1731,24 +1953,48 @@ var Screen = class {
|
|
|
1731
1953
|
visible[index] = `${plain.slice(0, start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(stop)}`;
|
|
1732
1954
|
}
|
|
1733
1955
|
}
|
|
1956
|
+
const findHit = this.find?.hits[this.find.index];
|
|
1957
|
+
if (findHit !== void 0) {
|
|
1958
|
+
const first = Math.max(0, end - height);
|
|
1959
|
+
const index = findHit.row - first;
|
|
1960
|
+
if (index >= 0 && index < visible.length) {
|
|
1961
|
+
const plain = (visible[index] ?? "").replaceAll(STYLES, "");
|
|
1962
|
+
const marked = plain.slice(findHit.start, findHit.end);
|
|
1963
|
+
if (marked !== "") visible[index] = `${plain.slice(0, findHit.start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(findHit.end)}`;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1734
1966
|
const hovered = this.hovered;
|
|
1735
1967
|
if (hovered !== void 0) {
|
|
1736
|
-
const
|
|
1737
|
-
|
|
1738
|
-
|
|
1968
|
+
const range = this.foldRanges().find((entry) => entry.fold === hovered);
|
|
1969
|
+
if (range !== void 0) {
|
|
1970
|
+
const first = Math.max(0, end - height);
|
|
1971
|
+
const width = this.contentColumns();
|
|
1972
|
+
for (let at = range.from; at <= range.to; at += 1) {
|
|
1973
|
+
const index = at - first;
|
|
1974
|
+
if (index >= 0 && index < visible.length) visible[index] = fill(visible[index] ?? "", width, this.light);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1739
1977
|
}
|
|
1740
1978
|
const viewport = [...visible, ...padding];
|
|
1741
1979
|
if (this.offset > 0 && this.notice !== "" && viewport.length > 0) viewport[0] = truncate(this.notice, this.contentColumns());
|
|
1980
|
+
if (this.overlay.length > 0 && viewport.length > 0) {
|
|
1981
|
+
const width = this.contentColumns();
|
|
1982
|
+
const start = Math.max(0, viewport.length - this.overlay.length);
|
|
1983
|
+
this.overlay.forEach((row, index) => {
|
|
1984
|
+
const at = start + index;
|
|
1985
|
+
if (at < viewport.length) viewport[at] = fill(truncate(row, width), width, this.light);
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1742
1988
|
const frame = [...viewport, ...this.chrome];
|
|
1743
1989
|
let out = SYNC_BEGIN + HIDE_CURSOR;
|
|
1744
1990
|
frame.forEach((row, index) => {
|
|
1745
1991
|
if (this.painted[index] === row) return;
|
|
1746
|
-
out += `\u001B[${index + 1};1H${CLEAR_LINE}${row}`;
|
|
1992
|
+
out += `\u001B[${index + 1};1H${CLEAR_LINE}${" ".repeat(GUTTER)}${row}`;
|
|
1747
1993
|
});
|
|
1748
1994
|
for (let index = frame.length; index < this.painted.length; index += 1) out += `\u001B[${index + 1};1H${CLEAR_LINE}`;
|
|
1749
1995
|
if (this.chromeFocus) {
|
|
1750
1996
|
const row = frame.length - this.chrome.length + this.chromeCursor.row + 1;
|
|
1751
|
-
out += `\u001B[${row};${this.chromeCursor.column + 1}H${SHOW_CURSOR}`;
|
|
1997
|
+
out += `\u001B[${row};${this.chromeCursor.column + 1 + GUTTER}H${SHOW_CURSOR}`;
|
|
1752
1998
|
}
|
|
1753
1999
|
out += SYNC_END;
|
|
1754
2000
|
this.host.write(out);
|
|
@@ -1837,13 +2083,16 @@ var TerminalConsole = class {
|
|
|
1837
2083
|
return Math.max(this.output.columns ?? FALLBACK_COLUMNS, MIN_COLUMNS);
|
|
1838
2084
|
}
|
|
1839
2085
|
/**
|
|
1840
|
-
* Columns content may be laid out for
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
2086
|
+
* Columns content may be laid out for.
|
|
2087
|
+
*
|
|
2088
|
+
* One less than the width so a row cannot wrap the terminal, and on a
|
|
2089
|
+
* viewport two less again for the left gutter. Markdown, the live line, and
|
|
2090
|
+
* the chrome MUST use this figure — a box laid out one gutter wider is
|
|
2091
|
+
* truncated with an ellipsis on every row, and a live line that fills the
|
|
2092
|
+
* width wraps into the box beneath it.
|
|
1844
2093
|
*/
|
|
1845
2094
|
get contentColumns() {
|
|
1846
|
-
return Math.max(1, this.columns - 1);
|
|
2095
|
+
return Math.max(1, this.columns - 1 - (this.screen !== void 0 ? GUTTER : 0));
|
|
1847
2096
|
}
|
|
1848
2097
|
/** Whether the output stream is a terminal. */
|
|
1849
2098
|
get isTty() {
|
|
@@ -1886,6 +2135,32 @@ var TerminalConsole = class {
|
|
|
1886
2135
|
leaveScreen() {
|
|
1887
2136
|
this.screen?.leave();
|
|
1888
2137
|
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Hand the real TTY to a child, then take the viewport back.
|
|
2140
|
+
*
|
|
2141
|
+
* Raw mode and the alternate screen both have to go: the shell needs cooked
|
|
2142
|
+
* input and the person's own buffer, the way Claude Code and opencode yield
|
|
2143
|
+
* `!` to sh. SIGINT is swallowed here so Ctrl-C reaches the child.
|
|
2144
|
+
* @param work - runs while this process is not reading the keyboard.
|
|
2145
|
+
*/
|
|
2146
|
+
async runInForeground(work) {
|
|
2147
|
+
if (!this.readsKeys) return work();
|
|
2148
|
+
this.input.pause();
|
|
2149
|
+
this.input.setRawMode?.(false);
|
|
2150
|
+
this.output.write(DISABLE_PASTE_MARKERS);
|
|
2151
|
+
this.screen?.leave();
|
|
2152
|
+
const ignore = () => {};
|
|
2153
|
+
process.on("SIGINT", ignore);
|
|
2154
|
+
try {
|
|
2155
|
+
return await work();
|
|
2156
|
+
} finally {
|
|
2157
|
+
process.removeListener("SIGINT", ignore);
|
|
2158
|
+
this.input.setRawMode?.(true);
|
|
2159
|
+
this.output.write(ENABLE_PASTE_MARKERS);
|
|
2160
|
+
this.input.resume();
|
|
2161
|
+
this.screen?.enter();
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
1889
2164
|
/** Whether this surface currently holds its own screen. */
|
|
1890
2165
|
get owningScreen() {
|
|
1891
2166
|
return this.screen?.entered === true;
|
|
@@ -1905,6 +2180,13 @@ var TerminalConsole = class {
|
|
|
1905
2180
|
this.screen?.setScrollNotice(text);
|
|
1906
2181
|
}
|
|
1907
2182
|
/**
|
|
2183
|
+
* Float rows over the transcript just above the chrome.
|
|
2184
|
+
* @param rows - the overlay, or empty to clear it.
|
|
2185
|
+
*/
|
|
2186
|
+
setOverlay(rows) {
|
|
2187
|
+
this.screen?.setOverlay(rows);
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
1908
2190
|
* Scroll the transcript by a whole viewport.
|
|
1909
2191
|
* @param direction - -1 for back into history, 1 towards the tail.
|
|
1910
2192
|
*/
|
|
@@ -1915,6 +2197,28 @@ var TerminalConsole = class {
|
|
|
1915
2197
|
scrollToBottom() {
|
|
1916
2198
|
this.screen?.scrollToBottom();
|
|
1917
2199
|
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Search the owned scrollback.
|
|
2202
|
+
* @param query - the needle.
|
|
2203
|
+
*/
|
|
2204
|
+
searchTranscript(query) {
|
|
2205
|
+
return this.screen?.searchTranscript(query);
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Step to another hit of the current query.
|
|
2209
|
+
* @param direction - 1 towards the tail, -1 towards the head.
|
|
2210
|
+
*/
|
|
2211
|
+
nextTranscriptHit(direction) {
|
|
2212
|
+
return this.screen?.nextTranscriptHit(direction);
|
|
2213
|
+
}
|
|
2214
|
+
/** Close find. Transcript content is untouched. */
|
|
2215
|
+
clearTranscriptSearch() {
|
|
2216
|
+
this.screen?.clearTranscriptSearch();
|
|
2217
|
+
}
|
|
2218
|
+
/** Incremental find over the scrollback, absent when find is closed. */
|
|
2219
|
+
get transcriptSearch() {
|
|
2220
|
+
return this.screen?.transcriptSearch;
|
|
2221
|
+
}
|
|
1918
2222
|
/** Physical rows currently scrolled out of view; zero means at the tail. */
|
|
1919
2223
|
get scrolledBy() {
|
|
1920
2224
|
return this.screen?.scrolledBy ?? 0;
|
|
@@ -2042,15 +2346,23 @@ var TerminalConsole = class {
|
|
|
2042
2346
|
* @param rule - a styled left rule for the whole block, `''` for none.
|
|
2043
2347
|
* @param label - what the block is, for the readout naming what the pointer
|
|
2044
2348
|
* is over.
|
|
2349
|
+
* @param enter - child session a click opens instead of folding, when set.
|
|
2045
2350
|
*/
|
|
2046
|
-
appendFold(summary, full, rule = "", label = "") {
|
|
2351
|
+
appendFold(summary, full, rule = "", label = "", enter) {
|
|
2047
2352
|
if (this.screen !== void 0) {
|
|
2048
|
-
this.screen.appendFold(summary, full, rule, label);
|
|
2353
|
+
this.screen.appendFold(summary, full, rule, label, enter);
|
|
2049
2354
|
return;
|
|
2050
2355
|
}
|
|
2051
2356
|
for (const line of summary) this.output.write(`${line}\n`);
|
|
2052
2357
|
}
|
|
2053
2358
|
/**
|
|
2359
|
+
* What a click on a view-card does.
|
|
2360
|
+
* @param handler - receives the child session id; omit to restore folding.
|
|
2361
|
+
*/
|
|
2362
|
+
setEnter(handler) {
|
|
2363
|
+
this.screen?.setEnter(handler);
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2054
2366
|
* Swap every collapsible block between summary and full form.
|
|
2055
2367
|
* @returns false when there is nothing to toggle.
|
|
2056
2368
|
*/
|
|
@@ -2142,6 +2454,7 @@ var TerminalConsole = class {
|
|
|
2142
2454
|
}
|
|
2143
2455
|
/** Take the pinned rows down, leaving the transcript alone. */
|
|
2144
2456
|
clearRegion() {
|
|
2457
|
+
this.screen?.setOverlay([]);
|
|
2145
2458
|
this.screen?.setChrome([], {
|
|
2146
2459
|
row: 0,
|
|
2147
2460
|
column: 0
|
|
@@ -2165,6 +2478,13 @@ var TerminalConsole = class {
|
|
|
2165
2478
|
if (this.background !== void 0) handler(this.background);
|
|
2166
2479
|
}
|
|
2167
2480
|
/**
|
|
2481
|
+
* Adopt the light- or dark-background hover fill.
|
|
2482
|
+
* @param light - true when OSC 11 named a light color.
|
|
2483
|
+
*/
|
|
2484
|
+
setLight(light) {
|
|
2485
|
+
this.screen?.setLight(light);
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2168
2488
|
* Set the terminal window title.
|
|
2169
2489
|
* @param title - the title text; control bytes are the terminal's to reject.
|
|
2170
2490
|
*/
|
|
@@ -2406,6 +2726,8 @@ var Editor = class {
|
|
|
2406
2726
|
browsing = 0;
|
|
2407
2727
|
/** The buffer set aside while history is being browsed. */
|
|
2408
2728
|
stashed;
|
|
2729
|
+
/** Reverse-i-search over {@link history}, absent when the box is typing. */
|
|
2730
|
+
search;
|
|
2409
2731
|
constructor(sources) {
|
|
2410
2732
|
this.sources = sources;
|
|
2411
2733
|
}
|
|
@@ -2417,7 +2739,13 @@ var Editor = class {
|
|
|
2417
2739
|
column: this.column,
|
|
2418
2740
|
candidates: this.candidates,
|
|
2419
2741
|
selected: this.selected,
|
|
2420
|
-
token: this.token()
|
|
2742
|
+
token: this.token(),
|
|
2743
|
+
hits: this.gestureHits(),
|
|
2744
|
+
...this.search === void 0 ? {} : { search: {
|
|
2745
|
+
query: this.search.query,
|
|
2746
|
+
hits: this.searchHits().length,
|
|
2747
|
+
index: this.search.index
|
|
2748
|
+
} }
|
|
2421
2749
|
};
|
|
2422
2750
|
}
|
|
2423
2751
|
/** The buffer as one string. */
|
|
@@ -2462,7 +2790,9 @@ var Editor = class {
|
|
|
2462
2790
|
* @returns what the caller must do about it.
|
|
2463
2791
|
*/
|
|
2464
2792
|
handle(key) {
|
|
2793
|
+
if (this.search !== void 0) return this.handleSearch(key);
|
|
2465
2794
|
switch (key.kind) {
|
|
2795
|
+
case "history-search": return this.openSearch();
|
|
2466
2796
|
case "text": return this.insert(key.text);
|
|
2467
2797
|
case "paste": return this.insert(key.text);
|
|
2468
2798
|
case "enter": return this.accept();
|
|
@@ -2575,6 +2905,40 @@ var Editor = class {
|
|
|
2575
2905
|
return points(this.line()).slice(this.tokenStart(), this.column).join("");
|
|
2576
2906
|
}
|
|
2577
2907
|
/**
|
|
2908
|
+
* Spans in the buffer that name a registered command or skill.
|
|
2909
|
+
*
|
|
2910
|
+
* `/` only counts at the start of the first line, matching how a command is
|
|
2911
|
+
* submitted. `$` counts as a word anywhere, matching how a skill is invoked.
|
|
2912
|
+
*/
|
|
2913
|
+
gestureHits() {
|
|
2914
|
+
const commands = new Set(this.sources.commands().map((entry) => entry.name));
|
|
2915
|
+
const skills = new Set((this.sources.skills?.() ?? []).map((entry) => entry.name));
|
|
2916
|
+
const hits = [];
|
|
2917
|
+
this.lines.forEach((line, row) => {
|
|
2918
|
+
if (row === 0) {
|
|
2919
|
+
const found = /^\/([a-z][a-z0-9_-]*)(?=\s|$)/u.exec(line);
|
|
2920
|
+
if (found?.[1] !== void 0 && commands.has(found[1])) hits.push({
|
|
2921
|
+
row,
|
|
2922
|
+
start: 0,
|
|
2923
|
+
end: points(found[0]).length,
|
|
2924
|
+
kind: "command"
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
for (const match of line.matchAll(/(^|\s)\$([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/gu)) {
|
|
2928
|
+
const name$1 = match[2] ?? "";
|
|
2929
|
+
if (!skills.has(name$1) || match.index === void 0) continue;
|
|
2930
|
+
const dollarAt = match.index + (match[1] ?? "").length;
|
|
2931
|
+
hits.push({
|
|
2932
|
+
row,
|
|
2933
|
+
start: points(line.slice(0, dollarAt)).length,
|
|
2934
|
+
end: points(line.slice(0, dollarAt + 1 + name$1.length)).length,
|
|
2935
|
+
kind: "skill"
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
});
|
|
2939
|
+
return hits;
|
|
2940
|
+
}
|
|
2941
|
+
/**
|
|
2578
2942
|
* Recompute the candidate list for the token under the cursor.
|
|
2579
2943
|
*
|
|
2580
2944
|
* Recomputed on every edit rather than only on Tab, which is what makes the
|
|
@@ -2592,7 +2956,13 @@ var Editor = class {
|
|
|
2592
2956
|
value,
|
|
2593
2957
|
detail: ""
|
|
2594
2958
|
}));
|
|
2595
|
-
} else if (
|
|
2959
|
+
} else if (token.startsWith("$")) {
|
|
2960
|
+
const typed = token.slice(1);
|
|
2961
|
+
this.candidates = rankContains(this.sources.skills?.() ?? [], typed, (entry) => entry.name).map((entry) => ({
|
|
2962
|
+
value: `$${entry.name}`,
|
|
2963
|
+
detail: entry.description
|
|
2964
|
+
}));
|
|
2965
|
+
} else if (wholeLine && token.startsWith("/")) this.candidates = rankContains(this.sources.commands(), token.slice(1), (entry) => entry.name).map((entry) => ({
|
|
2596
2966
|
value: `/${entry.name}`,
|
|
2597
2967
|
detail: entry.description
|
|
2598
2968
|
}));
|
|
@@ -2745,6 +3115,102 @@ var Editor = class {
|
|
|
2745
3115
|
return { kind: "none" };
|
|
2746
3116
|
}
|
|
2747
3117
|
/**
|
|
3118
|
+
* Open reverse search over history, stashing the draft.
|
|
3119
|
+
* @returns always `none`.
|
|
3120
|
+
*/
|
|
3121
|
+
openSearch() {
|
|
3122
|
+
this.search = {
|
|
3123
|
+
query: "",
|
|
3124
|
+
index: 0,
|
|
3125
|
+
stash: [...this.lines],
|
|
3126
|
+
row: this.row,
|
|
3127
|
+
column: this.column
|
|
3128
|
+
};
|
|
3129
|
+
this.candidates = [];
|
|
3130
|
+
this.applySearch();
|
|
3131
|
+
return { kind: "none" };
|
|
3132
|
+
}
|
|
3133
|
+
/**
|
|
3134
|
+
* Keys while reverse-i-search is open.
|
|
3135
|
+
* @param key - the decoded keystroke.
|
|
3136
|
+
* @returns what the caller must do about it.
|
|
3137
|
+
*/
|
|
3138
|
+
handleSearch(key) {
|
|
3139
|
+
switch (key.kind) {
|
|
3140
|
+
case "history-search":
|
|
3141
|
+
if (this.search !== void 0) this.search.index += 1;
|
|
3142
|
+
this.applySearch();
|
|
3143
|
+
return { kind: "none" };
|
|
3144
|
+
case "text":
|
|
3145
|
+
if (this.search !== void 0) {
|
|
3146
|
+
this.search.query += key.text;
|
|
3147
|
+
this.search.index = 0;
|
|
3148
|
+
}
|
|
3149
|
+
this.applySearch();
|
|
3150
|
+
return { kind: "none" };
|
|
3151
|
+
case "paste":
|
|
3152
|
+
if (this.search !== void 0) {
|
|
3153
|
+
this.search.query += key.text.replaceAll("\n", "");
|
|
3154
|
+
this.search.index = 0;
|
|
3155
|
+
}
|
|
3156
|
+
this.applySearch();
|
|
3157
|
+
return { kind: "none" };
|
|
3158
|
+
case "backspace":
|
|
3159
|
+
if (this.search !== void 0 && this.search.query.length > 0) {
|
|
3160
|
+
this.search.query = points(this.search.query).slice(0, -1).join("");
|
|
3161
|
+
this.search.index = 0;
|
|
3162
|
+
}
|
|
3163
|
+
this.applySearch();
|
|
3164
|
+
return { kind: "none" };
|
|
3165
|
+
case "enter":
|
|
3166
|
+
this.search = void 0;
|
|
3167
|
+
this.candidates = [];
|
|
3168
|
+
return { kind: "none" };
|
|
3169
|
+
case "escape":
|
|
3170
|
+
this.restoreSearchStash();
|
|
3171
|
+
this.search = void 0;
|
|
3172
|
+
this.candidates = [];
|
|
3173
|
+
return { kind: "none" };
|
|
3174
|
+
case "interrupt":
|
|
3175
|
+
this.restoreSearchStash();
|
|
3176
|
+
this.search = void 0;
|
|
3177
|
+
return { kind: "interrupt" };
|
|
3178
|
+
default: return { kind: "none" };
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
/** History entries matching the query, newest first. */
|
|
3182
|
+
searchHits() {
|
|
3183
|
+
const needle = (this.search?.query ?? "").toLowerCase();
|
|
3184
|
+
const hits = [];
|
|
3185
|
+
for (let i = this.history.length - 1; i >= 0; i -= 1) {
|
|
3186
|
+
const entry = this.history[i] ?? "";
|
|
3187
|
+
if (needle === "" || entry.toLowerCase().includes(needle)) hits.push(entry);
|
|
3188
|
+
}
|
|
3189
|
+
return hits;
|
|
3190
|
+
}
|
|
3191
|
+
/** Put the current hit in the buffer, or the stashed draft when none match. */
|
|
3192
|
+
applySearch() {
|
|
3193
|
+
const hits = this.searchHits();
|
|
3194
|
+
if (this.search !== void 0 && this.search.index >= hits.length) this.search.index = Math.max(0, hits.length - 1);
|
|
3195
|
+
const hit = hits[this.search?.index ?? 0];
|
|
3196
|
+
if (hit === void 0) {
|
|
3197
|
+
this.restoreSearchStash();
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
this.lines = hit.split("\n");
|
|
3201
|
+
this.row = this.lines.length - 1;
|
|
3202
|
+
this.column = points(this.line()).length;
|
|
3203
|
+
this.candidates = [];
|
|
3204
|
+
}
|
|
3205
|
+
/** Put the draft that search set aside back in the buffer. */
|
|
3206
|
+
restoreSearchStash() {
|
|
3207
|
+
const stash = this.search?.stash;
|
|
3208
|
+
if (stash === void 0) return;
|
|
3209
|
+
this.lines = [...stash];
|
|
3210
|
+
this.row = this.search?.row ?? 0;
|
|
3211
|
+
this.column = this.search?.column ?? 0;
|
|
3212
|
+
}
|
|
3213
|
+
/**
|
|
2748
3214
|
* Close the menu, or report Escape when there is none to close.
|
|
2749
3215
|
* @returns `none` when a menu was dismissed, otherwise `escape`.
|
|
2750
3216
|
*/
|
|
@@ -2767,6 +3233,10 @@ const FRAME_WIDTH = 4;
|
|
|
2767
3233
|
const GUTTER_WIDTH = 2;
|
|
2768
3234
|
/** Content rows shown before the box windows around the cursor. */
|
|
2769
3235
|
const MAX_CONTENT_ROWS = 6;
|
|
3236
|
+
/** Underline on, used only for the typed fragment inside a menu label. */
|
|
3237
|
+
const UNDERLINE_ON = "\x1B[4m";
|
|
3238
|
+
/** Underline off, without resetting other attributes. */
|
|
3239
|
+
const UNDERLINE_OFF = "\x1B[24m";
|
|
2770
3240
|
/**
|
|
2771
3241
|
* Wrap one logical line into segments no wider than the budget.
|
|
2772
3242
|
* @param line - the logical line.
|
|
@@ -2807,6 +3277,83 @@ function wrapLine(line, logical, budget) {
|
|
|
2807
3277
|
return rows;
|
|
2808
3278
|
}
|
|
2809
3279
|
/**
|
|
3280
|
+
* Paint a menu label so the typed fragment and the selection never share a
|
|
3281
|
+
* colour: the contained span is underlined, the selected name stays full
|
|
3282
|
+
* intensity, and the rest recedes.
|
|
3283
|
+
*/
|
|
3284
|
+
function paintMenuLabel(value, token, chosen, theme) {
|
|
3285
|
+
const rest = (text) => chosen ? text : theme.dim(text);
|
|
3286
|
+
const match = (text) => theme.colored ? `${UNDERLINE_ON}${text}${UNDERLINE_OFF}` : text;
|
|
3287
|
+
if (token === "") return rest(value);
|
|
3288
|
+
const lower = value.toLowerCase();
|
|
3289
|
+
let at = lower.indexOf(token.toLowerCase());
|
|
3290
|
+
let span = token.length;
|
|
3291
|
+
if (at < 0 && /^[/@$]/u.test(token) && token.length > 1) {
|
|
3292
|
+
const inner = token.slice(1);
|
|
3293
|
+
at = lower.indexOf(inner.toLowerCase());
|
|
3294
|
+
span = inner.length;
|
|
3295
|
+
}
|
|
3296
|
+
if (at < 0) return rest(value);
|
|
3297
|
+
return `${rest(value.slice(0, at))}${match(value.slice(at, at + span))}${rest(value.slice(at + span))}`;
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* Shift first-line hits left by one when the leading `!` is the gutter, not
|
|
3301
|
+
* buffer text the person sees.
|
|
3302
|
+
*/
|
|
3303
|
+
function displayHits(hits, shell) {
|
|
3304
|
+
if (!shell) return [...hits];
|
|
3305
|
+
return hits.flatMap((hit) => {
|
|
3306
|
+
if (hit.row !== 0) return [hit];
|
|
3307
|
+
const start = Math.max(0, hit.start - 1);
|
|
3308
|
+
const end = Math.max(0, hit.end - 1);
|
|
3309
|
+
return end > start ? [{
|
|
3310
|
+
...hit,
|
|
3311
|
+
start,
|
|
3312
|
+
end
|
|
3313
|
+
}] : [];
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
/**
|
|
3317
|
+
* Colour a wrapped segment's known `/command` and `$skill` spans.
|
|
3318
|
+
*/
|
|
3319
|
+
function paintGestures(text, logical, start, hits, theme) {
|
|
3320
|
+
const end = start + Array.from(text).length;
|
|
3321
|
+
const here = hits.filter((hit) => hit.row === logical && hit.start < end && hit.end > start).sort((left, right) => left.start - right.start);
|
|
3322
|
+
if (here.length === 0) return text;
|
|
3323
|
+
const cells = Array.from(text);
|
|
3324
|
+
let out = "";
|
|
3325
|
+
let at = 0;
|
|
3326
|
+
for (const hit of here) {
|
|
3327
|
+
const from = Math.max(0, hit.start - start);
|
|
3328
|
+
const to = Math.min(cells.length, hit.end - start);
|
|
3329
|
+
if (from >= to) continue;
|
|
3330
|
+
out += cells.slice(at, from).join("");
|
|
3331
|
+
const slice = cells.slice(from, to).join("");
|
|
3332
|
+
out += hit.kind === "skill" ? theme.user(slice) : theme.tool(slice);
|
|
3333
|
+
at = to;
|
|
3334
|
+
}
|
|
3335
|
+
return out + cells.slice(at).join("");
|
|
3336
|
+
}
|
|
3337
|
+
/** Candidate rows painted as a floating layer, empty when the menu is closed. */
|
|
3338
|
+
function menuRows(view, theme, columns) {
|
|
3339
|
+
if (view.candidates.length === 0) return [];
|
|
3340
|
+
const first = Math.min(Math.max(0, view.selected - MENU_LIMIT + 1), Math.max(0, view.candidates.length - MENU_LIMIT));
|
|
3341
|
+
const menu = view.candidates.slice(first, first + MENU_LIMIT);
|
|
3342
|
+
const width = Math.max(...menu.map((candidate) => displayWidth(candidate.value)));
|
|
3343
|
+
const rows = [];
|
|
3344
|
+
if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
|
|
3345
|
+
for (const [index, candidate] of menu.entries()) {
|
|
3346
|
+
const chosen = first + index === view.selected;
|
|
3347
|
+
const label = paintMenuLabel(candidate.value, view.token, chosen, theme);
|
|
3348
|
+
const pad = " ".repeat(Math.max(0, width - displayWidth(candidate.value)));
|
|
3349
|
+
const detail = candidate.detail === "" ? "" : ` ${candidate.detail}`;
|
|
3350
|
+
rows.push(truncate(`${chosen ? theme.user("❯") : " "} ${label}${pad}${theme.dim(detail)}`, columns));
|
|
3351
|
+
}
|
|
3352
|
+
const below = view.candidates.length - first - menu.length;
|
|
3353
|
+
if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
|
|
3354
|
+
return rows;
|
|
3355
|
+
}
|
|
3356
|
+
/**
|
|
2810
3357
|
* Lay out the input box.
|
|
2811
3358
|
* @param view - what the editor is showing.
|
|
2812
3359
|
* @param theme - styling for the frame, the marker, and the menu.
|
|
@@ -2815,53 +3362,47 @@ function wrapLine(line, logical, budget) {
|
|
|
2815
3362
|
* @returns the rows and cursor position.
|
|
2816
3363
|
*/
|
|
2817
3364
|
function inputBox(view, theme, columns, options = {}) {
|
|
3365
|
+
const shell = options.shell === true && (view.lines[0] ?? "").startsWith("!");
|
|
2818
3366
|
const accent = options.accent ?? ((text) => theme.dim(text));
|
|
2819
3367
|
const inner = Math.max(8, columns - FRAME_WIDTH);
|
|
2820
3368
|
const budget = inner - GUTTER_WIDTH;
|
|
2821
3369
|
const rule = "─".repeat(inner + 2);
|
|
2822
|
-
const
|
|
2823
|
-
const
|
|
3370
|
+
const lines = shell ? [(view.lines[0] ?? "").slice(1), ...view.lines.slice(1)] : view.lines;
|
|
3371
|
+
const column = shell && view.row === 0 ? Math.max(0, view.column - 1) : view.column;
|
|
3372
|
+
const visual = lines.flatMap((line, index) => wrapLine(line, index, budget));
|
|
3373
|
+
const cursorVisual = visual.findIndex((row) => row.logical === view.row && column >= row.start && (column < row.start + row.length || row.last && column === row.start + row.length));
|
|
2824
3374
|
const cursorAt = Math.max(0, cursorVisual);
|
|
2825
3375
|
const start = Math.max(0, Math.min(visual.length - MAX_CONTENT_ROWS, cursorAt - (MAX_CONTENT_ROWS - 1)));
|
|
2826
3376
|
const end = Math.min(visual.length, start + MAX_CONTENT_ROWS);
|
|
2827
3377
|
const shown = visual.slice(start, end);
|
|
2828
|
-
const empty =
|
|
3378
|
+
const empty = lines.length === 1 && lines[0] === "";
|
|
3379
|
+
const mark$1 = shell ? theme.pending("!") : theme.user("›");
|
|
3380
|
+
const hits = displayHits(view.hits, shell);
|
|
3381
|
+
const overlay = menuRows(view, theme, columns);
|
|
2829
3382
|
const rows = [accent(`╭${rule}╮`)];
|
|
2830
|
-
if (empty && options.placeholder !== void 0) {
|
|
2831
|
-
const text = truncate(options.placeholder, budget);
|
|
3383
|
+
if (empty && (shell || options.placeholder !== void 0)) {
|
|
3384
|
+
const text = truncate(shell ? "command" : options.placeholder ?? "", budget);
|
|
2832
3385
|
const pad = " ".repeat(Math.max(0, budget - displayWidth(text)));
|
|
2833
|
-
rows.push(`${accent("│")} ${
|
|
3386
|
+
rows.push(`${accent("│")} ${mark$1} ${theme.dim(text)}${pad} ${accent("│")}`);
|
|
2834
3387
|
} else shown.forEach((row, index) => {
|
|
2835
3388
|
const first = start + index === 0;
|
|
2836
3389
|
const clippedAbove = index === 0 && start > 0;
|
|
2837
3390
|
const clippedBelow = index === shown.length - 1 && end < visual.length;
|
|
2838
|
-
const gutter = clippedAbove || clippedBelow ? theme.dim("…") : first ?
|
|
3391
|
+
const gutter = clippedAbove || clippedBelow ? theme.dim("…") : first ? mark$1 : " ";
|
|
3392
|
+
const painted = paintGestures(row.text, row.logical, row.start, hits, theme);
|
|
2839
3393
|
const pad = " ".repeat(Math.max(0, budget - displayWidth(row.text)));
|
|
2840
|
-
rows.push(`${accent("│")} ${gutter} ${
|
|
3394
|
+
rows.push(`${accent("│")} ${gutter} ${painted}${pad} ${accent("│")}`);
|
|
2841
3395
|
});
|
|
2842
3396
|
rows.push(accent(`╰${rule}╯`));
|
|
2843
|
-
if (view.candidates.length
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
|
|
2848
|
-
menu.forEach((candidate, index) => {
|
|
2849
|
-
const chosen = first + index === view.selected;
|
|
2850
|
-
const matched = view.token !== "" && candidate.value.startsWith(view.token) ? view.token.length : 0;
|
|
2851
|
-
const head = candidate.value.slice(0, matched);
|
|
2852
|
-
const tail = candidate.value.slice(matched);
|
|
2853
|
-
const label = chosen ? theme.bold(theme.tool(candidate.value)) : `${theme.tool(head)}${tail}`;
|
|
2854
|
-
const pad = " ".repeat(Math.max(0, width - displayWidth(candidate.value)));
|
|
2855
|
-
const detail = candidate.detail === "" ? "" : ` ${candidate.detail}`;
|
|
2856
|
-
rows.push(truncate(`${chosen ? theme.user("❯") : " "} ${label}${pad}${theme.dim(detail)}`, columns));
|
|
2857
|
-
});
|
|
2858
|
-
const below = view.candidates.length - first - menu.length;
|
|
2859
|
-
if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
|
|
2860
|
-
} else if (options.hint !== void 0) rows.push(theme.dim(truncate(options.hint, columns)));
|
|
3397
|
+
if (view.candidates.length === 0 && view.search !== void 0) {
|
|
3398
|
+
const label = view.search.hits === 0 ? "failing bck-i-search" : "bck-i-search";
|
|
3399
|
+
rows.push(theme.dim(truncate(` ${label}: ${view.search.query}`, columns)));
|
|
3400
|
+
} else if (view.candidates.length === 0 && options.hint !== void 0) rows.push(theme.dim(truncate(options.hint, columns)));
|
|
2861
3401
|
const inCursorRow = visual[cursorAt];
|
|
2862
|
-
const before = inCursorRow === void 0 ? "" : Array.from(
|
|
3402
|
+
const before = inCursorRow === void 0 ? "" : Array.from(lines[view.row] ?? "").slice(inCursorRow.start, column).join("");
|
|
2863
3403
|
return {
|
|
2864
3404
|
rows,
|
|
3405
|
+
overlay,
|
|
2865
3406
|
cursorRow: 1 + (cursorAt - start),
|
|
2866
3407
|
cursorColumn: FRAME_WIDTH + Math.min(displayWidth(before), budget)
|
|
2867
3408
|
};
|
|
@@ -2874,17 +3415,25 @@ function inputBox(view, theme, columns, options = {}) {
|
|
|
2874
3415
|
const VISIBLE_ROWS = 10;
|
|
2875
3416
|
var Selector = class {
|
|
2876
3417
|
selected = 0;
|
|
3418
|
+
query = "";
|
|
2877
3419
|
checked = /* @__PURE__ */ new Set();
|
|
2878
3420
|
constructor(spec) {
|
|
2879
3421
|
this.spec = spec;
|
|
2880
3422
|
}
|
|
3423
|
+
/** Original option indices currently shown, in order. */
|
|
3424
|
+
matching() {
|
|
3425
|
+
const options = this.spec.options;
|
|
3426
|
+
if (this.query === "" || this.spec.filterable !== true) return options.map((_, index) => index);
|
|
3427
|
+
const needle = this.query.toLowerCase();
|
|
3428
|
+
return options.flatMap((option, index) => `${option.label} ${option.detail ?? ""}`.toLowerCase().includes(needle) ? [index] : []);
|
|
3429
|
+
}
|
|
2881
3430
|
/** How many rows the widget offers, the custom row included. */
|
|
2882
3431
|
get count() {
|
|
2883
|
-
return this.
|
|
3432
|
+
return this.matching().length + (this.spec.custom === void 0 ? 0 : 1);
|
|
2884
3433
|
}
|
|
2885
|
-
/** Whether a row index is the custom "type your own" row. */
|
|
3434
|
+
/** Whether a visible row index is the custom "type your own" row. */
|
|
2886
3435
|
isCustom(index) {
|
|
2887
|
-
return this.spec.custom !== void 0 && index === this.
|
|
3436
|
+
return this.spec.custom !== void 0 && index === this.matching().length;
|
|
2888
3437
|
}
|
|
2889
3438
|
/**
|
|
2890
3439
|
* Apply one key.
|
|
@@ -2894,10 +3443,12 @@ var Selector = class {
|
|
|
2894
3443
|
handle(key) {
|
|
2895
3444
|
switch (key.kind) {
|
|
2896
3445
|
case "up":
|
|
3446
|
+
if (this.count === 0) return { kind: "pending" };
|
|
2897
3447
|
this.selected = (this.selected - 1 + this.count) % this.count;
|
|
2898
3448
|
return { kind: "pending" };
|
|
2899
3449
|
case "down":
|
|
2900
3450
|
case "tab":
|
|
3451
|
+
if (this.count === 0) return { kind: "pending" };
|
|
2901
3452
|
this.selected = (this.selected + 1) % this.count;
|
|
2902
3453
|
return { kind: "pending" };
|
|
2903
3454
|
case "enter": return this.accept(this.selected);
|
|
@@ -2905,6 +3456,12 @@ var Selector = class {
|
|
|
2905
3456
|
kind: "done",
|
|
2906
3457
|
outcome: { kind: "cancelled" }
|
|
2907
3458
|
};
|
|
3459
|
+
case "backspace":
|
|
3460
|
+
if (this.spec.filterable === true && this.query.length > 0) {
|
|
3461
|
+
this.query = Array.from(this.query).slice(0, -1).join("");
|
|
3462
|
+
this.selected = 0;
|
|
3463
|
+
}
|
|
3464
|
+
return { kind: "pending" };
|
|
2908
3465
|
case "text": return this.typed(key.text);
|
|
2909
3466
|
default: return { kind: "pending" };
|
|
2910
3467
|
}
|
|
@@ -2915,28 +3472,41 @@ var Selector = class {
|
|
|
2915
3472
|
* @returns whether the selection settled.
|
|
2916
3473
|
*/
|
|
2917
3474
|
typed(text) {
|
|
3475
|
+
if (this.spec.filterable === true) {
|
|
3476
|
+
if (this.query === "") {
|
|
3477
|
+
const shortcut$1 = this.spec.options.findIndex((option) => option.shortcut === text.toLowerCase());
|
|
3478
|
+
if (shortcut$1 >= 0) return this.acceptOriginal(shortcut$1);
|
|
3479
|
+
}
|
|
3480
|
+
this.query += text;
|
|
3481
|
+
this.selected = 0;
|
|
3482
|
+
return { kind: "pending" };
|
|
3483
|
+
}
|
|
2918
3484
|
if (this.spec.multi === true && text === " ") {
|
|
2919
|
-
if (!this.isCustom(this.selected))
|
|
2920
|
-
|
|
3485
|
+
if (!this.isCustom(this.selected)) {
|
|
3486
|
+
const original = this.matching()[this.selected];
|
|
3487
|
+
if (original !== void 0) if (this.checked.has(original)) this.checked.delete(original);
|
|
3488
|
+
else this.checked.add(original);
|
|
3489
|
+
}
|
|
2921
3490
|
return { kind: "pending" };
|
|
2922
3491
|
}
|
|
2923
3492
|
const digit = Number(text);
|
|
2924
3493
|
if (Number.isInteger(digit) && digit >= 1 && digit <= this.count) {
|
|
2925
3494
|
if (this.spec.multi === true && !this.isCustom(digit - 1)) {
|
|
2926
3495
|
this.selected = digit - 1;
|
|
2927
|
-
|
|
2928
|
-
|
|
3496
|
+
const original = this.matching()[digit - 1];
|
|
3497
|
+
if (original !== void 0) if (this.checked.has(original)) this.checked.delete(original);
|
|
3498
|
+
else this.checked.add(original);
|
|
2929
3499
|
return { kind: "pending" };
|
|
2930
3500
|
}
|
|
2931
3501
|
return this.accept(digit - 1);
|
|
2932
3502
|
}
|
|
2933
3503
|
const shortcut = this.spec.options.findIndex((option) => option.shortcut === text.toLowerCase());
|
|
2934
|
-
if (shortcut >= 0) return this.
|
|
3504
|
+
if (shortcut >= 0) return this.acceptOriginal(shortcut);
|
|
2935
3505
|
return { kind: "pending" };
|
|
2936
3506
|
}
|
|
2937
3507
|
/**
|
|
2938
|
-
* Settle on a row.
|
|
2939
|
-
* @param index - the row accepted.
|
|
3508
|
+
* Settle on a visible row.
|
|
3509
|
+
* @param index - the visible row accepted.
|
|
2940
3510
|
* @returns the settled step.
|
|
2941
3511
|
*/
|
|
2942
3512
|
accept(index) {
|
|
@@ -2944,18 +3514,28 @@ var Selector = class {
|
|
|
2944
3514
|
kind: "done",
|
|
2945
3515
|
outcome: { kind: "custom" }
|
|
2946
3516
|
};
|
|
3517
|
+
const original = this.matching()[index];
|
|
3518
|
+
if (original === void 0) return { kind: "pending" };
|
|
3519
|
+
return this.acceptOriginal(original);
|
|
3520
|
+
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Settle on an original option index.
|
|
3523
|
+
* @param original - the option's index in the spec.
|
|
3524
|
+
* @returns the settled step.
|
|
3525
|
+
*/
|
|
3526
|
+
acceptOriginal(original) {
|
|
2947
3527
|
if (this.spec.multi === true) return {
|
|
2948
3528
|
kind: "done",
|
|
2949
3529
|
outcome: {
|
|
2950
3530
|
kind: "chosen",
|
|
2951
|
-
indices: this.checked.size > 0 ? [...this.checked].sort((a, b) => a - b) : [
|
|
3531
|
+
indices: this.checked.size > 0 ? [...this.checked].sort((a, b) => a - b) : [original]
|
|
2952
3532
|
}
|
|
2953
3533
|
};
|
|
2954
3534
|
return {
|
|
2955
3535
|
kind: "done",
|
|
2956
3536
|
outcome: {
|
|
2957
3537
|
kind: "chosen",
|
|
2958
|
-
indices: [
|
|
3538
|
+
indices: [original]
|
|
2959
3539
|
}
|
|
2960
3540
|
};
|
|
2961
3541
|
}
|
|
@@ -2967,17 +3547,22 @@ var Selector = class {
|
|
|
2967
3547
|
*/
|
|
2968
3548
|
view(theme, columns) {
|
|
2969
3549
|
const rows = [theme.bold(truncate(this.spec.title, columns))];
|
|
3550
|
+
if (this.spec.filterable === true && this.query !== "") rows.push(theme.dim(truncate(` filter: ${this.query}`, columns)));
|
|
3551
|
+
const shown = this.matching();
|
|
2970
3552
|
const total = this.count;
|
|
2971
3553
|
const first = Math.min(Math.max(0, this.selected - VISIBLE_ROWS + 1), Math.max(0, total - VISIBLE_ROWS));
|
|
2972
3554
|
if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
|
|
2973
3555
|
for (let index = first; index < Math.min(total, first + VISIBLE_ROWS); index += 1) {
|
|
2974
|
-
|
|
3556
|
+
if (this.isCustom(index)) {
|
|
3557
|
+
rows.push(this.row(index, theme.dim(this.spec.custom ?? ""), void 0, theme, columns));
|
|
3558
|
+
continue;
|
|
3559
|
+
}
|
|
3560
|
+
const option = this.spec.options[shown[index] ?? -1];
|
|
2975
3561
|
if (option !== void 0) rows.push(this.row(index, this.label(option, theme), option.detail, theme, columns));
|
|
2976
|
-
else if (this.spec.custom !== void 0) rows.push(this.row(index, theme.dim(this.spec.custom), void 0, theme, columns));
|
|
2977
3562
|
}
|
|
2978
3563
|
const below = total - first - VISIBLE_ROWS;
|
|
2979
3564
|
if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
|
|
2980
|
-
const how = this.spec.multi === true ? "Space toggles · Enter confirms · Esc cancels" : "↑↓ move · Enter accepts · Esc cancels";
|
|
3565
|
+
const how = this.spec.multi === true ? "Space toggles · Enter confirms · Esc cancels" : this.spec.filterable === true ? "type to filter · ↑↓ move · Enter accepts · Esc cancels" : "↑↓ move · Enter accepts · Esc cancels";
|
|
2981
3566
|
rows.push(theme.dim(truncate(` ${how}`, columns)));
|
|
2982
3567
|
return rows;
|
|
2983
3568
|
}
|
|
@@ -3003,7 +3588,7 @@ var Selector = class {
|
|
|
3003
3588
|
row(index, label, detail, theme, columns) {
|
|
3004
3589
|
const marked = index === this.selected;
|
|
3005
3590
|
const marker = marked ? theme.user("❯") : " ";
|
|
3006
|
-
const box = this.spec.multi === true && !this.isCustom(index) ? this.checked.has(index) ? theme.success("◉ ") : theme.dim("○ ") : "";
|
|
3591
|
+
const box = this.spec.multi === true && !this.isCustom(index) ? this.checked.has(this.matching()[index] ?? index) ? theme.success("◉ ") : theme.dim("○ ") : "";
|
|
3007
3592
|
const number = theme.dim(`${index + 1}.`);
|
|
3008
3593
|
const trail = detail === void 0 || detail === "" ? "" : theme.dim(` ${detail}`);
|
|
3009
3594
|
return truncate(`${marker} ${number} ${box}${marked ? theme.bold(theme.tool(label)) : label}${trail}`, columns);
|
|
@@ -3172,6 +3757,10 @@ var Prompt = class {
|
|
|
3172
3757
|
todos = [];
|
|
3173
3758
|
/** Whether the todo readout shows every item or only the one in flight. */
|
|
3174
3759
|
todosExpanded = false;
|
|
3760
|
+
/** Incremental find over the transcript, absent when find is closed. */
|
|
3761
|
+
finding;
|
|
3762
|
+
/** Whether the shortcuts overlay is occupying chrome. */
|
|
3763
|
+
shortcutsOpen = false;
|
|
3175
3764
|
/** The assistant line still arriving, shown above the box. */
|
|
3176
3765
|
streaming;
|
|
3177
3766
|
/** Frame styling for the current mode, e.g. plan mode's accent. */
|
|
@@ -3264,7 +3853,8 @@ var Prompt = class {
|
|
|
3264
3853
|
}
|
|
3265
3854
|
/**
|
|
3266
3855
|
* Set the status row, the region's always-current last line.
|
|
3267
|
-
* @param text - the styled row, or undefined to drop it.
|
|
3856
|
+
* @param text - the full styled row, or undefined to drop it. Truncation is
|
|
3857
|
+
* applied at paint time so a resize can grow the line back.
|
|
3268
3858
|
*/
|
|
3269
3859
|
setStatus(text) {
|
|
3270
3860
|
if (text === this.status) return;
|
|
@@ -3384,6 +3974,7 @@ var Prompt = class {
|
|
|
3384
3974
|
*/
|
|
3385
3975
|
onKey(key) {
|
|
3386
3976
|
if (key.kind === "interrupt") {
|
|
3977
|
+
this.shortcutsOpen = false;
|
|
3387
3978
|
this.handlers.interrupt();
|
|
3388
3979
|
return;
|
|
3389
3980
|
}
|
|
@@ -3405,6 +3996,31 @@ var Prompt = class {
|
|
|
3405
3996
|
this.render();
|
|
3406
3997
|
return;
|
|
3407
3998
|
}
|
|
3999
|
+
if (key.kind === "transcript-search") {
|
|
4000
|
+
if (this.finding === void 0) {
|
|
4001
|
+
this.finding = "";
|
|
4002
|
+
this.console.searchTranscript("");
|
|
4003
|
+
} else this.console.nextTranscriptHit(1);
|
|
4004
|
+
this.render();
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
if (this.finding !== void 0) {
|
|
4008
|
+
this.onFindKey(key);
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
if (this.shortcutsOpen) {
|
|
4012
|
+
if (key.kind === "escape" || key.kind === "text" && key.text === "?") {
|
|
4013
|
+
this.shortcutsOpen = false;
|
|
4014
|
+
this.render();
|
|
4015
|
+
return;
|
|
4016
|
+
}
|
|
4017
|
+
this.shortcutsOpen = false;
|
|
4018
|
+
}
|
|
4019
|
+
if (key.kind === "text" && key.text === "?" && this.editor.empty) {
|
|
4020
|
+
this.shortcutsOpen = true;
|
|
4021
|
+
this.render();
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
3408
4024
|
if (key.kind === "page") {
|
|
3409
4025
|
this.console.scrollPage(key.direction);
|
|
3410
4026
|
this.render();
|
|
@@ -3441,7 +4057,7 @@ var Prompt = class {
|
|
|
3441
4057
|
}
|
|
3442
4058
|
if (key.kind === "mouse-move") {
|
|
3443
4059
|
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"}`);
|
|
4060
|
+
const readout = block === void 0 ? void 0 : this.theme.dim(` ${block.label} · ${block.lines} lines · click to ${block.enter === true ? "enter" : block.expanded ? "fold" : "expand"}`);
|
|
3445
4061
|
if (readout === this.hover) return;
|
|
3446
4062
|
this.hover = readout;
|
|
3447
4063
|
this.render();
|
|
@@ -3487,6 +4103,14 @@ var Prompt = class {
|
|
|
3487
4103
|
break;
|
|
3488
4104
|
}
|
|
3489
4105
|
case "escape":
|
|
4106
|
+
if (this.queued.length > 0) {
|
|
4107
|
+
const last = this.queued.pop();
|
|
4108
|
+
if (last !== void 0) {
|
|
4109
|
+
for (const image of last.images) this.pendingImages.set(image.id, image);
|
|
4110
|
+
this.editor.prefill(last.text);
|
|
4111
|
+
}
|
|
4112
|
+
break;
|
|
4113
|
+
}
|
|
3490
4114
|
this.handlers.escape();
|
|
3491
4115
|
break;
|
|
3492
4116
|
case "eof": {
|
|
@@ -3593,27 +4217,88 @@ var Prompt = class {
|
|
|
3593
4217
|
limit: TODO_ROWS
|
|
3594
4218
|
});
|
|
3595
4219
|
}
|
|
4220
|
+
/**
|
|
4221
|
+
* Keys while transcript find is open: typing is the query, arrows step,
|
|
4222
|
+
* Escape closes. The transcript is not edited.
|
|
4223
|
+
* @param key - the decoded keystroke.
|
|
4224
|
+
*/
|
|
4225
|
+
onFindKey(key) {
|
|
4226
|
+
if (key.kind === "escape") {
|
|
4227
|
+
this.finding = void 0;
|
|
4228
|
+
this.console.clearTranscriptSearch();
|
|
4229
|
+
this.render();
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
if (key.kind === "up") {
|
|
4233
|
+
this.console.nextTranscriptHit(-1);
|
|
4234
|
+
this.render();
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
if (key.kind === "down") {
|
|
4238
|
+
this.console.nextTranscriptHit(1);
|
|
4239
|
+
this.render();
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
if (key.kind === "backspace") {
|
|
4243
|
+
this.finding = Array.from(this.finding ?? "").slice(0, -1).join("");
|
|
4244
|
+
this.console.searchTranscript(this.finding);
|
|
4245
|
+
this.render();
|
|
4246
|
+
return;
|
|
4247
|
+
}
|
|
4248
|
+
if (key.kind === "text") {
|
|
4249
|
+
this.finding = `${this.finding ?? ""}${key.text}`;
|
|
4250
|
+
this.console.searchTranscript(this.finding);
|
|
4251
|
+
this.render();
|
|
4252
|
+
return;
|
|
4253
|
+
}
|
|
4254
|
+
if (key.kind === "paste") {
|
|
4255
|
+
this.finding = `${this.finding ?? ""}${key.text.replaceAll("\n", "")}`;
|
|
4256
|
+
this.console.searchTranscript(this.finding);
|
|
4257
|
+
this.render();
|
|
4258
|
+
return;
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
/**
|
|
4262
|
+
* The find readout, or undefined when find is closed.
|
|
4263
|
+
* @param columns - display columns available.
|
|
4264
|
+
*/
|
|
4265
|
+
findRow(columns) {
|
|
4266
|
+
if (this.finding === void 0) return void 0;
|
|
4267
|
+
const found = this.console.transcriptSearch;
|
|
4268
|
+
const hits = found?.hits ?? 0;
|
|
4269
|
+
const at = hits === 0 ? 0 : (found?.index ?? 0) + 1;
|
|
4270
|
+
const status = hits === 0 ? "no matches" : `${at}/${hits}`;
|
|
4271
|
+
return this.theme.dim(truncate(` find: ${this.finding} ${status} ↑↓ next Esc closes`, columns));
|
|
4272
|
+
}
|
|
3596
4273
|
/** Recompose and redraw the bottom region. */
|
|
3597
4274
|
render() {
|
|
3598
4275
|
if (!this.console.readsKeys) return;
|
|
3599
|
-
const columns = this.console.
|
|
4276
|
+
const columns = this.console.contentColumns;
|
|
3600
4277
|
const rows = [];
|
|
3601
4278
|
let cursor = {
|
|
3602
4279
|
row: 0,
|
|
3603
4280
|
column: 0
|
|
3604
4281
|
};
|
|
3605
4282
|
if (this.streaming !== void 0) rows.push(this.streaming);
|
|
4283
|
+
let menuOverlay = [];
|
|
3606
4284
|
if (this.select_ !== void 0) rows.push(...this.select_.selector.view(this.theme, columns));
|
|
3607
4285
|
else if (this.engaged || this.reading) {
|
|
4286
|
+
const bang = (this.editor.view.lines[0] ?? "").startsWith("!");
|
|
3608
4287
|
const box = inputBox(this.editor.view, this.theme, columns, {
|
|
3609
4288
|
placeholder: this.placeholder,
|
|
3610
|
-
accent: this.accent
|
|
4289
|
+
accent: bang ? (text) => this.theme.pending(text) : this.accent,
|
|
4290
|
+
shell: bang
|
|
3611
4291
|
});
|
|
3612
4292
|
cursor = {
|
|
3613
4293
|
row: rows.length + box.cursorRow,
|
|
3614
4294
|
column: box.cursorColumn
|
|
3615
4295
|
};
|
|
3616
4296
|
rows.push(...box.rows);
|
|
4297
|
+
menuOverlay = box.overlay;
|
|
4298
|
+
}
|
|
4299
|
+
if (this.shortcutsOpen) {
|
|
4300
|
+
rows.push(this.theme.dim(truncate(" Ctrl+R history · Ctrl+F find · Ctrl+O folds · Ctrl+T todos", columns)));
|
|
4301
|
+
rows.push(this.theme.dim(truncate(" Ctrl+V image · Shift-Enter newline · Esc interrupt · ? closes", columns)));
|
|
3617
4302
|
}
|
|
3618
4303
|
if (this.queued.length > 0) {
|
|
3619
4304
|
const preview = this.queued[0]?.text ?? "";
|
|
@@ -3622,9 +4307,10 @@ var Prompt = class {
|
|
|
3622
4307
|
}
|
|
3623
4308
|
rows.push(...this.todoRows(columns));
|
|
3624
4309
|
this.console.setScrollNotice(this.console.scrolledBy > 0 ? this.theme.dim(truncate(` ↑ ${this.console.scrolledBy} rows above · PgDn returns to the latest`, columns)) : "");
|
|
3625
|
-
const
|
|
3626
|
-
if (
|
|
3627
|
-
if (this.
|
|
4310
|
+
const overlay = this.flash ?? this.findRow(columns) ?? this.hover;
|
|
4311
|
+
if (overlay !== void 0) rows.push(overlay);
|
|
4312
|
+
else if (this.hint !== void 0) rows.push(this.hint);
|
|
4313
|
+
if (this.status !== void 0 && (overlay === void 0 || this.hint !== void 0)) rows.push(truncate(this.status, columns));
|
|
3628
4314
|
if (rows.length === 0) {
|
|
3629
4315
|
this.console.clearRegion();
|
|
3630
4316
|
return;
|
|
@@ -3634,6 +4320,7 @@ var Prompt = class {
|
|
|
3634
4320
|
row: rows.length - 1,
|
|
3635
4321
|
column: 0
|
|
3636
4322
|
};
|
|
4323
|
+
this.console.setOverlay(menuOverlay);
|
|
3637
4324
|
this.console.setRegion(rows, cursor, focus$1);
|
|
3638
4325
|
}
|
|
3639
4326
|
};
|
|
@@ -4132,16 +4819,28 @@ var TerminalQuestions = class {
|
|
|
4132
4819
|
}
|
|
4133
4820
|
};
|
|
4134
4821
|
|
|
4822
|
+
//#endregion
|
|
4823
|
+
//#region src/bang.ts
|
|
4824
|
+
/**
|
|
4825
|
+
* Running a `!` line in the person's own shell.
|
|
4826
|
+
* @module codsh-bundle/src/bang
|
|
4827
|
+
*/
|
|
4828
|
+
/** The login shell, falling back to sh. */
|
|
4829
|
+
function userShell() {
|
|
4830
|
+
return process.env["SHELL"] ?? (process.platform === "win32" ? process.env["ComSpec"] ?? "cmd.exe" : "/bin/sh");
|
|
4831
|
+
}
|
|
4832
|
+
|
|
4135
4833
|
//#endregion
|
|
4136
4834
|
//#region src/ship.ts
|
|
4137
4835
|
/**
|
|
4138
4836
|
* The `/ship` prompt: a canned workflow that takes a one-sentence requirement
|
|
4139
|
-
* from idea to shipped, verified code — a
|
|
4140
|
-
*
|
|
4141
|
-
* until the spec's acceptance criteria
|
|
4837
|
+
* from idea to shipped, verified code — a grill-me interview (design tree,
|
|
4838
|
+
* frontier rounds), then automatic to-spec (gate 1), automatic to-tickets
|
|
4839
|
+
* (gate 2), then autonomous TDD landing until the spec's acceptance criteria
|
|
4840
|
+
* pass.
|
|
4142
4841
|
*
|
|
4143
4842
|
* The spec FILE is the workflow's memory, not the conversation: the approved
|
|
4144
|
-
*
|
|
4843
|
+
* tickets are written into it, its Status line names the phase, its checkboxes
|
|
4145
4844
|
* are the progress, and a bare /ship offers to resume whatever it finds
|
|
4146
4845
|
* unfinished. Conversations get interrupted, compacted, and cleared; the file
|
|
4147
4846
|
* survives all three, which is what makes the landing reliable rather than
|
|
@@ -4156,13 +4855,13 @@ $ARGUMENTS
|
|
|
4156
4855
|
|
|
4157
4856
|
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
4857
|
|
|
4159
|
-
Phase 1 —
|
|
4858
|
+
Phase 1 — grill-me. Research before you ask: read the repository layout, CONTEXT.md if it exists, the docs, ADRs, and the code paths the idea touches. Finding facts is your job, never the user's — where code lives or how current behavior works is yours to find out; do not ask anything inspection can answer. Then map the idea as a design tree: every decision branches into the decisions that hang off it. Work the tree in rounds. The frontier is every decision whose prerequisites are already settled — the questions you can ask now without guessing at answers you have not heard yet. Each round, put the whole frontier into a single ask_user_question call (the tool accepts a list of questions; do not serialize independent frontier questions across separate calls). For each question: a short title, a body grounded in what you found, concrete options rather than an open prompt, and your recommended answer as the first option with a description that says so. Then wait for that call to return before the next round. A question whose answer depends on another still open in this round belongs to a later round. Each round of answers reshapes the tree — settled decisions push the frontier outward. The session is done when the frontier is empty: every branch visited, nothing left silently assumed. Confirm shared understanding through ask_user_question before writing the spec. Do not pad the interview to look thorough, and do not act on the design until that confirmation.
|
|
4160
4859
|
|
|
4161
|
-
Phase 2 —
|
|
4860
|
+
Phase 2 — automatic to-spec (gate 1). Do not interview further — synthesize what the grill already settled and what the codebase already is. Write the spec to a 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. Use the project's domain glossary throughout, and respect ADRs in the area you are touching. The spec must stand alone for a reader without this conversation, with these sections in order: a \`Status:\` line (interviewing, confirmed, planned, landing, shipped) kept current at every phase change; the one-sentence requirement; Problem Statement (from the user's perspective); Solution (from the user's perspective); each grill decision with its reason; User Stories (numbered, "As an <actor>, I want a <feature>, so that <benefit>", covering the feature); Implementation Decisions (modules, interfaces, architecture, contracts — no file paths or code snippets unless a prototype encoded a decision more precisely than prose); Testing Decisions (what a good test is here: external behavior at public seams, not internals; the seams this spec will be tested at, preferring existing ones; prior art in the repo); Out of Scope; 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. Record the proposed seams in Testing Decisions; the fewer across the codebase, the better — the ideal number is one. 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
4861
|
|
|
4163
|
-
Phase 3 —
|
|
4862
|
+
Phase 3 — automatic to-tickets (gate 2). Only after the spec is confirmed, and without another interview, break the spec into tracer-bullet tickets: each a narrow but complete vertical slice through every layer it needs (not a horizontal slice of one layer), demoable or verifiable on its own, sized to fit a single fresh context window. Give each ticket its blocking edges — the other tickets that must complete before it can start. Prefactoring that makes the change easy comes first. A wide refactor (one mechanical change whose blast radius fans across the codebase) is the exception: sequence it expand–contract, not as a fake tracer bullet. Present the breakdown through ask_user_question as a numbered list (title, blocked by, what it delivers) and get an explicit yes; fold rejections back in and present again. Once approved, write the tickets into the spec file as a \`## Plan\` section with one checkbox per ticket — an approved plan lives on disk, not in a conversation that can be compacted or lost. Each checkbox names the ticket, what it delivers, and which tickets block it. 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 spec'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 grill.
|
|
4164
4863
|
|
|
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
|
|
4864
|
+
Phase 4 — automatic 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 ticket, tick the ticket's checkbox and update Status as you go, and commit after each ticket turns green — small commits are the progress that survives a crash and the history a reviewer can walk. Implement test-first at the seams the spec recorded: red before green, one seam and one test and one minimal implementation per cycle, through the public interface, never internals. Do not write a test at an unconfirmed seam. Do not bulk-write tests then bulk-implement — vertical slices, matching the tickets. Run typecheck and the focused tests each cycle; run the full suite the spec named once at the end of the ticket. Choose the mechanism by the approved plan's size. If it has at most three tickets and you expect the whole change to fit comfortably in this session's context, implement in-session: track the tickets with todo_write, and for each one red-green, run the tests, fix until green, then commit before moving on. If it is larger — four or more substantially independent tickets, 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, seams), pick the first unchecked ticket whose blockers are ticked, implement it test-first at those seams, 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 ticket, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.
|
|
4166
4865
|
|
|
4167
4866
|
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
4867
|
|
|
@@ -4210,7 +4909,10 @@ var Spinner = class {
|
|
|
4210
4909
|
this.theme = theme;
|
|
4211
4910
|
this.label = label;
|
|
4212
4911
|
this.now = now;
|
|
4912
|
+
this.activity = label.verb;
|
|
4213
4913
|
}
|
|
4914
|
+
/** Current verb, updated as tools fire so the line names what is in flight. */
|
|
4915
|
+
activity;
|
|
4214
4916
|
/** Whether the indicator is running. */
|
|
4215
4917
|
get running() {
|
|
4216
4918
|
return this.timer !== void 0;
|
|
@@ -4245,10 +4947,22 @@ var Spinner = class {
|
|
|
4245
4947
|
this.pause();
|
|
4246
4948
|
this.startedAt = 0;
|
|
4247
4949
|
this.frame = 0;
|
|
4950
|
+
this.activity = this.label.verb;
|
|
4951
|
+
}
|
|
4952
|
+
/**
|
|
4953
|
+
* Name what is in flight. The next tick (or this one, if running) shows it.
|
|
4954
|
+
* @param verb - e.g. `Reading`, `Running`. Empty restores the default verb.
|
|
4955
|
+
*/
|
|
4956
|
+
setActivity(verb) {
|
|
4957
|
+
this.activity = verb === "" ? this.label.verb : verb;
|
|
4958
|
+
if (this.timer !== void 0) this.draw();
|
|
4248
4959
|
}
|
|
4249
4960
|
/** Paint the current frame. */
|
|
4250
4961
|
draw() {
|
|
4251
|
-
this.surface.setLive(spinnerText(this.frame, this.now() - this.startedAt,
|
|
4962
|
+
this.surface.setLive(spinnerText(this.frame, this.now() - this.startedAt, {
|
|
4963
|
+
...this.label,
|
|
4964
|
+
verb: this.activity
|
|
4965
|
+
}, this.theme));
|
|
4252
4966
|
}
|
|
4253
4967
|
};
|
|
4254
4968
|
|
|
@@ -4564,6 +5278,25 @@ const FOLD_LABELS = {
|
|
|
4564
5278
|
thinking: "thinking",
|
|
4565
5279
|
answer: "answer"
|
|
4566
5280
|
};
|
|
5281
|
+
/**
|
|
5282
|
+
* The child session a continuable subagent result names, when the card can
|
|
5283
|
+
* open that session.
|
|
5284
|
+
*
|
|
5285
|
+
* Continuable starts return `started subagent <id>` (and a JSON form with the
|
|
5286
|
+
* same id). One-shot background jobs name a job, not a session, and are not a
|
|
5287
|
+
* view.
|
|
5288
|
+
* @param text - the tool result's visible text.
|
|
5289
|
+
* @returns the child session id, or undefined when this result is not a view.
|
|
5290
|
+
*/
|
|
5291
|
+
function childSessionId(text) {
|
|
5292
|
+
const trimmed = text.trim();
|
|
5293
|
+
const started = /^started subagent (\S+)/u.exec(trimmed.split("\n")[0] ?? trimmed);
|
|
5294
|
+
if (started?.[1] !== void 0) return started[1];
|
|
5295
|
+
try {
|
|
5296
|
+
const parsed = JSON.parse(trimmed);
|
|
5297
|
+
if (parsed !== null && typeof parsed === "object" && "kind" in parsed && parsed.kind === "continuable" && "subagentId" in parsed && typeof parsed.subagentId === "string" && parsed.subagentId !== "") return parsed.subagentId;
|
|
5298
|
+
} catch {}
|
|
5299
|
+
}
|
|
4567
5300
|
/** A finished answer longer than this many rendered lines becomes a fold. */
|
|
4568
5301
|
const ANSWER_FOLD_LINES = 24;
|
|
4569
5302
|
/** How many of its head lines a collapsed answer keeps visible. */
|
|
@@ -4657,6 +5390,8 @@ var Transcript = class {
|
|
|
4657
5390
|
label = "";
|
|
4658
5391
|
/** The left rule the block {@link render} just returned belongs to. */
|
|
4659
5392
|
rule = "";
|
|
5393
|
+
/** Child session a click on this card should open, when the result names one. */
|
|
5394
|
+
enter;
|
|
4660
5395
|
constructor(options, presenters) {
|
|
4661
5396
|
this.options = options;
|
|
4662
5397
|
this.presenters = presenters;
|
|
@@ -4701,6 +5436,7 @@ var Transcript = class {
|
|
|
4701
5436
|
const { theme } = this.options;
|
|
4702
5437
|
const rules = blockRules(theme);
|
|
4703
5438
|
this.rule = "";
|
|
5439
|
+
this.enter = void 0;
|
|
4704
5440
|
switch (event.type) {
|
|
4705
5441
|
case "user/message": {
|
|
4706
5442
|
if (event.data.source.kind !== "user") return [];
|
|
@@ -4798,37 +5534,54 @@ var Transcript = class {
|
|
|
4798
5534
|
if (failed) this.rule = blockRules(theme).error;
|
|
4799
5535
|
const marker = failed ? theme.error("✗") : theme.success("●");
|
|
4800
5536
|
if (pending === void 0) {
|
|
4801
|
-
const
|
|
5537
|
+
const text = this.resultText(block.content);
|
|
5538
|
+
const raw = text.split("\n");
|
|
4802
5539
|
const head$1 = `${marker} ${theme.dim("(result)")}`;
|
|
5540
|
+
const enter$1 = failed ? void 0 : childSessionId(text);
|
|
5541
|
+
const hint$1 = enter$1 === void 0 ? [] : [theme.dim(" click to enter")];
|
|
4803
5542
|
if (raw.length > MAX_RESULT_LINES) {
|
|
4804
5543
|
this.fold = [
|
|
4805
5544
|
head$1,
|
|
4806
5545
|
...raw,
|
|
5546
|
+
...hint$1,
|
|
4807
5547
|
""
|
|
4808
5548
|
];
|
|
4809
5549
|
this.label = "tool result";
|
|
4810
5550
|
}
|
|
5551
|
+
if (enter$1 !== void 0) {
|
|
5552
|
+
this.enter = enter$1;
|
|
5553
|
+
this.label = "tool result";
|
|
5554
|
+
}
|
|
4811
5555
|
return [
|
|
4812
5556
|
head$1,
|
|
4813
5557
|
...cap(raw, MAX_RESULT_LINES, theme),
|
|
5558
|
+
...hint$1,
|
|
4814
5559
|
""
|
|
4815
5560
|
];
|
|
4816
5561
|
}
|
|
4817
5562
|
const view = this.safeResult(pending, block.content, failed, meta);
|
|
4818
5563
|
const title = view?.title === void 0 ? pending.title : this.relativizeIn(view.title);
|
|
4819
5564
|
const { suffix, body, full } = this.outcome(view, block);
|
|
4820
|
-
const
|
|
5565
|
+
const enter = failed ? void 0 : childSessionId(this.resultText(block.content));
|
|
5566
|
+
const hint = enter === void 0 ? [] : [theme.dim(" click to enter")];
|
|
5567
|
+
const head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 && hint.length === 0 ? [` ${theme.success("✓")}`] : [];
|
|
4821
5568
|
if (full !== void 0) {
|
|
4822
5569
|
this.fold = [
|
|
4823
5570
|
...head,
|
|
4824
5571
|
...full,
|
|
5572
|
+
...hint,
|
|
4825
5573
|
""
|
|
4826
5574
|
];
|
|
4827
5575
|
this.label = title;
|
|
4828
5576
|
}
|
|
5577
|
+
if (enter !== void 0) {
|
|
5578
|
+
this.enter = enter;
|
|
5579
|
+
this.label = title;
|
|
5580
|
+
}
|
|
4829
5581
|
return [
|
|
4830
5582
|
...head,
|
|
4831
5583
|
...body,
|
|
5584
|
+
...hint,
|
|
4832
5585
|
""
|
|
4833
5586
|
];
|
|
4834
5587
|
}
|
|
@@ -4858,6 +5611,18 @@ var Transcript = class {
|
|
|
4858
5611
|
return label;
|
|
4859
5612
|
}
|
|
4860
5613
|
/**
|
|
5614
|
+
* The child session the block {@link render} just returned can open.
|
|
5615
|
+
*
|
|
5616
|
+
* A click on that card enters the child's transcript rather than folding
|
|
5617
|
+
* the card. Taken once, like {@link takeFold}.
|
|
5618
|
+
* @returns the child session id, or undefined when the card is not a view.
|
|
5619
|
+
*/
|
|
5620
|
+
takeEnter() {
|
|
5621
|
+
const enter = this.enter;
|
|
5622
|
+
this.enter = void 0;
|
|
5623
|
+
return enter;
|
|
5624
|
+
}
|
|
5625
|
+
/**
|
|
4861
5626
|
* The left rule for the block {@link render} just returned, `''` when the
|
|
4862
5627
|
* block stands flush.
|
|
4863
5628
|
*
|
|
@@ -5092,8 +5857,9 @@ function replay(session, transcript, io, theme) {
|
|
|
5092
5857
|
const full = transcript.takeFold();
|
|
5093
5858
|
const rule = transcript.takeRule();
|
|
5094
5859
|
const label = transcript.takeLabel();
|
|
5095
|
-
|
|
5096
|
-
|
|
5860
|
+
const enter = transcript.takeEnter();
|
|
5861
|
+
if (enter !== void 0 || full !== void 0) {
|
|
5862
|
+
io.console.appendFold(lines, full ?? lines, rule, label, enter);
|
|
5097
5863
|
continue;
|
|
5098
5864
|
}
|
|
5099
5865
|
for (const line of lines) io.console.write(line, rule);
|
|
@@ -5102,6 +5868,15 @@ function replay(session, transcript, io, theme) {
|
|
|
5102
5868
|
if (summary !== void 0) io.console.foldRecent(lines.length, summary, FOLD_LABELS.answer);
|
|
5103
5869
|
}
|
|
5104
5870
|
}
|
|
5871
|
+
/** Working-line verb from the in-flight tool name. */
|
|
5872
|
+
function toolActivity(name$1) {
|
|
5873
|
+
const n = name$1.toLowerCase();
|
|
5874
|
+
if (n.includes("bash") || n.includes("shell") || n.includes("terminal") || n.includes("pwsh")) return "Running";
|
|
5875
|
+
if (n.includes("read")) return "Reading";
|
|
5876
|
+
if (n.includes("search") || n.includes("grep") || n.includes("glob") || n.includes("find")) return "Searching";
|
|
5877
|
+
if (n.includes("edit") || n.includes("write") || n.includes("apply")) return "Editing";
|
|
5878
|
+
return name$1;
|
|
5879
|
+
}
|
|
5105
5880
|
async function turn(agent, text, working, source = { kind: "user" }, extra) {
|
|
5106
5881
|
agent.followup(createUserMessage({
|
|
5107
5882
|
content: [
|
|
@@ -5176,8 +5951,15 @@ function capture(file, args, options) {
|
|
|
5176
5951
|
]
|
|
5177
5952
|
});
|
|
5178
5953
|
let output = "";
|
|
5954
|
+
let pending = "";
|
|
5179
5955
|
const take = (chunk) => {
|
|
5180
|
-
|
|
5956
|
+
const text = chunk.toString().replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
5957
|
+
output += text;
|
|
5958
|
+
if (options.onLine === void 0) return;
|
|
5959
|
+
pending += text;
|
|
5960
|
+
const parts = pending.split("\n");
|
|
5961
|
+
pending = parts.pop() ?? "";
|
|
5962
|
+
for (const line of parts) options.onLine(line);
|
|
5181
5963
|
};
|
|
5182
5964
|
child.stdout.on("data", take);
|
|
5183
5965
|
child.stderr.on("data", take);
|
|
@@ -5198,6 +5980,7 @@ function capture(file, args, options) {
|
|
|
5198
5980
|
child.on("close", (code, signal) => {
|
|
5199
5981
|
if (timer !== void 0) clearTimeout(timer);
|
|
5200
5982
|
options.signal?.removeEventListener("abort", onAbort);
|
|
5983
|
+
if (pending !== "" && options.onLine !== void 0) options.onLine(pending);
|
|
5201
5984
|
resolve({
|
|
5202
5985
|
output,
|
|
5203
5986
|
code,
|
|
@@ -5310,7 +6093,10 @@ async function run(ctx, config, io) {
|
|
|
5310
6093
|
const theme = createTheme(io.console.isTty, process.env);
|
|
5311
6094
|
io.console.onBackground((payload) => {
|
|
5312
6095
|
const light = backgroundIsLight(payload);
|
|
5313
|
-
if (light !== void 0)
|
|
6096
|
+
if (light !== void 0) {
|
|
6097
|
+
theme.setLight(light);
|
|
6098
|
+
io.console.setLight(light);
|
|
6099
|
+
}
|
|
5314
6100
|
});
|
|
5315
6101
|
const preset = await installPackagedPreset();
|
|
5316
6102
|
if (preset.installed) io.console.write(theme.dim(`installed preset into ${preset.path}`));
|
|
@@ -5330,6 +6116,8 @@ async function run(ctx, config, io) {
|
|
|
5330
6116
|
cwd
|
|
5331
6117
|
}, presentersFor(ctx, composed.handle.agent))
|
|
5332
6118
|
};
|
|
6119
|
+
/** Nested view of a child subagent session; Esc restores the parent. */
|
|
6120
|
+
let viewing;
|
|
5333
6121
|
const facts = (branch$1) => statusFacts(ctx, live.agent, cwd, selection, presetId, branch$1);
|
|
5334
6122
|
io.console.setTitle(`dsh code — ${basename(cwd)}`);
|
|
5335
6123
|
let branch = await gitBranch(cwd);
|
|
@@ -5342,7 +6130,7 @@ async function run(ctx, config, io) {
|
|
|
5342
6130
|
session: live.agent.session.id,
|
|
5343
6131
|
readsKeys: io.console.readsKeys,
|
|
5344
6132
|
resumed: config.resume !== ""
|
|
5345
|
-
}, theme, io.console.
|
|
6133
|
+
}, theme, io.console.contentColumns)) io.console.write(line);
|
|
5346
6134
|
const disposers = [];
|
|
5347
6135
|
const commands = ctx.get("commands");
|
|
5348
6136
|
/** The advertised model catalog, fetched once and refreshed per /model call. */
|
|
@@ -5368,14 +6156,23 @@ async function run(ctx, config, io) {
|
|
|
5368
6156
|
/**
|
|
5369
6157
|
* Whether the current route explicitly accepts image input.
|
|
5370
6158
|
*
|
|
5371
|
-
*
|
|
5372
|
-
*
|
|
5373
|
-
*
|
|
6159
|
+
* Exact-model metadata, not the advisory catalog, owns this decision. The
|
|
6160
|
+
* catalog is fetched in the background and can be empty or stale while a
|
|
6161
|
+
* saved route is already usable. The same explicit-true test the adapter
|
|
6162
|
+
* applies before throwing UNSUPPORTED_CONTENT remains fail-closed: absent
|
|
6163
|
+
* modalities or a failed resolution get the text fallback, which degrades,
|
|
6164
|
+
* where the block path would crash the turn.
|
|
5374
6165
|
*/
|
|
5375
|
-
const routeAcceptsImages = () => {
|
|
6166
|
+
const routeAcceptsImages = async () => {
|
|
5376
6167
|
const current = selection.current;
|
|
5377
6168
|
if (current === void 0) return false;
|
|
5378
|
-
|
|
6169
|
+
const llm = ctx.get("llm");
|
|
6170
|
+
if (llm === void 0) return false;
|
|
6171
|
+
try {
|
|
6172
|
+
return (await llm.resolveModelInfo(current.provider, current.model)).inputModalities?.includes("image") === true;
|
|
6173
|
+
} catch {
|
|
6174
|
+
return false;
|
|
6175
|
+
}
|
|
5379
6176
|
};
|
|
5380
6177
|
/**
|
|
5381
6178
|
* Resolve a /model argument to a selection.
|
|
@@ -5420,7 +6217,7 @@ async function run(ctx, config, io) {
|
|
|
5420
6217
|
},
|
|
5421
6218
|
{
|
|
5422
6219
|
name: "ship",
|
|
5423
|
-
description: "
|
|
6220
|
+
description: "grill an idea, then spec, tickets, and verified code"
|
|
5424
6221
|
},
|
|
5425
6222
|
...custom.commands.map((command) => ({
|
|
5426
6223
|
name: command.name,
|
|
@@ -5431,6 +6228,24 @@ async function run(ctx, config, io) {
|
|
|
5431
6228
|
description: "leave the session"
|
|
5432
6229
|
}
|
|
5433
6230
|
];
|
|
6231
|
+
let userSkills = [];
|
|
6232
|
+
const refreshSkills = async () => {
|
|
6233
|
+
try {
|
|
6234
|
+
userSkills = (await ctx.get("skills")?.list({
|
|
6235
|
+
cwd,
|
|
6236
|
+
scope: live.agent
|
|
6237
|
+
}) ?? []).filter(isUserInvocable).map((entry) => ({
|
|
6238
|
+
name: entry.name,
|
|
6239
|
+
description: entry.description
|
|
6240
|
+
}));
|
|
6241
|
+
} catch {
|
|
6242
|
+
userSkills = [];
|
|
6243
|
+
}
|
|
6244
|
+
};
|
|
6245
|
+
refreshSkills();
|
|
6246
|
+
ctx.on("skills/change", () => {
|
|
6247
|
+
refreshSkills();
|
|
6248
|
+
});
|
|
5434
6249
|
const completePath$1 = createCompleter(completable, cwd);
|
|
5435
6250
|
/**
|
|
5436
6251
|
* The first-argument candidates per command, read live: plan's argument
|
|
@@ -5474,7 +6289,8 @@ async function run(ctx, config, io) {
|
|
|
5474
6289
|
const prompt = new Prompt(io.console, theme, {
|
|
5475
6290
|
commands: completable,
|
|
5476
6291
|
paths: completePath$1,
|
|
5477
|
-
commandArguments: argumentsFor
|
|
6292
|
+
commandArguments: argumentsFor,
|
|
6293
|
+
skills: () => userSkills
|
|
5478
6294
|
}, {
|
|
5479
6295
|
interrupt: () => {
|
|
5480
6296
|
onInterruptKey();
|
|
@@ -5493,7 +6309,7 @@ async function run(ctx, config, io) {
|
|
|
5493
6309
|
if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
|
|
5494
6310
|
},
|
|
5495
6311
|
readClipboardImage: () => readClipboardImage(process.env)
|
|
5496
|
-
}, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
|
|
6312
|
+
}, "Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Tab plan mode");
|
|
5497
6313
|
let turnBaseTokens = 0;
|
|
5498
6314
|
const spinner = new Spinner({
|
|
5499
6315
|
setLive: (text) => {
|
|
@@ -5524,6 +6340,7 @@ async function run(ctx, config, io) {
|
|
|
5524
6340
|
const old = live.handle;
|
|
5525
6341
|
adopt(next, replayLog);
|
|
5526
6342
|
await old.dispose();
|
|
6343
|
+
refreshSkills();
|
|
5527
6344
|
};
|
|
5528
6345
|
if (commands !== void 0) {
|
|
5529
6346
|
disposers.push(commands.register({
|
|
@@ -5561,7 +6378,7 @@ async function run(ctx, config, io) {
|
|
|
5561
6378
|
session: live.agent.session.id,
|
|
5562
6379
|
readsKeys: io.console.readsKeys,
|
|
5563
6380
|
resumed: false
|
|
5564
|
-
}, theme, io.console.
|
|
6381
|
+
}, theme, io.console.contentColumns)) prompt.write(line);
|
|
5565
6382
|
return {
|
|
5566
6383
|
kind: "success",
|
|
5567
6384
|
text: `new session ${live.agent.session.id}`
|
|
@@ -5620,7 +6437,8 @@ async function run(ctx, config, io) {
|
|
|
5620
6437
|
options: rows.map((row) => ({
|
|
5621
6438
|
label: row.label,
|
|
5622
6439
|
detail: row.detail
|
|
5623
|
-
}))
|
|
6440
|
+
})),
|
|
6441
|
+
filterable: true
|
|
5624
6442
|
}, signal);
|
|
5625
6443
|
if (outcome.kind !== "chosen") return {
|
|
5626
6444
|
kind: "success",
|
|
@@ -5703,7 +6521,8 @@ async function run(ctx, config, io) {
|
|
|
5703
6521
|
label: `${entry.provider}/${entry.id}`,
|
|
5704
6522
|
detail: active ? `${entry.name} · current` : entry.name
|
|
5705
6523
|
};
|
|
5706
|
-
})
|
|
6524
|
+
}),
|
|
6525
|
+
filterable: true
|
|
5707
6526
|
});
|
|
5708
6527
|
if (outcome.kind !== "chosen") return {
|
|
5709
6528
|
kind: "success",
|
|
@@ -5770,16 +6589,79 @@ async function run(ctx, config, io) {
|
|
|
5770
6589
|
/** Push the always-current status row; the pipe shape prints it instead. */
|
|
5771
6590
|
const refreshStatus = () => {
|
|
5772
6591
|
if (!io.console.readsKeys) return;
|
|
5773
|
-
|
|
6592
|
+
if (viewing !== void 0) {
|
|
6593
|
+
prompt.setStatus(theme.dim("subagent · Esc returns to the parent"));
|
|
6594
|
+
return;
|
|
6595
|
+
}
|
|
6596
|
+
prompt.setStatus(statusLine(facts(branch), theme));
|
|
5774
6597
|
prompt.setTodos(todoList(ctx, live.agent));
|
|
5775
6598
|
};
|
|
5776
6599
|
if (planModeFrom(live.agent.session.events)) prompt.setAccent((text) => theme.pending(text));
|
|
5777
6600
|
refreshStatus();
|
|
6601
|
+
/**
|
|
6602
|
+
* Open a child subagent's transcript in place of the parent's.
|
|
6603
|
+
* @param id - the child session the card named.
|
|
6604
|
+
*/
|
|
6605
|
+
const enterView = (id) => {
|
|
6606
|
+
const session = sessions.get(SessionId(id));
|
|
6607
|
+
if (session === void 0) {
|
|
6608
|
+
prompt.setFlash(theme.dim(" subagent is no longer running"));
|
|
6609
|
+
return;
|
|
6610
|
+
}
|
|
6611
|
+
viewing = {
|
|
6612
|
+
session,
|
|
6613
|
+
transcript: new Transcript({
|
|
6614
|
+
theme,
|
|
6615
|
+
columns: io.console.columns,
|
|
6616
|
+
cwd
|
|
6617
|
+
}, presentersFor(ctx, live.agent))
|
|
6618
|
+
};
|
|
6619
|
+
spinner.pause();
|
|
6620
|
+
io.console.clearScreen();
|
|
6621
|
+
replay(session, viewing.transcript, io, theme);
|
|
6622
|
+
refreshStatus();
|
|
6623
|
+
};
|
|
6624
|
+
/** Restore the parent session's transcript. */
|
|
6625
|
+
const exitView = () => {
|
|
6626
|
+
if (viewing === void 0) return;
|
|
6627
|
+
viewing = void 0;
|
|
6628
|
+
live.transcript = new Transcript({
|
|
6629
|
+
theme,
|
|
6630
|
+
columns: io.console.columns,
|
|
6631
|
+
cwd
|
|
6632
|
+
}, presentersFor(ctx, live.agent));
|
|
6633
|
+
io.console.clearScreen();
|
|
6634
|
+
replay(live.agent.session, live.transcript, io, theme);
|
|
6635
|
+
refreshStatus();
|
|
6636
|
+
if (live.agent.status === "running") spinner.start();
|
|
6637
|
+
};
|
|
6638
|
+
io.console.setEnter(enterView);
|
|
5778
6639
|
ctx.on("session/event", (session, event) => {
|
|
6640
|
+
if (session === live.agent.session) {
|
|
6641
|
+
if (event.type === "plan/mode") prompt.setAccent(event.data.active ? (text) => theme.pending(text) : void 0);
|
|
6642
|
+
refreshStatus();
|
|
6643
|
+
if (event.type === "todo/write") prompt.setTodos(event.data.todos);
|
|
6644
|
+
}
|
|
6645
|
+
if (viewing !== void 0) {
|
|
6646
|
+
if (session !== viewing.session) return;
|
|
6647
|
+
if (event.type === "tool/call") spinner.setActivity(toolActivity(event.data.name));
|
|
6648
|
+
if (event.type === "tool/result") spinner.setActivity("working");
|
|
6649
|
+
const lines$1 = viewing.transcript.render(event);
|
|
6650
|
+
const full$1 = viewing.transcript.takeFold();
|
|
6651
|
+
const rule$1 = viewing.transcript.takeRule();
|
|
6652
|
+
const label$1 = viewing.transcript.takeLabel();
|
|
6653
|
+
const enter$1 = viewing.transcript.takeEnter();
|
|
6654
|
+
if (enter$1 !== void 0 || full$1 !== void 0) {
|
|
6655
|
+
prompt.setStreaming(void 0);
|
|
6656
|
+
io.console.appendFold(lines$1, full$1 ?? lines$1, rule$1, label$1, enter$1);
|
|
6657
|
+
return;
|
|
6658
|
+
}
|
|
6659
|
+
emit(lines$1, void 0, rule$1);
|
|
6660
|
+
return;
|
|
6661
|
+
}
|
|
5779
6662
|
if (session !== live.agent.session) return;
|
|
5780
|
-
if (event.type === "
|
|
5781
|
-
|
|
5782
|
-
if (event.type === "todo/write") prompt.setTodos(event.data.todos);
|
|
6663
|
+
if (event.type === "tool/call") spinner.setActivity(toolActivity(event.data.name));
|
|
6664
|
+
if (event.type === "tool/result") spinner.setActivity("working");
|
|
5783
6665
|
if (config.print && event.type === "user/message") return;
|
|
5784
6666
|
if (event.type === "assistant/chunk") {
|
|
5785
6667
|
const { chunk } = event.data;
|
|
@@ -5809,12 +6691,13 @@ async function run(ctx, config, io) {
|
|
|
5809
6691
|
const full = live.transcript.takeFold();
|
|
5810
6692
|
const rule = live.transcript.takeRule();
|
|
5811
6693
|
const label = live.transcript.takeLabel();
|
|
5812
|
-
|
|
6694
|
+
const enter = live.transcript.takeEnter();
|
|
6695
|
+
if (enter === void 0 && full === void 0) {
|
|
5813
6696
|
emit(lines, void 0, rule);
|
|
5814
6697
|
return;
|
|
5815
6698
|
}
|
|
5816
6699
|
prompt.setStreaming(void 0);
|
|
5817
|
-
io.console.appendFold(lines, full, rule, label);
|
|
6700
|
+
io.console.appendFold(lines, full ?? lines, rule, label, enter);
|
|
5818
6701
|
});
|
|
5819
6702
|
/** Pause the indicator around a decision, and resume it if work continues. */
|
|
5820
6703
|
const whileDeciding = async (decide) => {
|
|
@@ -5859,6 +6742,8 @@ async function run(ctx, config, io) {
|
|
|
5859
6742
|
});
|
|
5860
6743
|
ctx.on("approval/request", (req, next) => req.agent === live.agent ? approval.decide(req) : next());
|
|
5861
6744
|
adopt = (next, replayLog) => {
|
|
6745
|
+
viewing = void 0;
|
|
6746
|
+
prompt.setHint(void 0);
|
|
5862
6747
|
live.handle = next;
|
|
5863
6748
|
live.agent = next.agent;
|
|
5864
6749
|
live.transcript = new Transcript({
|
|
@@ -5917,6 +6802,10 @@ async function run(ctx, config, io) {
|
|
|
5917
6802
|
let lastInterrupt = 0;
|
|
5918
6803
|
let recallArmed;
|
|
5919
6804
|
onEscapeKey = () => {
|
|
6805
|
+
if (viewing !== void 0) {
|
|
6806
|
+
exitView();
|
|
6807
|
+
return;
|
|
6808
|
+
}
|
|
5920
6809
|
if (interrupt()) return;
|
|
5921
6810
|
if (!prompt.empty) return;
|
|
5922
6811
|
const last = prompt.history.findLast((entry) => !entry.startsWith("/") && !entry.startsWith("!"));
|
|
@@ -5969,7 +6858,7 @@ async function run(ctx, config, io) {
|
|
|
5969
6858
|
if (images.length === 0) return void 0;
|
|
5970
6859
|
const store = ctx.get("attachments");
|
|
5971
6860
|
const limits = store?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
|
|
5972
|
-
if (routeAcceptsImages() && store !== void 0) try {
|
|
6861
|
+
if (await routeAcceptsImages() && store !== void 0) try {
|
|
5973
6862
|
return {
|
|
5974
6863
|
leading: (await admitEncodedImages(store, await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits))))).map((attachment) => ({
|
|
5975
6864
|
type: "image",
|
|
@@ -6046,34 +6935,31 @@ async function run(ctx, config, io) {
|
|
|
6046
6935
|
* @param command - the line after the `!`.
|
|
6047
6936
|
*/
|
|
6048
6937
|
const passthrough = async (command) => {
|
|
6049
|
-
prompt.write(`${theme.
|
|
6938
|
+
prompt.write(`${theme.pending("●")} ${theme.tool("bash")}`);
|
|
6939
|
+
prompt.write(` $ ${command}`);
|
|
6050
6940
|
running = new AbortController();
|
|
6051
6941
|
try {
|
|
6052
|
-
|
|
6942
|
+
let streamed = 0;
|
|
6943
|
+
const result = await capture(userShell(), process.platform === "win32" ? ["/c", command] : ["-c", command], {
|
|
6053
6944
|
cwd,
|
|
6054
6945
|
signal: running.signal,
|
|
6055
|
-
timeoutMs: config.bangTimeoutMs
|
|
6946
|
+
timeoutMs: config.bangTimeoutMs,
|
|
6947
|
+
onLine: (line) => {
|
|
6948
|
+
if (streamed < config.bangOutputLines) prompt.write(` ${line}`);
|
|
6949
|
+
streamed += 1;
|
|
6950
|
+
}
|
|
6056
6951
|
});
|
|
6057
|
-
|
|
6058
|
-
const kept = lines.slice(0, config.bangOutputLines);
|
|
6059
|
-
const dropped = lines.length - kept.length;
|
|
6060
|
-
for (const line of kept) prompt.write(theme.dim(` ${line}`));
|
|
6061
|
-
if (dropped > 0) prompt.write(theme.dim(` … ${dropped} more lines`));
|
|
6952
|
+
if (streamed > config.bangOutputLines) prompt.write(theme.dim(` … ${streamed - config.bangOutputLines} more lines`));
|
|
6062
6953
|
const status = result.signal !== null ? theme.error(` ✗ killed by ${result.signal}`) : result.code !== 0 ? theme.error(` ✗ exit ${result.code ?? "?"}`) : void 0;
|
|
6063
6954
|
if (status !== void 0) prompt.write(status);
|
|
6064
6955
|
prompt.write("");
|
|
6065
|
-
const
|
|
6066
|
-
const
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
source: {
|
|
6073
|
-
kind: "plugin",
|
|
6074
|
-
plugin: "coding-cli"
|
|
6075
|
-
}
|
|
6076
|
-
}));
|
|
6956
|
+
const lines = result.output.trimEnd() === "" ? [] : result.output.trimEnd().split("\n");
|
|
6957
|
+
const kept = lines.slice(0, config.bangOutputLines);
|
|
6958
|
+
const dropped = lines.length - kept.length;
|
|
6959
|
+
await answer(`<bash-input>${command}</bash-input>\n<bash-output>\n${[...kept, ...dropped > 0 ? [`… ${dropped} more lines`] : []].join("\n")}\n</bash-output>\n<bash-exit>${result.signal !== null ? `killed by ${result.signal}` : String(result.code ?? 0)}</bash-exit>`, {
|
|
6960
|
+
kind: "plugin",
|
|
6961
|
+
plugin: "coding-cli"
|
|
6962
|
+
});
|
|
6077
6963
|
} finally {
|
|
6078
6964
|
running = void 0;
|
|
6079
6965
|
}
|
|
@@ -6099,10 +6985,15 @@ async function run(ctx, config, io) {
|
|
|
6099
6985
|
const trimmed = line.trim();
|
|
6100
6986
|
if (trimmed === "") continue;
|
|
6101
6987
|
if (trimmed === "/exit" || trimmed === "/quit") break;
|
|
6988
|
+
if (viewing !== void 0) {
|
|
6989
|
+
prompt.setFlash(theme.dim(" Esc returns to the parent"));
|
|
6990
|
+
continue;
|
|
6991
|
+
}
|
|
6102
6992
|
if (trimmed.startsWith("!")) {
|
|
6103
6993
|
if (images.length > 0) prompt.setFlash(theme.dim(" images do not ride ! commands — send them with a prompt"));
|
|
6104
6994
|
const command = trimmed.slice(1).trim();
|
|
6105
|
-
if (command
|
|
6995
|
+
if (command === "") continue;
|
|
6996
|
+
await passthrough(command);
|
|
6106
6997
|
continue;
|
|
6107
6998
|
}
|
|
6108
6999
|
if (trimmed.startsWith("/")) {
|
|
@@ -6131,7 +7022,7 @@ async function run(ctx, config, io) {
|
|
|
6131
7022
|
continue;
|
|
6132
7023
|
}
|
|
6133
7024
|
let batch = [];
|
|
6134
|
-
if (images.length > 0) if (routeAcceptsImages()) {
|
|
7025
|
+
if (images.length > 0) if (await routeAcceptsImages()) {
|
|
6135
7026
|
const limits = ctx.get("attachments")?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
|
|
6136
7027
|
batch = await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits)));
|
|
6137
7028
|
} else prompt.setFlash(theme.error(" this model does not accept images with commands — they were dropped"));
|
|
@@ -6143,7 +7034,7 @@ async function run(ctx, config, io) {
|
|
|
6143
7034
|
}
|
|
6144
7035
|
continue;
|
|
6145
7036
|
}
|
|
6146
|
-
await answer(trimmed, void 0, images);
|
|
7037
|
+
await answer(expandSkillGestures(trimmed, new Set(userSkills.map((entry) => entry.name))), void 0, images);
|
|
6147
7038
|
}
|
|
6148
7039
|
await sessions.flush(live.agent.session);
|
|
6149
7040
|
try {
|