@sagmans/dsh-tui 0.2.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 (174) hide show
  1. package/README.md +282 -25
  2. package/cordis.patch.yml +14 -0
  3. package/lib/agent/model.d.ts +26 -0
  4. package/lib/agent/model.d.ts.map +1 -1
  5. package/lib/agent/model.js +41 -0
  6. package/lib/agent/model.js.map +1 -1
  7. package/lib/agent/projections.d.ts +22 -6
  8. package/lib/agent/projections.d.ts.map +1 -1
  9. package/lib/agent/projections.js +21 -10
  10. package/lib/agent/projections.js.map +1 -1
  11. package/lib/agent/prompt-history.d.ts +89 -0
  12. package/lib/agent/prompt-history.d.ts.map +1 -0
  13. package/lib/agent/prompt-history.js +380 -0
  14. package/lib/agent/prompt-history.js.map +1 -0
  15. package/lib/agent/status.d.ts +16 -0
  16. package/lib/agent/status.d.ts.map +1 -1
  17. package/lib/agent/status.js +3 -0
  18. package/lib/agent/status.js.map +1 -1
  19. package/lib/cards.d.ts +1 -1
  20. package/lib/cards.d.ts.map +1 -1
  21. package/lib/cards.js +4 -3
  22. package/lib/cards.js.map +1 -1
  23. package/lib/gates.d.ts +145 -2
  24. package/lib/gates.d.ts.map +1 -1
  25. package/lib/gates.js +335 -40
  26. package/lib/gates.js.map +1 -1
  27. package/lib/herdr/client.d.ts +57 -0
  28. package/lib/herdr/client.d.ts.map +1 -0
  29. package/lib/herdr/client.js +218 -0
  30. package/lib/herdr/client.js.map +1 -0
  31. package/lib/herdr/constants.d.ts +93 -0
  32. package/lib/herdr/constants.d.ts.map +1 -0
  33. package/lib/herdr/constants.js +79 -0
  34. package/lib/herdr/constants.js.map +1 -0
  35. package/lib/herdr/reporter.d.ts +60 -0
  36. package/lib/herdr/reporter.d.ts.map +1 -0
  37. package/lib/herdr/reporter.js +217 -0
  38. package/lib/herdr/reporter.js.map +1 -0
  39. package/lib/herdr/state.d.ts +57 -0
  40. package/lib/herdr/state.d.ts.map +1 -0
  41. package/lib/herdr/state.js +62 -0
  42. package/lib/herdr/state.js.map +1 -0
  43. package/lib/index.d.ts.map +1 -1
  44. package/lib/index.js +631 -118
  45. package/lib/index.js.map +1 -1
  46. package/lib/input/actions.d.ts +136 -0
  47. package/lib/input/actions.d.ts.map +1 -0
  48. package/lib/input/actions.js +743 -0
  49. package/lib/input/actions.js.map +1 -0
  50. package/lib/input/ghost.d.ts +46 -0
  51. package/lib/input/ghost.d.ts.map +1 -0
  52. package/lib/input/ghost.js +58 -0
  53. package/lib/input/ghost.js.map +1 -0
  54. package/lib/input/keymap-settings.d.ts +21 -0
  55. package/lib/input/keymap-settings.d.ts.map +1 -0
  56. package/lib/input/keymap-settings.js +28 -0
  57. package/lib/input/keymap-settings.js.map +1 -0
  58. package/lib/input/keymap.d.ts +91 -0
  59. package/lib/input/keymap.d.ts.map +1 -0
  60. package/lib/input/keymap.js +172 -0
  61. package/lib/input/keymap.js.map +1 -0
  62. package/lib/input/match.d.ts +10 -0
  63. package/lib/input/match.d.ts.map +1 -0
  64. package/lib/input/match.js +83 -0
  65. package/lib/input/match.js.map +1 -0
  66. package/lib/input/submission.d.ts +29 -1
  67. package/lib/input/submission.d.ts.map +1 -1
  68. package/lib/input/submission.js +33 -2
  69. package/lib/input/submission.js.map +1 -1
  70. package/lib/input.d.ts +17 -0
  71. package/lib/input.d.ts.map +1 -0
  72. package/lib/input.js +27 -0
  73. package/lib/input.js.map +1 -0
  74. package/lib/keys-command.d.ts +16 -0
  75. package/lib/keys-command.d.ts.map +1 -0
  76. package/lib/keys-command.js +60 -0
  77. package/lib/keys-command.js.map +1 -0
  78. package/lib/queue.d.ts +6 -0
  79. package/lib/queue.d.ts.map +1 -0
  80. package/lib/queue.js +68 -0
  81. package/lib/queue.js.map +1 -0
  82. package/lib/settings-notice.d.ts +19 -0
  83. package/lib/settings-notice.d.ts.map +1 -0
  84. package/lib/settings-notice.js +30 -0
  85. package/lib/settings-notice.js.map +1 -0
  86. package/lib/stash/lock.d.ts +72 -0
  87. package/lib/stash/lock.d.ts.map +1 -0
  88. package/lib/stash/lock.js +374 -0
  89. package/lib/stash/lock.js.map +1 -0
  90. package/lib/stash/paths.d.ts +22 -0
  91. package/lib/stash/paths.d.ts.map +1 -0
  92. package/lib/stash/paths.js +83 -0
  93. package/lib/stash/paths.js.map +1 -0
  94. package/lib/stash/private-fs.d.ts +61 -0
  95. package/lib/stash/private-fs.d.ts.map +1 -0
  96. package/lib/stash/private-fs.js +379 -0
  97. package/lib/stash/private-fs.js.map +1 -0
  98. package/lib/stash/schema.d.ts +57 -0
  99. package/lib/stash/schema.d.ts.map +1 -0
  100. package/lib/stash/schema.js +127 -0
  101. package/lib/stash/schema.js.map +1 -0
  102. package/lib/stash/store.d.ts +92 -0
  103. package/lib/stash/store.d.ts.map +1 -0
  104. package/lib/stash/store.js +262 -0
  105. package/lib/stash/store.js.map +1 -0
  106. package/lib/stash.d.ts +91 -0
  107. package/lib/stash.d.ts.map +1 -0
  108. package/lib/stash.js +296 -0
  109. package/lib/stash.js.map +1 -0
  110. package/lib/theme-settings.d.ts +72 -8
  111. package/lib/theme-settings.d.ts.map +1 -1
  112. package/lib/theme-settings.js +125 -5
  113. package/lib/theme-settings.js.map +1 -1
  114. package/lib/theme-tokens.d.ts +4 -4
  115. package/lib/theme-tokens.d.ts.map +1 -1
  116. package/lib/theme-tokens.js +39 -5
  117. package/lib/theme-tokens.js.map +1 -1
  118. package/lib/todo-guard.d.ts +116 -0
  119. package/lib/todo-guard.d.ts.map +1 -0
  120. package/lib/todo-guard.js +259 -0
  121. package/lib/todo-guard.js.map +1 -0
  122. package/lib/ui/editor.d.ts +58 -1
  123. package/lib/ui/editor.d.ts.map +1 -1
  124. package/lib/ui/editor.js +174 -19
  125. package/lib/ui/editor.js.map +1 -1
  126. package/lib/ui/frame.d.ts +54 -0
  127. package/lib/ui/frame.d.ts.map +1 -0
  128. package/lib/ui/frame.js +79 -0
  129. package/lib/ui/frame.js.map +1 -0
  130. package/lib/ui/gate-input.d.ts +20 -0
  131. package/lib/ui/gate-input.d.ts.map +1 -0
  132. package/lib/ui/gate-input.js +96 -0
  133. package/lib/ui/gate-input.js.map +1 -0
  134. package/lib/ui/history-picker.d.ts +15 -0
  135. package/lib/ui/history-picker.d.ts.map +1 -0
  136. package/lib/ui/history-picker.js +26 -0
  137. package/lib/ui/history-picker.js.map +1 -0
  138. package/lib/ui/markdown.d.ts +11 -2
  139. package/lib/ui/markdown.d.ts.map +1 -1
  140. package/lib/ui/markdown.js +25 -6
  141. package/lib/ui/markdown.js.map +1 -1
  142. package/lib/ui/mermaid.d.ts +44 -0
  143. package/lib/ui/mermaid.d.ts.map +1 -0
  144. package/lib/ui/mermaid.js +178 -0
  145. package/lib/ui/mermaid.js.map +1 -0
  146. package/lib/ui/picker.d.ts +40 -8
  147. package/lib/ui/picker.d.ts.map +1 -1
  148. package/lib/ui/picker.js +79 -24
  149. package/lib/ui/picker.js.map +1 -1
  150. package/lib/ui/prompt.d.ts +32 -0
  151. package/lib/ui/prompt.d.ts.map +1 -0
  152. package/lib/ui/prompt.js +49 -0
  153. package/lib/ui/prompt.js.map +1 -0
  154. package/lib/ui/queue.d.ts +24 -0
  155. package/lib/ui/queue.d.ts.map +1 -0
  156. package/lib/ui/queue.js +57 -0
  157. package/lib/ui/queue.js.map +1 -0
  158. package/lib/ui/stash-picker.d.ts +43 -0
  159. package/lib/ui/stash-picker.d.ts.map +1 -0
  160. package/lib/ui/stash-picker.js +78 -0
  161. package/lib/ui/stash-picker.js.map +1 -0
  162. package/lib/ui/status.d.ts +6 -0
  163. package/lib/ui/status.d.ts.map +1 -1
  164. package/lib/ui/status.js +15 -0
  165. package/lib/ui/status.js.map +1 -1
  166. package/lib/ui/view.d.ts +22 -2
  167. package/lib/ui/view.d.ts.map +1 -1
  168. package/lib/ui/view.js +84 -24
  169. package/lib/ui/view.js.map +1 -1
  170. package/lib/work.d.ts +51 -1
  171. package/lib/work.d.ts.map +1 -1
  172. package/lib/work.js +53 -1
  173. package/lib/work.js.map +1 -1
  174. package/package.json +7 -2
