codsh-bundle 0.6.1 → 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 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 wrapped.
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
- return truncate([
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(" · ")), columns);
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
- * The lettermark, drawn in half-block glyphs.
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
- * Forty columns wide: it fits an eighty-column terminal with room to spare,
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 LOGO = [
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 widest logo row. */
387
- const LOGO_WIDTH = 41;
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
- ...LOGO.map((row) => theme.user(` ${row}`)),
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
- const names = commands().map((command) => `/${command.name}`);
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
- /** Start underline, which is how the block under the pointer shows itself. */
1112
- const UNDERLINE = "\x1B[4m";
1113
- /** End underline only, leaving any other attributes alone. */
1114
- const UNDERLINE_OFF = "\x1B[24m";
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
- * Underline a whole rendered row.
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 underline partway along the row — so the attribute is armed again after
1122
- * each one, and turned off alone at the end so nothing else is disturbed.
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
- * @returns the row, underlined end to end.
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 underline(row) {
1127
- return `${UNDERLINE}${row.replaceAll(RESET, `${RESET}${UNDERLINE}`)}${UNDERLINE_OFF}`;
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 head = this.foldRanges().find((range) => range.fold === hovered)?.from;
1737
- const index = head === void 0 ? -1 : head - Math.max(0, end - height);
1738
- if (index >= 0 && index < visible.length) visible[index] = underline(visible[index] ?? "");
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: one less than the width, because the
1841
- * viewport wraps at that boundary. Markdown layout MUST use this figure — a
1842
- * table laid out one column wider is refolded by the viewport, and its rows
1843
- * shear apart.
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 (wholeLine && token.startsWith("/")) this.candidates = this.sources.commands().filter((entry) => `/${entry.name}`.startsWith(token)).map((entry) => ({
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 visual = view.lines.flatMap((line, index) => wrapLine(line, index, budget));
2823
- const cursorVisual = visual.findIndex((row) => row.logical === view.row && view.column >= row.start && (view.column < row.start + row.length || row.last && view.column === row.start + row.length));
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 = view.lines.length === 1 && view.lines[0] === "";
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("│")} ${theme.user("›")} ${theme.dim(text)}${pad} ${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 ? theme.user("›") : " ";
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} ${row.text}${pad} ${accent("│")}`);
3394
+ rows.push(`${accent("│")} ${gutter} ${painted}${pad} ${accent("│")}`);
2841
3395
  });
2842
3396
  rows.push(accent(`╰${rule}╯`));
2843
- if (view.candidates.length > 0) {
2844
- const first = Math.min(Math.max(0, view.selected - MENU_LIMIT + 1), Math.max(0, view.candidates.length - MENU_LIMIT));
2845
- const menu = view.candidates.slice(first, first + MENU_LIMIT);
2846
- const width = Math.max(...menu.map((candidate) => displayWidth(candidate.value)));
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(view.lines[view.row] ?? "").slice(inCursorRow.start, view.column).join("");
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.spec.options.length + (this.spec.custom === void 0 ? 0 : 1);
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.spec.options.length;
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)) if (this.checked.has(this.selected)) this.checked.delete(this.selected);
2920
- else this.checked.add(this.selected);
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
- if (this.checked.has(digit - 1)) this.checked.delete(digit - 1);
2928
- else this.checked.add(digit - 1);
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.accept(shortcut);
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) : [index]
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: [index]
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
- const option = this.spec.options[index];
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.columns - 1;
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 notice = this.flash ?? this.hover ?? this.hint;
3626
- if (notice !== void 0) rows.push(notice);
3627
- if (this.status !== void 0) rows.push(this.status);
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 research-grounded interview, a
4140
- * confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
4141
- * until the spec's acceptance criteria pass.
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
- * plan is written into it, its Status line names the phase, its checkboxes
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 — grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer — where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.
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 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion names the exact command that proves it and the output that counts as passing — the final phase runs those commands verbatim, so a criterion without a command is not finished. Give the file a \`Status:\` line (interviewing, confirmed, planned, landing, shipped) and keep it current at every phase change: it is what lets an interrupted /ship resume instead of starting over. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.
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 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Once approved, write the plan into the spec file as a \`## Plan\` section with one checkbox per milestone — an approved plan lives on disk, not in a conversation that can be compacted or lost. Then, still before any implementation code, establish the ground: check the working tree is clean (uncommitted unrelated changes are the user's to decide about — ask), and run the plan's proof commands once, recording the baseline in the spec. A baseline that is already red changes what "green" will mean, so surface it here rather than discovering it under your own diff. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
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 milestone, tick the milestone's checkbox and update Status as you go, and commit after each milestone turns green — small commits are the progress that survives a crash and the history a reviewer can walk. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, fix until green, then commit before moving on. If it is larger — four or more substantially independent milestones, or work you expect to exceed what one session can hold — the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the single source of truth, instructs each round to read the spec from disk (plan, checkboxes, baseline), pick the first unchecked milestone, implement and test it, then commit and tick its checkbox, and defines completion as every acceptance criterion in the spec passing. Bound the loop: budget about three rounds per milestone, and instruct it to stop and report rather than continue past two consecutive rounds that tick nothing.
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, this.label, this.theme));
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 raw = this.resultText(block.content).split("\n");
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 head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [];
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
- if (full !== void 0) {
5096
- io.console.appendFold(lines, full, rule, label);
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
- output += chunk.toString();
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) theme.setLight(light);
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.columns)) io.console.write(line);
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. */
@@ -5429,7 +6217,7 @@ async function run(ctx, config, io) {
5429
6217
  },
5430
6218
  {
5431
6219
  name: "ship",
5432
- description: "take a one-sentence idea to shipped code"
6220
+ description: "grill an idea, then spec, tickets, and verified code"
5433
6221
  },
5434
6222
  ...custom.commands.map((command) => ({
5435
6223
  name: command.name,
@@ -5440,6 +6228,24 @@ async function run(ctx, config, io) {
5440
6228
  description: "leave the session"
5441
6229
  }
5442
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
+ });
5443
6249
  const completePath$1 = createCompleter(completable, cwd);
5444
6250
  /**
5445
6251
  * The first-argument candidates per command, read live: plan's argument
@@ -5483,7 +6289,8 @@ async function run(ctx, config, io) {
5483
6289
  const prompt = new Prompt(io.console, theme, {
5484
6290
  commands: completable,
5485
6291
  paths: completePath$1,
5486
- commandArguments: argumentsFor
6292
+ commandArguments: argumentsFor,
6293
+ skills: () => userSkills
5487
6294
  }, {
5488
6295
  interrupt: () => {
5489
6296
  onInterruptKey();
@@ -5502,7 +6309,7 @@ async function run(ctx, config, io) {
5502
6309
  if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
5503
6310
  },
5504
6311
  readClipboardImage: () => readClipboardImage(process.env)
5505
- }, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
6312
+ }, "Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Tab plan mode");
5506
6313
  let turnBaseTokens = 0;
5507
6314
  const spinner = new Spinner({
5508
6315
  setLive: (text) => {
@@ -5533,6 +6340,7 @@ async function run(ctx, config, io) {
5533
6340
  const old = live.handle;
5534
6341
  adopt(next, replayLog);
5535
6342
  await old.dispose();
6343
+ refreshSkills();
5536
6344
  };
5537
6345
  if (commands !== void 0) {
5538
6346
  disposers.push(commands.register({
@@ -5570,7 +6378,7 @@ async function run(ctx, config, io) {
5570
6378
  session: live.agent.session.id,
5571
6379
  readsKeys: io.console.readsKeys,
5572
6380
  resumed: false
5573
- }, theme, io.console.columns)) prompt.write(line);
6381
+ }, theme, io.console.contentColumns)) prompt.write(line);
5574
6382
  return {
5575
6383
  kind: "success",
5576
6384
  text: `new session ${live.agent.session.id}`
@@ -5629,7 +6437,8 @@ async function run(ctx, config, io) {
5629
6437
  options: rows.map((row) => ({
5630
6438
  label: row.label,
5631
6439
  detail: row.detail
5632
- }))
6440
+ })),
6441
+ filterable: true
5633
6442
  }, signal);
5634
6443
  if (outcome.kind !== "chosen") return {
5635
6444
  kind: "success",
@@ -5712,7 +6521,8 @@ async function run(ctx, config, io) {
5712
6521
  label: `${entry.provider}/${entry.id}`,
5713
6522
  detail: active ? `${entry.name} · current` : entry.name
5714
6523
  };
5715
- })
6524
+ }),
6525
+ filterable: true
5716
6526
  });
5717
6527
  if (outcome.kind !== "chosen") return {
5718
6528
  kind: "success",
@@ -5779,16 +6589,79 @@ async function run(ctx, config, io) {
5779
6589
  /** Push the always-current status row; the pipe shape prints it instead. */
5780
6590
  const refreshStatus = () => {
5781
6591
  if (!io.console.readsKeys) return;
5782
- prompt.setStatus(statusLine(facts(branch), theme, io.console.columns - 1));
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));
5783
6597
  prompt.setTodos(todoList(ctx, live.agent));
5784
6598
  };
5785
6599
  if (planModeFrom(live.agent.session.events)) prompt.setAccent((text) => theme.pending(text));
5786
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);
5787
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
+ }
5788
6662
  if (session !== live.agent.session) return;
5789
- if (event.type === "plan/mode") prompt.setAccent(event.data.active ? (text) => theme.pending(text) : void 0);
5790
- refreshStatus();
5791
- 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");
5792
6665
  if (config.print && event.type === "user/message") return;
5793
6666
  if (event.type === "assistant/chunk") {
5794
6667
  const { chunk } = event.data;
@@ -5818,12 +6691,13 @@ async function run(ctx, config, io) {
5818
6691
  const full = live.transcript.takeFold();
5819
6692
  const rule = live.transcript.takeRule();
5820
6693
  const label = live.transcript.takeLabel();
5821
- if (full === void 0) {
6694
+ const enter = live.transcript.takeEnter();
6695
+ if (enter === void 0 && full === void 0) {
5822
6696
  emit(lines, void 0, rule);
5823
6697
  return;
5824
6698
  }
5825
6699
  prompt.setStreaming(void 0);
5826
- io.console.appendFold(lines, full, rule, label);
6700
+ io.console.appendFold(lines, full ?? lines, rule, label, enter);
5827
6701
  });
5828
6702
  /** Pause the indicator around a decision, and resume it if work continues. */
5829
6703
  const whileDeciding = async (decide) => {
@@ -5868,6 +6742,8 @@ async function run(ctx, config, io) {
5868
6742
  });
5869
6743
  ctx.on("approval/request", (req, next) => req.agent === live.agent ? approval.decide(req) : next());
5870
6744
  adopt = (next, replayLog) => {
6745
+ viewing = void 0;
6746
+ prompt.setHint(void 0);
5871
6747
  live.handle = next;
5872
6748
  live.agent = next.agent;
5873
6749
  live.transcript = new Transcript({
@@ -5926,6 +6802,10 @@ async function run(ctx, config, io) {
5926
6802
  let lastInterrupt = 0;
5927
6803
  let recallArmed;
5928
6804
  onEscapeKey = () => {
6805
+ if (viewing !== void 0) {
6806
+ exitView();
6807
+ return;
6808
+ }
5929
6809
  if (interrupt()) return;
5930
6810
  if (!prompt.empty) return;
5931
6811
  const last = prompt.history.findLast((entry) => !entry.startsWith("/") && !entry.startsWith("!"));
@@ -6055,34 +6935,31 @@ async function run(ctx, config, io) {
6055
6935
  * @param command - the line after the `!`.
6056
6936
  */
6057
6937
  const passthrough = async (command) => {
6058
- prompt.write(`${theme.user("")} ${theme.tool(`!${command}`)}`);
6938
+ prompt.write(`${theme.pending("")} ${theme.tool("bash")}`);
6939
+ prompt.write(` $ ${command}`);
6059
6940
  running = new AbortController();
6060
6941
  try {
6061
- const result = await capture(process.env["SHELL"] ?? "/bin/sh", ["-c", command], {
6942
+ let streamed = 0;
6943
+ const result = await capture(userShell(), process.platform === "win32" ? ["/c", command] : ["-c", command], {
6062
6944
  cwd,
6063
6945
  signal: running.signal,
6064
- timeoutMs: config.bangTimeoutMs
6946
+ timeoutMs: config.bangTimeoutMs,
6947
+ onLine: (line) => {
6948
+ if (streamed < config.bangOutputLines) prompt.write(` ${line}`);
6949
+ streamed += 1;
6950
+ }
6065
6951
  });
6066
- const lines = result.output.trimEnd() === "" ? [] : result.output.trimEnd().split("\n");
6067
- const kept = lines.slice(0, config.bangOutputLines);
6068
- const dropped = lines.length - kept.length;
6069
- for (const line of kept) prompt.write(theme.dim(` ${line}`));
6070
- if (dropped > 0) prompt.write(theme.dim(` … ${dropped} more lines`));
6952
+ if (streamed > config.bangOutputLines) prompt.write(theme.dim(` … ${streamed - config.bangOutputLines} more lines`));
6071
6953
  const status = result.signal !== null ? theme.error(` ✗ killed by ${result.signal}`) : result.code !== 0 ? theme.error(` ✗ exit ${result.code ?? "?"}`) : void 0;
6072
6954
  if (status !== void 0) prompt.write(status);
6073
6955
  prompt.write("");
6074
- const report = [...kept, ...dropped > 0 ? [`… ${dropped} more lines`] : []].join("\n");
6075
- const exit = result.signal !== null ? `killed by ${result.signal}` : String(result.code ?? 0);
6076
- live.agent.inject(createUserMessage({
6077
- content: [{
6078
- type: "text",
6079
- text: `<bash-input>${command}</bash-input>\n<bash-output>\n${report}\n</bash-output>\n<bash-exit>${exit}</bash-exit>`
6080
- }],
6081
- source: {
6082
- kind: "plugin",
6083
- plugin: "coding-cli"
6084
- }
6085
- }));
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
+ });
6086
6963
  } finally {
6087
6964
  running = void 0;
6088
6965
  }
@@ -6108,10 +6985,15 @@ async function run(ctx, config, io) {
6108
6985
  const trimmed = line.trim();
6109
6986
  if (trimmed === "") continue;
6110
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
+ }
6111
6992
  if (trimmed.startsWith("!")) {
6112
6993
  if (images.length > 0) prompt.setFlash(theme.dim(" images do not ride ! commands — send them with a prompt"));
6113
6994
  const command = trimmed.slice(1).trim();
6114
- if (command !== "") await passthrough(command);
6995
+ if (command === "") continue;
6996
+ await passthrough(command);
6115
6997
  continue;
6116
6998
  }
6117
6999
  if (trimmed.startsWith("/")) {
@@ -6152,7 +7034,7 @@ async function run(ctx, config, io) {
6152
7034
  }
6153
7035
  continue;
6154
7036
  }
6155
- await answer(trimmed, void 0, images);
7037
+ await answer(expandSkillGestures(trimmed, new Set(userSkills.map((entry) => entry.name))), void 0, images);
6156
7038
  }
6157
7039
  await sessions.flush(live.agent.session);
6158
7040
  try {