codsh-bundle 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -586,9 +586,9 @@ function parseCommandFile(source) {
586
586
  if (source.startsWith("---\n")) {
587
587
  const end = source.indexOf("\n---\n", 4);
588
588
  if (end >= 0) {
589
- const header = source.slice(4, end);
589
+ const header$1 = source.slice(4, end);
590
590
  return {
591
- description: /^description:\s*(.+)$/m.exec(header)?.[1]?.trim() ?? "",
591
+ description: /^description:\s*(.+)$/m.exec(header$1)?.[1]?.trim() ?? "",
592
592
  body: source.slice(end + 5).trim()
593
593
  };
594
594
  }
@@ -697,9 +697,17 @@ const KITTY_CTRL = 4;
697
697
  const WHEEL_UP = 64;
698
698
  /** Modifier bits in an SGR button code: Shift, Meta, and Control. */
699
699
  const MOUSE_MODIFIERS = 28;
700
- /** The motion bit, set on reports sent while a button is held. */
700
+ /** The motion bit, set on every report the pointer's movement produces. */
701
701
  const MOUSE_MOTION = 32;
702
702
  /**
703
+ * The button code any-motion tracking sends when no button is held.
704
+ *
705
+ * Motion with a button reports that button; motion with none reports 3, the
706
+ * same code a release carries — so a move is the motion bit over this, which
707
+ * is what tells the surface which block the pointer is merely resting on.
708
+ */
709
+ const MOUSE_NO_BUTTON = 3;
710
+ /**
703
711
  * An OSC reply from the terminal: `ESC ] code ; payload (BEL | ESC \)`.
704
712
  *
705
713
  * The viewport asks for the background color (OSC 11) on entry; the reply
@@ -785,6 +793,7 @@ const CONTROLS = {
785
793
  "\v": { kind: "kill-line" },
786
794
  "\f": { kind: "clear-screen" },
787
795
  "": { kind: "expand-output" },
796
+ "": { kind: "toggle-todos" },
788
797
  "": { kind: "kill-input" },
789
798
  "": { kind: "kill-word" }
790
799
  };
@@ -855,6 +864,11 @@ var KeyDecoder = class {
855
864
  }];
856
865
  const column = Number(mouse[2]);
857
866
  const row = Number(mouse[3]);
867
+ if ((button & ~MOUSE_MODIFIERS) === (MOUSE_MOTION | MOUSE_NO_BUTTON)) return [{
868
+ kind: "mouse-move",
869
+ row,
870
+ column
871
+ }];
858
872
  if ((button & ~MOUSE_MOTION) === 0 && (button & MOUSE_MODIFIERS) === 0) {
859
873
  if (mouse[4] === "m") return [{
860
874
  kind: "mouse-up",
@@ -986,7 +1000,7 @@ const SGR = /^\u001B\[[0-9;]*m/;
986
1000
  /** Any other escape sequence, also zero-width. */
987
1001
  const ESCAPE = /^(?:\u001B\[[0-9;?]*[A-Za-z]|\u001B\][^\u0007]*\u0007|\u001B.)/;
988
1002
  /** Closes every style a row opened, so a row never bleeds into the next. */
989
- const RESET = "\x1B[0m";
1003
+ const RESET$1 = "\x1B[0m";
990
1004
  /**
991
1005
  * Break one styled line into rows no wider than `columns` display columns.
992
1006
  *
@@ -1005,7 +1019,7 @@ function wrapStyled(text, columns) {
1005
1019
  let width = 0;
1006
1020
  let rest = text;
1007
1021
  const flush = () => {
1008
- rows.push(active.length > 0 ? `${row}${RESET}` : row);
1022
+ rows.push(active.length > 0 ? `${row}${RESET$1}` : row);
1009
1023
  row = active.join("");
1010
1024
  width = 0;
1011
1025
  };
@@ -1032,18 +1046,9 @@ function wrapStyled(text, columns) {
1032
1046
  width += cost;
1033
1047
  rest = rest.slice(character.length);
1034
1048
  }
1035
- rows.push(active.length > 0 ? `${row}${RESET}` : row);
1049
+ rows.push(active.length > 0 ? `${row}${RESET$1}` : row);
1036
1050
  return rows;
1037
1051
  }
1038
- /**
1039
- * Wrap many lines, keeping their order.
1040
- * @param lines - styled lines.
1041
- * @param columns - display columns per row.
1042
- * @returns the physical rows they occupy.
1043
- */
1044
- function wrapAll(lines, columns) {
1045
- return lines.flatMap((line) => wrapStyled(line, columns));
1046
- }
1047
1052
 
1048
1053
  //#endregion
1049
1054
  //#region src/screen.ts
@@ -1054,16 +1059,19 @@ const ENTER_ALT = "\x1B[?1049h";
1054
1059
  /** Leave it, restoring both. */
1055
1060
  const LEAVE_ALT = "\x1B[?1049l";
1056
1061
  /**
1057
- * Report wheel and button events, in the SGR encoding.
1062
+ * Report wheel, button, and pointer-motion events, in the SGR encoding.
1058
1063
  *
1059
- * Button tracking (1002) rather than any-motion (1003): the wheel and clicks
1060
- * are all this surface reads, and motion reporting floods the input for
1061
- * nothing. Most terminals still hand a Shift-drag to their own selection, so
1062
- * copying text keeps working.
1064
+ * Any-motion tracking (1003) is what lets the surface say which block the
1065
+ * pointer rests on the blocks are clickable, and a target that gives no
1066
+ * feedback until it is hit is not an affordance. Button tracking (1002) is
1067
+ * pushed under it so terminals that implement only that one keep the drag
1068
+ * that selects. Motion is a report per cell crossed, so the surface repaints
1069
+ * only when the block under the pointer changes, not on every report. Most
1070
+ * terminals still hand a Shift-drag to their own selection either way.
1063
1071
  */
1064
- const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1006h";
1072
+ const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1003h\x1B[?1006h";
1065
1073
  /** Stop reporting them. */
1066
- const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1002l";
1074
+ const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1003l\x1B[?1002l";
1067
1075
  /**
1068
1076
  * Push the kitty keyboard protocol's disambiguate flag — what Claude Code
1069
1077
  * pushes — so Shift+Enter, Esc, and control chords report unambiguously on
@@ -1098,6 +1106,24 @@ const STYLES = /\u001B\[[0-9;]*m/gu;
1098
1106
  const INVERSE = "\x1B[7m";
1099
1107
  /** End reverse video only, leaving any other attributes alone. */
1100
1108
  const INVERSE_OFF = "\x1B[27m";
1109
+ /** Start underline, which is how the block under the pointer shows itself. */
1110
+ const UNDERLINE = "\x1B[4m";
1111
+ /** End underline only, leaving any other attributes alone. */
1112
+ const UNDERLINE_OFF = "\x1B[24m";
1113
+ /** A full SGR reset, which every styled span this surface prints ends with. */
1114
+ const RESET = "\x1B[0m";
1115
+ /**
1116
+ * Underline a whole rendered row.
1117
+ *
1118
+ * Every styled span this surface prints ends in a full reset, which would drop
1119
+ * the underline partway along the row — so the attribute is armed again after
1120
+ * each one, and turned off alone at the end so nothing else is disturbed.
1121
+ * @param row - the styled row.
1122
+ * @returns the row, underlined end to end.
1123
+ */
1124
+ function underline(row) {
1125
+ return `${UNDERLINE}${row.replaceAll(RESET, `${RESET}${UNDERLINE}`)}${UNDERLINE_OFF}`;
1126
+ }
1101
1127
  /**
1102
1128
  * The string index where a display column begins.
1103
1129
  *
@@ -1121,8 +1147,18 @@ function columnIndex(text, column) {
1121
1147
  var Screen = class {
1122
1148
  /** Logical transcript lines, unwrapped, oldest first. */
1123
1149
  logical = [];
1150
+ /**
1151
+ * The left rule each logical line carries, `''` for none.
1152
+ *
1153
+ * Kept beside the text rather than inside it: a rule has to repeat on every
1154
+ * row a line wraps to, and it must never reach the clipboard — it is a mark
1155
+ * the surface draws, not something the person wrote.
1156
+ */
1157
+ rules = [];
1124
1158
  /** The same lines wrapped to the current width — what the viewport slices. */
1125
1159
  physical = [];
1160
+ /** Display columns the rule occupies on each physical row, for copy and hits. */
1161
+ ruleWidths = [];
1126
1162
  /** The bottom rows: input box, menu, indicator, status. */
1127
1163
  chrome = [];
1128
1164
  chromeCursor = {
@@ -1139,6 +1175,18 @@ var Screen = class {
1139
1175
  selection;
1140
1176
  /** Collapsed blocks in the transcript, in order, with both of their forms. */
1141
1177
  folds = [];
1178
+ /** The block the pointer rests on, or undefined when it rests on none. */
1179
+ hovered;
1180
+ /**
1181
+ * Physical row ranges the blocks occupy, or undefined when they need
1182
+ * measuring again.
1183
+ *
1184
+ * Motion arrives a report per cell crossed, and measuring a block's row from
1185
+ * the wrapped height of everything above it is a walk over the buffer — far
1186
+ * too much to redo per report. The walk happens once after the buffer
1187
+ * changes instead, and every report in between is a lookup.
1188
+ */
1189
+ ranges;
1142
1190
  /** Whether the folds currently show their full form. */
1143
1191
  expanded = false;
1144
1192
  /** The last painted frame, so a repaint only touches rows that changed. */
@@ -1184,16 +1232,23 @@ var Screen = class {
1184
1232
  * where they are, and the new rows accumulate below them.
1185
1233
  * @param lines - the lines to keep, already styled.
1186
1234
  */
1187
- append(lines) {
1235
+ append(lines, rule = "") {
1188
1236
  if (lines.length === 0) return;
1189
1237
  const columns = this.contentColumns();
1190
1238
  for (const line of lines) {
1239
+ const own = line === "" ? "" : rule;
1191
1240
  this.logical.push(line);
1192
- this.physical.push(...wrapStyled(line, columns));
1241
+ this.rules.push(own);
1242
+ for (const row of this.wrapLine(line, own, columns)) {
1243
+ this.physical.push(row);
1244
+ this.ruleWidths.push(displayWidth(own));
1245
+ }
1193
1246
  }
1247
+ this.ranges = void 0;
1194
1248
  if (this.logical.length > MAX_SCROLLBACK) {
1195
1249
  const dropped = this.logical.length - MAX_SCROLLBACK;
1196
1250
  this.logical.splice(0, dropped);
1251
+ this.rules.splice(0, dropped);
1197
1252
  this.folds = this.folds.flatMap((fold) => {
1198
1253
  const at = fold.at - dropped;
1199
1254
  return at >= 0 ? [{
@@ -1201,6 +1256,7 @@ var Screen = class {
1201
1256
  at
1202
1257
  }] : [];
1203
1258
  });
1259
+ this.hovered = void 0;
1204
1260
  this.rewrap();
1205
1261
  }
1206
1262
  this.render();
@@ -1213,17 +1269,21 @@ var Screen = class {
1213
1269
  * place, exactly like a details/summary element.
1214
1270
  * @param summary - the collapsed lines, already styled.
1215
1271
  * @param full - the expanded lines, already styled.
1272
+ * @param rule - a styled left rule for the whole block, `''` for none.
1273
+ * @param label - what the block is, for the hover readout that names it.
1216
1274
  */
1217
- appendFold(summary, full) {
1275
+ appendFold(summary, full, rule = "", label = "") {
1218
1276
  const shown = this.expanded ? full : summary;
1219
1277
  this.folds.push({
1220
1278
  at: this.logical.length,
1221
1279
  shownLength: shown.length,
1222
1280
  summary: [...summary],
1223
1281
  full: [...full],
1224
- expanded: this.expanded
1282
+ expanded: this.expanded,
1283
+ rule,
1284
+ label
1225
1285
  });
1226
- this.append(shown);
1286
+ this.append(shown, rule);
1227
1287
  }
1228
1288
  /**
1229
1289
  * Turn the last `count` appended lines into a collapsible block after the
@@ -1235,8 +1295,9 @@ var Screen = class {
1235
1295
  * collapses with the rest when the conversation moves on.
1236
1296
  * @param count - how many trailing lines the block owns.
1237
1297
  * @param summary - the collapsed lines, already styled.
1298
+ * @param label - what the block is, for the hover readout that names it.
1238
1299
  */
1239
- foldBack(count, summary) {
1300
+ foldBack(count, summary, label = "") {
1240
1301
  const at = this.logical.length - count;
1241
1302
  if (count <= 0 || at < 0) return;
1242
1303
  const last = this.folds.at(-1);
@@ -1246,8 +1307,11 @@ var Screen = class {
1246
1307
  shownLength: count,
1247
1308
  summary: [...summary],
1248
1309
  full: this.logical.slice(at),
1249
- expanded: true
1310
+ expanded: true,
1311
+ rule: this.rules.slice(at).find((rule) => rule !== "") ?? "",
1312
+ label
1250
1313
  });
1314
+ this.ranges = void 0;
1251
1315
  }
1252
1316
  /** Whether any collapsible block exists. */
1253
1317
  get hasFolds() {
@@ -1259,11 +1323,15 @@ var Screen = class {
1259
1323
  }
1260
1324
  /**
1261
1325
  * Swap every fold between its summary and its full form.
1326
+ *
1327
+ * What the blocks show decides the direction, not what the last Ctrl+O did:
1328
+ * clicking blocks open one at a time would otherwise leave the key pointing
1329
+ * the wrong way, and a press that visibly does nothing reads as broken.
1262
1330
  * @returns false when there is nothing to toggle.
1263
1331
  */
1264
1332
  toggleFolds() {
1265
1333
  if (this.folds.length === 0) return false;
1266
- this.setFolds(!this.expanded);
1334
+ this.setFolds(!this.folds.every((fold) => fold.expanded));
1267
1335
  return true;
1268
1336
  }
1269
1337
  /** Return every fold to its summary, the way moving on reads as dismissal. */
@@ -1271,6 +1339,87 @@ var Screen = class {
1271
1339
  if (this.folds.some((fold) => fold.expanded)) this.setFolds(false);
1272
1340
  else this.expanded = false;
1273
1341
  }
1342
+ /**
1343
+ * Work the block a bare click landed on, the way a details element opens.
1344
+ *
1345
+ * The whole block is the target, in both forms: collapsed, the `+N lines`
1346
+ * line is what a person aims at, and open, anywhere inside the text folds it
1347
+ * back — hunting for a head row that has scrolled off the top is not an
1348
+ * affordance. Selecting text inside a block is a drag, which never reaches
1349
+ * here, so reading is unaffected.
1350
+ * @param row - physical buffer row the press anchored on.
1351
+ */
1352
+ clickFold(row) {
1353
+ const fold = this.foldAt(row);
1354
+ if (fold === void 0) return;
1355
+ this.setFold(fold, !fold.expanded);
1356
+ }
1357
+ /**
1358
+ * Where each block sits in physical rows.
1359
+ *
1360
+ * Blocks are recorded in logical lines while the mouse reports physical
1361
+ * rows, so the wrapped height of everything above a block is what bridges
1362
+ * the two — measured under each line's own rule, which costs columns and so
1363
+ * changes the height.
1364
+ * @returns one range per block, in buffer order.
1365
+ */
1366
+ foldRanges() {
1367
+ if (this.ranges !== void 0) return this.ranges;
1368
+ const columns = this.contentColumns();
1369
+ const height = (index$1) => this.wrapLine(this.logical[index$1] ?? "", this.rules[index$1] ?? "", columns).length;
1370
+ const ranges = [];
1371
+ let physical = 0;
1372
+ let index = 0;
1373
+ for (const fold of this.folds) {
1374
+ for (; index < fold.at; index += 1) physical += height(index);
1375
+ const from = physical;
1376
+ for (; index < fold.at + fold.shownLength; index += 1) physical += height(index);
1377
+ ranges.push({
1378
+ fold,
1379
+ from,
1380
+ to: physical - 1
1381
+ });
1382
+ }
1383
+ this.ranges = ranges;
1384
+ return ranges;
1385
+ }
1386
+ /**
1387
+ * The block covering a physical row.
1388
+ * @param row - physical buffer row, 0-based.
1389
+ * @returns the block, or undefined when the row is not in one.
1390
+ */
1391
+ foldAt(row) {
1392
+ return this.foldRanges().find((range) => row >= range.from && row <= range.to)?.fold;
1393
+ }
1394
+ /**
1395
+ * Swap one block, leaving the reader where they were.
1396
+ *
1397
+ * Someone who opened a block halfway up their history did not ask to be
1398
+ * moved to the tail: the rows above the block keep their screen positions,
1399
+ * and the transcript grows or shrinks below them. Following the tail there
1400
+ * is nothing to hold on to, so the frame keeps following it — which is what
1401
+ * Ctrl+O does for every block at once.
1402
+ * @param fold - the block to swap.
1403
+ * @param expanded - the form to put on screen.
1404
+ */
1405
+ setFold(fold, expanded) {
1406
+ const shown = expanded ? fold.full : fold.summary;
1407
+ const delta = shown.length - fold.shownLength;
1408
+ this.logical.splice(fold.at, fold.shownLength, ...shown);
1409
+ this.rules.splice(fold.at, fold.shownLength, ...shown.map(() => fold.rule));
1410
+ fold.shownLength = shown.length;
1411
+ fold.expanded = expanded;
1412
+ for (const other of this.folds) if (other.at > fold.at) other.at += delta;
1413
+ const before = this.physical.length;
1414
+ const offset = this.offset;
1415
+ this.rewrap();
1416
+ if (offset > 0) {
1417
+ const limit = Math.max(0, this.physical.length - this.viewportHeight());
1418
+ this.offset = Math.min(limit, Math.max(0, offset + this.physical.length - before));
1419
+ }
1420
+ this.painted = [];
1421
+ this.render();
1422
+ }
1274
1423
  /** Put every fold into one form, whatever mix of states they are in now. */
1275
1424
  setFolds(expanded) {
1276
1425
  this.expanded = expanded;
@@ -1282,6 +1431,7 @@ var Screen = class {
1282
1431
  }
1283
1432
  const shown = expanded ? fold.full : fold.summary;
1284
1433
  this.logical.splice(fold.at, fold.shownLength, ...shown);
1434
+ this.rules.splice(fold.at, fold.shownLength, ...shown.map(() => fold.rule));
1285
1435
  deltas.set(fold, shown.length - fold.shownLength);
1286
1436
  fold.shownLength = shown.length;
1287
1437
  fold.expanded = expanded;
@@ -1302,10 +1452,10 @@ var Screen = class {
1302
1452
  * @param cursor - where the cursor belongs among them.
1303
1453
  * @param focus - whether to show the cursor there.
1304
1454
  */
1305
- setChrome(rows, cursor, focus) {
1455
+ setChrome(rows, cursor, focus$1) {
1306
1456
  this.chrome = rows.map((row) => truncate(row, this.contentColumns()));
1307
1457
  this.chromeCursor = { ...cursor };
1308
- this.chromeFocus = focus;
1458
+ this.chromeFocus = focus$1;
1309
1459
  this.render();
1310
1460
  }
1311
1461
  /**
@@ -1353,8 +1503,12 @@ var Screen = class {
1353
1503
  */
1354
1504
  clearTranscript() {
1355
1505
  this.logical = [];
1506
+ this.rules = [];
1356
1507
  this.physical = [];
1508
+ this.ruleWidths = [];
1357
1509
  this.folds = [];
1510
+ this.ranges = void 0;
1511
+ this.hovered = void 0;
1358
1512
  this.expanded = false;
1359
1513
  this.offset = 0;
1360
1514
  this.painted = [];
@@ -1367,6 +1521,32 @@ var Screen = class {
1367
1521
  this.render();
1368
1522
  }
1369
1523
  /**
1524
+ * Note where the pointer is resting, with nothing held down.
1525
+ *
1526
+ * A block is clickable, so it says so while the pointer is on it rather
1527
+ * than only once it is hit. Reports arrive a cell at a time, so the frame is
1528
+ * only touched when the block under the pointer actually changes — moving
1529
+ * along one block, or across the chrome, costs a lookup and nothing else.
1530
+ * @param row - terminal row, 1-based.
1531
+ * @param column - terminal column, 1-based.
1532
+ * @returns the block under the pointer, or undefined for none — reported
1533
+ * every time, so a caller need not track the changes itself.
1534
+ */
1535
+ mouseMove(row, column) {
1536
+ const at = this.locate(row, column, false);
1537
+ const fold = at === void 0 ? void 0 : this.foldAt(at.row);
1538
+ if (fold !== this.hovered) {
1539
+ this.hovered = fold;
1540
+ this.render();
1541
+ }
1542
+ if (fold === void 0) return void 0;
1543
+ return {
1544
+ label: fold.label,
1545
+ lines: fold.full.length,
1546
+ expanded: fold.expanded
1547
+ };
1548
+ }
1549
+ /**
1370
1550
  * Anchor a selection where the left button went down.
1371
1551
  *
1372
1552
  * The terminal cannot select for us while mouse reporting is on, so the
@@ -1403,7 +1583,9 @@ var Screen = class {
1403
1583
  * Finish the gesture.
1404
1584
  *
1405
1585
  * The highlight stays up — the copy already happened, and the marks show
1406
- * what it took — until the next click or reflow dismisses it.
1586
+ * what it took — until the next click or reflow dismisses it. A press that
1587
+ * never moved is not a selection but a click, and a click on a collapsible
1588
+ * block works that one block: open it, or fold it back.
1407
1589
  * @returns the selected text, or undefined for a bare click.
1408
1590
  */
1409
1591
  mouseUp() {
@@ -1411,6 +1593,7 @@ var Screen = class {
1411
1593
  if (selection === void 0) return void 0;
1412
1594
  if (!selection.dragged) {
1413
1595
  this.selection = void 0;
1596
+ this.clickFold(selection.anchor.row);
1414
1597
  return;
1415
1598
  }
1416
1599
  const text = this.selectedText();
@@ -1425,8 +1608,8 @@ var Screen = class {
1425
1608
  orderedSelection() {
1426
1609
  const selection = this.selection;
1427
1610
  if (selection === void 0 || !selection.dragged) return void 0;
1428
- const { anchor, focus } = selection;
1429
- const [from, to] = focus.row < anchor.row || focus.row === anchor.row && focus.column < anchor.column ? [focus, anchor] : [anchor, focus];
1611
+ const { anchor, focus: focus$1 } = selection;
1612
+ const [from, to] = focus$1.row < anchor.row || focus$1.row === anchor.row && focus$1.column < anchor.column ? [focus$1, anchor] : [anchor, focus$1];
1430
1613
  return {
1431
1614
  from,
1432
1615
  to
@@ -1439,9 +1622,10 @@ var Screen = class {
1439
1622
  const rows = [];
1440
1623
  for (let index = bounds.from.row; index <= bounds.to.row; index += 1) {
1441
1624
  const plain = (this.physical[index] ?? "").replaceAll(STYLES, "");
1442
- const start = index === bounds.from.row ? columnIndex(plain, bounds.from.column) : 0;
1625
+ const rule = this.ruleWidths[index] ?? 0;
1626
+ const start = columnIndex(plain, index === bounds.from.row ? Math.max(bounds.from.column, rule) : rule);
1443
1627
  const end = index === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
1444
- rows.push(plain.slice(start, end));
1628
+ rows.push(plain.slice(start, Math.max(start, end)));
1445
1629
  }
1446
1630
  return rows.join("\n").replace(/^\n+|\n+$/gu, "") === "" ? "" : rows.join("\n");
1447
1631
  }
@@ -1474,10 +1658,40 @@ var Screen = class {
1474
1658
  contentColumns() {
1475
1659
  return Math.max(1, this.host.columns() - 1);
1476
1660
  }
1661
+ /**
1662
+ * Wrap one logical line, repeating its rule on every row.
1663
+ *
1664
+ * The rule costs columns, so the text wraps inside what is left of the width;
1665
+ * a continuation row without the rule would break the block's left edge
1666
+ * exactly where a long line made it matter most.
1667
+ * @param line - the styled logical line.
1668
+ * @param rule - the styled left rule, `''` for none.
1669
+ * @param columns - display columns available for rule and text together.
1670
+ * @returns the physical rows, rule included.
1671
+ */
1672
+ wrapLine(line, rule, columns) {
1673
+ if (rule === "") return wrapStyled(line, columns);
1674
+ return wrapStyled(line, Math.max(1, columns - displayWidth(rule))).map((row) => `${rule}${row}`);
1675
+ }
1676
+ /** Re-wrap every kept line at the current width, rules and all. */
1677
+ wrapBuffer() {
1678
+ const columns = this.contentColumns();
1679
+ this.ranges = void 0;
1680
+ this.physical = [];
1681
+ this.ruleWidths = [];
1682
+ for (const [at, line] of this.logical.entries()) {
1683
+ const rule = this.rules[at] ?? "";
1684
+ const width = displayWidth(rule);
1685
+ for (const row of this.wrapLine(line, rule, columns)) {
1686
+ this.physical.push(row);
1687
+ this.ruleWidths.push(width);
1688
+ }
1689
+ }
1690
+ }
1477
1691
  /** Re-wrap every kept line at the current width. */
1478
1692
  rewrap() {
1479
1693
  this.selection = void 0;
1480
- this.physical = wrapAll(this.logical, this.contentColumns());
1694
+ this.wrapBuffer();
1481
1695
  const limit = Math.max(0, this.physical.length - this.viewportHeight());
1482
1696
  this.offset = Math.min(this.offset, limit);
1483
1697
  }
@@ -1492,7 +1706,7 @@ var Screen = class {
1492
1706
  if (!this.active) return;
1493
1707
  const columns = this.host.columns();
1494
1708
  if (columns !== this.paintedColumns) {
1495
- this.physical = wrapAll(this.logical, this.contentColumns());
1709
+ this.wrapBuffer();
1496
1710
  this.painted = [];
1497
1711
  this.paintedColumns = columns;
1498
1712
  }
@@ -1515,6 +1729,12 @@ var Screen = class {
1515
1729
  visible[index] = `${plain.slice(0, start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(stop)}`;
1516
1730
  }
1517
1731
  }
1732
+ const hovered = this.hovered;
1733
+ if (hovered !== void 0) {
1734
+ const head = this.foldRanges().find((range) => range.fold === hovered)?.from;
1735
+ const index = head === void 0 ? -1 : head - Math.max(0, end - height);
1736
+ if (index >= 0 && index < visible.length) visible[index] = underline(visible[index] ?? "");
1737
+ }
1518
1738
  const viewport = [...visible, ...padding];
1519
1739
  if (this.offset > 0 && this.notice !== "" && viewport.length > 0) viewport[0] = truncate(this.notice, this.contentColumns());
1520
1740
  const frame = [...viewport, ...this.chrome];
@@ -1786,10 +2006,12 @@ var TerminalConsole = class {
1786
2006
  * what lets the transcript scroll under a prompt that does not move. Off one
1787
2007
  * it is written straight out, because a pipe's reader wants exactly that.
1788
2008
  * @param line - the line, without its terminator.
2009
+ * @param rule - a styled left rule to draw down the line, `''` for none. Off
2010
+ * a terminal it is dropped: a pipe's reader wants the text, not the frame.
1789
2011
  */
1790
- write(line) {
2012
+ write(line, rule = "") {
1791
2013
  if (this.screen !== void 0) {
1792
- this.screen.append([line]);
2014
+ this.screen.append([line], rule);
1793
2015
  return;
1794
2016
  }
1795
2017
  this.output.write(`${line}\n`);
@@ -1805,8 +2027,8 @@ var TerminalConsole = class {
1805
2027
  * @param focus - whether the rows hold input focus, which is when the cursor
1806
2028
  * shows. Parked anywhere else it reads as content colliding with it.
1807
2029
  */
1808
- setRegion(rows, cursor, focus = true) {
1809
- this.screen?.setChrome(rows, cursor, focus);
2030
+ setRegion(rows, cursor, focus$1 = true) {
2031
+ this.screen?.setChrome(rows, cursor, focus$1);
1810
2032
  }
1811
2033
  /**
1812
2034
  * Keep one collapsible block: summary now, full form behind the toggle.
@@ -1815,10 +2037,13 @@ var TerminalConsole = class {
1815
2037
  * with, and scripts want the digest.
1816
2038
  * @param summary - the collapsed lines.
1817
2039
  * @param full - the expanded lines.
2040
+ * @param rule - a styled left rule for the whole block, `''` for none.
2041
+ * @param label - what the block is, for the readout naming what the pointer
2042
+ * is over.
1818
2043
  */
1819
- appendFold(summary, full) {
2044
+ appendFold(summary, full, rule = "", label = "") {
1820
2045
  if (this.screen !== void 0) {
1821
- this.screen.appendFold(summary, full);
2046
+ this.screen.appendFold(summary, full, rule, label);
1822
2047
  return;
1823
2048
  }
1824
2049
  for (const line of summary) this.output.write(`${line}\n`);
@@ -1844,6 +2069,16 @@ var TerminalConsole = class {
1844
2069
  this.screen?.mouseDrag(row, column);
1845
2070
  }
1846
2071
  /**
2072
+ * Note where the pointer is resting, nothing held down.
2073
+ * @param row - terminal row, 1-based.
2074
+ * @param column - terminal column, 1-based.
2075
+ * @returns the block now under the pointer when it changed, undefined
2076
+ * otherwise.
2077
+ */
2078
+ mouseMove(row, column) {
2079
+ return this.screen?.mouseMove(row, column);
2080
+ }
2081
+ /**
1847
2082
  * Finish the mouse selection.
1848
2083
  * @returns the selected text, or undefined for a bare click.
1849
2084
  */
@@ -1890,9 +2125,11 @@ var TerminalConsole = class {
1890
2125
  * summary would subtract from it.
1891
2126
  * @param count - how many trailing lines the block owns.
1892
2127
  * @param summary - the collapsed lines, already styled.
2128
+ * @param label - what the block is, for the readout naming what the pointer
2129
+ * is over.
1893
2130
  */
1894
- foldRecent(count, summary) {
1895
- this.screen?.foldBack(count, summary);
2131
+ foldRecent(count, summary, label = "") {
2132
+ this.screen?.foldBack(count, summary, label);
1896
2133
  }
1897
2134
  toggleFolds() {
1898
2135
  return this.screen?.toggleFolds() ?? false;
@@ -2617,10 +2854,131 @@ var Selector = class {
2617
2854
  }
2618
2855
  };
2619
2856
 
2857
+ //#endregion
2858
+ //#region src/todos.ts
2859
+ /**
2860
+ * Mark for one lifecycle state, styled by what the state means.
2861
+ *
2862
+ * The three marks are codsh's own (`✔`/`▶`/`○`), not the reference agent's
2863
+ * squares: the transcript has used them since todos first rendered, and one
2864
+ * surface speaking two alphabets for the same list is worse than differing from
2865
+ * the reference on a glyph.
2866
+ * @param status - the item's lifecycle state.
2867
+ * @param theme - styling for the mark.
2868
+ * @returns the styled mark.
2869
+ */
2870
+ function mark(status, theme) {
2871
+ if (status === "completed") return theme.success("✔");
2872
+ if (status === "in_progress") return theme.pending("▶");
2873
+ return theme.dim("○");
2874
+ }
2875
+ /**
2876
+ * Count each lifecycle state once, so callers never fold the list twice.
2877
+ * @param todos - the list to count.
2878
+ * @returns done, active, and open counts alongside the total.
2879
+ */
2880
+ function tally(todos) {
2881
+ let done = 0;
2882
+ let active = 0;
2883
+ for (const todo of todos) if (todo.status === "completed") done += 1;
2884
+ else if (todo.status === "in_progress") active += 1;
2885
+ return {
2886
+ done,
2887
+ active,
2888
+ open: todos.length - done - active,
2889
+ total: todos.length
2890
+ };
2891
+ }
2892
+ /**
2893
+ * The header both the card and the expanded list carry.
2894
+ *
2895
+ * Progress leads because it is the figure a glance wants; the state breakdown
2896
+ * follows, and a state with nothing in it is dropped rather than shown as zero —
2897
+ * the same rule the status line follows.
2898
+ * @param todos - the list to summarize.
2899
+ * @param theme - styling for the segments.
2900
+ * @param hint - a trailing note, e.g. the key that collapses the list.
2901
+ * @returns the header line.
2902
+ */
2903
+ function header(todos, theme, hint) {
2904
+ const { done, active, open, total } = tally(todos);
2905
+ const segments = [
2906
+ theme.dim(`${done}/${total}`),
2907
+ ...active === 0 ? [] : [theme.dim(`${active} in progress`)],
2908
+ ...open === 0 ? [] : [theme.dim(`${open} open`)],
2909
+ ...hint === void 0 ? [] : [theme.dim(hint)]
2910
+ ];
2911
+ return `${theme.tool("todos")} ${segments.join(theme.dim(" · "))}`;
2912
+ }
2913
+ /**
2914
+ * The item a person watching the run cares about: what is being worked now,
2915
+ * or, with nothing active, what comes next.
2916
+ * @param todos - the list to look through.
2917
+ * @returns the item, or undefined when every item is finished.
2918
+ */
2919
+ function focus(todos) {
2920
+ return todos.find((todo) => todo.status === "in_progress") ?? todos.find((todo) => todo.status === "pending");
2921
+ }
2922
+ /**
2923
+ * Render the pinned row: one line naming the work in flight and the progress
2924
+ * around it.
2925
+ *
2926
+ * This row is the whole point of reading from a projection rather than from the
2927
+ * write event: the card that announced the list scrolls away, the row does not,
2928
+ * so the list stays answerable at a glance for the rest of the session.
2929
+ * @param todos - the current list.
2930
+ * @param theme - styling for the segments.
2931
+ * @param columns - display columns available; a longer row is cut, never wrapped.
2932
+ * @param hint - a trailing note, e.g. the key that expands the list.
2933
+ * @returns the row, or undefined when there is no list to report.
2934
+ */
2935
+ function todoRow(todos, theme, columns, hint) {
2936
+ if (todos.length === 0) return void 0;
2937
+ const { done, total } = tally(todos);
2938
+ const next = focus(todos);
2939
+ const body = next === void 0 ? `${theme.success("✔")} ${theme.dim("all done")}` : `${mark(next.status, theme)} ${next.status === "in_progress" ? next.content : `next: ${next.content}`}`;
2940
+ return truncate([
2941
+ theme.tool("todos"),
2942
+ theme.dim(`${done}/${total}`),
2943
+ body,
2944
+ ...hint === void 0 ? [] : [theme.dim(hint)]
2945
+ ].join(theme.dim(" · ")), columns);
2946
+ }
2947
+ /**
2948
+ * Render the whole list: the header, then every item under it.
2949
+ *
2950
+ * A surface that cannot scroll passes `limit`, and the items past it are
2951
+ * counted rather than dropped in silence — a list that looks complete but is
2952
+ * not is worse than an honest tail.
2953
+ * @param todos - the current list.
2954
+ * @param theme - styling for the marks and text.
2955
+ * @param columns - display columns available; longer lines are cut.
2956
+ * @param options - header note and item cap.
2957
+ * @returns the lines, empty when there is no list.
2958
+ */
2959
+ function todoReport(todos, theme, columns, options = {}) {
2960
+ if (todos.length === 0) return [];
2961
+ const { hint, limit } = options;
2962
+ const shown = limit === void 0 ? todos : todos.slice(0, Math.max(0, limit));
2963
+ const hidden = todos.length - shown.length;
2964
+ return [
2965
+ truncate(header(todos, theme, hint), columns),
2966
+ ...shown.map((todo) => truncate(` ${mark(todo.status, theme)} ${todo.status === "completed" ? theme.dim(todo.content) : todo.content}`, columns)),
2967
+ ...hidden === 0 ? [] : [theme.dim(truncate(` … +${hidden} more`, columns))]
2968
+ ];
2969
+ }
2970
+
2620
2971
  //#endregion
2621
2972
  //#region src/prompt.ts
2622
2973
  /** How long a flash notice holds the hint row. */
2623
2974
  const FLASH_MS = 1500;
2975
+ /**
2976
+ * Most todo items the expanded list may occupy.
2977
+ *
2978
+ * The chrome never scrolls, so an unbounded list would push the box off a short
2979
+ * terminal; the items past the cap are counted, never silently dropped.
2980
+ */
2981
+ const TODO_ROWS = 10;
2624
2982
  /** Drives the input box and answers reads and selections. */
2625
2983
  var Prompt = class {
2626
2984
  editor;
@@ -2637,9 +2995,18 @@ var Prompt = class {
2637
2995
  hint;
2638
2996
  /** A short-lived notice that borrows the hint row, e.g. the copy toast. */
2639
2997
  flash;
2998
+ /** What the pointer is resting on, borrowing the hint row while it rests. */
2999
+ hover;
2640
3000
  flashTimer;
2641
3001
  /** The always-current session facts shown as the region's last row. */
2642
3002
  status;
3003
+ /**
3004
+ * The agent's current todo list, kept in the chrome rather than only in the
3005
+ * transcript: the card that announced it scrolls away, this does not.
3006
+ */
3007
+ todos = [];
3008
+ /** Whether the todo readout shows every item or only the one in flight. */
3009
+ todosExpanded = false;
2643
3010
  /** The assistant line still arriving, shown above the box. */
2644
3011
  streaming;
2645
3012
  /** Frame styling for the current mode, e.g. plan mode's accent. */
@@ -2740,6 +3107,18 @@ var Prompt = class {
2740
3107
  this.render();
2741
3108
  }
2742
3109
  /**
3110
+ * Set the todo readout to the list the session now holds.
3111
+ *
3112
+ * Compared by content, not identity: this is pushed on every session event,
3113
+ * and a repaint per event would flicker the chrome for nothing.
3114
+ * @param todos - the current list, empty to drop the readout.
3115
+ */
3116
+ setTodos(todos) {
3117
+ if (todos.length === this.todos.length && todos.every((todo, at) => todo.content === this.todos[at]?.content && todo.status === this.todos[at]?.status)) return;
3118
+ this.todos = todos;
3119
+ this.render();
3120
+ }
3121
+ /**
2743
3122
  * Set the frame accent, which is how a mode shows on the box itself.
2744
3123
  * @param accent - the styling, or undefined for the default frame.
2745
3124
  */
@@ -2758,9 +3137,10 @@ var Prompt = class {
2758
3137
  /**
2759
3138
  * Write one finished transcript line above the region.
2760
3139
  * @param line - the line to keep.
3140
+ * @param rule - a styled left rule marking which block the line belongs to.
2761
3141
  */
2762
- write(line) {
2763
- this.console.write(line);
3142
+ write(line, rule = "") {
3143
+ this.console.write(line, rule);
2764
3144
  }
2765
3145
  /**
2766
3146
  * Wait for the next submitted text.
@@ -2851,6 +3231,11 @@ var Prompt = class {
2851
3231
  this.handlers.expandOutput?.();
2852
3232
  return;
2853
3233
  }
3234
+ if (key.kind === "toggle-todos") {
3235
+ this.todosExpanded = !this.todosExpanded;
3236
+ this.render();
3237
+ return;
3238
+ }
2854
3239
  if (key.kind === "page") {
2855
3240
  this.console.scrollPage(key.direction);
2856
3241
  this.render();
@@ -2885,6 +3270,14 @@ var Prompt = class {
2885
3270
  this.console.mouseDrag(key.row, key.column);
2886
3271
  return;
2887
3272
  }
3273
+ if (key.kind === "mouse-move") {
3274
+ const block = this.console.mouseMove(key.row, key.column);
3275
+ const readout = block === void 0 ? void 0 : this.theme.dim(` ${block.label} · ${block.lines} lines · click to ${block.expanded ? "fold" : "expand"}`);
3276
+ if (readout === this.hover) return;
3277
+ this.hover = readout;
3278
+ this.render();
3279
+ return;
3280
+ }
2888
3281
  if (key.kind === "mouse-up") {
2889
3282
  const text = this.console.mouseUp();
2890
3283
  if (text !== void 0 && this.console.copyText(text)) {
@@ -2933,6 +3326,22 @@ var Prompt = class {
2933
3326
  }
2934
3327
  this.render();
2935
3328
  }
3329
+ /**
3330
+ * The todo readout's rows: the one in flight, or the whole list once opened.
3331
+ * @param columns - display columns available.
3332
+ * @returns the rows, empty when no list is live.
3333
+ */
3334
+ todoRows(columns) {
3335
+ if (this.todos.length === 0) return [];
3336
+ if (!this.todosExpanded) {
3337
+ const row = todoRow(this.todos, this.theme, columns, "Ctrl+T opens the list");
3338
+ return row === void 0 ? [] : [row];
3339
+ }
3340
+ return todoReport(this.todos, this.theme, columns, {
3341
+ hint: "Ctrl+T closes",
3342
+ limit: TODO_ROWS
3343
+ });
3344
+ }
2936
3345
  /** Recompose and redraw the bottom region. */
2937
3346
  render() {
2938
3347
  if (!this.console.readsKeys) return;
@@ -2960,20 +3369,21 @@ var Prompt = class {
2960
3369
  const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
2961
3370
  rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
2962
3371
  }
3372
+ rows.push(...this.todoRows(columns));
2963
3373
  this.console.setScrollNotice(this.console.scrolledBy > 0 ? this.theme.dim(truncate(` ↑ ${this.console.scrolledBy} rows above · PgDn returns to the latest`, columns)) : "");
2964
- const notice = this.flash ?? this.hint;
3374
+ const notice = this.flash ?? this.hover ?? this.hint;
2965
3375
  if (notice !== void 0) rows.push(notice);
2966
3376
  if (this.status !== void 0) rows.push(this.status);
2967
3377
  if (rows.length === 0) {
2968
3378
  this.console.clearRegion();
2969
3379
  return;
2970
3380
  }
2971
- const focus = this.select_ === void 0 && (this.engaged || this.reading);
2972
- if (!focus) cursor = {
3381
+ const focus$1 = this.select_ === void 0 && (this.engaged || this.reading);
3382
+ if (!focus$1) cursor = {
2973
3383
  row: rows.length - 1,
2974
3384
  column: 0
2975
3385
  };
2976
- this.console.setRegion(rows, cursor, focus);
3386
+ this.console.setRegion(rows, cursor, focus$1);
2977
3387
  }
2978
3388
  };
2979
3389
 
@@ -3377,9 +3787,9 @@ function questionLines(question, theme) {
3377
3787
  if (question.detail !== void 0) lines.push(...question.detail.split("\n"));
3378
3788
  lines.push("", theme.bold(question.question));
3379
3789
  (question.options ?? []).forEach((option, index) => {
3380
- const mark = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
3790
+ const mark$1 = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
3381
3791
  const description = option.description === void 0 ? "" : theme.dim(` — ${option.description}`);
3382
- lines.push(` ${mark}. ${option.label}${description}`);
3792
+ lines.push(` ${mark$1}. ${option.label}${description}`);
3383
3793
  });
3384
3794
  lines.push(theme.dim(" (a number, or type your own answer)"));
3385
3795
  return lines;
@@ -3471,6 +3881,35 @@ var TerminalQuestions = class {
3471
3881
  }
3472
3882
  };
3473
3883
 
3884
+ //#endregion
3885
+ //#region src/ship.ts
3886
+ /**
3887
+ * The `/ship` prompt: a canned workflow that takes a one-sentence requirement
3888
+ * from idea to shipped, verified code — a research-grounded interview, a
3889
+ * confirmed spec (gate 1), an approved plan (gate 2), then autonomous landing
3890
+ * until the spec's acceptance criteria pass.
3891
+ */
3892
+ /** The `/ship` prompt body; `$ARGUMENTS` is the typed one-sentence requirement. */
3893
+ const SHIP_PROMPT = `Run the /ship workflow: take the one-sentence requirement below from idea to shipped, verified code in this repository. The requirement, exactly as typed:
3894
+
3895
+ <idea>
3896
+ $ARGUMENTS
3897
+ </idea>
3898
+
3899
+ If the idea between the <idea> tags is empty, that is not an error: before anything else, ask for the one-sentence requirement with ask_user_question, and use the answer as the idea for the rest of this workflow.
3900
+
3901
+ Phase 1 — grounded interview. Research before you ask: read the repository layout, the docs, and the code paths the idea touches, so every question is informed by what actually exists. Then interrogate the idea with ask_user_question, one focused question per call, never a batch. Cover, as far as they are genuinely open: who this is for and what success looks like, scope and explicit non-goals, constraints (compatibility, performance, security, dependencies), edge cases and failure behavior, and how the result should be verified. Prefer concrete options grounded in what you found over open-ended prompts. Do not ask what inspection can answer — where code lives or how current behavior works is yours to find out. Stop when answers stop changing the design; do not pad the interview to look thorough.
3902
+
3903
+ Phase 2 — the spec (gate 1). Write the agreed design to a spec file inside the repository. Follow the repo's existing convention for design documents if one exists (a specs, rfcs, or ADR directory); otherwise create docs/specs/<kebab-case-slug>.md. The spec must stand alone for a reader without this conversation: the one-sentence requirement, background, each interview decision with its reason, scope and non-goals, constraints, edge cases, and a numbered list of acceptance criteria where every criterion is objectively checkable — named tests, commands with expected output, observable behavior. Present the spec file path and a compact summary through ask_user_question and get an explicit yes. If the answer amends or rejects it, update the file and ask again. Do not proceed on silence or a vague reply.
3904
+
3905
+ Phase 3 — the plan (gate 2). Only after the spec is confirmed, produce an implementation plan: ordered milestones with the files each touches, the tests each milestone adds or changes, which acceptance criterion each milestone satisfies, and the commands that prove the whole thing (build, typecheck, test). Present the plan through ask_user_question and get an explicit yes; fold rejections back in and present again. Write no implementation code before this gate passes, and do not use todo_write before it either — it tracks landing, not the interview.
3906
+
3907
+ Phase 4 — landing. After gate 2, work autonomously; return to the user only for a genuine blocker that contradicts the spec, never for routine decisions. Choose the mechanism by the approved plan's size. If it has at most three milestones and you expect the whole change to fit comfortably in this session's context, implement in-session: track the milestones with todo_write, and for each one implement, run the tests, and fix until green before moving on. If it is larger — four or more substantially independent milestones, or work you expect to exceed what one session can hold — the user running /ship is their explicit request for a fresh-agent Ralph loop: call the ralph tool once, with an objective that names the spec file path as the durable source of truth, instructs each round to read the spec and plan from disk, pick up the next unfinished milestone, implement and test it, and record progress in the workspace, and defines completion as every acceptance criterion in the spec passing.
3908
+
3909
+ Phase 5 — done means verified. The workflow ends only when every acceptance criterion passes with you actually running the named tests and commands and reading their real output. Never report a result you did not run, and never weaken a criterion to make it pass; if one cannot be met, say so plainly and why. When a decision changes mid-flight, update the spec file first so the file on disk stays the truth. Close with a short honest report: what shipped, what was verified and how, and anything left open.
3910
+
3911
+ If the session is in plan mode, the plan-mode rules win: nothing here authorizes writes while it is active. Tell the user this workflow needs to write the spec file and ask them to leave plan mode before continuing past the interview.`;
3912
+
3474
3913
  //#endregion
3475
3914
  //#region src/spinner.ts
3476
3915
  /** Braille frames, one cell wide each, so the line never changes width. */
@@ -3498,10 +3937,10 @@ const TICK_MS = 90;
3498
3937
  */
3499
3938
  function spinnerText(frame, elapsedMs, label, theme) {
3500
3939
  const seconds = (elapsedMs / 1e3).toFixed(elapsedMs < 1e4 ? 1 : 0);
3501
- const mark = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
3940
+ const mark$1 = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
3502
3941
  const extra = label.detail?.();
3503
3942
  const detail = extra === void 0 || extra === "" ? "" : `${extra} · `;
3504
- return `${theme.pending(mark)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
3943
+ return `${theme.pending(mark$1)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
3505
3944
  }
3506
3945
  /** Drives the working indicator for as long as the agent is busy. */
3507
3946
  var Spinner = class {
@@ -3637,7 +4076,7 @@ const MAX_DIFF_LINES = 24;
3637
4076
  * Result body lines printed for one completed call before the card collapses.
3638
4077
  *
3639
4078
  * Small on purpose: a long output in the transcript is skimmed, not read, and
3640
- * the collapsed remainder is one Ctrl+O away in full.
4079
+ * the collapsed remainder is one click — or one Ctrl+O away in full.
3641
4080
  */
3642
4081
  const MAX_RESULT_LINES = 5;
3643
4082
  /**
@@ -3649,6 +4088,85 @@ function visibleText(content) {
3649
4088
  return content.filter((block) => block.type === "text").map((block) => block.text).join("");
3650
4089
  }
3651
4090
  /**
4091
+ * The left rules the transcript draws down a block's edge, one per kind.
4092
+ *
4093
+ * A rule is how a segment shows where it starts and ends without a frame or a
4094
+ * background fill: the references converge on a left border (Claude Code's
4095
+ * `borderLeft`, opencode's `border: ["left"]`), and a border costs one column
4096
+ * where a fill costs the terminal's own background — which is the theme's to
4097
+ * decide, not this surface's (ADR-0001).
4098
+ *
4099
+ * The person's own words get the heavy mark; a tool block gets the light one,
4100
+ * in the error colour when the call failed. What a person actually reads — an
4101
+ * answer, a thinking summary — stays flush, so the rules mark the machinery
4102
+ * around it rather than everything equally.
4103
+ * @param theme - styling for the marks.
4104
+ * @returns the rule per block kind.
4105
+ */
4106
+ function blockRules(theme) {
4107
+ return {
4108
+ user: theme.user("┃ "),
4109
+ tool: theme.tool("│ "),
4110
+ error: theme.error("│ ")
4111
+ };
4112
+ }
4113
+ /**
4114
+ * What the two nameless block kinds are called when a readout names them.
4115
+ *
4116
+ * A tool block answers with its card's own title, which is already on screen;
4117
+ * thinking and an answer have no title of their own, so these are theirs.
4118
+ */
4119
+ const FOLD_LABELS = {
4120
+ thinking: "thinking",
4121
+ answer: "answer"
4122
+ };
4123
+ /** A finished answer longer than this many rendered lines becomes a fold. */
4124
+ const ANSWER_FOLD_LINES = 24;
4125
+ /** How many of its head lines a collapsed answer keeps visible. */
4126
+ const ANSWER_HEAD_LINES = 8;
4127
+ /**
4128
+ * The collapsed form of a finished answer, when it is long enough to fold.
4129
+ *
4130
+ * Shared by the live turn and by replay: a resumed session must offer the same
4131
+ * summary the turn itself left behind, or history would read as a different
4132
+ * conversation from the one that happened.
4133
+ * @param lines - the answer's rendered lines, without its trailing blank.
4134
+ * @param theme - styling for the count line.
4135
+ * @returns the collapsed lines, or undefined when the answer is short enough
4136
+ * to stand as it is.
4137
+ */
4138
+ function answerSummary(lines, theme) {
4139
+ if (lines.length <= ANSWER_FOLD_LINES) return void 0;
4140
+ return [
4141
+ ...lines.slice(0, ANSWER_HEAD_LINES),
4142
+ theme.dim(` … +${lines.length - ANSWER_HEAD_LINES} lines (click or Ctrl+O expands)`),
4143
+ ""
4144
+ ];
4145
+ }
4146
+ /**
4147
+ * The two forms of a thinking block: one dim line, and the deliberation behind
4148
+ * it.
4149
+ *
4150
+ * Pages of reasoning would bury the conversation, so the transcript keeps the
4151
+ * summary and hands the rest to Ctrl+O — live and on replay alike.
4152
+ * @param lines - the rendered thinking lines, already styled.
4153
+ * @param theme - styling for the header.
4154
+ * @param seconds - how long the thinking took, when the surface timed it; a
4155
+ * replayed log carries no clock, so the header simply says it thought.
4156
+ * @returns the collapsed and expanded forms.
4157
+ */
4158
+ function thinkingFold(lines, theme, seconds) {
4159
+ const head = seconds === void 0 ? "✻ thought" : `✻ thought for ${seconds.toFixed(1)}s`;
4160
+ return {
4161
+ summary: [theme.dim(`${head} · +${lines.length} lines (click or Ctrl+O expands)`), ""],
4162
+ full: [
4163
+ theme.dim(head),
4164
+ ...lines,
4165
+ ""
4166
+ ]
4167
+ };
4168
+ }
4169
+ /**
3652
4170
  * Render one file's change as unified-diff body lines.
3653
4171
  *
3654
4172
  * A {@link FileDiff} carries one hunk's old and new blocks including their
@@ -3684,13 +4202,17 @@ function diffBody(diff, theme) {
3684
4202
  */
3685
4203
  function cap(lines, limit, theme) {
3686
4204
  if (lines.length <= limit) return lines;
3687
- return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (Ctrl+O expands)`)];
4205
+ return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (click or Ctrl+O expands)`)];
3688
4206
  }
3689
4207
  /** Renders one session's appended events as terminal lines. */
3690
4208
  var Transcript = class {
3691
4209
  calls = /* @__PURE__ */ new Map();
3692
4210
  /** The full form of the event just rendered, when its body was collapsed. */
3693
4211
  fold;
4212
+ /** What the block {@link render} just returned is, for a hover readout. */
4213
+ label = "";
4214
+ /** The left rule the block {@link render} just returned belongs to. */
4215
+ rule = "";
3694
4216
  constructor(options, presenters) {
3695
4217
  this.options = options;
3696
4218
  this.presenters = presenters;
@@ -3733,9 +4255,12 @@ var Transcript = class {
3733
4255
  */
3734
4256
  render(event) {
3735
4257
  const { theme } = this.options;
4258
+ const rules = blockRules(theme);
4259
+ this.rule = "";
3736
4260
  switch (event.type) {
3737
4261
  case "user/message": {
3738
4262
  if (event.data.source.kind !== "user") return [];
4263
+ this.rule = rules.user;
3739
4264
  const [first = "", ...rest] = visibleText(event.data.content).split("\n");
3740
4265
  return [
3741
4266
  `${theme.user("›")} ${first}`,
@@ -3747,24 +4272,22 @@ var Transcript = class {
3747
4272
  const text = visibleText(event.data.message.content);
3748
4273
  return text === "" ? [] : [...renderMarkdown(text, theme), ""];
3749
4274
  }
3750
- case "tool/call": return this.renderCall(event.data.callId, event.data.name, event.data.arguments);
3751
- case "tool/result": return this.renderResult(event.data);
4275
+ case "tool/call":
4276
+ this.rule = rules.tool;
4277
+ return this.renderCall(event.data.callId, event.data.name, event.data.arguments);
4278
+ case "tool/result":
4279
+ this.rule = rules.tool;
4280
+ return this.renderResult(event.data);
3752
4281
  case "todo/write": {
3753
- const { todos } = event.data;
3754
- if (todos.length === 0) return [];
3755
- const done = todos.filter((todo) => todo.status === "completed").length;
3756
- return [
3757
- `${theme.tool("todos")} ${theme.dim(`${done}/${todos.length}`)}`,
3758
- ...todos.map((todo) => {
3759
- if (todo.status === "completed") return theme.dim(` ✔ ${todo.content}`);
3760
- if (todo.status === "in_progress") return ` ${theme.pending("▶")} ${todo.content}`;
3761
- return theme.dim(` ○ ${todo.content}`);
3762
- }),
3763
- ""
3764
- ];
4282
+ this.rule = rules.tool;
4283
+ const lines = todoReport(event.data.todos, theme, this.options.columns);
4284
+ return lines.length === 0 ? [] : [...lines, ""];
3765
4285
  }
3766
4286
  case "plan/mode": return event.data.active ? [theme.pending("▲ plan mode — exploring only; no files will change until you approve a plan"), ""] : [theme.dim("▼ plan mode off"), ""];
3767
- case "turn/end": return event.data.reason.kind === "error" ? [theme.error(`✗ ${event.data.reason.error.code}: ${event.data.reason.error.message}`), ""] : [];
4287
+ case "turn/end":
4288
+ if (event.data.reason.kind !== "error") return [];
4289
+ this.rule = rules.error;
4290
+ return [theme.error(`✗ ${event.data.reason.error.code}: ${event.data.reason.error.message}`), ""];
3768
4291
  default: return [];
3769
4292
  }
3770
4293
  }
@@ -3794,11 +4317,11 @@ var Transcript = class {
3794
4317
  };
3795
4318
  if (view === void 0) return record(name$1, [`${theme.pending("●")} ${theme.tool(name$1)}`]);
3796
4319
  if (view.card === "terminal") {
3797
- const header = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
4320
+ const header$1 = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
3798
4321
  const description = view.description === void 0 ? [] : [theme.dim(` ${view.description}`)];
3799
4322
  const command = this.relativizeIn(view.title);
3800
4323
  return record(command, [
3801
- `${theme.pending("●")} ${theme.tool(name$1)}${header}`,
4324
+ `${theme.pending("●")} ${theme.tool(name$1)}${header$1}`,
3802
4325
  ` $ ${truncate(command, columns - 4)}`,
3803
4326
  ...description
3804
4327
  ]);
@@ -3826,15 +4349,19 @@ var Transcript = class {
3826
4349
  const pending = this.calls.get(callId);
3827
4350
  this.calls.delete(callId);
3828
4351
  const failed = error !== void 0 || block.isError === true;
4352
+ if (failed) this.rule = blockRules(theme).error;
3829
4353
  const marker = failed ? theme.error("✗") : theme.success("●");
3830
4354
  if (pending === void 0) {
3831
4355
  const raw = this.resultText(block.content).split("\n");
3832
4356
  const head$1 = `${marker} ${theme.dim("(result)")}`;
3833
- if (raw.length > MAX_RESULT_LINES) this.fold = [
3834
- head$1,
3835
- ...raw,
3836
- ""
3837
- ];
4357
+ if (raw.length > MAX_RESULT_LINES) {
4358
+ this.fold = [
4359
+ head$1,
4360
+ ...raw,
4361
+ ""
4362
+ ];
4363
+ this.label = "tool result";
4364
+ }
3838
4365
  return [
3839
4366
  head$1,
3840
4367
  ...cap(raw, MAX_RESULT_LINES, theme),
@@ -3845,11 +4372,14 @@ var Transcript = class {
3845
4372
  const title = view?.title === void 0 ? pending.title : this.relativizeIn(view.title);
3846
4373
  const { suffix, body, full } = this.outcome(view, block);
3847
4374
  const head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [];
3848
- if (full !== void 0) this.fold = [
3849
- ...head,
3850
- ...full,
3851
- ""
3852
- ];
4375
+ if (full !== void 0) {
4376
+ this.fold = [
4377
+ ...head,
4378
+ ...full,
4379
+ ""
4380
+ ];
4381
+ this.label = title;
4382
+ }
3853
4383
  return [
3854
4384
  ...head,
3855
4385
  ...body,
@@ -3870,6 +4400,31 @@ var Transcript = class {
3870
4400
  return fold;
3871
4401
  }
3872
4402
  /**
4403
+ * What the block {@link takeFold} just described is called.
4404
+ *
4405
+ * The card's title, so the readout that names what the pointer rests on says
4406
+ * the same thing the block's own head line says.
4407
+ * @returns the label, or `''` when the block has no name of its own.
4408
+ */
4409
+ takeLabel() {
4410
+ const label = this.label;
4411
+ this.label = "";
4412
+ return label;
4413
+ }
4414
+ /**
4415
+ * The left rule for the block {@link render} just returned, `''` when the
4416
+ * block stands flush.
4417
+ *
4418
+ * Paired with the lines rather than baked into them: the rule repeats on
4419
+ * every row the block wraps to, which only the buffer that wraps them knows.
4420
+ * @returns the styled rule, or `''`.
4421
+ */
4422
+ takeRule() {
4423
+ const rule = this.rule;
4424
+ this.rule = "";
4425
+ return rule;
4426
+ }
4427
+ /**
3873
4428
  * Render one completed call's status suffix and body from its declared view.
3874
4429
  * @param view - the result view, absent when no presenter answered.
3875
4430
  * @param block - the model-facing result block, used by the generic fallback.
@@ -4060,14 +4615,46 @@ function statusFacts(ctx, agent, cwd, selection, presetId, branch) {
4060
4615
  };
4061
4616
  }
4062
4617
  /**
4618
+ * The agent's todo list as the chrome's readout wants it.
4619
+ *
4620
+ * Read from the projection rather than remembered from the write event, so a
4621
+ * resumed session shows the list it left off with and a `/clear` shows none.
4622
+ * @param ctx - plugin context carrying the projection service.
4623
+ * @param agent - the live agent.
4624
+ * @returns the current list, empty before any write.
4625
+ */
4626
+ function todoList(ctx, agent) {
4627
+ return ctx.get("sessionProjections")?.snapshot(agent.session).values.todos ?? [];
4628
+ }
4629
+ /**
4063
4630
  * Render every event a resumed session already holds, so the person sees the
4064
4631
  * conversation they are continuing.
4065
4632
  * @param session - the reconstructed session.
4066
4633
  * @param transcript - the renderer, which also learns the pending call table.
4067
4634
  * @param io - the terminal to write to.
4068
4635
  */
4069
- function replay(session, transcript, io) {
4070
- for (const event of session.events) for (const line of transcript.render(event)) io.console.write(line);
4636
+ function replay(session, transcript, io, theme) {
4637
+ for (const event of session.events) {
4638
+ if (event.type === "assistant/message") {
4639
+ const thought = event.data.message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
4640
+ if (thought !== "") {
4641
+ const { summary: summary$1, full: full$1 } = thinkingFold(thought.split("\n").map((line) => theme.dim(` ${line}`)), theme);
4642
+ io.console.appendFold(summary$1, full$1, "", FOLD_LABELS.thinking);
4643
+ }
4644
+ }
4645
+ const lines = transcript.render(event);
4646
+ const full = transcript.takeFold();
4647
+ const rule = transcript.takeRule();
4648
+ const label = transcript.takeLabel();
4649
+ if (full !== void 0) {
4650
+ io.console.appendFold(lines, full, rule, label);
4651
+ continue;
4652
+ }
4653
+ for (const line of lines) io.console.write(line, rule);
4654
+ if (event.type !== "assistant/message") continue;
4655
+ const summary = answerSummary(lines.at(-1) === "" ? lines.slice(0, -1) : lines, theme);
4656
+ if (summary !== void 0) io.console.foldRecent(lines.length, summary, FOLD_LABELS.answer);
4657
+ }
4071
4658
  }
4072
4659
  /**
4073
4660
  * Run one conversation turn and wait for the agent to go idle.
@@ -4114,7 +4701,7 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
4114
4701
  io.console.write("");
4115
4702
  return;
4116
4703
  }
4117
- const execution = await commands.execute(agent, line, signal);
4704
+ const execution = await commands.execute(agent, line, [], signal);
4118
4705
  if (execution === void 0) {
4119
4706
  io.console.write(theme.error(` unknown command: ${line}`));
4120
4707
  return;
@@ -4128,10 +4715,6 @@ async function runCommand(ctx, agent, line, io, theme, signal) {
4128
4715
  const RECALL_WINDOW_MS = 1500;
4129
4716
  /** Turns longer than this ring the bell on completion, when the bell is on. */
4130
4717
  const BELL_TURN_MS = 1e4;
4131
- /** A finished answer longer than this many rendered lines becomes a fold. */
4132
- const ANSWER_FOLD_LINES = 24;
4133
- /** How many of its head lines a collapsed answer keeps visible. */
4134
- const ANSWER_HEAD_LINES = 8;
4135
4718
  /**
4136
4719
  * Run one subprocess and capture everything it printed.
4137
4720
  * @param file - the executable, or a shell when `shell` is given.
@@ -4308,7 +4891,7 @@ async function run(ctx, config, io) {
4308
4891
  const facts = (branch$1) => statusFacts(ctx, live.agent, cwd, selection, presetId, branch$1);
4309
4892
  io.console.setTitle(`dsh code — ${basename(cwd)}`);
4310
4893
  let branch = await gitBranch(cwd);
4311
- if (config.resume !== "") replay(live.agent.session, live.transcript, io);
4894
+ if (config.resume !== "") replay(live.agent.session, live.transcript, io, theme);
4312
4895
  for (const line of bannerLines({
4313
4896
  model,
4314
4897
  preset: presetId,
@@ -4365,6 +4948,7 @@ async function run(ctx, config, io) {
4365
4948
  "quit",
4366
4949
  "help",
4367
4950
  "init",
4951
+ "ship",
4368
4952
  "status",
4369
4953
  "model",
4370
4954
  "clear",
@@ -4379,6 +4963,10 @@ async function run(ctx, config, io) {
4379
4963
  name: "init",
4380
4964
  description: "analyze the repo and draft AGENTS.md"
4381
4965
  },
4966
+ {
4967
+ name: "ship",
4968
+ description: "take a one-sentence idea to shipped code"
4969
+ },
4382
4970
  ...custom.commands.map((command) => ({
4383
4971
  name: command.name,
4384
4972
  description: command.description
@@ -4444,7 +5032,7 @@ async function run(ctx, config, io) {
4444
5032
  },
4445
5033
  shiftTab: () => {
4446
5034
  const line = planModeFrom(live.agent.session.events) ? "/plan off" : "/plan";
4447
- commands?.execute(live.agent, line, new AbortController().signal);
5035
+ commands?.execute(live.agent, line, [], new AbortController().signal);
4448
5036
  },
4449
5037
  expandOutput: () => {
4450
5038
  if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
@@ -4490,6 +5078,20 @@ async function run(ctx, config, io) {
4490
5078
  text: statusReport(facts(branch), live.agent.session.id)
4491
5079
  })
4492
5080
  }));
5081
+ disposers.push(commands.register({
5082
+ name: "todos",
5083
+ description: "print the agent's todo list as it now stands",
5084
+ handler: () => {
5085
+ const lines = todoReport(todoList(ctx, live.agent), theme, io.console.columns);
5086
+ return lines.length === 0 ? {
5087
+ kind: "success",
5088
+ text: "no todos yet"
5089
+ } : {
5090
+ kind: "success",
5091
+ text: lines.join("\n")
5092
+ };
5093
+ }
5094
+ }));
4493
5095
  disposers.push(commands.register({
4494
5096
  name: "clear",
4495
5097
  description: "start a fresh session in place",
@@ -4626,14 +5228,14 @@ async function run(ctx, config, io) {
4626
5228
  if (typed === "") {
4627
5229
  await refreshModelCatalog();
4628
5230
  const current = selection.current;
4629
- const header = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
5231
+ const header$1 = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
4630
5232
  if (modelCatalog.length === 0) return {
4631
5233
  kind: "success",
4632
- text: header
5234
+ text: header$1
4633
5235
  };
4634
5236
  if (!io.console.readsKeys) return {
4635
5237
  kind: "success",
4636
- text: `${header}\n${modelCatalog.map((entry) => {
5238
+ text: `${header$1}\n${modelCatalog.map((entry) => {
4637
5239
  return `${entry.provider === current?.provider && entry.id === current.model ? "❯" : " "} ${entry.provider}/${entry.id} ${entry.name}`;
4638
5240
  }).join("\n")}`
4639
5241
  };
@@ -4683,11 +5285,8 @@ async function run(ctx, config, io) {
4683
5285
  const tail = stream.flush();
4684
5286
  emit([...tail, ""]);
4685
5287
  answerLines.push(...tail);
4686
- if (answerLines.length > ANSWER_FOLD_LINES) io.console.foldRecent(answerLines.length + 1, [
4687
- ...answerLines.slice(0, ANSWER_HEAD_LINES),
4688
- theme.dim(` … +${answerLines.length - ANSWER_HEAD_LINES} lines (Ctrl+O expands)`),
4689
- ""
4690
- ]);
5288
+ const summary = answerSummary(answerLines, theme);
5289
+ if (summary !== void 0) io.console.foldRecent(answerLines.length + 1, summary, FOLD_LABELS.answer);
4691
5290
  answerLines = [];
4692
5291
  };
4693
5292
  const thinking = new TextStream(theme, () => io.console.contentColumns, true);
@@ -4696,13 +5295,9 @@ async function run(ctx, config, io) {
4696
5295
  const flushThinking = () => {
4697
5296
  thinkingLines.push(...thinking.flush());
4698
5297
  if (thinkingLines.length === 0) return;
4699
- const seconds = ((performance.now() - thinkingStartedAt) / 1e3).toFixed(1);
4700
5298
  prompt.setStreaming(void 0);
4701
- io.console.appendFold([theme.dim(`✻ thought for ${seconds}s · +${thinkingLines.length} lines (Ctrl+O expands)`), ""], [
4702
- theme.dim(`✻ thought for ${seconds}s`),
4703
- ...thinkingLines,
4704
- ""
4705
- ]);
5299
+ const { summary, full } = thinkingFold(thinkingLines, theme, (performance.now() - thinkingStartedAt) / 1e3);
5300
+ io.console.appendFold(summary, full, "", FOLD_LABELS.thinking);
4706
5301
  thinkingLines = [];
4707
5302
  thinkingStartedAt = 0;
4708
5303
  };
@@ -4711,15 +5306,16 @@ async function run(ctx, config, io) {
4711
5306
  * @param lines - finished lines for the transcript.
4712
5307
  * @param live - the in-progress line, or undefined to release the region.
4713
5308
  */
4714
- const emit = (lines, live$1) => {
5309
+ const emit = (lines, live$1, rule = "") => {
4715
5310
  if (lines.length > 0) prompt.setStreaming(void 0);
4716
- for (const line of lines) prompt.write(line);
5311
+ for (const line of lines) prompt.write(line, rule);
4717
5312
  prompt.setStreaming(live$1);
4718
5313
  };
4719
5314
  /** Push the always-current status row; the pipe shape prints it instead. */
4720
5315
  const refreshStatus = () => {
4721
5316
  if (!io.console.readsKeys) return;
4722
5317
  prompt.setStatus(statusLine(facts(branch), theme, io.console.columns - 1));
5318
+ prompt.setTodos(todoList(ctx, live.agent));
4723
5319
  };
4724
5320
  if (planModeFrom(live.agent.session.events)) prompt.setAccent((text) => theme.pending(text));
4725
5321
  refreshStatus();
@@ -4727,6 +5323,7 @@ async function run(ctx, config, io) {
4727
5323
  if (session !== live.agent.session) return;
4728
5324
  if (event.type === "plan/mode") prompt.setAccent(event.data.active ? (text) => theme.pending(text) : void 0);
4729
5325
  refreshStatus();
5326
+ if (event.type === "todo/write") prompt.setTodos(event.data.todos);
4730
5327
  if (config.print && event.type === "user/message") return;
4731
5328
  if (event.type === "assistant/chunk") {
4732
5329
  const { chunk } = event.data;
@@ -4754,12 +5351,14 @@ async function run(ctx, config, io) {
4754
5351
  }
4755
5352
  const lines = live.transcript.render(event);
4756
5353
  const full = live.transcript.takeFold();
5354
+ const rule = live.transcript.takeRule();
5355
+ const label = live.transcript.takeLabel();
4757
5356
  if (full === void 0) {
4758
- emit(lines);
5357
+ emit(lines, void 0, rule);
4759
5358
  return;
4760
5359
  }
4761
5360
  prompt.setStreaming(void 0);
4762
- io.console.appendFold(lines, full);
5361
+ io.console.appendFold(lines, full, rule, label);
4763
5362
  });
4764
5363
  /** Pause the indicator around a decision, and resume it if work continues. */
4765
5364
  const whileDeciding = async (decide) => {
@@ -4815,7 +5414,7 @@ async function run(ctx, config, io) {
4815
5414
  approval.clear();
4816
5415
  turnBaseTokens = 0;
4817
5416
  prompt.setAccent(planModeFrom(next.agent.session.events) ? (text) => theme.pending(text) : void 0);
4818
- if (replayLog) replay(next.agent.session, live.transcript, io);
5417
+ if (replayLog) replay(next.agent.session, live.transcript, io, theme);
4819
5418
  refreshStatus();
4820
5419
  };
4821
5420
  const questions = ctx.get("userQuestions");
@@ -5001,6 +5600,13 @@ async function run(ctx, config, io) {
5001
5600
  });
5002
5601
  continue;
5003
5602
  }
5603
+ if (name$1 === "ship") {
5604
+ await answer(expandTemplate(SHIP_PROMPT, rest.trim()), {
5605
+ kind: "plugin",
5606
+ plugin: "coding-cli"
5607
+ });
5608
+ continue;
5609
+ }
5004
5610
  const canned = customByName.get(name$1);
5005
5611
  if (canned !== void 0) {
5006
5612
  await answer(expandTemplate(canned.template, rest.trim()), {