@sagmans/dsh-tui 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +34 -12
  2. package/lib/agent/host.d.ts +11 -0
  3. package/lib/agent/host.d.ts.map +1 -1
  4. package/lib/agent/host.js +16 -2
  5. package/lib/agent/host.js.map +1 -1
  6. package/lib/agent/undo.d.ts +52 -0
  7. package/lib/agent/undo.d.ts.map +1 -0
  8. package/lib/agent/undo.js +106 -0
  9. package/lib/agent/undo.js.map +1 -0
  10. package/lib/cards.d.ts +57 -2
  11. package/lib/cards.d.ts.map +1 -1
  12. package/lib/cards.js +38 -8
  13. package/lib/cards.js.map +1 -1
  14. package/lib/herdr/reporter.d.ts +9 -4
  15. package/lib/herdr/reporter.d.ts.map +1 -1
  16. package/lib/herdr/reporter.js +5 -9
  17. package/lib/herdr/reporter.js.map +1 -1
  18. package/lib/herdr/state.d.ts +35 -4
  19. package/lib/herdr/state.d.ts.map +1 -1
  20. package/lib/herdr/state.js +37 -4
  21. package/lib/herdr/state.js.map +1 -1
  22. package/lib/index.d.ts.map +1 -1
  23. package/lib/index.js +349 -14
  24. package/lib/index.js.map +1 -1
  25. package/lib/input/actions.d.ts.map +1 -1
  26. package/lib/input/actions.js +4 -0
  27. package/lib/input/actions.js.map +1 -1
  28. package/lib/input/completion.d.ts +10 -0
  29. package/lib/input/completion.d.ts.map +1 -1
  30. package/lib/input/completion.js +15 -0
  31. package/lib/input/completion.js.map +1 -1
  32. package/lib/input/keymap.d.ts.map +1 -1
  33. package/lib/input/keymap.js +2 -0
  34. package/lib/input/keymap.js.map +1 -1
  35. package/lib/input/submission.d.ts +11 -1
  36. package/lib/input/submission.d.ts.map +1 -1
  37. package/lib/input/submission.js +10 -1
  38. package/lib/input/submission.js.map +1 -1
  39. package/lib/terminal/warning-screen.d.ts +8 -1
  40. package/lib/terminal/warning-screen.d.ts.map +1 -1
  41. package/lib/terminal/warning-screen.js +9 -0
  42. package/lib/terminal/warning-screen.js.map +1 -1
  43. package/lib/theme-schema.d.ts +6 -6
  44. package/lib/theme-settings.d.ts +8 -8
  45. package/lib/theme-tokens.d.ts +1 -1
  46. package/lib/theme-tokens.d.ts.map +1 -1
  47. package/lib/theme-tokens.js +27 -1
  48. package/lib/theme-tokens.js.map +1 -1
  49. package/lib/transcript.d.ts +50 -14
  50. package/lib/transcript.d.ts.map +1 -1
  51. package/lib/transcript.js +82 -47
  52. package/lib/transcript.js.map +1 -1
  53. package/lib/ui/copy.d.ts +17 -0
  54. package/lib/ui/copy.d.ts.map +1 -0
  55. package/lib/ui/copy.js +104 -0
  56. package/lib/ui/copy.js.map +1 -0
  57. package/lib/ui/dock.d.ts +21 -1
  58. package/lib/ui/dock.d.ts.map +1 -1
  59. package/lib/ui/dock.js +79 -17
  60. package/lib/ui/dock.js.map +1 -1
  61. package/lib/ui/frame.d.ts +31 -0
  62. package/lib/ui/frame.d.ts.map +1 -1
  63. package/lib/ui/frame.js +27 -4
  64. package/lib/ui/frame.js.map +1 -1
  65. package/lib/ui/view.d.ts +73 -2
  66. package/lib/ui/view.d.ts.map +1 -1
  67. package/lib/ui/view.js +180 -20
  68. package/lib/ui/view.js.map +1 -1
  69. package/lib/work.d.ts +8 -0
  70. package/lib/work.d.ts.map +1 -1
  71. package/lib/work.js +11 -0
  72. package/lib/work.js.map +1 -1
  73. package/package.json +18 -16
  74. package/themes/deepseek-blue.yaml +9 -0
  75. package/themes/violet-orbit.yaml +7 -0
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ import { createPromptHistory } from "./agent/prompt-history.js";
8
8
  import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
