@sagmans/dsh-tui 0.3.0 → 0.4.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 (113) hide show
  1. package/README.md +158 -6
  2. package/cordis.patch.yml +14 -0
  3. package/lib/agent/projections.d.ts +22 -6
  4. package/lib/agent/projections.d.ts.map +1 -1
  5. package/lib/agent/projections.js +21 -10
  6. package/lib/agent/projections.js.map +1 -1
  7. package/lib/agent/prompt-history.d.ts +89 -0
  8. package/lib/agent/prompt-history.d.ts.map +1 -0
  9. package/lib/agent/prompt-history.js +380 -0
  10. package/lib/agent/prompt-history.js.map +1 -0
  11. package/lib/agent/status.d.ts +6 -0
  12. package/lib/agent/status.d.ts.map +1 -1
  13. package/lib/agent/status.js +1 -0
  14. package/lib/agent/status.js.map +1 -1
  15. package/lib/herdr/client.d.ts +57 -0
  16. package/lib/herdr/client.d.ts.map +1 -0
  17. package/lib/herdr/client.js +218 -0
  18. package/lib/herdr/client.js.map +1 -0
  19. package/lib/herdr/constants.d.ts +93 -0
  20. package/lib/herdr/constants.d.ts.map +1 -0
  21. package/lib/herdr/constants.js +79 -0
  22. package/lib/herdr/constants.js.map +1 -0
  23. package/lib/herdr/reporter.d.ts +60 -0
  24. package/lib/herdr/reporter.d.ts.map +1 -0
  25. package/lib/herdr/reporter.js +217 -0
  26. package/lib/herdr/reporter.js.map +1 -0
  27. package/lib/herdr/state.d.ts +57 -0
  28. package/lib/herdr/state.d.ts.map +1 -0
  29. package/lib/herdr/state.js +62 -0
  30. package/lib/herdr/state.js.map +1 -0
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +252 -20
  33. package/lib/index.js.map +1 -1
  34. package/lib/input/actions.d.ts +1 -1
  35. package/lib/input/actions.d.ts.map +1 -1
  36. package/lib/input/actions.js +3 -0
  37. package/lib/input/actions.js.map +1 -1
  38. package/lib/input/ghost.d.ts +46 -0
  39. package/lib/input/ghost.d.ts.map +1 -0
  40. package/lib/input/ghost.js +58 -0
  41. package/lib/input/ghost.js.map +1 -0
  42. package/lib/input/keymap.d.ts.map +1 -1
  43. package/lib/input/keymap.js +2 -0
  44. package/lib/input/keymap.js.map +1 -1
  45. package/lib/input/submission.d.ts +24 -1
  46. package/lib/input/submission.d.ts.map +1 -1
  47. package/lib/input/submission.js +28 -1
  48. package/lib/input/submission.js.map +1 -1
  49. package/lib/stash/lock.d.ts +72 -0
  50. package/lib/stash/lock.d.ts.map +1 -0
  51. package/lib/stash/lock.js +374 -0
  52. package/lib/stash/lock.js.map +1 -0
  53. package/lib/stash/paths.d.ts +22 -0
  54. package/lib/stash/paths.d.ts.map +1 -0
  55. package/lib/stash/paths.js +83 -0
  56. package/lib/stash/paths.js.map +1 -0
  57. package/lib/stash/private-fs.d.ts +61 -0
  58. package/lib/stash/private-fs.d.ts.map +1 -0
  59. package/lib/stash/private-fs.js +379 -0
  60. package/lib/stash/private-fs.js.map +1 -0
  61. package/lib/stash/schema.d.ts +57 -0
  62. package/lib/stash/schema.d.ts.map +1 -0
  63. package/lib/stash/schema.js +127 -0
  64. package/lib/stash/schema.js.map +1 -0
  65. package/lib/stash/store.d.ts +92 -0
  66. package/lib/stash/store.d.ts.map +1 -0
  67. package/lib/stash/store.js +262 -0
  68. package/lib/stash/store.js.map +1 -0
  69. package/lib/stash.d.ts +91 -0
  70. package/lib/stash.d.ts.map +1 -0
  71. package/lib/stash.js +296 -0
  72. package/lib/stash.js.map +1 -0
  73. package/lib/theme-settings.d.ts +37 -8
  74. package/lib/theme-settings.d.ts.map +1 -1
  75. package/lib/theme-settings.js +60 -3
  76. package/lib/theme-settings.js.map +1 -1
  77. package/lib/theme-tokens.d.ts +1 -1
  78. package/lib/theme-tokens.d.ts.map +1 -1
  79. package/lib/theme-tokens.js +8 -0
  80. package/lib/theme-tokens.js.map +1 -1
  81. package/lib/todo-guard.d.ts +116 -0
  82. package/lib/todo-guard.d.ts.map +1 -0
  83. package/lib/todo-guard.js +259 -0
  84. package/lib/todo-guard.js.map +1 -0
  85. package/lib/ui/editor.d.ts +41 -1
  86. package/lib/ui/editor.d.ts.map +1 -1
  87. package/lib/ui/editor.js +98 -4
  88. package/lib/ui/editor.js.map +1 -1
  89. package/lib/ui/history-picker.d.ts +15 -0
  90. package/lib/ui/history-picker.d.ts.map +1 -0
  91. package/lib/ui/history-picker.js +26 -0
  92. package/lib/ui/history-picker.js.map +1 -0
  93. package/lib/ui/picker.d.ts +11 -1
  94. package/lib/ui/picker.d.ts.map +1 -1
  95. package/lib/ui/picker.js +17 -2
  96. package/lib/ui/picker.js.map +1 -1
  97. package/lib/ui/prompt.d.ts +2 -0
  98. package/lib/ui/prompt.d.ts.map +1 -1
  99. package/lib/ui/prompt.js +4 -0
  100. package/lib/ui/prompt.js.map +1 -1
  101. package/lib/ui/stash-picker.d.ts +43 -0
  102. package/lib/ui/stash-picker.d.ts.map +1 -0
  103. package/lib/ui/stash-picker.js +78 -0
  104. package/lib/ui/stash-picker.js.map +1 -0
  105. package/lib/ui/status.d.ts +2 -0
  106. package/lib/ui/status.d.ts.map +1 -1
  107. package/lib/ui/status.js +7 -0
  108. package/lib/ui/status.js.map +1 -1
  109. package/lib/work.d.ts +3 -1
  110. package/lib/work.d.ts.map +1 -1
  111. package/lib/work.js +9 -1
  112. package/lib/work.js.map +1 -1
  113. package/package.json +5 -1
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
4
4
  import { ProcessTerminal, ScrollView, VStack, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
5
5
  import { startAgent } from "./agent/host.js";
6
6
  import { PICKER_LIMIT, createSessionHistory, presetOfStoredSession, readSessionTitle, } from "./agent/history.js";
7
+ import { createPromptHistory } from "./agent/prompt-history.js";
7
8
  import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
8
9
  import { createToolPresenter } from "./agent/present.js";
9
10
  import { forkPoint } from "./agent/fork.js";
@@ -15,6 +16,7 @@ import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagent
15
16
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
16
17
  import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
17
18
  import { createCompletionProvider } from "./input/completion.js";
19
+ import { ghostSuffix } from "./input/ghost.js";
18
20
  import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
19
21
  import { createDeferredNotice } from "./settings-notice.js";
20
22
  import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
@@ -26,6 +28,8 @@ import { WarningSafeTui } from "./terminal/warning-screen.js";
26
28
  import { BELL, shouldRingBell } from "./terminal/bell.js";
27
29
  import { clipboardSequence } from "./terminal/clipboard.js";
28
30
  import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
31
+ import { sessionStartReason } from "./herdr/state.js";
32
+ import { createHerdrReporter } from "./herdr/reporter.js";
29
33
  import { defaultExportFile, transcriptToText } from "./export.js";
30
34
  import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
31
35
  import { detectColourMode } from "./theme-capability.js";
@@ -33,19 +37,23 @@ import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SE
33
37
  import { pendingPrompts } from "./queue.js";
34
38
  import { renderThemeTable } from "./theme-command.js";
35
39
  import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
40
+ import { resetSequence } from "./theme-tokens.js";
36
41
  import { SessionId } from '@deepseek-ai/dsh-session';
37
42
  import { formatTokens } from "./tokens.js";
38
43
  import { TranscriptModel } from "./transcript.js";
39
44
  import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
40
45
  import { WorkDock } from "./ui/dock.js";
41
46
  import { GateInputBar } from "./ui/gate-input.js";
47
+ import { HistoryPicker } from "./ui/history-picker.js";
42
48
  import { PromptBar } from "./ui/prompt.js";
43
49
  import { MarkdownRenderer } from "./ui/markdown.js";
44
50
  import { createMermaidTransform } from "./ui/mermaid.js";
45
51
  import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
46
52
  import { QueueBar } from "./ui/queue.js";
47
- import { StatusBar } from "./ui/status.js";
53
+ import { shortPath, StatusBar } from "./ui/status.js";
48
54
  import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
55
+ import { PromptStash } from "./stash.js";
56
+ import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
49
57
  export const name = 'tui';
50
58
  /**
51
59
  * Services the surface cannot run without.
@@ -65,6 +73,8 @@ const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK
65
73
  const TITLE_CONCURRENCY = 4;
66
74
  /** How often the running-state clock repaints while a turn is open. */
67
75
  const STATUS_TICK_MS = 1000;
76
+ /** Reverse video for the cell the cursor occupies, so a ghost keeps the cursor visible. */
77
+ const GHOST_CURSOR_PREFIX = '\u001b[7m';
68
78
  function asRecord(value) {
69
79
  return typeof value === 'object' && value !== null ? value : undefined;
70
80
  }
@@ -162,6 +172,10 @@ export function apply(ctx, config) {
162
172
  let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
163
173
  /** Every action's keys in force; the settings document owns it and a press reads it live. */
164
174
  let keymap = defaultKeymap();
175
+ /** Whether prompts are recorded and offered, and the cap on how many; the settings document owns all three. */
176
+ let historyEnabled = defaultSettings().history.enabled;
177
+ let historyGhost = defaultSettings().history.ghost;
178
+ let historyMaxEntries = defaultSettings().history.maxEntries;
165
179
  /**
166
180
  * The chord between a prefix and the action that follows it.
167
181
  *
@@ -187,6 +201,9 @@ export function apply(ctx, config) {
187
201
  // Installed where the library reads it, so a remap lands on the next press
188
202
  // rather than at the next restart.
189
203
  installKeybindings(keymap);
204
+ historyEnabled = section.history.enabled;
205
+ historyGhost = section.history.ghost;
206
+ historyMaxEntries = section.history.maxEntries;
190
207
  // A chord armed under the keymap the reader just replaced is not their chord.
191
208
  keyChord.disarm();
192
209
  };
@@ -219,7 +236,10 @@ export function apply(ctx, config) {
219
236
  scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
220
237
  }
221
238
  catch (error) {
222
- settingsNotice.post(settingsProblemMessage(error));
239
+ // Nothing registered means nothing to read, so the reader's switch cannot
240
+ // be confirmed: recording stays off rather than falling back to on.
241
+ historyEnabled = false;
242
+ settingsNotice.post(settingsProblemMessage(error) + ' · prompt history stays off until the section parses');
223
243
  return;
224
244
  }
225
245
  readSection = () => readScope(scope, message => settingsNotice.post(message));
@@ -235,6 +255,33 @@ export function apply(ctx, config) {
235
255
  */
236
256
  let presentScope;
237
257
  const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
258
+ /**
259
+ * The reader's prompt history: global across projects, recorded from every
260
+ * submitted line, and offered back as they type. Built before the bar so its
261
+ * first load cannot race the first suggestion.
262
+ */
263
+ const promptHistory = createPromptHistory({
264
+ cap: () => historyMaxEntries,
265
+ warn: message => {
266
+ model.notice(message);
267
+ tui.requestRender();
268
+ },
269
+ });
270
+ /**
271
+ * The dimmed completion drawn from recorded prompts.
272
+ *
273
+ * Colour is the affordance: with styling off the suggestion would be
274
+ * unreadable text the reader could still accept, which is worse than none.
275
+ * The cursor cell is also reversed so the cursor stays visible on the ghost.
276
+ */
277
+ const ghostBrush = {
278
+ enabled: () => historyEnabled && historyGhost && theme.color && theme.visible('editor.ghost'),
279
+ suggestion: input => ghostSuffix({ entries: promptHistory.entries(), ...input }),
280
+ paint: (text, cell) => {
281
+ const styled = theme.style('editor.ghost', text);
282
+ return cell === 'cursor' ? GHOST_CURSOR_PREFIX + styled + resetSequence() : styled;
283
+ },
284
+ };
238
285
  const work = new WorkFold();
239
286
  const modelSwitch = new ModelSwitch();
240
287
  const agentPresets = createPresetRoster(ctx);
@@ -285,7 +332,7 @@ export function apply(ctx, config) {
285
332
  // send the library submits on by default. A settings document read after this
286
333
  // point installs over it, which is why the bar reads the map per press.
287
334
  installKeybindings(keymap);
288
- const editor = new GateInputBar(tui, theme.editor, () => keymap);
335
+ const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
289
336
  // Answers are written in the reader's own editor, which is why a question
290
337
  // borrows the bar instead of drawing a second one beside it.
291
338
  const promptBar = new PromptBar(editor);
@@ -306,6 +353,9 @@ export function apply(ctx, config) {
306
353
  jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
307
354
  tui.requestRender();
308
355
  };
356
+ // The bank is built once the picker exists to answer for it, so the status
357
+ // source is late-bound: the footer must not read a half-constructed stash.
358
+ let stash;
309
359
  const statusFacts = createStatusFacts(ctx, {
310
360
  sessionId: () => activeSession,
311
361
  activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
@@ -316,6 +366,7 @@ export function apply(ctx, config) {
316
366
  // a hint stored with the transcript would keep naming the key of the day it
317
367
  // was written, and the reader may remap it with the row already on screen.
318
368
  back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
369
+ stash: () => stash?.entryCount,
319
370
  });
320
371
  const statusBar = new StatusBar(statusFacts, theme);
321
372
  const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
@@ -330,11 +381,28 @@ export function apply(ctx, config) {
330
381
  disposers.push(() => clearInterval(statusTicker));
331
382
  // A window that outlived the surface would repaint a screen that is gone.
332
383
  disposers.push(() => keyChord.disarm());
384
+ // A containing Herdr is told what this pane is doing; away from one the
385
+ // reporter is inert, so the surface never depends on being multiplexed.
386
+ const herdr = createHerdrReporter();
387
+ // Kept out of the disposal list on purpose: the row is handed back before
388
+ // this stops guarding it, so a host that leaves during the release still
389
+ // releases synchronously.
390
+ const unregisterExit = herdr.registerExitRelease();
333
391
  restore.add(() => tui.stop());
334
392
  ctx.effect(() => () => {
393
+ // The pane stops being an agent before the process that claimed it unwinds:
394
+ // a release that ran after the reports were unregistered would race them,
395
+ // and one that never ran would leave a row that reads as a live agent. The
396
+ // reports already on the wire are settled first, because Herdr ignores a
397
+ // release for a pane nothing has claimed yet — the report that followed it
398
+ // would otherwise claim the row back. The promise is returned so a host that
399
+ // waits for teardown waits for the row too, and the exit listener outlives
400
+ // the wait, so one that does not still hands the row back synchronously.
401
+ const released = herdr.release().catch(() => undefined);
335
402
  restore.restore();
336
403
  for (const dispose of disposers.reverse())
337
404
  dispose();
405
+ return released.finally(unregisterExit);
338
406
  });
339
407
  tui.setLayoutRoot(new VStack([
340
408
  {
@@ -359,26 +427,37 @@ export function apply(ctx, config) {
359
427
  return;
360
428
  exited = true;
361
429
  clearInterval(statusTicker);
362
- // Hand the window label back before the screen does, so a shell that sets
363
- // its own title can take over cleanly.
430
+ // The screen goes back at once, so leaving feels like leaving; the row goes
431
+ // back behind it. Reports already on the wire are settled first, because
432
+ // Herdr ignores the release of a pane nothing has claimed yet — the report
433
+ // that followed it would otherwise claim the row back during shutdown.
364
434
  terminal.write(CLEAR_TITLE);
365
435
  restore.restore();
366
- // Everything below is written after the release: the alternate screen closes
367
- // over whatever was painted on it, and a failure nobody can read is not a
368
- // failure that was reported.
369
- if (reason !== undefined)
370
- terminal.write(`\ndsh-tui: ${reason}\n`);
371
- // The hint is computed here rather than read from the context because only
372
- // the surface knows which session it is leaving: a fork or a switch moves it.
373
- // A run that opened nothing has nothing to offer back: the identity it was
374
- // launched with names no log, so pointing at it would send the reader to a
375
- // conversation that does not exist.
376
- if (sessionOpened)
377
- terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
378
- appExit(code);
436
+ void herdr
437
+ .release()
438
+ // Nobody is left to report a release that failed on the way out, and a
439
+ // row that could not be cleared is not a reason to keep the process.
440
+ .catch(() => undefined)
441
+ .finally(() => {
442
+ // Everything below is written after the release: a failure nobody can
443
+ // read is not a failure that was reported.
444
+ if (reason !== undefined)
445
+ terminal.write(`\ndsh-tui: ${reason}\n`);
446
+ // The hint is computed here rather than read from the context because
447
+ // only the surface knows which session it is leaving: a fork or a
448
+ // switch moves it. A run that opened nothing has nothing to offer back:
449
+ // the identity it was launched with names no log, so pointing at it
450
+ // would send the reader to a conversation that does not exist.
451
+ if (sessionOpened)
452
+ terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
453
+ appExit(code);
454
+ });
379
455
  };
380
456
  const openGate = (next) => {
381
457
  pending = next;
458
+ // The card's own title names the decision, which is what a reader glancing
459
+ // at a wall of panes needs in order to know which one to open.
460
+ herdr.block(next.gate.card().title);
382
461
  // A gate owns the keyboard: the editor must not collect the decision keys.
383
462
  editor.disableSubmit = true;
384
463
  tui.setFocus(null);
@@ -390,6 +469,7 @@ export function apply(ctx, config) {
390
469
  };
391
470
  const closeGate = () => {
392
471
  pending = undefined;
472
+ herdr.unblock();
393
473
  editor.disableSubmit = false;
394
474
  // The question is answered or skipped, so the reader gets their prompt back
395
475
  // in the bar they left it in.
@@ -400,7 +480,7 @@ export function apply(ctx, config) {
400
480
  /**
401
481
  * What each key the surface answers itself does; false hands the press back.
402
482
  *
403
- * Keyed by the table's own ids, so a key added to {@link SURFACE_KEYS}
483
+ * Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
404
484
  * without a handler here fails to compile rather than doing nothing.
405
485
  */
406
486
  const surfaceActions = {
@@ -423,6 +503,10 @@ export function apply(ctx, config) {
423
503
  void openEffortPicker();
424
504
  return true;
425
505
  },
506
+ history: () => {
507
+ void openHistoryPicker();
508
+ return true;
509
+ },
426
510
  back: () => {
427
511
  if (viewedSession !== activeSession)
428
512
  void showAgentSession();
@@ -564,6 +648,7 @@ export function apply(ctx, config) {
564
648
  const settlePicker = (id) => {
565
649
  const settle = pendingPicker?.settle;
566
650
  pendingPicker = undefined;
651
+ herdr.unblock();
567
652
  editor.disableSubmit = false;
568
653
  tui.setFocus(editor);
569
654
  settle?.(id);
@@ -590,10 +675,39 @@ export function apply(ctx, config) {
590
675
  /** Take the keyboard for a picker and answer with the id it settled on. */
591
676
  const openPicker = (picker, vet) => new Promise(resolve => {
592
677
  pendingPicker = { picker, settle: resolve, vet };
678
+ // A picker owns the keyboard exactly as a gate does: nothing moves until
679
+ // the reader chooses, so it is the same kind of wait.
680
+ herdr.block(picker.card().title);
593
681
  editor.disableSubmit = true;
594
682
  tui.setFocus(null);
595
683
  tui.requestRender();
596
684
  });
685
+ /**
686
+ * Reverse search over recorded prompts, seeded with whatever is in the bar.
687
+ *
688
+ * A pick replaces the draft; a cancel leaves it exactly as it was, because the
689
+ * list was opened to look rather than to lose what is already typed.
690
+ */
691
+ const openHistoryPicker = async () => {
692
+ if (!historyEnabled) {
693
+ model.notice('prompt history is disabled in ' + TUI_SETTINGS_NAMESPACE + ' settings');
694
+ tui.requestRender();
695
+ return;
696
+ }
697
+ if (promptHistory.entries().length === 0) {
698
+ const blocked = promptHistory.blockedReason();
699
+ model.notice(blocked === undefined ? 'no prompt history yet' : 'prompt history is unavailable: ' + blocked);
700
+ tui.requestRender();
701
+ return;
702
+ }
703
+ // The expanded text is what the reader wrote; a large paste sits in the bar
704
+ // as a marker, and seeding with it would filter out the prompt it came from.
705
+ const picked = await openPicker(new HistoryPicker(() => promptHistory.entries(), editor.getExpandedText(), () => keymap));
706
+ if (picked === undefined)
707
+ return;
708
+ editor.setText(picked);
709
+ tui.requestRender();
710
+ };
597
711
  /** The roster as the picker paints it, refreshed when the picker opens. */
598
712
  let presetRows = [];
599
713
  /** Choose the mode a session that has not started yet will run. */
@@ -603,6 +717,28 @@ export function apply(ctx, config) {
603
717
  presetRows = await agentPresets.list();
604
718
  return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
605
719
  };
720
+ /**
721
+ * The prompt bank for this working directory.
722
+ *
723
+ * The surface owns the editor, the picker, and the notices, so the bank is
724
+ * handed the few things it needs to reach them and nothing else: the commands
725
+ * stay free of terminal state and are exercised without one in the tests.
726
+ */
727
+ const stashCwdLabel = shortPath(process.cwd(), process.env.HOME);
728
+ stash = new PromptStash({
729
+ getEditorText: () => editor.getExpandedText(),
730
+ setEditorText: text => {
731
+ editor.setText(text);
732
+ tui.requestRender();
733
+ },
734
+ // A question answers in this editor, so a draft written into a borrowed bar
735
+ // would become somebody's answer instead of a parked prompt.
736
+ editorIsAvailable: () => !promptBar.isBorrowed(),
737
+ notice: message => model.notice(message),
738
+ pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
739
+ confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
740
+ render: () => tui.requestRender(),
741
+ }, { cwd: process.cwd() });
606
742
  /** Title the listed sessions without making the reader wait for the slowest log. */
607
743
  const loadTitles = async (history, sessions, titles) => {
608
744
  const queue = [...sessions];
@@ -812,6 +948,13 @@ export function apply(ctx, config) {
812
948
  activeSession = id;
813
949
  viewedSession = id;
814
950
  agent = handle;
951
+ // The agent's own id is reported rather than the requested one: a resume can
952
+ // be answered by the session the log actually holds.
953
+ herdr.session({
954
+ id: String(handle.sessionId),
955
+ cwd: process.cwd(),
956
+ reason: sessionStartReason({ forked: fork !== undefined, resumed: resume }),
957
+ });
815
958
  // A session with no history to fold still has to present its first live
816
959
  // card through the right scope, so the scope is set before any event can.
817
960
  presentScope = handle.agent;
@@ -1433,6 +1576,47 @@ export function apply(ctx, config) {
1433
1576
  ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1434
1577
  });
1435
1578
  };
1579
+ /** Show where history is kept, or forget it; the file is global to this machine. */
1580
+ const runHistoryCommand = (argument) => {
1581
+ // The line that asked for this is recorded before the command runs, but that
1582
+ // write rides the store's queue; waiting for it lets the count describe the
1583
+ // file the reader has, not the state before their own line landed.
1584
+ void promptHistory.flush().then(() => {
1585
+ if (argument === '') {
1586
+ const count = promptHistory.entries().length;
1587
+ const blocked = promptHistory.blockedReason();
1588
+ model.notice([
1589
+ count + (count === 1 ? ' prompt recorded' : ' prompts recorded'),
1590
+ promptHistory.path(),
1591
+ blocked === undefined ? undefined : 'writes disabled: ' + blocked,
1592
+ ].filter(part => part !== undefined).join(' · '));
1593
+ tui.requestRender();
1594
+ return;
1595
+ }
1596
+ if (argument !== 'clear') {
1597
+ model.notice('usage: /history shows where history is kept · /history clear forgets every prompt');
1598
+ tui.requestRender();
1599
+ return;
1600
+ }
1601
+ // A refused write cannot remove anything, so saying "forgot 0 prompts"
1602
+ // would describe a successful clear the file never had.
1603
+ const blocked = promptHistory.blockedReason();
1604
+ if (blocked !== undefined) {
1605
+ model.notice('prompt history is unavailable: ' + blocked);
1606
+ tui.requestRender();
1607
+ return;
1608
+ }
1609
+ return promptHistory.clear().then(removed => {
1610
+ model.notice('forgot ' + removed + (removed === 1 ? ' prompt' : ' prompts'));
1611
+ tui.requestRender();
1612
+ });
1613
+ }).catch(error => {
1614
+ // The store keeps what the file still holds, so the reader is told the
1615
+ // clear failed rather than being shown a count that never landed.
1616
+ model.notice('could not clear prompt history: ' + (error instanceof Error ? error.message : String(error)));
1617
+ tui.requestRender();
1618
+ });
1619
+ };
1436
1620
  /**
1437
1621
  * Carry out one classified line, wherever it was asked for.
1438
1622
  *
@@ -1493,6 +1677,36 @@ export function apply(ctx, config) {
1493
1677
  case 'copy':
1494
1678
  runCopyCommand();
1495
1679
  return;
1680
+ case 'history':
1681
+ runHistoryCommand(submission.argument);
1682
+ return;
1683
+ case 'stash':
1684
+ // Submitting a command consumes the line it was typed on, so this path
1685
+ // can only carry a draft it was given; parking the bar's own draft is
1686
+ // what the chord is for.
1687
+ if (submission.argument.trim() === '')
1688
+ model.notice('usage: /stash <draft>, or ctrl+x then s to park the editor');
1689
+ else
1690
+ void stash?.stashEditor(submission.argument);
1691
+ return;
1692
+ case 'stash-draft':
1693
+ void stash?.stashEditor();
1694
+ return;
1695
+ case 'stash-pop':
1696
+ void stash?.pop(submission.selector);
1697
+ return;
1698
+ case 'stash-apply':
1699
+ void stash?.apply(submission.selector);
1700
+ return;
1701
+ case 'stash-list':
1702
+ void stash?.list(stashCwdLabel);
1703
+ return;
1704
+ case 'stash-drop':
1705
+ void stash?.drop(submission.selector);
1706
+ return;
1707
+ case 'stash-clear':
1708
+ void stash?.clear();
1709
+ return;
1496
1710
  case 'status': {
1497
1711
  const facts = statusFacts();
1498
1712
  const context = facts.contextTokens === undefined
@@ -1553,7 +1767,17 @@ export function apply(ctx, config) {
1553
1767
  }
1554
1768
  }
1555
1769
  };
1556
- editor.onSubmit = text => runSubmission(classifySubmission(text));
1770
+ editor.onSubmit = text => {
1771
+ const submission = classifySubmission(text);
1772
+ if (submission.kind === 'empty')
1773
+ return;
1774
+ // The library's own history feeds the up/down keys; the store below feeds
1775
+ // ghost completion and reverse search, and is global rather than per-session.
1776
+ editor.addToHistory(text);
1777
+ if (historyEnabled)
1778
+ promptHistory.record(text);
1779
+ runSubmission(submission);
1780
+ };
1557
1781
  disposers.push(ctx.on('session/event', (session, event) => {
1558
1782
  // The surface state — activity, timer, title, bell, job board — belongs to
1559
1783
  // the agent this terminal drives, even while a child is on screen.
@@ -1562,12 +1786,14 @@ export function apply(ctx, config) {
1562
1786
  turnOpen = true;
1563
1787
  turnStartedAt = Date.now();
1564
1788
  terminal.write(windowTitle(process.cwd(), 'working'));
1789
+ herdr.working();
1565
1790
  }
1566
1791
  if (event.type === 'turn/end') {
1567
1792
  const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
1568
1793
  turnOpen = false;
1569
1794
  turnStartedAt = undefined;
1570
1795
  terminal.write(windowTitle(process.cwd(), 'ready'));
1796
+ herdr.idle();
1571
1797
  if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
1572
1798
  terminal.write(BELL);
1573
1799
  // A job the turn started may have settled while the reader was watching
@@ -1742,7 +1968,13 @@ export function apply(ctx, config) {
1742
1968
  if (!resolved.resumePicker)
1743
1969
  await presetFor(resolved.sessionId, resolved.resume, undefined);
1744
1970
  tui.start();
1971
+ // Loaded now so the footer's count is real from the first paint, and because
1972
+ // a bank that cannot be read should say so before a reader trusts it.
1973
+ void stash?.open();
1745
1974
  terminal.write(windowTitle(process.cwd(), 'ready'));
1975
+ // Claiming the pane's agent row does not wait for a session: the pane is
1976
+ // already on screen and already idle, and a session may still be chosen.
1977
+ herdr.publish();
1746
1978
  // A refused settings edit is only visible now that the surface owns the
1747
1979
  // screen; whatever the scope found before this point prints here instead.
1748
1980
  settingsNotice.open(message => model.notice(message));