package/lib/index.js CHANGED
@@ -4,18 +4,23 @@ 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";
10
11
  import { PROFILE_NAME, resumeHint } from "./identity.js";
11
12
  import { createStatusFacts } from "./agent/status.js";
12
- import { ModelSwitch, createModelCatalog, parseModelArgument } from "./agent/model.js";
13
+ import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
13
14
  import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
14
15
  import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
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";
21
+ import { createDeferredNotice } from "./settings-notice.js";
22
+ import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
23
+ import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
19
24
  import { resolveConfig } from "./config.js";
20
25
  import { FoldCursor } from "./fold-cursor.js";
21
26
  import { createRestoreRegistry } from "./terminal/restore.js";
@@ -23,21 +28,32 @@ import { WarningSafeTui } from "./terminal/warning-screen.js";
23
28
  import { BELL, shouldRingBell } from "./terminal/bell.js";
24
29
  import { clipboardSequence } from "./terminal/clipboard.js";
25
30
  import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
31
+ import { sessionStartReason } from "./herdr/state.js";
32
+ import { createHerdrReporter } from "./herdr/reporter.js";
26
33
  import { defaultExportFile, transcriptToText } from "./export.js";
27
34
  import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
28
35
  import { detectColourMode } from "./theme-capability.js";
29
- import { defaultSettings, readScope, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
36
+ import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
37
+ import { pendingPrompts } from "./queue.js";
30
38
  import { renderThemeTable } from "./theme-command.js";
39
+ import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
40
+ import { resetSequence } from "./theme-tokens.js";
31
41
  import { SessionId } from '@deepseek-ai/dsh-session';
32
42
  import { formatTokens } from "./tokens.js";
33
43
  import { TranscriptModel } from "./transcript.js";
34
- import { WorkFold, describeTodos } from "./work.js";
44
+ import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
35
45
  import { WorkDock } from "./ui/dock.js";
36
- import { BoxedEditor } from "./ui/editor.js";
46
+ import { GateInputBar } from "./ui/gate-input.js";
47
+ import { HistoryPicker } from "./ui/history-picker.js";
48
+ import { PromptBar } from "./ui/prompt.js";
37
49
  import { MarkdownRenderer } from "./ui/markdown.js";
38
- import { EffortPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
39
- import { StatusBar } from "./ui/status.js";
50
+ import { createMermaidTransform } from "./ui/mermaid.js";
51
+ import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
52
+ import { QueueBar } from "./ui/queue.js";
53
+ import { shortPath, StatusBar } from "./ui/status.js";
40
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";
41
57
  export const name = 'tui';
42
58
  /**
43
59
  * Services the surface cannot run without.
@@ -47,14 +63,18 @@ export const name = 'tui';
47
63
  * every card to a bare generic row.
48
64
  */
49
65
  export const inject = ['agents', 'tools'];
50
- /** Keys the surface answers itself, listed wherever the reader asks for help. */
51
- const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+y nested calls · shift+tab reasoning · ctrl+t reasoning effort · ctrl+b back to this session · ctrl+c interrupt or exit';
52
- /** The one thing to say about a view a reader did not open. */
53
- const LOCAL_KEYS_BACK = 'ctrl+b returns';
66
+ /** One second in the unit a chord window is scheduled in. */
67
+ const MS_PER_SECOND = 1000;
68
+ /** What the back hint names when the reader has unbound the key it would advertise. */
69
+ const BACK_HINT_FALLBACK = 'ctrl+b';
70
+ /** What the reader presses to leave a view they did not open. */
71
+ const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
54
72
  /** Stored sessions titled at once when the picker opens. */
55
73
  const TITLE_CONCURRENCY = 4;
56
74
  /** How often the running-state clock repaints while a turn is open. */
57
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';
58
78
  function asRecord(value) {
59
79
  return typeof value === 'object' && value !== null ? value : undefined;
60
80
  }
@@ -108,11 +128,14 @@ export function apply(ctx, config) {
108
128
  * late-bound read that the injection point and the change event both use.
109
129
  */
110
130
  let readSection = () => defaultSettings();
111
- /** A refused settings edit, kept until the surface can show it: stderr is behind the alt screen. */
112
- let pendingSettingsProblem;
131
+ /**
132
+ * A refused settings edit, kept until the surface can show it: stderr is
133
+ * behind the alt screen, and the section is read on a schedule of its own.
134
+ */
135
+ const settingsNotice = createDeferredNotice();
113
136
  let current = createTheme(themeMode());
114
- const applyTheme = () => {
115
- current = createTheme(themeMode(), toOverrides(readSection()));
137
+ const applyTheme = (section) => {
138
+ current = createTheme(themeMode(), toOverrides(section));
116
139
  };
117
140
  const theme = {
118
141
  get revision() { return current.revision; },
@@ -137,13 +160,63 @@ export function apply(ctx, config) {
137
160
  */
138
161
  const viewState = { ...DEFAULT_VIEW_STATE };
139
162
  /**
140
- * Seed the nested-call display the reader configured.
163
+ * How a reply's mermaid fences draw, seeded from the reader's section.
141
164
  *
142
- * The key toggles it for one session, but a settings edit is a deliberate act,
143
- * so it re-seeds and becomes the new starting point.
165
+ * The transform reads this per render instead of capturing it, because the
166
+ * settings document is hot-reloaded and a session already on screen has to
167
+ * follow the edit.
144
168
  */
145
- const applyDisplay = () => {
146
- viewState.expandSubCalls = readSection().subcalls === 'inline';
169
+ let mermaidMode = defaultSettings().mermaid;
170
+ /** The keys that start a chord, and how long one waits; the settings document owns all of it. */
171
+ let prefixKeys = DEFAULT_PREFIX_KEYS;
172
+ let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
173
+ /** Every action's keys in force; the settings document owns it and a press reads it live. */
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;
179
+ /**
180
+ * The chord between a prefix and the action that follows it.
181
+ *
182
+ * Built here, before the terminal exists, because the settings scope applies
183
+ * first and has to be able to end a chord armed under the keymap it replaced.
184
+ * The repaint the window also wants is late-bound: only a key press reaches
185
+ * it, and no key can arrive before the surface has started.
186
+ */
187
+ const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
188
+ /**
189
+ * Seed the display the reader configured.
190
+ *
191
+ * The key toggles nested calls for one session, but a settings edit is a
192
+ * deliberate act, so it re-seeds and becomes the new starting point; the
193
+ * mermaid mode has no key of its own and only ever comes from the document.
194
+ */
195
+ const applyDisplay = (section) => {
196
+ viewState.expandSubCalls = section.subcalls === 'inline';
197
+ mermaidMode = section.mermaid;
198
+ prefixKeys = section.prefixes;
199
+ prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
200
+ keymap = section.keymap;
201
+ // Installed where the library reads it, so a remap lands on the next press
202
+ // rather than at the next restart.
203
+ installKeybindings(keymap);
204
+ historyEnabled = section.history.enabled;
205
+ historyGhost = section.history.ghost;
206
+ historyMaxEntries = section.history.maxEntries;
207
+ // A chord armed under the keymap the reader just replaced is not their chord.
208
+ keyChord.disarm();
209
+ };
210
+ /**
211
+ * Read the reader's section once and apply everything it configures.
212
+ *
213
+ * One read per change, because a refused section is reported on the way past:
214
+ * reading it once per field would show the reader the same refusal twice.
215
+ */
216
+ const applySettings = () => {
217
+ const section = readSection();
218
+ applyTheme(section);
219
+ applyDisplay(section);
147
220
  };
148
221
  /**
149
222
  * Own the section, so the harness validates and persists it for the reader.
@@ -154,10 +227,23 @@ export function apply(ctx, config) {
154
227
  * may be touched in.
155
228
  */
156
229
  ctx.inject(['settings'], settingsCtx => {
157
- const scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
158
- readSection = () => readScope(scope, message => { pendingSettingsProblem = message; });
159
- applyTheme();
160
- applyDisplay();
230
+ // Registration parses the document against the schema, so a section the
231
+ // schema itself refuses throws here — inside a fiber whose failure the
232
+ // screen never shows. Reporting it through the same holder keeps a typo
233
+ // from costing the reader every setting they wrote, silently.
234
+ let scope;
235
+ try {
236
+ scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
237
+ }
238
+ catch (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');
243
+ return;
244
+ }
245
+ readSection = () => readScope(scope, message => settingsNotice.post(message));
246
+ applySettings();
161
247
  });
162
248
  /**
163
249
  * The agent scope the tool presenter resolves against.
@@ -169,6 +255,33 @@ export function apply(ctx, config) {
169
255
  */
170
256
  let presentScope;
171
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
+ };
172
285
  const work = new WorkFold();
173
286
  const modelSwitch = new ModelSwitch();
174
287
  const agentPresets = createPresetRoster(ctx);
@@ -197,7 +310,7 @@ export function apply(ctx, config) {
197
310
  let jobs = [];
198
311
  const roster = new SubagentRoster();
199
312
  const subagentControl = createSubagentControl(ctx);
200
- const markdown = new MarkdownRenderer(theme.markdown);
313
+ const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
201
314
  const restore = createRestoreRegistry();
202
315
  const terminal = new ProcessTerminal();
203
316
  const tui = new WarningSafeTui(terminal);
@@ -207,14 +320,22 @@ export function apply(ctx, config) {
207
320
  let viewedSession = resolved.sessionId;
208
321
  let pending;
209
322
  let pendingPicker;
210
- /** A pick being vetted owns the keyboard: a key would answer what is unanswered. */
323
+ /** A pick being vetted owns the list, not the keyboard: filtering stays live while its verdict is read. */
211
324
  let vetting = false;
212
325
  const view = new TranscriptView(model, theme, markdown, {
213
326
  state: () => viewState,
214
327
  gate: () => pending?.gate.card(),
215
328
  picker: () => pendingPicker?.picker.card(),
329
+ keys: () => keymap,
216
330
  });
217
- const editor = new BoxedEditor(tui, theme.editor);
331
+ // The key map goes in before the bar exists, so no press can be read as the
332
+ // send the library submits on by default. A settings document read after this
333
+ // point installs over it, which is why the bar reads the map per press.
334
+ installKeybindings(keymap);
335
+ const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
336
+ // Answers are written in the reader's own editor, which is why a question
337
+ // borrows the bar instead of drawing a second one beside it.
338
+ const promptBar = new PromptBar(editor);
218
339
  const disposers = [];
219
340
  // The presenter closure outlives the composition's own teardown, so it must
220
341
  // not keep an agent alive after its world unwinds.
@@ -232,25 +353,56 @@ export function apply(ctx, config) {
232
353
  jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
233
354
  tui.requestRender();
234
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;
235
359
  const statusFacts = createStatusFacts(ctx, {
236
360
  sessionId: () => activeSession,
237
361
  activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
238
362
  override: () => modelSwitch.current(),
239
363
  home: process.env.HOME,
364
+ chord: () => keyChord.hint(),
365
+ // Read per paint rather than written into the marker the view left behind:
366
+ // a hint stored with the transcript would keep naming the key of the day it
367
+ // was written, and the reader may remap it with the row already on screen.
368
+ back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
369
+ stash: () => stash?.entryCount,
240
370
  });
241
371
  const statusBar = new StatusBar(statusFacts, theme);
242
372
  const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
373
+ // The queue is read from the agent this terminal drives rather than from the
374
+ // session on screen, because it sits on the editor that submits to that agent.
375
+ const queue = new QueueBar(() => pendingPrompts(ctx, liveSession(activeSession)), theme);
243
376
  // Only a running turn has anything to say over time, so the clock stops with it.
244
377
  const statusTicker = setInterval(() => {
245
378
  if (turnOpen)
246
379
  tui.requestRender();
247
380
  }, STATUS_TICK_MS);
248
381
  disposers.push(() => clearInterval(statusTicker));
382
+ // A window that outlived the surface would repaint a screen that is gone.
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();
249
391
  restore.add(() => tui.stop());
250
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);
251
402
  restore.restore();
252
403
  for (const dispose of disposers.reverse())
253
404
  dispose();
405
+ return released.finally(unregisterExit);
254
406
  });
255
407
  tui.setLayoutRoot(new VStack([
256
408
  {
@@ -262,7 +414,11 @@ export function apply(ctx, config) {
262
414
  // Work state earns rows only when there is some: a dock that always
263
415
  // occupied a row would cost every conversation one line of transcript.
264
416
  { component: dock, basis: 'auto', shrink: 0, minSize: 0 },
265
- { component: new VStack([{ component: editor, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 1 },
417
+ // Queued input earns rows only while something is waiting, and it gives
418
+ // them up before the editor does: the bar being typed in outranks what is
419
+ // waiting behind it.
420
+ { component: queue, basis: 'auto', shrink: 2, minSize: 0 },
421
+ { component: new VStack([{ component: promptBar, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 0 },
266
422
  { component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
267
423
  ]));
268
424
  tui.setFocus(editor);
@@ -271,37 +427,104 @@ export function apply(ctx, config) {
271
427
  return;
272
428
  exited = true;
273
429
  clearInterval(statusTicker);
274
- // Hand the window label back before the screen does, so a shell that sets
275
- // 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.
276
434
  terminal.write(CLEAR_TITLE);
277
435
  restore.restore();
278
- // Everything below is written after the release: the alternate screen closes
279
- // over whatever was painted on it, and a failure nobody can read is not a
280
- // failure that was reported.
281
- if (reason !== undefined)
282
- terminal.write(`\ndsh-tui: ${reason}\n`);
283
- // The hint is computed here rather than read from the context because only
284
- // the surface knows which session it is leaving: a fork or a switch moves it.
285
- // A run that opened nothing has nothing to offer back: the identity it was
286
- // launched with names no log, so pointing at it would send the reader to a
287
- // conversation that does not exist.
288
- if (sessionOpened)
289
- terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
290
- 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
+ });
291
455
  };
292
456
  const openGate = (next) => {
293
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);
294
461
  // A gate owns the keyboard: the editor must not collect the decision keys.
295
462
  editor.disableSubmit = true;
296
463
  tui.setFocus(null);
464
+ // A question is answered in this editor, which the gate draws under the row
465
+ // being answered, so the hardware cursor belongs to it while it is borrowed.
466
+ // It is set after the focus is cleared, which unmarks the component it left.
467
+ editor.focused = next.kind === 'question';
297
468
  tui.requestRender();
298
469
  };
299
470
  const closeGate = () => {
300
471
  pending = undefined;
472
+ herdr.unblock();
301
473
  editor.disableSubmit = false;
474
+ // The question is answered or skipped, so the reader gets their prompt back
475
+ // in the bar they left it in.
476
+ promptBar.giveBack();
302
477
  tui.setFocus(editor);
303
478
  tui.requestRender();
304
479
  };
480
+ /**
481
+ * What each key the surface answers itself does; false hands the press back.
482
+ *
483
+ * Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
484
+ * without a handler here fails to compile rather than doing nothing.
485
+ */
486
+ const surfaceActions = {
487
+ toolDetail: () => {
488
+ viewState.expandCards = !viewState.expandCards;
489
+ tui.requestRender();
490
+ return true;
491
+ },
492
+ subCalls: () => {
493
+ viewState.expandSubCalls = !viewState.expandSubCalls;
494
+ tui.requestRender();
495
+ return true;
496
+ },
497
+ reasoning: () => {
498
+ viewState.expandReasoning = !viewState.expandReasoning;
499
+ tui.requestRender();
500
+ return true;
501
+ },
502
+ effort: () => {
503
+ void openEffortPicker();
504
+ return true;
505
+ },
506
+ history: () => {
507
+ void openHistoryPicker();
508
+ return true;
509
+ },
510
+ back: () => {
511
+ if (viewedSession !== activeSession)
512
+ void showAgentSession();
513
+ return true;
514
+ },
515
+ interrupt: () => {
516
+ // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
517
+ // decides: stop the work in flight, or leave when there is none.
518
+ if (!turnOpen) {
519
+ requestExit(0);
520
+ return true;
521
+ }
522
+ agent?.interrupt();
523
+ model.notice('interrupt requested');
524
+ tui.requestRender();
525
+ return true;
526
+ },
527
+ };
305
528
  disposers.push(tui.addInputListener(data => {
306
529
  // A key arrives as a press and a release once the surface asks the terminal
307
530
  // to report key events, and this library drops the release only for the
@@ -333,13 +556,19 @@ export function apply(ctx, config) {
333
556
  return { consume: true };
334
557
  }
335
558
  if (pendingPicker !== undefined) {
336
- if (vetting)
337
- return { consume: true };
338
559
  const action = pendingPicker.picker.handleKey(data);
339
560
  if (action === undefined) {
340
561
  tui.requestRender();
341
562
  return { consume: true };
342
563
  }
564
+ if (vetting) {
565
+ // A refusal check must not take the keyboard with it: the reader keeps
566
+ // filtering and can still leave, while a second pick waits for the
567
+ // first verdict rather than racing it.
568
+ if (action.kind === 'cancel')
569
+ settlePicker(undefined);
570
+ return { consume: true };
571
+ }
343
572
  if (action.kind === 'cancel') {
344
573
  settlePicker(undefined);
345
574
  return { consume: true };
@@ -370,44 +599,23 @@ export function apply(ctx, config) {
370
599
  })();
371
600
  return { consume: true };
372
601
  }
373
- // Detail the reader asked for is always available, even mid-turn: the
602
+ // A chord is the surface's second key: the prefix is consumed and the
603
+ // footer names what may follow, while a key that finishes nothing is handed
604
+ // on. Detail the reader asked for is always available, even mid-turn: the
374
605
  // collapsed view is a default, not the only state.
375
- if (matchesKey(data, 'ctrl+o')) {
376
- viewState.expandCards = !viewState.expandCards;
377
- tui.requestRender();
378
- return { consume: true };
379
- }
380
- if (matchesKey(data, 'shift+tab')) {
381
- viewState.expandReasoning = !viewState.expandReasoning;
382
- tui.requestRender();
383
- return { consume: true };
384
- }
385
- if (matchesKey(data, 'ctrl+y')) {
386
- viewState.expandSubCalls = !viewState.expandSubCalls;
606
+ const chorded = keyChord.handle(data);
607
+ if (chorded !== undefined) {
608
+ if (chorded.kind === 'action')
609
+ runSubmission(chorded.binding.submission);
387
610
  tui.requestRender();
388
611
  return { consume: true };
389
612
  }
390
- if (matchesKey(data, 'ctrl+t')) {
391
- void openEffortPicker();
392
- return { consume: true };
393
- }
394
- if (matchesKey(data, 'ctrl+b')) {
395
- if (viewedSession !== activeSession)
396
- void showAgentSession();
397
- return { consume: true };
398
- }
399
- // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
400
- // decides: stop the work in flight, or leave when there is none.
401
- if (!matchesKey(data, 'ctrl+c'))
402
- return undefined;
403
- if (turnOpen) {
404
- agent?.interrupt();
405
- model.notice('interrupt requested');
406
- tui.requestRender();
407
- return { consume: true };
613
+ for (const binding of surfaceBindings(keymap)) {
614
+ if (!matchesKey(data, binding.key))
615
+ continue;
616
+ return surfaceActions[binding.action]() ? { consume: true } : undefined;
408
617
  }
409
- requestExit(0);
410
- return { consume: true };
618
+ return undefined;
411
619
  }));
412
620
  const registry = () => ctx.get('commands');
413
621
  /**
@@ -433,13 +641,14 @@ export function apply(ctx, config) {
433
641
  const askForSession = async (history, sessions) => {
434
642
  const titles = new Map();
435
643
  void loadTitles(history, sessions, titles);
436
- const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
644
+ const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
437
645
  return picked === undefined ? undefined : SessionId(picked);
438
646
  };
439
647
  /** Give the keyboard back to the editor and answer whoever opened the picker. */
440
648
  const settlePicker = (id) => {
441
649
  const settle = pendingPicker?.settle;
442
650
  pendingPicker = undefined;
651
+ herdr.unblock();
443
652
  editor.disableSubmit = false;
444
653
  tui.setFocus(editor);
445
654
  settle?.(id);
@@ -466,10 +675,39 @@ export function apply(ctx, config) {
466
675
  /** Take the keyboard for a picker and answer with the id it settled on. */
467
676
  const openPicker = (picker, vet) => new Promise(resolve => {
468
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);
469
681
  editor.disableSubmit = true;
470
682
  tui.setFocus(null);
471
683
  tui.requestRender();
472
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
+ };
473
711
  /** The roster as the picker paints it, refreshed when the picker opens. */
474
712
  let presetRows = [];
475
713
  /** Choose the mode a session that has not started yet will run. */
@@ -477,8 +715,30 @@ export function apply(ctx, config) {
477
715
  if (agentPresets === undefined)
478
716
  return undefined;
479
717
  presetRows = await agentPresets.list();
480
- return await openPicker(new PresetPicker(() => presetRows, () => currentId));
718
+ return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
481
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() });
482
742
  /** Title the listed sessions without making the reader wait for the slowest log. */
483
743
  const loadTitles = async (history, sessions, titles) => {
484
744
  const queue = [...sessions];
@@ -604,9 +864,7 @@ export function apply(ctx, config) {
604
864
  model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
605
865
  }
606
866
  const returned = id === activeSession && previous !== activeSession;
607
- model.marker(returned
608
- ? 'back to the session this terminal drives'
609
- : `viewing ${id}${id === activeSession ? '' : ` — ${LOCAL_KEYS_BACK}`}`);
867
+ model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
610
868
  tui.requestRender();
611
869
  };
612
870
  const showAgentSession = async () => {
@@ -690,6 +948,13 @@ export function apply(ctx, config) {
690
948
  activeSession = id;
691
949
  viewedSession = id;
692
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
+ });
693
958
  // A session with no history to fold still has to present its first live
694
959
  // card through the right scope, so the scope is set before any event can.
695
960
  presentScope = handle.agent;
@@ -966,19 +1231,11 @@ export function apply(ctx, config) {
966
1231
  }
967
1232
  const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
968
1233
  switch (command.kind) {
969
- case 'current': {
970
- const facts = statusFacts();
971
- const current = modelSwitch.current();
972
- const route = current === undefined
973
- // Without a choice of its own the surface reports what the next step
974
- // would actually use, not that it has no opinion.
975
- ? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
976
- : `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
977
- const providers = catalog.providers().map(provider => provider.id);
978
- model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model>[/<effort>] switches, /model <provider> lists its models`);
979
- tui.requestRender();
1234
+ case 'current':
1235
+ // Choosing by eye is the point of a terminal selector; the picker
1236
+ // heads itself with the route the next step will actually use.
1237
+ void openModelPicker();
980
1238
  return;
981
- }
982
1239
  case 'list-models':
983
1240
  void catalog.models(command.provider).then(entries => {
984
1241
  model.notice(entries.length === 0
@@ -1038,6 +1295,124 @@ export function apply(ctx, config) {
1038
1295
  model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
1039
1296
  tui.requestRender();
1040
1297
  };
1298
+ /** Whether a model picker's catalog is being read, so a second key cannot race it. */
1299
+ let openingModels = false;
1300
+ /**
1301
+ * The route the next step would actually use.
1302
+ *
1303
+ * Without a choice of its own the surface reports the composition default,
1304
+ * not that it has no opinion: the picker's heading and its marked row have to
1305
+ * agree with the status line about the route in force.
1306
+ */
1307
+ const effectiveRoute = () => {
1308
+ const chosen = modelSwitch.current();
1309
+ if (chosen !== undefined)
1310
+ return chosen;
1311
+ const facts = statusFacts();
1312
+ if (facts.provider === undefined || facts.model === undefined)
1313
+ return undefined;
1314
+ return {
1315
+ provider: facts.provider,
1316
+ model: facts.model,
1317
+ ...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
1318
+ };
1319
+ };
1320
+ /** Put the reader's route choice in force for the next step. */
1321
+ const applyRoute = (route) => {
1322
+ const current = effectiveRoute();
1323
+ // The levels belong to the route, so a switch clears an explicit effort
1324
+ // while re-picking the route already in force is not a switch.
1325
+ const keep = current !== undefined && current.provider === route.provider && current.model === route.model
1326
+ ? current.reasoningEffort
1327
+ : undefined;
1328
+ modelSwitch.choose({
1329
+ provider: route.provider,
1330
+ model: route.model,
1331
+ ...keep === undefined ? {} : { reasoningEffort: keep },
1332
+ });
1333
+ model.notice(`model set to ${route.provider}/${route.model} for the next step`);
1334
+ tui.requestRender();
1335
+ };
1336
+ /**
1337
+ * Offer the levels a route advertises, after the route is already in force.
1338
+ *
1339
+ * Cancelling the list is a real choice — the reader keeps the model with the
1340
+ * provider's own default — which is why the route is applied first. The list
1341
+ * is read before the picker opens because the rows are the route's own
1342
+ * metadata; a menu painted before that arrived could offer a level the
1343
+ * request would then be refused for.
1344
+ */
1345
+ const offerRouteEfforts = async (route) => {
1346
+ if (catalog === undefined)
1347
+ return;
1348
+ try {
1349
+ const info = await catalog.efforts(route.provider, route.model);
1350
+ const efforts = info?.efforts ?? [];
1351
+ if (efforts.length === 0)
1352
+ return;
1353
+ const current = effectiveRoute();
1354
+ const effective = current !== undefined && current.provider === route.provider && current.model === route.model
1355
+ ? current.reasoningEffort
1356
+ : undefined;
1357
+ const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
1358
+ if (picked !== undefined)
1359
+ applyEffort(route.provider, route.model, picked);
1360
+ }
1361
+ catch (error) {
1362
+ model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1363
+ }
1364
+ tui.requestRender();
1365
+ };
1366
+ /**
1367
+ * Offer every model the configured routes advertise.
1368
+ *
1369
+ * Rows come from the routes the llm service registered — the providers this
1370
+ * deployment configured — and each provider's models join the open list as
1371
+ * its catalog resolves, so the picker is filterable before the slowest
1372
+ * adapter answers. A route whose catalog cannot be read stays reachable by
1373
+ * name through the text form rather than by an explanation in the list.
1374
+ */
1375
+ const openModelPicker = async () => {
1376
+ if (catalog === undefined) {
1377
+ model.notice('this profile has no llm service, so models cannot be listed or switched');
1378
+ tui.requestRender();
1379
+ return;
1380
+ }
1381
+ const providers = catalog.providers();
1382
+ if (providers.length === 0) {
1383
+ model.notice('no provider is configured; add one before choosing a model');
1384
+ tui.requestRender();
1385
+ return;
1386
+ }
1387
+ if (openingModels)
1388
+ return;
1389
+ openingModels = true;
1390
+ try {
1391
+ const routes = [];
1392
+ for (const provider of providers) {
1393
+ void catalog.models(provider.id).then(entries => {
1394
+ if (entries.length === 0)
1395
+ return;
1396
+ routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
1397
+ tui.requestRender();
1398
+ }).catch(() => {
1399
+ // One adapter's discovery failure is not the list's to explain.
1400
+ });
1401
+ }
1402
+ const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
1403
+ if (picked === undefined)
1404
+ return;
1405
+ const route = readModelRouteKey(picked);
1406
+ if (route === undefined)
1407
+ return;
1408
+ applyRoute(route);
1409
+ await offerRouteEfforts(route);
1410
+ }
1411
+ finally {
1412
+ openingModels = false;
1413
+ tui.requestRender();
1414
+ }
1415
+ };
1041
1416
  /**
1042
1417
  * Offer the efforts the route in force advertises.
1043
1418
  *
@@ -1067,7 +1442,7 @@ export function apply(ctx, config) {
1067
1442
  model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
1068
1443
  return;
1069
1444
  }
1070
- const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`));
1445
+ const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
1071
1446
  if (picked !== undefined)
1072
1447
  applyEffort(facts.provider, facts.model, picked);
1073
1448
  }
@@ -1136,8 +1511,26 @@ export function apply(ctx, config) {
1136
1511
  ? []
1137
1512
  : registry()?.list(current).map(command => `/${command.name}`) ?? [];
1138
1513
  const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
1139
- return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${LOCAL_KEYS}`;
1514
+ return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
1515
+ };
1516
+ /**
1517
+ * Whether the agent this surface is driving is in plan mode.
1518
+ *
1519
+ * The dock's fold is the fallback, for a composition without the plan
1520
+ * package: with the controller present its answer is the agent's own state
1521
+ * rather than a replay of the events this surface happened to see.
1522
+ */
1523
+ const planState = () => {
1524
+ const current = agent?.agent;
1525
+ if (current === undefined)
1526
+ return undefined;
1527
+ const presets = ctx.get('agentPresets');
1528
+ return readPlanState({
1529
+ direct: name => ctx.get(name),
1530
+ forAgent: (target, name) => presets?.serviceFor(target, name),
1531
+ }, current);
1140
1532
  };
1533
+ const planActive = () => planSelectedActive(planState(), work.state().planMode);
1141
1534
  const runCommand = (name, line) => {
1142
1535
  const current = agent;
1143
1536
  const commands = registry();
@@ -1183,8 +1576,54 @@ export function apply(ctx, config) {
1183
1576
  ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1184
1577
  });
1185
1578
  };
1186
- editor.onSubmit = text => {
1187
- const submission = classifySubmission(text);
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
+ };
1620
+ /**
1621
+ * Carry out one classified line, wherever it was asked for.
1622
+ *
1623
+ * A chord asks for the same things the command line does, so both arrive
1624
+ * here: a chord cannot behave differently from the command it stands for.
1625
+ */
1626
+ const runSubmission = (submission) => {
1188
1627
  switch (submission.kind) {
1189
1628
  case 'empty':
1190
1629
  return;
@@ -1223,9 +1662,51 @@ export function apply(ctx, config) {
1223
1662
  model.notice(line);
1224
1663
  tui.requestRender();
1225
1664
  return;
1665
+ case 'keys': {
1666
+ const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
1667
+ if (submission.argument !== '' && layer === undefined) {
1668
+ model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
1669
+ }
1670
+ else {
1671
+ for (const line of renderKeymap(keymap, layer))
1672
+ model.notice(line);
1673
+ }
1674
+ tui.requestRender();
1675
+ return;
1676
+ }
1226
1677
  case 'copy':
1227
1678
  runCopyCommand();
1228
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;
1229
1710
  case 'status': {
1230
1711
  const facts = statusFacts();
1231
1712
  const context = facts.contextTokens === undefined
@@ -1263,6 +1744,11 @@ export function apply(ctx, config) {
1263
1744
  tui.requestRender();
1264
1745
  });
1265
1746
  return;
1747
+ // Plan mode is a command the harness owns, so the chord asks the host for
1748
+ // the state and names the command for the other one: one key, both ways.
1749
+ case 'plan':
1750
+ runCommand('plan', planToggleLine(planActive()));
1751
+ return;
1266
1752
  case 'command':
1267
1753
  runCommand(submission.name, submission.line);
1268
1754
  return;
@@ -1281,6 +1767,17 @@ export function apply(ctx, config) {
1281
1767
  }
1282
1768
  }
1283
1769
  };
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
+ };
1284
1781
  disposers.push(ctx.on('session/event', (session, event) => {
1285
1782
  // The surface state — activity, timer, title, bell, job board — belongs to
1286
1783
  // the agent this terminal drives, even while a child is on screen.
@@ -1289,18 +1786,24 @@ export function apply(ctx, config) {
1289
1786
  turnOpen = true;
1290
1787
  turnStartedAt = Date.now();
1291
1788
  terminal.write(windowTitle(process.cwd(), 'working'));
1789
+ herdr.working();
1292
1790
  }
1293
1791
  if (event.type === 'turn/end') {
1294
1792
  const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
1295
1793
  turnOpen = false;
1296
1794
  turnStartedAt = undefined;
1297
1795
  terminal.write(windowTitle(process.cwd(), 'ready'));
1796
+ herdr.idle();
1298
1797
  if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
1299
1798
  terminal.write(BELL);
1300
1799
  // A job the turn started may have settled while the reader was watching
1301
1800
  // something else, and nothing else refreshes a live board.
1302
1801
  refreshJobs();
1303
1802
  }
1803
+ // A claim or a discard changes what is queued, and that belongs to this
1804
+ // session even while the transcript shows a child's conversation.
1805
+ if (event.type === 'agent/inbox/spliced')
1806
+ tui.requestRender();
1304
1807
  }
1305
1808
  if (session.id !== viewedSession)
1306
1809
  return;
@@ -1338,7 +1841,7 @@ export function apply(ctx, config) {
1338
1841
  if (request.agent.id !== activeSession)
1339
1842
  return next();
1340
1843
  return new Promise(resolve => {
1341
- const gate = new ApprovalGate(request.toolName, request.reason);
1844
+ const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
1342
1845
  request.signal?.addEventListener('abort', () => {
1343
1846
  gate.cancel();
1344
1847
  if (pending?.gate === gate)
@@ -1356,20 +1859,33 @@ export function apply(ctx, config) {
1356
1859
  if (questions.length === 0)
1357
1860
  return next();
1358
1861
  return new Promise(resolve => {
1359
- const gate = new QuestionGate(questions);
1862
+ const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
1360
1863
  // The seam takes mutable selection arrays and an optional custom field, so
1361
1864
  // the read-only gate answer is copied into that exact shape here.
1362
- openGate({
1363
- kind: 'question',
1364
- gate,
1365
- settle: answers => resolve({
1865
+ const settle = (answers) => {
1866
+ request.signal?.removeEventListener('abort', onAbort);
1867
+ resolve({
1366
1868
  answers: answers.map(answer => ({
1367
1869
  id: answer.id,
1368
1870
  selected: [...answer.selected],
1369
1871
  ...(answer.custom === undefined ? {} : { custom: answer.custom }),
1370
1872
  })),
1371
- }),
1372
- });
1873
+ });
1874
+ };
1875
+ // A question whose caller is gone has no reader, so the gate must release
1876
+ // the keyboard instead of collecting an answer the aborted call discards.
1877
+ const onAbort = () => {
1878
+ gate.cancel();
1879
+ if (pending?.gate === gate)
1880
+ closeGate();
1881
+ settle([]);
1882
+ };
1883
+ openGate({ kind: 'question', gate, settle });
1884
+ request.signal?.addEventListener('abort', onAbort, { once: true });
1885
+ // A signal that aborted before the listener existed never emits, so the
1886
+ // state has to be read once after subscribing.
1887
+ if (request.signal?.aborted === true)
1888
+ onAbort();
1373
1889
  });
1374
1890
  }));
1375
1891
  disposers.push(ctx.on('commands/change', () => installCompletion()));
@@ -1404,17 +1920,11 @@ export function apply(ctx, config) {
1404
1920
  disposers.push(ctx.on('settings/updated', ns => {
1405
1921
  if (String(ns) !== TUI_SETTINGS_NAMESPACE)
1406
1922
  return;
1407
- pendingSettingsProblem = undefined;
1408
- applyTheme();
1409
- applyDisplay();
1923
+ applySettings();
1410
1924
  // Both caches hold rows under the old table, so they have to be told the
1411
1925
  // table moved; a repaint alone would reuse what they already stored.
1412
1926
  markdown.invalidate();
1413
1927
  view.invalidate();
1414
- if (pendingSettingsProblem !== undefined) {
1415
- model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
1416
- pendingSettingsProblem = undefined;
1417
- }
1418
1928
  tui.requestRender();
1419
1929
  }));
1420
1930
  const degraded = describeMissingOptional(probe);
@@ -1458,13 +1968,16 @@ export function apply(ctx, config) {
1458
1968
  if (!resolved.resumePicker)
1459
1969
  await presetFor(resolved.sessionId, resolved.resume, undefined);
1460
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();
1461
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();
1462
1978
  // A refused settings edit is only visible now that the surface owns the
1463
- // screen; a bare stderr line would have been hidden behind it.
1464
- if (pendingSettingsProblem !== undefined) {
1465
- model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
1466
- pendingSettingsProblem = undefined;
1467
- }
1979
+ // screen; whatever the scope found before this point prints here instead.
1980
+ settingsNotice.open(message => model.notice(message));
1468
1981
  await boot();
1469
1982
  };
1470
1983
  void start().catch((error) => {