9
9
  import { createToolPresenter } from "./agent/present.js";
10
10
  import { forkPoint } from "./agent/fork.js";
11
+ import { hiddenTail, NO_UNDO, redoStep, resetUndo, turnsOf, UNDO_TURN_SETTLE_MS, undoStep } from "./agent/undo.js";
11
12
  import { PROFILE_NAME, resumeHint } from "./identity.js";
12
13
  import { createStatusFacts } from "./agent/status.js";
13
14
  import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
@@ -16,7 +17,8 @@ import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagent
16
17
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
17
18
  import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
18
19
  import { cancelStep, quitStep } from "./input/cancel.js";
19
- import { createCompletionProvider } from "./input/completion.js";
20
+ import { createAnswerCompletionProvider, createCompletionProvider } from "./input/completion.js";
21
+ import { createFileIndex } from "./input/file-search.js";
20
22
  import { ghostSuffix } from "./input/ghost.js";
21
23
  import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
22
24
  import { createDeferredNotice } from "./settings-notice.js";
@@ -31,7 +33,7 @@ import { WarningSafeTui } from "./terminal/warning-screen.js";
31
33
  import { BELL, shouldRingBell } from "./terminal/bell.js";
32
34
  import { clipboardSequence } from "./terminal/clipboard.js";
33
35
  import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
34
- import { sessionStartReason } from "./herdr/state.js";
36
+ import { driverReportFor, sessionStartReason } from "./herdr/state.js";
35
37
  import { createHerdrReporter } from "./herdr/reporter.js";
36
38
  import { defaultExportFile, transcriptToText } from "./export.js";
37
39
  import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
@@ -47,6 +49,7 @@ import { SessionId } from '@deepseek-ai/dsh-session';
47
49
  import { formatTokens } from "./tokens.js";
48
50
  import { TranscriptModel } from "./transcript.js";
49
51
  import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
52
+ import { cleanCopied } from "./ui/copy.js";
50
53
  import { WorkDock } from "./ui/dock.js";
51
54
  import { GateInputBar } from "./ui/gate-input.js";
52
55
  import { HistoryPicker } from "./ui/history-picker.js";
@@ -408,7 +411,7 @@ export function apply(ctx, config) {
408
411
  const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
409
412
  const restore = createRestoreRegistry();
410
413
  const terminal = new ProcessTerminal();
411
- const tui = new WarningSafeTui(terminal);
414
+ const tui = new WarningSafeTui(terminal, { copySelection });
412
415
  // A frame that cannot be drawn leaves the last good screen up, so the failure
413
416
  // has to reach the transcript: otherwise the surface looks frozen and nothing
414
417
  // on screen can say why.
@@ -431,6 +434,20 @@ export function apply(ctx, config) {
431
434
  if (!handedOver)
432
435
  terminal.write(text);
433
436
  };
437
+ /**
438
+ * Put a copied selection on the clipboard as the words it selected.
439
+ *
440
+ * A terminal copies the screen, so a drag across a message takes the box the
441
+ * message was drawn in along with it. The rows the last frame drew are the
442
+ * account of that frame, so the copy is read back through them: the shape the
443
+ * surface added comes off, and nothing the reader wrote does.
444
+ */
445
+ function copySelection(text) {
446
+ if (handedOver)
447
+ return Promise.resolve(false);
448
+ terminal.write(clipboardSequence(cleanCopied(text, view.copyRows())));
449
+ return Promise.resolve(true);
450
+ }
434
451
  /** The session this surface drives: commands, approvals, and the bell belong to it. */
435
452
  let activeSession = resolved.sessionId;
436
453
  /** The session the transcript is showing, which can be one of its children. */
@@ -462,6 +479,17 @@ export function apply(ctx, config) {
462
479
  });
463
480
  let agent;
464
481
  let turnOpen = false;
482
+ /**
483
+ * The staged undo cursor for the session this terminal drives.
484
+ *
485
+ * Prompts are never deleted: the cursor chooses how much of the live log the
486
+ * transcript shows. A cursor counts the turns of one log, so openAgent resets it.
487
+ */
488
+ let undoState = NO_UNDO;
489
+ /** Event index the transcript must not fold past while turns are hidden. */
490
+ let stagedCut;
491
+ /** Whether an undo is settling an interrupt, so a second press cannot race it. */
492
+ let undoPending = false;
465
493
  let turnStartedAt;
466
494
  let exited = false;
