@sagmans/dsh-tui 0.2.0 → 0.3.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 (109) hide show
  1. package/README.md +126 -21
  2. package/lib/agent/model.d.ts +26 -0
  3. package/lib/agent/model.d.ts.map +1 -1
  4. package/lib/agent/model.js +41 -0
  5. package/lib/agent/model.js.map +1 -1
  6. package/lib/agent/status.d.ts +10 -0
  7. package/lib/agent/status.d.ts.map +1 -1
  8. package/lib/agent/status.js +2 -0
  9. package/lib/agent/status.js.map +1 -1
  10. package/lib/cards.d.ts +1 -1
  11. package/lib/cards.d.ts.map +1 -1
  12. package/lib/cards.js +4 -3
  13. package/lib/cards.js.map +1 -1
  14. package/lib/gates.d.ts +145 -2
  15. package/lib/gates.d.ts.map +1 -1
  16. package/lib/gates.js +335 -40
  17. package/lib/gates.js.map +1 -1
  18. package/lib/index.d.ts.map +1 -1
  19. package/lib/index.js +383 -102
  20. package/lib/index.js.map +1 -1
  21. package/lib/input/actions.d.ts +136 -0
  22. package/lib/input/actions.d.ts.map +1 -0
  23. package/lib/input/actions.js +740 -0
  24. package/lib/input/actions.js.map +1 -0
  25. package/lib/input/keymap-settings.d.ts +21 -0
  26. package/lib/input/keymap-settings.d.ts.map +1 -0
  27. package/lib/input/keymap-settings.js +28 -0
  28. package/lib/input/keymap-settings.js.map +1 -0
  29. package/lib/input/keymap.d.ts +91 -0
  30. package/lib/input/keymap.d.ts.map +1 -0
  31. package/lib/input/keymap.js +170 -0
  32. package/lib/input/keymap.js.map +1 -0
  33. package/lib/input/match.d.ts +10 -0
  34. package/lib/input/match.d.ts.map +1 -0
  35. package/lib/input/match.js +83 -0
  36. package/lib/input/match.js.map +1 -0
  37. package/lib/input/submission.d.ts +6 -1
  38. package/lib/input/submission.d.ts.map +1 -1
  39. package/lib/input/submission.js +6 -2
  40. package/lib/input/submission.js.map +1 -1
  41. package/lib/input.d.ts +17 -0
  42. package/lib/input.d.ts.map +1 -0
  43. package/lib/input.js +27 -0
  44. package/lib/input.js.map +1 -0
  45. package/lib/keys-command.d.ts +16 -0
  46. package/lib/keys-command.d.ts.map +1 -0
  47. package/lib/keys-command.js +60 -0
  48. package/lib/keys-command.js.map +1 -0
  49. package/lib/queue.d.ts +6 -0
  50. package/lib/queue.d.ts.map +1 -0
  51. package/lib/queue.js +68 -0
  52. package/lib/queue.js.map +1 -0
  53. package/lib/settings-notice.d.ts +19 -0
  54. package/lib/settings-notice.d.ts.map +1 -0
  55. package/lib/settings-notice.js +30 -0
  56. package/lib/settings-notice.js.map +1 -0
  57. package/lib/theme-settings.d.ts +43 -8
  58. package/lib/theme-settings.d.ts.map +1 -1
  59. package/lib/theme-settings.js +66 -3
  60. package/lib/theme-settings.js.map +1 -1
  61. package/lib/theme-tokens.d.ts +4 -4
  62. package/lib/theme-tokens.d.ts.map +1 -1
  63. package/lib/theme-tokens.js +31 -5
  64. package/lib/theme-tokens.js.map +1 -1
  65. package/lib/ui/editor.d.ts +18 -1
  66. package/lib/ui/editor.d.ts.map +1 -1
  67. package/lib/ui/editor.js +80 -19
  68. package/lib/ui/editor.js.map +1 -1
  69. package/lib/ui/frame.d.ts +54 -0
  70. package/lib/ui/frame.d.ts.map +1 -0
  71. package/lib/ui/frame.js +79 -0
  72. package/lib/ui/frame.js.map +1 -0
  73. package/lib/ui/gate-input.d.ts +20 -0
  74. package/lib/ui/gate-input.d.ts.map +1 -0
  75. package/lib/ui/gate-input.js +96 -0
  76. package/lib/ui/gate-input.js.map +1 -0
  77. package/lib/ui/markdown.d.ts +11 -2
  78. package/lib/ui/markdown.d.ts.map +1 -1
  79. package/lib/ui/markdown.js +25 -6
  80. package/lib/ui/markdown.js.map +1 -1
  81. package/lib/ui/mermaid.d.ts +44 -0
  82. package/lib/ui/mermaid.d.ts.map +1 -0
  83. package/lib/ui/mermaid.js +178 -0
  84. package/lib/ui/mermaid.js.map +1 -0
  85. package/lib/ui/picker.d.ts +30 -8
  86. package/lib/ui/picker.d.ts.map +1 -1
  87. package/lib/ui/picker.js +63 -23
  88. package/lib/ui/picker.js.map +1 -1
  89. package/lib/ui/prompt.d.ts +30 -0
  90. package/lib/ui/prompt.d.ts.map +1 -0
  91. package/lib/ui/prompt.js +45 -0
  92. package/lib/ui/prompt.js.map +1 -0
  93. package/lib/ui/queue.d.ts +24 -0
  94. package/lib/ui/queue.d.ts.map +1 -0
  95. package/lib/ui/queue.js +57 -0
  96. package/lib/ui/queue.js.map +1 -0
  97. package/lib/ui/status.d.ts +4 -0
  98. package/lib/ui/status.d.ts.map +1 -1
  99. package/lib/ui/status.js +8 -0
  100. package/lib/ui/status.js.map +1 -1
  101. package/lib/ui/view.d.ts +22 -2
  102. package/lib/ui/view.d.ts.map +1 -1
  103. package/lib/ui/view.js +84 -24
  104. package/lib/ui/view.js.map +1 -1
  105. package/lib/work.d.ts +48 -0
  106. package/lib/work.d.ts.map +1 -1
  107. package/lib/work.js +44 -0
  108. package/lib/work.js.map +1 -1
  109. package/package.json +3 -2
package/lib/index.js CHANGED
@@ -9,13 +9,16 @@ import { createToolPresenter } from "./agent/present.js";
9
9
  import { forkPoint } from "./agent/fork.js";
10
10
  import { PROFILE_NAME, resumeHint } from "./identity.js";
11
11
  import { createStatusFacts } from "./agent/status.js";
12
- import { ModelSwitch, createModelCatalog, parseModelArgument } from "./agent/model.js";
12
+ import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
13
13
  import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
14
14
  import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
15
15
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
16
16
  import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
17
17
  import { createCompletionProvider } from "./input/completion.js";
18
18
  import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
19
+ import { createDeferredNotice } from "./settings-notice.js";
20
+ import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
21
+ import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
19
22
  import { resolveConfig } from "./config.js";
20
23
  import { FoldCursor } from "./fold-cursor.js";
21
24
  import { createRestoreRegistry } from "./terminal/restore.js";
@@ -26,16 +29,21 @@ import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
26
29
  import { defaultExportFile, transcriptToText } from "./export.js";
27
30
  import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
28
31
  import { detectColourMode } from "./theme-capability.js";
29
- import { defaultSettings, readScope, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
32
+ import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
33
+ import { pendingPrompts } from "./queue.js";
30
34
  import { renderThemeTable } from "./theme-command.js";
35
+ import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
31
36
  import { SessionId } from '@deepseek-ai/dsh-session';
32
37
  import { formatTokens } from "./tokens.js";
33
38
  import { TranscriptModel } from "./transcript.js";
34
- import { WorkFold, describeTodos } from "./work.js";
39
+ import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
35
40
  import { WorkDock } from "./ui/dock.js";
36
- import { BoxedEditor } from "./ui/editor.js";
41
+ import { GateInputBar } from "./ui/gate-input.js";
42
+ import { PromptBar } from "./ui/prompt.js";
37
43
  import { MarkdownRenderer } from "./ui/markdown.js";
38
- import { EffortPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
44
+ import { createMermaidTransform } from "./ui/mermaid.js";
45
+ import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
46
+ import { QueueBar } from "./ui/queue.js";
39
47
  import { StatusBar } from "./ui/status.js";
40
48
  import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
41
49
  export const name = 'tui';
@@ -47,10 +55,12 @@ export const name = 'tui';
47
55
  * every card to a bare generic row.
48
56
  */
49
57
  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';
58
+ /** One second in the unit a chord window is scheduled in. */
59
+ const MS_PER_SECOND = 1000;
60
+ /** What the back hint names when the reader has unbound the key it would advertise. */
61
+ const BACK_HINT_FALLBACK = 'ctrl+b';
62
+ /** What the reader presses to leave a view they did not open. */
63
+ const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
54
64
  /** Stored sessions titled at once when the picker opens. */
55
65
  const TITLE_CONCURRENCY = 4;
56
66
  /** How often the running-state clock repaints while a turn is open. */
@@ -108,11 +118,14 @@ export function apply(ctx, config) {
108
118
  * late-bound read that the injection point and the change event both use.
109
119
  */
110
120
  let readSection = () => defaultSettings();
111
- /** A refused settings edit, kept until the surface can show it: stderr is behind the alt screen. */
112
- let pendingSettingsProblem;
121
+ /**
122
+ * A refused settings edit, kept until the surface can show it: stderr is
123
+ * behind the alt screen, and the section is read on a schedule of its own.
124
+ */
125
+ const settingsNotice = createDeferredNotice();
113
126
  let current = createTheme(themeMode());
114
- const applyTheme = () => {
115
- current = createTheme(themeMode(), toOverrides(readSection()));
127
+ const applyTheme = (section) => {
128
+ current = createTheme(themeMode(), toOverrides(section));
116
129
  };
117
130
  const theme = {
118
131
  get revision() { return current.revision; },
@@ -137,13 +150,56 @@ export function apply(ctx, config) {
137
150
  */
138
151
  const viewState = { ...DEFAULT_VIEW_STATE };
139
152
  /**
140
- * Seed the nested-call display the reader configured.
153
+ * How a reply's mermaid fences draw, seeded from the reader's section.
154
+ *
155
+ * The transform reads this per render instead of capturing it, because the
156
+ * settings document is hot-reloaded and a session already on screen has to
157
+ * follow the edit.
158
+ */
159
+ let mermaidMode = defaultSettings().mermaid;
160
+ /** The keys that start a chord, and how long one waits; the settings document owns all of it. */
161
+ let prefixKeys = DEFAULT_PREFIX_KEYS;
162
+ let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
163
+ /** Every action's keys in force; the settings document owns it and a press reads it live. */
164
+ let keymap = defaultKeymap();
165
+ /**
166
+ * The chord between a prefix and the action that follows it.
167
+ *
168
+ * Built here, before the terminal exists, because the settings scope applies
169
+ * first and has to be able to end a chord armed under the keymap it replaced.
170
+ * The repaint the window also wants is late-bound: only a key press reaches
171
+ * it, and no key can arrive before the surface has started.
172
+ */
173
+ const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
174
+ /**
175
+ * Seed the display the reader configured.
176
+ *
177
+ * The key toggles nested calls for one session, but a settings edit is a
178
+ * deliberate act, so it re-seeds and becomes the new starting point; the
179
+ * mermaid mode has no key of its own and only ever comes from the document.
180
+ */
181
+ const applyDisplay = (section) => {
182
+ viewState.expandSubCalls = section.subcalls === 'inline';
183
+ mermaidMode = section.mermaid;
184
+ prefixKeys = section.prefixes;
185
+ prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
186
+ keymap = section.keymap;
187
+ // Installed where the library reads it, so a remap lands on the next press
188
+ // rather than at the next restart.
189
+ installKeybindings(keymap);
190
+ // A chord armed under the keymap the reader just replaced is not their chord.
191
+ keyChord.disarm();
192
+ };
193
+ /**
194
+ * Read the reader's section once and apply everything it configures.
141
195
  *
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.
196
+ * One read per change, because a refused section is reported on the way past:
197
+ * reading it once per field would show the reader the same refusal twice.
144
198
  */
145
- const applyDisplay = () => {
146
- viewState.expandSubCalls = readSection().subcalls === 'inline';
199
+ const applySettings = () => {
200
+ const section = readSection();
201
+ applyTheme(section);
202
+ applyDisplay(section);
147
203
  };
148
204
  /**
149
205
  * Own the section, so the harness validates and persists it for the reader.
@@ -154,10 +210,20 @@ export function apply(ctx, config) {
154
210
  * may be touched in.
155
211
  */
156
212
  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();
213
+ // Registration parses the document against the schema, so a section the
214
+ // schema itself refuses throws here — inside a fiber whose failure the
215
+ // screen never shows. Reporting it through the same holder keeps a typo
216
+ // from costing the reader every setting they wrote, silently.
217
+ let scope;
218
+ try {
219
+ scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
220
+ }
221
+ catch (error) {
222
+ settingsNotice.post(settingsProblemMessage(error));
223
+ return;
224
+ }
225
+ readSection = () => readScope(scope, message => settingsNotice.post(message));
226
+ applySettings();
161
227
  });
162
228
  /**
163
229
  * The agent scope the tool presenter resolves against.
@@ -197,7 +263,7 @@ export function apply(ctx, config) {
197
263
  let jobs = [];
198
264
  const roster = new SubagentRoster();
199
265
  const subagentControl = createSubagentControl(ctx);
200
- const markdown = new MarkdownRenderer(theme.markdown);
266
+ const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
201
267
  const restore = createRestoreRegistry();
202
268
  const terminal = new ProcessTerminal();
203
269
  const tui = new WarningSafeTui(terminal);
@@ -207,14 +273,22 @@ export function apply(ctx, config) {
207
273
  let viewedSession = resolved.sessionId;
208
274
  let pending;
209
275
  let pendingPicker;
210
- /** A pick being vetted owns the keyboard: a key would answer what is unanswered. */
276
+ /** A pick being vetted owns the list, not the keyboard: filtering stays live while its verdict is read. */
211
277
  let vetting = false;
212
278
  const view = new TranscriptView(model, theme, markdown, {
213
279
  state: () => viewState,
214
280
  gate: () => pending?.gate.card(),
215
281
  picker: () => pendingPicker?.picker.card(),
282
+ keys: () => keymap,
216
283
  });
217
- const editor = new BoxedEditor(tui, theme.editor);
284
+ // The key map goes in before the bar exists, so no press can be read as the
285
+ // send the library submits on by default. A settings document read after this
286
+ // point installs over it, which is why the bar reads the map per press.
287
+ installKeybindings(keymap);
288
+ const editor = new GateInputBar(tui, theme.editor, () => keymap);
289
+ // Answers are written in the reader's own editor, which is why a question
290
+ // borrows the bar instead of drawing a second one beside it.
291
+ const promptBar = new PromptBar(editor);
218
292
  const disposers = [];
219
293
  // The presenter closure outlives the composition's own teardown, so it must
220
294
  // not keep an agent alive after its world unwinds.
@@ -237,15 +311,25 @@ export function apply(ctx, config) {
237
311
  activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
238
312
  override: () => modelSwitch.current(),
239
313
  home: process.env.HOME,
314
+ chord: () => keyChord.hint(),
315
+ // Read per paint rather than written into the marker the view left behind:
316
+ // a hint stored with the transcript would keep naming the key of the day it
317
+ // was written, and the reader may remap it with the row already on screen.
318
+ back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
240
319
  });
241
320
  const statusBar = new StatusBar(statusFacts, theme);
242
321
  const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
322
+ // The queue is read from the agent this terminal drives rather than from the
323
+ // session on screen, because it sits on the editor that submits to that agent.
324
+ const queue = new QueueBar(() => pendingPrompts(ctx, liveSession(activeSession)), theme);
243
325
  // Only a running turn has anything to say over time, so the clock stops with it.
244
326
  const statusTicker = setInterval(() => {
245
327
  if (turnOpen)
246
328
  tui.requestRender();
247
329
  }, STATUS_TICK_MS);
248
330
  disposers.push(() => clearInterval(statusTicker));
331
+ // A window that outlived the surface would repaint a screen that is gone.
332
+ disposers.push(() => keyChord.disarm());
249
333
  restore.add(() => tui.stop());
250
334
  ctx.effect(() => () => {
251
335
  restore.restore();
@@ -262,7 +346,11 @@ export function apply(ctx, config) {
262
346
  // Work state earns rows only when there is some: a dock that always
263
347
  // occupied a row would cost every conversation one line of transcript.
264
348
  { 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 },
349
+ // Queued input earns rows only while something is waiting, and it gives
350
+ // them up before the editor does: the bar being typed in outranks what is
351
+ // waiting behind it.
352
+ { component: queue, basis: 'auto', shrink: 2, minSize: 0 },
353
+ { component: new VStack([{ component: promptBar, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 0 },
266
354
  { component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
267
355
  ]));
268
356
  tui.setFocus(editor);
@@ -294,14 +382,65 @@ export function apply(ctx, config) {
294
382
  // A gate owns the keyboard: the editor must not collect the decision keys.
295
383
  editor.disableSubmit = true;
296
384
  tui.setFocus(null);
385
+ // A question is answered in this editor, which the gate draws under the row
386
+ // being answered, so the hardware cursor belongs to it while it is borrowed.
387
+ // It is set after the focus is cleared, which unmarks the component it left.
388
+ editor.focused = next.kind === 'question';
297
389
  tui.requestRender();
298
390
  };
299
391
  const closeGate = () => {
300
392
  pending = undefined;
301
393
  editor.disableSubmit = false;
394
+ // The question is answered or skipped, so the reader gets their prompt back
395
+ // in the bar they left it in.
396
+ promptBar.giveBack();
302
397
  tui.setFocus(editor);
303
398
  tui.requestRender();
304
399
  };
400
+ /**
401
+ * What each key the surface answers itself does; false hands the press back.
402
+ *
403
+ * Keyed by the table's own ids, so a key added to {@link SURFACE_KEYS}
404
+ * without a handler here fails to compile rather than doing nothing.
405
+ */
406
+ const surfaceActions = {
407
+ toolDetail: () => {
408
+ viewState.expandCards = !viewState.expandCards;
409
+ tui.requestRender();
410
+ return true;
411
+ },
412
+ subCalls: () => {
413
+ viewState.expandSubCalls = !viewState.expandSubCalls;
414
+ tui.requestRender();
415
+ return true;
416
+ },
417
+ reasoning: () => {
418
+ viewState.expandReasoning = !viewState.expandReasoning;
419
+ tui.requestRender();
420
+ return true;
421
+ },
422
+ effort: () => {
423
+ void openEffortPicker();
424
+ return true;
425
+ },
426
+ back: () => {
427
+ if (viewedSession !== activeSession)
428
+ void showAgentSession();
429
+ return true;
430
+ },
431
+ interrupt: () => {
432
+ // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
433
+ // decides: stop the work in flight, or leave when there is none.
434
+ if (!turnOpen) {
435
+ requestExit(0);
436
+ return true;
437
+ }
438
+ agent?.interrupt();
439
+ model.notice('interrupt requested');
440
+ tui.requestRender();
441
+ return true;
442
+ },
443
+ };
305
444
  disposers.push(tui.addInputListener(data => {
306
445
  // A key arrives as a press and a release once the surface asks the terminal
307
446
  // to report key events, and this library drops the release only for the
@@ -333,13 +472,19 @@ export function apply(ctx, config) {
333
472
  return { consume: true };
334
473
  }
335
474
  if (pendingPicker !== undefined) {
336
- if (vetting)
337
- return { consume: true };
338
475
  const action = pendingPicker.picker.handleKey(data);
339
476
  if (action === undefined) {
340
477
  tui.requestRender();
341
478
  return { consume: true };
342
479
  }
480
+ if (vetting) {
481
+ // A refusal check must not take the keyboard with it: the reader keeps
482
+ // filtering and can still leave, while a second pick waits for the
483
+ // first verdict rather than racing it.
484
+ if (action.kind === 'cancel')
485
+ settlePicker(undefined);
486
+ return { consume: true };
487
+ }
343
488
  if (action.kind === 'cancel') {
344
489
  settlePicker(undefined);
345
490
  return { consume: true };
@@ -370,44 +515,23 @@ export function apply(ctx, config) {
370
515
  })();
371
516
  return { consume: true };
372
517
  }
373
- // Detail the reader asked for is always available, even mid-turn: the
518
+ // A chord is the surface's second key: the prefix is consumed and the
519
+ // footer names what may follow, while a key that finishes nothing is handed
520
+ // on. Detail the reader asked for is always available, even mid-turn: the
374
521
  // collapsed view is a default, not the only state.
375
- if (matchesKey(data, 'ctrl+o')) {
376
- viewState.expandCards = !viewState.expandCards;
522
+ const chorded = keyChord.handle(data);
523
+ if (chorded !== undefined) {
524
+ if (chorded.kind === 'action')
525
+ runSubmission(chorded.binding.submission);
377
526
  tui.requestRender();
378
527
  return { consume: true };
379
528
  }
380
- if (matchesKey(data, 'shift+tab')) {
381
- viewState.expandReasoning = !viewState.expandReasoning;
382
- tui.requestRender();
383
- return { consume: true };
529
+ for (const binding of surfaceBindings(keymap)) {
530
+ if (!matchesKey(data, binding.key))
531
+ continue;
532
+ return surfaceActions[binding.action]() ? { consume: true } : undefined;
384
533
  }
385
- if (matchesKey(data, 'ctrl+y')) {
386
- viewState.expandSubCalls = !viewState.expandSubCalls;
387
- tui.requestRender();
388
- return { consume: true };
389
- }
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 };
408
- }
409
- requestExit(0);
410
- return { consume: true };
534
+ return undefined;
411
535
  }));
412
536
  const registry = () => ctx.get('commands');
413
537
  /**
@@ -433,7 +557,7 @@ export function apply(ctx, config) {
433
557
  const askForSession = async (history, sessions) => {
434
558
  const titles = new Map();
435
559
  void loadTitles(history, sessions, titles);
436
- const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
560
+ const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
437
561
  return picked === undefined ? undefined : SessionId(picked);
438
562
  };
439
563
  /** Give the keyboard back to the editor and answer whoever opened the picker. */
@@ -477,7 +601,7 @@ export function apply(ctx, config) {
477
601
  if (agentPresets === undefined)
478
602
  return undefined;
479
603
  presetRows = await agentPresets.list();
480
- return await openPicker(new PresetPicker(() => presetRows, () => currentId));
604
+ return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
481
605
  };
482
606
  /** Title the listed sessions without making the reader wait for the slowest log. */
483
607
  const loadTitles = async (history, sessions, titles) => {
@@ -604,9 +728,7 @@ export function apply(ctx, config) {
604
728
  model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
605
729
  }
606
730
  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}`}`);
731
+ model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
610
732
  tui.requestRender();
611
733
  };
612
734
  const showAgentSession = async () => {
@@ -966,19 +1088,11 @@ export function apply(ctx, config) {
966
1088
  }
967
1089
  const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
968
1090
  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();
1091
+ case 'current':
1092
+ // Choosing by eye is the point of a terminal selector; the picker
1093
+ // heads itself with the route the next step will actually use.
1094
+ void openModelPicker();
980
1095
  return;
981
- }
982
1096
  case 'list-models':
983
1097
  void catalog.models(command.provider).then(entries => {
984
1098
  model.notice(entries.length === 0
@@ -1038,6 +1152,124 @@ export function apply(ctx, config) {
1038
1152
  model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
1039
1153
  tui.requestRender();
1040
1154
  };
1155
+ /** Whether a model picker's catalog is being read, so a second key cannot race it. */
1156
+ let openingModels = false;
1157
+ /**
1158
+ * The route the next step would actually use.
1159
+ *
1160
+ * Without a choice of its own the surface reports the composition default,
1161
+ * not that it has no opinion: the picker's heading and its marked row have to
1162
+ * agree with the status line about the route in force.
1163
+ */
1164
+ const effectiveRoute = () => {
1165
+ const chosen = modelSwitch.current();
1166
+ if (chosen !== undefined)
1167
+ return chosen;
1168
+ const facts = statusFacts();
1169
+ if (facts.provider === undefined || facts.model === undefined)
1170
+ return undefined;
1171
+ return {
1172
+ provider: facts.provider,
1173
+ model: facts.model,
1174
+ ...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
1175
+ };
1176
+ };
1177
+ /** Put the reader's route choice in force for the next step. */
1178
+ const applyRoute = (route) => {
1179
+ const current = effectiveRoute();
1180
+ // The levels belong to the route, so a switch clears an explicit effort
1181
+ // while re-picking the route already in force is not a switch.
1182
+ const keep = current !== undefined && current.provider === route.provider && current.model === route.model
1183
+ ? current.reasoningEffort
1184
+ : undefined;
1185
+ modelSwitch.choose({
1186
+ provider: route.provider,
1187
+ model: route.model,
1188
+ ...keep === undefined ? {} : { reasoningEffort: keep },
1189
+ });
1190
+ model.notice(`model set to ${route.provider}/${route.model} for the next step`);
1191
+ tui.requestRender();
1192
+ };
1193
+ /**
1194
+ * Offer the levels a route advertises, after the route is already in force.
1195
+ *
1196
+ * Cancelling the list is a real choice — the reader keeps the model with the
1197
+ * provider's own default — which is why the route is applied first. The list
1198
+ * is read before the picker opens because the rows are the route's own
1199
+ * metadata; a menu painted before that arrived could offer a level the
1200
+ * request would then be refused for.
1201
+ */
1202
+ const offerRouteEfforts = async (route) => {
1203
+ if (catalog === undefined)
1204
+ return;
1205
+ try {
1206
+ const info = await catalog.efforts(route.provider, route.model);
1207
+ const efforts = info?.efforts ?? [];
1208
+ if (efforts.length === 0)
1209
+ return;
1210
+ const current = effectiveRoute();
1211
+ const effective = current !== undefined && current.provider === route.provider && current.model === route.model
1212
+ ? current.reasoningEffort
1213
+ : undefined;
1214
+ const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
1215
+ if (picked !== undefined)
1216
+ applyEffort(route.provider, route.model, picked);
1217
+ }
1218
+ catch (error) {
1219
+ model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1220
+ }
1221
+ tui.requestRender();
1222
+ };
1223
+ /**
1224
+ * Offer every model the configured routes advertise.
1225
+ *
1226
+ * Rows come from the routes the llm service registered — the providers this
1227
+ * deployment configured — and each provider's models join the open list as
1228
+ * its catalog resolves, so the picker is filterable before the slowest
1229
+ * adapter answers. A route whose catalog cannot be read stays reachable by
1230
+ * name through the text form rather than by an explanation in the list.
1231
+ */
1232
+ const openModelPicker = async () => {
1233
+ if (catalog === undefined) {
1234
+ model.notice('this profile has no llm service, so models cannot be listed or switched');
1235
+ tui.requestRender();
1236
+ return;
1237
+ }
1238
+ const providers = catalog.providers();
1239
+ if (providers.length === 0) {
1240
+ model.notice('no provider is configured; add one before choosing a model');
1241
+ tui.requestRender();
1242
+ return;
1243
+ }
1244
+ if (openingModels)
1245
+ return;
1246
+ openingModels = true;
1247
+ try {
1248
+ const routes = [];
1249
+ for (const provider of providers) {
1250
+ void catalog.models(provider.id).then(entries => {
1251
+ if (entries.length === 0)
1252
+ return;
1253
+ routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
1254
+ tui.requestRender();
1255
+ }).catch(() => {
1256
+ // One adapter's discovery failure is not the list's to explain.
1257
+ });
1258
+ }
1259
+ const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
1260
+ if (picked === undefined)
1261
+ return;
1262
+ const route = readModelRouteKey(picked);
1263
+ if (route === undefined)
1264
+ return;
1265
+ applyRoute(route);
1266
+ await offerRouteEfforts(route);
1267
+ }
1268
+ finally {
1269
+ openingModels = false;
1270
+ tui.requestRender();
1271
+ }
1272
+ };
1041
1273
  /**
1042
1274
  * Offer the efforts the route in force advertises.
1043
1275
  *
@@ -1067,7 +1299,7 @@ export function apply(ctx, config) {
1067
1299
  model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
1068
1300
  return;
1069
1301
  }
1070
- const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`));
1302
+ const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
1071
1303
  if (picked !== undefined)
1072
1304
  applyEffort(facts.provider, facts.model, picked);
1073
1305
  }
@@ -1136,8 +1368,26 @@ export function apply(ctx, config) {
1136
1368
  ? []
1137
1369
  : registry()?.list(current).map(command => `/${command.name}`) ?? [];
1138
1370
  const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
1139
- return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${LOCAL_KEYS}`;
1371
+ return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
1372
+ };
1373
+ /**
1374
+ * Whether the agent this surface is driving is in plan mode.
1375
+ *
1376
+ * The dock's fold is the fallback, for a composition without the plan
1377
+ * package: with the controller present its answer is the agent's own state
1378
+ * rather than a replay of the events this surface happened to see.
1379
+ */
1380
+ const planState = () => {
1381
+ const current = agent?.agent;
1382
+ if (current === undefined)
1383
+ return undefined;
1384
+ const presets = ctx.get('agentPresets');
1385
+ return readPlanState({
1386
+ direct: name => ctx.get(name),
1387
+ forAgent: (target, name) => presets?.serviceFor(target, name),
1388
+ }, current);
1140
1389
  };
1390
+ const planActive = () => planSelectedActive(planState(), work.state().planMode);
1141
1391
  const runCommand = (name, line) => {
1142
1392
  const current = agent;
1143
1393
  const commands = registry();
@@ -1183,8 +1433,13 @@ export function apply(ctx, config) {
1183
1433
  ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1184
1434
  });
1185
1435
  };
1186
- editor.onSubmit = text => {
1187
- const submission = classifySubmission(text);
1436
+ /**
1437
+ * Carry out one classified line, wherever it was asked for.
1438
+ *
1439
+ * A chord asks for the same things the command line does, so both arrive
1440
+ * here: a chord cannot behave differently from the command it stands for.
1441
+ */
1442
+ const runSubmission = (submission) => {
1188
1443
  switch (submission.kind) {
1189
1444
  case 'empty':
1190
1445
  return;
@@ -1223,6 +1478,18 @@ export function apply(ctx, config) {
1223
1478
  model.notice(line);
1224
1479
  tui.requestRender();
1225
1480
  return;
1481
+ case 'keys': {
1482
+ const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
1483
+ if (submission.argument !== '' && layer === undefined) {
1484
+ model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
1485
+ }
1486
+ else {
1487
+ for (const line of renderKeymap(keymap, layer))
1488
+ model.notice(line);
1489
+ }
1490
+ tui.requestRender();
1491
+ return;
1492
+ }
1226
1493
  case 'copy':
1227
1494
  runCopyCommand();
1228
1495
  return;
@@ -1263,6 +1530,11 @@ export function apply(ctx, config) {
1263
1530
  tui.requestRender();
1264
1531
  });
1265
1532
  return;
1533
+ // Plan mode is a command the harness owns, so the chord asks the host for
1534
+ // the state and names the command for the other one: one key, both ways.
1535
+ case 'plan':
1536
+ runCommand('plan', planToggleLine(planActive()));
1537
+ return;
1266
1538
  case 'command':
1267
1539
  runCommand(submission.name, submission.line);
1268
1540
  return;
@@ -1281,6 +1553,7 @@ export function apply(ctx, config) {
1281
1553
  }
1282
1554
  }
1283
1555
  };
1556
+ editor.onSubmit = text => runSubmission(classifySubmission(text));
1284
1557
  disposers.push(ctx.on('session/event', (session, event) => {
1285
1558
  // The surface state — activity, timer, title, bell, job board — belongs to
1286
1559
  // the agent this terminal drives, even while a child is on screen.
@@ -1301,6 +1574,10 @@ export function apply(ctx, config) {
1301
1574
  // something else, and nothing else refreshes a live board.
1302
1575
  refreshJobs();
1303
1576
  }
1577
+ // A claim or a discard changes what is queued, and that belongs to this
1578
+ // session even while the transcript shows a child's conversation.
1579
+ if (event.type === 'agent/inbox/spliced')
1580
+ tui.requestRender();
1304
1581
  }
1305
1582
  if (session.id !== viewedSession)
1306
1583
  return;
@@ -1338,7 +1615,7 @@ export function apply(ctx, config) {
1338
1615
  if (request.agent.id !== activeSession)
1339
1616
  return next();
1340
1617
  return new Promise(resolve => {
1341
- const gate = new ApprovalGate(request.toolName, request.reason);
1618
+ const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
1342
1619
  request.signal?.addEventListener('abort', () => {
1343
1620
  gate.cancel();
1344
1621
  if (pending?.gate === gate)
@@ -1356,20 +1633,33 @@ export function apply(ctx, config) {
1356
1633
  if (questions.length === 0)
1357
1634
  return next();
1358
1635
  return new Promise(resolve => {
1359
- const gate = new QuestionGate(questions);
1636
+ const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
1360
1637
  // The seam takes mutable selection arrays and an optional custom field, so
1361
1638
  // the read-only gate answer is copied into that exact shape here.
1362
- openGate({
1363
- kind: 'question',
1364
- gate,
1365
- settle: answers => resolve({
1639
+ const settle = (answers) => {
1640
+ request.signal?.removeEventListener('abort', onAbort);
1641
+ resolve({
1366
1642
  answers: answers.map(answer => ({
1367
1643
  id: answer.id,
1368
1644
  selected: [...answer.selected],
1369
1645
  ...(answer.custom === undefined ? {} : { custom: answer.custom }),
1370
1646
  })),
1371
- }),
1372
- });
1647
+ });
1648
+ };
1649
+ // A question whose caller is gone has no reader, so the gate must release
1650
+ // the keyboard instead of collecting an answer the aborted call discards.
1651
+ const onAbort = () => {
1652
+ gate.cancel();
1653
+ if (pending?.gate === gate)
1654
+ closeGate();
1655
+ settle([]);
1656
+ };
1657
+ openGate({ kind: 'question', gate, settle });
1658
+ request.signal?.addEventListener('abort', onAbort, { once: true });
1659
+ // A signal that aborted before the listener existed never emits, so the
1660
+ // state has to be read once after subscribing.
1661
+ if (request.signal?.aborted === true)
1662
+ onAbort();
1373
1663
  });
1374
1664
  }));
1375
1665
  disposers.push(ctx.on('commands/change', () => installCompletion()));
@@ -1404,17 +1694,11 @@ export function apply(ctx, config) {
1404
1694
  disposers.push(ctx.on('settings/updated', ns => {
1405
1695
  if (String(ns) !== TUI_SETTINGS_NAMESPACE)
1406
1696
  return;
1407
- pendingSettingsProblem = undefined;
1408
- applyTheme();
1409
- applyDisplay();
1697
+ applySettings();
1410
1698
  // Both caches hold rows under the old table, so they have to be told the
1411
1699
  // table moved; a repaint alone would reuse what they already stored.
1412
1700
  markdown.invalidate();
1413
1701
  view.invalidate();
1414
- if (pendingSettingsProblem !== undefined) {
1415
- model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
1416
- pendingSettingsProblem = undefined;
1417
- }
1418
1702
  tui.requestRender();
1419
1703
  }));
1420
1704
  const degraded = describeMissingOptional(probe);
@@ -1460,11 +1744,8 @@ export function apply(ctx, config) {
1460
1744
  tui.start();
1461
1745
  terminal.write(windowTitle(process.cwd(), 'ready'));
1462
1746
  // 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
- }
1747
+ // screen; whatever the scope found before this point prints here instead.
1748
+ settingsNotice.open(message => model.notice(message));
1468
1749
  await boot();
1469
1750
  };
1470
1751
  void start().catch((error) => {