467
495
  /** Whether a session was really opened, which is what an exit hint can name. */
@@ -595,8 +623,9 @@ export function apply(ctx, config) {
595
623
  herdr.unblock();
596
624
  editor.disableSubmit = false;
597
625
  // The question is answered or skipped, so the reader gets their prompt back
598
- // in the bar they left it in.
626
+ // in the bar they left it in, with the menu the prompt bar offered.
599
627
  promptBar.giveBack();
628
+ applyCompletion();
600
629
  tui.setFocus(editor);
601
630
  tui.requestRender();
602
631
  };
@@ -791,19 +820,42 @@ export function apply(ctx, config) {
791
820
  return undefined;
792
821
  }));
793
822
  const registry = () => ctx.get('commands');
823
+ /** One scan of the workspace, shared by both menus: the listing is read-only and cached. */
824
+ const fileIndex = createFileIndex(process.cwd());
825
+ /** The menu the prompt bar offers: the commands this session can run, and the workspace's files. */
826
+ let promptCompletion;
827
+ /** The menu an answer offers, which is the same menu without the commands. */
828
+ let answerCompletion;
829
+ /**
830
+ * Give the bar the menu its current role calls for.
831
+ *
832
+ * A question borrows the prompt bar's editor rather than drawing its own, so
833
+ * the provider has to follow the borrow: a slash command is a line this
834
+ * surface would run, and an answer is text the model reads, so the two roles
835
+ * must not share one menu.
836
+ */
837
+ const applyCompletion = () => {
838
+ const chosen = promptBar.isBorrowed() ? answerCompletion : promptCompletion;
839
+ if (chosen !== undefined)
840
+ editor.setAutocompleteProvider(chosen);
841
+ };
794
842
  /**
795
843
  * Offer completion for whatever this session can run right now.
796
844
  *
797
845
  * The registry is agent-scoped and still empty while the agent starts, so the
798
846
  * menu is rebuilt when the agent arrives and whenever a package registers
799
- * another command.
847
+ * another command. Both providers are built here because the answer's is the
848
+ * prompt bar's own minus the commands, and a rebuild while a question is open
849
+ * must not leave the answer menu unreachable.
800
850
  */
801
851
  const installCompletion = () => {
802
852
  const current = agent?.agent;
803
853
  const commands = registry();
804
854
  if (current === undefined || commands === undefined)
805
855
  return;
806
- editor.setAutocompleteProvider(createCompletionProvider(commands.list(current), process.cwd()));
856
+ answerCompletion = createAnswerCompletionProvider(process.cwd(), fileIndex);
857
+ promptCompletion = createCompletionProvider(commands.list(current), process.cwd(), fileIndex);
858
+ applyCompletion();
807
859
  };
808
860
  /**
809
861
  * Take the keyboard for a picker.
@@ -1093,12 +1145,19 @@ export function apply(ctx, config) {
1093
1145
  * what tells the two apart.
1094
1146
  */
1095
1147
  const foldCursor = new FoldCursor();
1096
- /** Feed one durable event to the model, unless a fold has already folded it. */
1148
+ /**
1149
+ * Fold one durable event into the view, unless a staged cursor hides it.
1150
+ *
1151
+ * Undo never deletes, so the log keeps the hidden suffix; this filter is the
1152
+ * one place the transcript and the log disagree about where the session ends.
1153
+ */
1097
1154
  const applyDurable = (session, event) => {
1155
+ if (stagedCut !== undefined && session === activeSession && typeof event.seq === 'number' && event.seq >= stagedCut)
1156
+ return;
1098
1157
  if (foldCursor.accept(session, event))
1099
1158
  applyEvent(event);
1100
1159
  };
1101
- const foldHistory = async (id) => {
1160
+ const foldHistory = async (id, through) => {
1102
1161
  // Resolved before the fold so every card reads its tool through the scope
1103
1162
  // that actually registered it; a stored session nobody runs has none.
1104
1163
  presentScope = ctx.agents?.get(id);
@@ -1107,10 +1166,13 @@ export function apply(ctx, config) {
1107
1166
  foldCursor.reset();
1108
1167
  const inMemory = liveSession(id)?.snapshotEvents?.();
1109
1168
  if (inMemory !== undefined) {
1110
- for (const event of inMemory)
1169
+ const events = through === undefined ? inMemory : inMemory.slice(0, through);
1170
+ for (const event of events)
1111
1171
  applyDurable(id, event);
1112
- return inMemory.length;
1172
+ return events.length;
1113
1173
  }
1174
+ // Only a live session can be staged: undo acts on the agent this terminal
1175
+ // drives, and a stored log has no cursor to hide a suffix from.
1114
1176
  const history = createSessionHistory(ctx);
1115
1177
  if (history === undefined)
1116
1178
  return 0;
@@ -1167,6 +1229,192 @@ export function apply(ctx, config) {
1167
1229
  const history = createSessionHistory(ctx);
1168
1230
  return history === undefined ? [] : history.read(id);
1169
1231
  };
1232
+ /** The closed turns of the session this terminal drives, newest last. */
1233
+ const currentTurns = async () => turnsOf(await sessionEvents(activeSession));
1234
+ /** Fold the transcript through the staged cut, or the whole log at the tip. */
1235
+ const redrawStaged = async () => {
1236
+ model.reset();
1237
+ work.reset();
1238
+ await foldHistory(activeSession, stagedCut);
1239
+ };
1240
+ /** Park queued prompts one per stash entry, newest first so popping replays queue order. */
1241
+ const parkQueued = async (queued) => {
1242
+ for (const text of [...queued].reverse())
1243
+ await stash?.stashEditor(text);
1244
+ };
1245
+ /** Resolver for the turn/end an undo is waiting on, set only while waiting. */
1246
+ let turnSettled;
1247
+ /** Wait for the interrupted turn to close, bounded so undo can give up honestly. */
1248
+ const waitForTurnEnd = (timeoutMs) => new Promise(resolve => {
1249
+ const timer = setTimeout(() => {
1250
+ turnSettled = undefined;
1251
+ resolve(false);
1252
+ }, timeoutMs);
1253
+ turnSettled = () => {
1254
+ clearTimeout(timer);
1255
+ turnSettled = undefined;
1256
+ resolve(true);
1257
+ };
1258
+ });
1259
+ /**
1260
+ * Stop the running turn and empty the inbox so the cut lands on a closed
1261
+ * turn/end; queued prompts are parked first because cancelling drops them.
1262
+ *
1263
+ * Returns undefined when the reader's words cannot be kept: a queue with no
1264
+ * stash to park it in, or a turn that will not close. Both leave the cursor
1265
+ * untouched, so the transcript keeps showing what the model actually saw; a
1266
+ * number is how many queued prompts were parked before the turn was stopped.
1267
+ */
1268
+ const settleForUndo = async () => {
1269
+ const queued = queuedPrompts();
1270
+ if (!turnOpen && queued.length === 0)
1271
+ return 0;
1272
+ if (queued.length > 0 && stash === undefined) {
1273
+ model.notice('queued prompts have no stash to park in; undo cancelled');
1274
+ tui.requestRender();
1275
+ return undefined;
1276
+ }
1277
+ agent?.interrupt();
1278
+ if (queued.length > 0)
1279
+ await parkQueued(queued);
1280
+ if (turnOpen && !(await waitForTurnEnd(UNDO_TURN_SETTLE_MS))) {
1281
+ model.notice('could not stop the turn; undo cancelled');
1282
+ tui.requestRender();
1283
+ return undefined;
1284
+ }
1285
+ return queued.length;
1286
+ };
1287
+ const runUndoCommand = () => {
1288
+ if (undoPending)
1289
+ return;
1290
+ undoPending = true;
1291
+ void (async () => {
1292
+ if (viewedSession !== activeSession) {
1293
+ model.notice('undo works on the session this terminal drives · ctrl+b comes back');
1294
+ tui.requestRender();
1295
+ return;
1296
+ }
1297
+ const turns = await currentTurns();
1298
+ if (undoState.hidden >= turns.length && !turnOpen) {
1299
+ model.notice('nothing to undo');
1300
+ tui.requestRender();
1301
+ return;
1302
+ }
1303
+ const draft = editor.getExpandedText();
1304
+ // Text this state itself restored is not the reader's draft, so undoing
1305
+ // again must not park it and end up with two copies.
1306
+ const holdsDraft = draft !== '' && draft !== undoState.lastRestored;
1307
+ if (holdsDraft && stash === undefined) {
1308
+ model.notice('the bar holds a draft and there is no stash to park it in; undo cancelled');
1309
+ tui.requestRender();
1310
+ return;
1311
+ }
1312
+ const parked = await settleForUndo();
1313
+ if (parked === undefined)
1314
+ return;
1315
+ const settled = await currentTurns();
1316
+ const next = undoStep(undoState, settled);
1317
+ if (next === undefined) {
1318
+ model.notice('nothing to undo');
1319
+ tui.requestRender();
1320
+ return;
1321
+ }
1322
+ if (holdsDraft) {
1323
+ await stash?.stashEditor(draft);
1324
+ model.notice('the draft in the bar was parked in the stash');
1325
+ }
1326
+ undoState = next;
1327
+ stagedCut = hiddenTail(next, settled)?.seedCount;
1328
+ await redrawStaged();
1329
+ editor.setText(next.lastRestored);
1330
+ // The parking count rides the undo notice: a notice of its own would be
1331
+ // replaced before the frame could show it.
1332
+ const parkedNote = parked === 0 ? '' : ` · ${parked} queued prompt${parked === 1 ? '' : 's'} parked in the stash`;
1333
+ model.notice(`undo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden${parkedNote} · prefix r redo`);
1334
+ tui.requestRender();
1335
+ })()
1336
+ .catch((error) => {
1337
+ model.notice(`undo failed: ${error instanceof Error ? error.message : String(error)}`);
1338
+ tui.requestRender();
1339
+ })
1340
+ .finally(() => {
1341
+ undoPending = false;
1342
+ });
1343
+ };
1344
+ const runRedoCommand = () => {
1345
+ void (async () => {
1346
+ if (viewedSession !== activeSession) {
1347
+ model.notice('redo works on the session this terminal drives · ctrl+b comes back');
1348
+ tui.requestRender();
1349
+ return;
1350
+ }
1351
+ const turns = await currentTurns();
1352
+ const before = editor.getExpandedText();
1353
+ const restored = undoState.lastRestored;
1354
+ const next = redoStep(undoState, turns);
1355
+ if (next === undefined) {
1356
+ model.notice('nothing to redo');
1357
+ tui.requestRender();
1358
+ return;
1359
+ }
1360
+ undoState = next;
1361
+ stagedCut = hiddenTail(next, turns)?.seedCount;
1362
+ await redrawStaged();
1363
+ // A composer the reader edited is theirs; only text this state wrote is replaced.
1364
+ if (before === restored)
1365
+ editor.setText(next.lastRestored);
1366
+ model.notice(next.hidden === 0
1367
+ ? 'redo · back at the newest prompt'
1368
+ : `redo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden`);
1369
+ tui.requestRender();
1370
+ })().catch((error) => {
1371
+ model.notice(`redo failed: ${error instanceof Error ? error.message : String(error)}`);
1372
+ tui.requestRender();
1373
+ });
1374
+ };
1375
+ /**
1376
+ * Send the reader's prompt, branching first when the transcript is staged.
1377
+ *
1378
+ * Context is derived from the log, so a send while turns are hidden has to
1379
+ * continue in a child seeded with the visible prefix; submitting in place would
1380
+ * show the model the prompts the reader undid. Every send passes through here so
1381
+ * the branch has one commit point, not one per caller.
1382
+ */
1383
+ const commitStagedSend = async (text) => {
1384
+ try {
1385
+ const previous = agent;
1386
+ if (previous === undefined) {
1387
+ model.notice('the agent is still starting; try again in a moment');
1388
+ tui.requestRender();
1389
+ return;
1390
+ }
1391
+ if (undoState.hidden === 0) {
1392
+ previous.submit(text);
1393
+ return;
1394
+ }
1395
+ const source = activeSession;
1396
+ const events = await sessionEvents(source);
1397
+ const turns = turnsOf(events);
1398
+ const tail = hiddenTail(undoState, turns);
1399
+ const seed = tail === undefined ? [] : events.slice(0, tail.seedCount);
1400
+ const childId = SessionId(`tui-session-${randomUUID()}`);
1401
+ agent = undefined;
1402
+ turnOpen = false;
1403
+ turnStartedAt = undefined;
1404
+ presentScope = undefined;
1405
+ model.reset();
1406
+ work.reset();
1407
+ roster.reset();
1408
+ await previous.dispose();
1409
+ const opened = await openAgent(childId, false, seed.length === 0 ? undefined : { from: source, events: seed });
1410
+ model.notice(`continuing in a new branch · ${source} keeps the undone turns`);
1411
+ opened.submit(text);
1412
+ }
1413
+ catch (error) {
1414
+ model.notice(`could not send: ${error instanceof Error ? error.message : String(error)}`);
1415
+ tui.requestRender();
1416
+ }
1417
+ };
1170
1418
  /**
1171
1419
  * The mode one agent joins.
1172
1420
  *
@@ -1232,9 +1480,19 @@ export function apply(ctx, config) {
1232
1480
  activeSession = id;
1233
1481
  viewedSession = id;
1234
1482
  agent = handle;
1483
+ // A cursor counts the turns of one log; the session just opened has its own.
1484
+ undoState = resetUndo(id);
1485
+ stagedCut = undefined;
1235
1486
  // The bank follows the session, so the footer stops counting the drafts of
1236
1487
  // the session just left and the next command reads this session's file.
1237
1488
  void stash?.open();
1489
+ // agent/status is emitted on transitions only, so a driver that was
1490
+ // already running when this surface attached — a resume that wakes
1491
+ // straight away — would otherwise stay unreported until it stops. The
1492
+ // status is read once here and settled before the session identity, so the
1493
+ // report that carries the new session id states the driver's real phase
1494
+ // rather than the one the previous session left behind.
1495
+ herdr.driver(handle.agent.status);
1238
1496
  // The agent's own id is reported rather than the requested one: a resume can
1239
1497
  // be answered by the session the log actually holds.
1240
1498
  herdr.session({
@@ -1262,6 +1520,9 @@ export function apply(ctx, config) {
1262
1520
  installCompletion();
1263
1521
  model.notice(`session ${handle.sessionId}${resume ? ' (resumed)' : ''}`);
1264
1522
  tui.requestRender();
1523
+ // Returned so a caller that replaced the handle can send through the new
1524
+ // one without a fresh read of state TypeScript can no longer widen.
1525
+ return handle;
1265
1526
  };
1266
1527
  /** Move the surface to another stored session without leaving the terminal. */
1267
1528
  const switchSession = async (id) => {
@@ -1271,6 +1532,9 @@ export function apply(ctx, config) {
1271
1532
  // during the transition can read a torn-down world.
1272
1533
  presentScope = undefined;
1273
1534
  turnOpen = false;
1535
+ // The outgoing turn's clock dies with its agent; leaving it set would time
1536
+ // the session being joined by work it never ran.
1537
+ turnStartedAt = undefined;
1274
1538
  model.reset();
1275
1539
  work.reset();
1276
1540
  roster.reset();
@@ -1431,6 +1695,48 @@ export function apply(ctx, config) {
1431
1695
  tui.requestRender();
1432
1696
  });
1433
1697
  };
1698
+ /**
1699
+ * Compose this session's agent again without leaving the conversation.
1700
+ *
1701
+ * A preset's standing mount only re-reads its composition file for an agent
1702
+ * that joins after the file changed, so an edited preset, skill, or prompt
1703
+ * file reaches a running session only by joining anew. The durable log is
1704
+ * replayed afterwards, so the reader keeps the conversation they were reading.
1705
+ */
1706
+ const runReloadCommand = () => {
1707
+ // A failed reload leaves no agent behind, so the command has to be usable
1708
+ // again: the retry is what makes a broken composition file recoverable,
1709
+ // while a surface that has opened no session yet is still starting.
1710
+ if (agent === undefined && !sessionOpened) {
1711
+ model.notice('the agent is still starting; try again in a moment');
1712
+ tui.requestRender();
1713
+ return;
1714
+ }
1715
+ const queued = queuedPrompts();
1716
+ // Work the reader would lose is a decision, and the interrupt key already
1717
+ // owns that decision: it stops the turn and hands queued words back to the
1718
+ // bar. Reopening the session drops the inbox, so a reload that would take
1719
+ // those words asks for the key instead of asking a question of its own.
1720
+ if (turnOpen || queued.length > 0) {
1721
+ model.notice(queued.length > 0
1722
+ ? `${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} would be dropped — ctrl+c hands them back to the bar, then /reload`
1723
+ : 'a turn is running — ctrl+c interrupts it first (delivered text is kept), then /reload');
1724
+ tui.requestRender();
1725
+ return;
1726
+ }
1727
+ const id = activeSession;
1728
+ void (async () => {
1729
+ // The same transition a session switch takes, aimed at the session
1730
+ // already open: dispose, then join its preset generation anew.
1731
+ await switchSession(id);
1732
+ model.notice("reloaded this session's composition; the transcript was replayed");
1733
+ tui.requestRender();
1734
+ })().catch((error) => {
1735
+ const reason = error instanceof Error ? error.message : String(error);
1736
+ model.notice(`could not reload: ${reason} — fix the composition and /reload again`);
1737
+ tui.requestRender();
1738
+ });
1739
+ };
1434
1740
  /** Show the todo list the agent has been keeping. */
1435
1741
  const runTodoCommand = () => {
1436
1742
  model.notice(describeTodos(work.state().todos));
@@ -1941,6 +2247,9 @@ export function apply(ctx, config) {
1941
2247
  case 'new':
1942
2248
  runNewCommand(submission.title);
1943
2249
  return;
2250
+ case 'reload':
2251
+ runReloadCommand();
2252
+ return;
1944
2253
  case 'todo':
1945
2254
  runTodoCommand();
1946
2255
  return;
@@ -2084,6 +2393,12 @@ export function apply(ctx, config) {
2084
2393
  work.reset();
2085
2394
  tui.requestRender();
2086
2395
  return;
2396
+ case 'undo':
2397
+ runUndoCommand();
2398
+ return;
2399
+ case 'redo':
2400
+ runRedoCommand();
2401
+ return;
2087
2402
  case 'help':
2088
2403
  model.notice(helpText());
2089
2404
  tui.requestRender();
@@ -2113,7 +2428,7 @@ export function apply(ctx, config) {
2113
2428
  agent.steer(submission.text);
2114
2429
  else {
2115
2430
  adoptDefaultRoute();
2116
- agent.submit(submission.text);
2431
+ void commitStagedSend(submission.text);
2117
2432
  }
2118
2433
  }
2119
2434
  };
@@ -2136,14 +2451,14 @@ export function apply(ctx, config) {
2136
2451
  turnOpen = true;
2137
2452
  turnStartedAt = Date.now();
2138
2453
  writeTerminal(windowTitle(process.cwd(), 'working'));
2139
- herdr.working();
2140
2454
  }
2141
2455
  if (event.type === 'turn/end') {
2142
2456
  const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
2143
2457
  turnOpen = false;
2144
2458
  turnStartedAt = undefined;
2459
+ // An undo may be parked waiting for exactly this boundary.
2460
+ turnSettled?.();
2145
2461
  writeTerminal(windowTitle(process.cwd(), 'ready'));
2146
- herdr.idle();
2147
2462
  if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
2148
2463
  writeTerminal(BELL);
2149
2464
  // A job the turn started may have settled while the reader was watching
@@ -2168,6 +2483,20 @@ export function apply(ctx, config) {
2168
2483
  * harness cannot break compilation of this surface.
2169
2484
  */
2170
2485
  const listenFor = (name, handler) => ctx.on(name, handler);
2486
+ // Herdr hears the driver rather than each turn: a run that chains turns
2487
+ // through a pending inbox is one stretch of work, and a turn boundary inside
2488
+ // it would read as done between two turns of an agent that is still working.
2489
+ // The turn listeners above keep the title and the bell, which are about the
2490
+ // reader's own conversation.
2491
+ disposers.push(listenFor('agent/status', payload => {
2492
+ // Read against the driven session at delivery time: the reader can switch
2493
+ // sessions between two transitions, and the row must follow the one this
2494
+ // terminal now drives.
2495
+ const status = driverReportFor(payload, activeSession);
2496
+ if (status === undefined)
2497
+ return;
2498
+ herdr.driver(status);
2499
+ }));
2171
2500
  disposers.push(listenFor('subagent/start', info => {
2172
2501
  roster.start(info);
2173
2502
  const record = asRecord(info);
@@ -2209,7 +2538,13 @@ export function apply(ctx, config) {
2209
2538
  if (questions.length === 0)
2210
2539
  return next();
2211
2540
  return new Promise(resolve => {
2212
- const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
2541
+ const gate = promptBar.borrow(() => {
2542
+ // The bar is an answer's for as long as the gate holds it, so the menu
2543
+ // it once offered commands through is replaced before a key can reach it.
2544
+ const built = new QuestionGate(questions, editor, () => keymap);
2545
+ applyCompletion();
2546
+ return built;
2547
+ });
2213
2548
  // The seam takes mutable selection arrays and an optional custom field, so
2214
2549
  // the read-only gate answer is copied into that exact shape here.
2215
2550
  const settle = (answers) => {