@sagmans/dsh-tui 0.1.2 → 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 (158) hide show
  1. package/README.md +196 -17
  2. package/cordis.patch.yml +4 -2
  3. package/lib/agent/host.d.ts +14 -0
  4. package/lib/agent/host.d.ts.map +1 -1
  5. package/lib/agent/host.js +20 -4
  6. package/lib/agent/host.js.map +1 -1
  7. package/lib/agent/model.d.ts +50 -0
  8. package/lib/agent/model.d.ts.map +1 -1
  9. package/lib/agent/model.js +87 -1
  10. package/lib/agent/model.js.map +1 -1
  11. package/lib/agent/present.d.ts +7 -1
  12. package/lib/agent/present.d.ts.map +1 -1
  13. package/lib/agent/present.js +33 -4
  14. package/lib/agent/present.js.map +1 -1
  15. package/lib/agent/presets.d.ts +7 -1
  16. package/lib/agent/presets.d.ts.map +1 -1
  17. package/lib/agent/presets.js +7 -3
  18. package/lib/agent/presets.js.map +1 -1
  19. package/lib/agent/status.d.ts +10 -0
  20. package/lib/agent/status.d.ts.map +1 -1
  21. package/lib/agent/status.js +2 -0
  22. package/lib/agent/status.js.map +1 -1
  23. package/lib/cards.d.ts +127 -7
  24. package/lib/cards.d.ts.map +1 -1
  25. package/lib/cards.js +381 -52
  26. package/lib/cards.js.map +1 -1
  27. package/lib/export.d.ts +5 -4
  28. package/lib/export.d.ts.map +1 -1
  29. package/lib/export.js +62 -13
  30. package/lib/export.js.map +1 -1
  31. package/lib/fold-cursor.d.ts +23 -0
  32. package/lib/fold-cursor.d.ts.map +1 -0
  33. package/lib/fold-cursor.js +31 -0
  34. package/lib/fold-cursor.js.map +1 -0
  35. package/lib/gates.d.ts +145 -2
  36. package/lib/gates.d.ts.map +1 -1
  37. package/lib/gates.js +335 -40
  38. package/lib/gates.js.map +1 -1
  39. package/lib/index.d.ts +7 -1
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +651 -92
  42. package/lib/index.js.map +1 -1
  43. package/lib/input/actions.d.ts +136 -0
  44. package/lib/input/actions.d.ts.map +1 -0
  45. package/lib/input/actions.js +740 -0
  46. package/lib/input/actions.js.map +1 -0
  47. package/lib/input/keymap-settings.d.ts +21 -0
  48. package/lib/input/keymap-settings.d.ts.map +1 -0
  49. package/lib/input/keymap-settings.js +28 -0
  50. package/lib/input/keymap-settings.js.map +1 -0
  51. package/lib/input/keymap.d.ts +91 -0
  52. package/lib/input/keymap.d.ts.map +1 -0
  53. package/lib/input/keymap.js +170 -0
  54. package/lib/input/keymap.js.map +1 -0
  55. package/lib/input/match.d.ts +10 -0
  56. package/lib/input/match.d.ts.map +1 -0
  57. package/lib/input/match.js +83 -0
  58. package/lib/input/match.js.map +1 -0
  59. package/lib/input/submission.d.ts +8 -1
  60. package/lib/input/submission.d.ts.map +1 -1
  61. package/lib/input/submission.js +9 -2
  62. package/lib/input/submission.js.map +1 -1
  63. package/lib/input.d.ts +17 -0
  64. package/lib/input.d.ts.map +1 -0
  65. package/lib/input.js +27 -0
  66. package/lib/input.js.map +1 -0
  67. package/lib/keys-command.d.ts +16 -0
  68. package/lib/keys-command.d.ts.map +1 -0
  69. package/lib/keys-command.js +60 -0
  70. package/lib/keys-command.js.map +1 -0
  71. package/lib/queue.d.ts +6 -0
  72. package/lib/queue.d.ts.map +1 -0
  73. package/lib/queue.js +68 -0
  74. package/lib/queue.js.map +1 -0
  75. package/lib/settings-notice.d.ts +19 -0
  76. package/lib/settings-notice.d.ts.map +1 -0
  77. package/lib/settings-notice.js +30 -0
  78. package/lib/settings-notice.js.map +1 -0
  79. package/lib/terminal/warning-screen.d.ts +8 -0
  80. package/lib/terminal/warning-screen.d.ts.map +1 -0
  81. package/lib/terminal/warning-screen.js +66 -0
  82. package/lib/terminal/warning-screen.js.map +1 -0
  83. package/lib/theme-capability.d.ts +36 -0
  84. package/lib/theme-capability.d.ts.map +1 -0
  85. package/lib/theme-capability.js +190 -0
  86. package/lib/theme-capability.js.map +1 -0
  87. package/lib/theme-command.d.ts +10 -0
  88. package/lib/theme-command.d.ts.map +1 -0
  89. package/lib/theme-command.js +61 -0
  90. package/lib/theme-command.js.map +1 -0
  91. package/lib/theme-settings.d.ts +196 -0
  92. package/lib/theme-settings.d.ts.map +1 -0
  93. package/lib/theme-settings.js +229 -0
  94. package/lib/theme-settings.js.map +1 -0
  95. package/lib/theme-tokens.d.ts +110 -0
  96. package/lib/theme-tokens.d.ts.map +1 -0
  97. package/lib/theme-tokens.js +427 -0
  98. package/lib/theme-tokens.js.map +1 -0
  99. package/lib/theme.d.ts +32 -35
  100. package/lib/theme.d.ts.map +1 -1
  101. package/lib/theme.js +112 -63
  102. package/lib/theme.js.map +1 -1
  103. package/lib/tokens.d.ts +15 -0
  104. package/lib/tokens.d.ts.map +1 -0
  105. package/lib/tokens.js +29 -0
  106. package/lib/tokens.js.map +1 -0
  107. package/lib/transcript.d.ts +26 -1
  108. package/lib/transcript.d.ts.map +1 -1
  109. package/lib/transcript.js +153 -25
  110. package/lib/transcript.js.map +1 -1
  111. package/lib/ui/dock.d.ts.map +1 -1
  112. package/lib/ui/dock.js +67 -28
  113. package/lib/ui/dock.js.map +1 -1
  114. package/lib/ui/editor.d.ts +51 -0
  115. package/lib/ui/editor.d.ts.map +1 -0
  116. package/lib/ui/editor.js +159 -0
  117. package/lib/ui/editor.js.map +1 -0
  118. package/lib/ui/frame.d.ts +54 -0
  119. package/lib/ui/frame.d.ts.map +1 -0
  120. package/lib/ui/frame.js +79 -0
  121. package/lib/ui/frame.js.map +1 -0
  122. package/lib/ui/gate-input.d.ts +20 -0
  123. package/lib/ui/gate-input.d.ts.map +1 -0
  124. package/lib/ui/gate-input.js +96 -0
  125. package/lib/ui/gate-input.js.map +1 -0
  126. package/lib/ui/markdown.d.ts +19 -2
  127. package/lib/ui/markdown.d.ts.map +1 -1
  128. package/lib/ui/markdown.js +37 -6
  129. package/lib/ui/markdown.js.map +1 -1
  130. package/lib/ui/mermaid.d.ts +44 -0
  131. package/lib/ui/mermaid.d.ts.map +1 -0
  132. package/lib/ui/mermaid.js +178 -0
  133. package/lib/ui/mermaid.js.map +1 -0
  134. package/lib/ui/picker.d.ts +66 -7
  135. package/lib/ui/picker.d.ts.map +1 -1
  136. package/lib/ui/picker.js +112 -19
  137. package/lib/ui/picker.js.map +1 -1
  138. package/lib/ui/prompt.d.ts +30 -0
  139. package/lib/ui/prompt.d.ts.map +1 -0
  140. package/lib/ui/prompt.js +45 -0
  141. package/lib/ui/prompt.js.map +1 -0
  142. package/lib/ui/queue.d.ts +24 -0
  143. package/lib/ui/queue.d.ts.map +1 -0
  144. package/lib/ui/queue.js +57 -0
  145. package/lib/ui/queue.js.map +1 -0
  146. package/lib/ui/status.d.ts +7 -1
  147. package/lib/ui/status.d.ts.map +1 -1
  148. package/lib/ui/status.js +84 -24
  149. package/lib/ui/status.js.map +1 -1
  150. package/lib/ui/view.d.ts +65 -2
  151. package/lib/ui/view.d.ts.map +1 -1
  152. package/lib/ui/view.js +319 -59
  153. package/lib/ui/view.js.map +1 -1
  154. package/lib/work.d.ts +48 -0
  155. package/lib/work.d.ts.map +1 -1
  156. package/lib/work.js +44 -0
  157. package/lib/work.js.map +1 -1
  158. package/package.json +5 -2
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { writeFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { Editor, ProcessTerminal, ScrollView, TuiAltScreen, VStack, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
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
7
  import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
@@ -9,35 +9,58 @@ 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";
23
+ import { FoldCursor } from "./fold-cursor.js";
20
24
  import { createRestoreRegistry } from "./terminal/restore.js";
25
+ import { WarningSafeTui } from "./terminal/warning-screen.js";
21
26
  import { BELL, shouldRingBell } from "./terminal/bell.js";
22
27
  import { clipboardSequence } from "./terminal/clipboard.js";
23
28
  import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
24
29
  import { defaultExportFile, transcriptToText } from "./export.js";
25
- import { colorEnabled, createTheme } from "./theme.js";
30
+ import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
31
+ import { detectColourMode } from "./theme-capability.js";
32
+ import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
33
+ import { pendingPrompts } from "./queue.js";
34
+ import { renderThemeTable } from "./theme-command.js";
35
+ import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
26
36
  import { SessionId } from '@deepseek-ai/dsh-session';
37
+ import { formatTokens } from "./tokens.js";
27
38
  import { TranscriptModel } from "./transcript.js";
28
- import { WorkFold, describeTodos } from "./work.js";
39
+ import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
29
40
  import { WorkDock } from "./ui/dock.js";
41
+ import { GateInputBar } from "./ui/gate-input.js";
42
+ import { PromptBar } from "./ui/prompt.js";
30
43
  import { MarkdownRenderer } from "./ui/markdown.js";
31
- import { PresetPicker, SessionPicker } from "./ui/picker.js";
32
- import { StatusBar, formatTokens } from "./ui/status.js";
33
- import { TranscriptView } from "./ui/view.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";
47
+ import { StatusBar } from "./ui/status.js";
48
+ import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
34
49
  export const name = 'tui';
35
- /** `agents` is the only service the surface cannot run without. */
36
- export const inject = ['agents'];
37
- /** Keys the surface answers itself, listed wherever the reader asks for help. */
38
- const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+t reasoning · ctrl+b back to this session · ctrl+c interrupt or exit';
39
- /** The one thing to say about a view a reader did not open. */
40
- const LOCAL_KEYS_BACK = 'ctrl+b returns';
50
+ /**
51
+ * Services the surface cannot run without.
52
+ *
53
+ * `tools` is what lets a card read its tool's own render intent; a context that
54
+ * has not injected it throws on the property read, which silently degraded
55
+ * every card to a bare generic row.
56
+ */
57
+ export const inject = ['agents', 'tools'];
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`;
41
64
  /** Stored sessions titled at once when the picker opens. */
42
65
  const TITLE_CONCURRENCY = 4;
43
66
  /** How often the running-state clock repaints while a turn is open. */
@@ -78,47 +101,200 @@ export function apply(ctx, config) {
78
101
  if (appExit === undefined) {
79
102
  throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
80
103
  }
81
- const theme = createTheme(colorEnabled(resolved.color));
82
- const model = new TranscriptModel(createToolPresenter(ctx));
104
+ /**
105
+ * The surface's appearance, rebuilt whenever the reader's settings change.
106
+ *
107
+ * Renderers hold this object for the life of the session, so the current
108
+ * theme is swapped *behind* a stable delegate rather than reassigned: every
109
+ * row then reads one whole table, and a repaint can never observe a
110
+ * half-applied one. `--no-color` still outranks anything configured.
111
+ */
112
+ const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
113
+ /**
114
+ * The reader's section, or nothing when the service is not mounted.
115
+ *
116
+ * The service is only readable inside an `inject` scope — asking for it
117
+ * outside one is a composition error, not a missing value — so this stays a
118
+ * late-bound read that the injection point and the change event both use.
119
+ */
120
+ let readSection = () => defaultSettings();
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();
126
+ let current = createTheme(themeMode());
127
+ const applyTheme = (section) => {
128
+ current = createTheme(themeMode(), toOverrides(section));
129
+ };
130
+ const theme = {
131
+ get revision() { return current.revision; },
132
+ get color() { return current.color; },
133
+ style: (token, text) => current.style(token, text),
134
+ cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
135
+ glyph: token => current.glyph(token),
136
+ visible: token => current.visible(token),
137
+ // Forwarded rather than read, because the editor and the markdown view keep
138
+ // the theme object they were built with: a settings change has to reach them
139
+ // through a stable delegate or they would keep the boot appearance.
140
+ editor: forwardEditorTheme(() => current.editor),
141
+ markdown: forwardMarkdownTheme(() => current.markdown),
142
+ };
143
+ /**
144
+ * Rows the reader has opened. The model stays untouched; only the view reads
145
+ * this. A thought starts folded: it is the longest, least scannable row in the
146
+ * transcript, so leaving it open pushes the answer a reader came for off the
147
+ * screen, and a folded row still names itself and its key. A PTC card's calls
148
+ * start open for the opposite reason: each is one clipped line under a header
149
+ * that already names the program.
150
+ */
151
+ const viewState = { ...DEFAULT_VIEW_STATE };
152
+ /**
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.
195
+ *
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.
198
+ */
199
+ const applySettings = () => {
200
+ const section = readSection();
201
+ applyTheme(section);
202
+ applyDisplay(section);
203
+ };
204
+ /**
205
+ * Own the section, so the harness validates and persists it for the reader.
206
+ *
207
+ * Registration is how the document learns the section exists at all; without
208
+ * it a hand-written `dsh-tui:` block would be dropped on the next save. The
209
+ * first read happens here too, because this is the only scope the service
210
+ * may be touched in.
211
+ */
212
+ ctx.inject(['settings'], settingsCtx => {
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();
227
+ });
228
+ /**
229
+ * The agent scope the tool presenter resolves against.
230
+ *
231
+ * Tools are registered in the scoped world the session's preset mounts, so a
232
+ * card can only read its tool's own render intent while this names that
233
+ * agent. It follows whatever session the transcript is folding, because a
234
+ * child on screen reads through the child's scope, not the parent's.
235
+ */
236
+ let presentScope;
237
+ const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
83
238
  const work = new WorkFold();
84
239
  const modelSwitch = new ModelSwitch();
85
240
  const agentPresets = createPresetRoster(ctx);
241
+ /** The mode named on the command line, which is the only one that may conflict. */
242
+ const requestedPreset = resolved.preset;
86
243
  /**
87
244
  * The mode a session the reader starts from here on joins.
88
245
  *
89
246
  * A launch flag seeds it and a successful switch updates it, so `/new` and
90
247
  * `/fork` land in the mode the reader last chose; nothing is written to
91
248
  * settings, because the mode of a session is not a property of the machine.
249
+ * Unset means nobody chose, which is where the roster's default applies.
92
250
  */
93
- /** The mode named on the command line, which is the only one that may conflict. */
94
- const requestedPreset = resolved.preset;
95
- let seat = requestedPreset ?? agentPresets?.defaultId;
251
+ let seat = requestedPreset;
252
+ /**
253
+ * The mode a session takes its seat in: the reader's latest choice, or the
254
+ * roster's default read at this moment.
255
+ *
256
+ * The default cannot be captured when this row applies: it comes from the
257
+ * settings document, which may be read after that, so a captured copy would
258
+ * pin every later session to the mode the bundle happened to ship.
259
+ */
260
+ const seatMode = () => seat ?? agentPresets?.defaultId;
96
261
  const catalog = createModelCatalog(ctx);
97
262
  const jobDirectory = createJobDirectory(ctx);
98
263
  let jobs = [];
99
264
  const roster = new SubagentRoster();
100
265
  const subagentControl = createSubagentControl(ctx);
101
- const markdown = new MarkdownRenderer(theme.markdown);
102
- /** Rows the reader has opened. The model stays untouched; only the view reads this. */
103
- const viewState = { expandCards: false, expandReasoning: false };
266
+ const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
104
267
  const restore = createRestoreRegistry();
105
268
  const terminal = new ProcessTerminal();
106
- const tui = new TuiAltScreen(terminal);
269
+ const tui = new WarningSafeTui(terminal);
107
270
  /** The session this surface drives: commands, approvals, and the bell belong to it. */
108
271
  let activeSession = resolved.sessionId;
109
272
  /** The session the transcript is showing, which can be one of its children. */
110
273
  let viewedSession = resolved.sessionId;
111
274
  let pending;
112
275
  let pendingPicker;
113
- /** 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. */
114
277
  let vetting = false;
115
278
  const view = new TranscriptView(model, theme, markdown, {
116
279
  state: () => viewState,
117
280
  gate: () => pending?.gate.card(),
118
281
  picker: () => pendingPicker?.picker.card(),
282
+ keys: () => keymap,
119
283
  });
120
- const editor = new Editor(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);
121
292
  const disposers = [];
293
+ // The presenter closure outlives the composition's own teardown, so it must
294
+ // not keep an agent alive after its world unwinds.
295
+ disposers.push(() => {
296
+ presentScope = undefined;
297
+ });
122
298
  let agent;
123
299
  let turnOpen = false;
124
300
  let turnStartedAt;
@@ -135,15 +311,25 @@ export function apply(ctx, config) {
135
311
  activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
136
312
  override: () => modelSwitch.current(),
137
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)),
138
319
  });
139
320
  const statusBar = new StatusBar(statusFacts, theme);
140
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);
141
325
  // Only a running turn has anything to say over time, so the clock stops with it.
142
326
  const statusTicker = setInterval(() => {
143
327
  if (turnOpen)
144
328
  tui.requestRender();
145
329
  }, STATUS_TICK_MS);
146
330
  disposers.push(() => clearInterval(statusTicker));
331
+ // A window that outlived the surface would repaint a screen that is gone.
332
+ disposers.push(() => keyChord.disarm());
147
333
  restore.add(() => tui.stop());
148
334
  ctx.effect(() => () => {
149
335
  restore.restore();
@@ -160,7 +346,11 @@ export function apply(ctx, config) {
160
346
  // Work state earns rows only when there is some: a dock that always
161
347
  // occupied a row would cost every conversation one line of transcript.
162
348
  { component: dock, basis: 'auto', shrink: 0, minSize: 0 },
163
- { 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 },
164
354
  { component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
165
355
  ]));
166
356
  tui.setFocus(editor);
@@ -192,14 +382,65 @@ export function apply(ctx, config) {
192
382
  // A gate owns the keyboard: the editor must not collect the decision keys.
193
383
  editor.disableSubmit = true;
194
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';
195
389
  tui.requestRender();
196
390
  };
197
391
  const closeGate = () => {
198
392
  pending = undefined;
199
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();
200
397
  tui.setFocus(editor);
201
398
  tui.requestRender();
202
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
+ };
203
444
  disposers.push(tui.addInputListener(data => {
204
445
  // A key arrives as a press and a release once the surface asks the terminal
205
446
  // to report key events, and this library drops the release only for the
@@ -231,13 +472,19 @@ export function apply(ctx, config) {
231
472
  return { consume: true };
232
473
  }
233
474
  if (pendingPicker !== undefined) {
234
- if (vetting)
235
- return { consume: true };
236
475
  const action = pendingPicker.picker.handleKey(data);
237
476
  if (action === undefined) {
238
477
  tui.requestRender();
239
478
  return { consume: true };
240
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
+ }
241
488
  if (action.kind === 'cancel') {
242
489
  settlePicker(undefined);
243
490
  return { consume: true };
@@ -268,35 +515,23 @@ export function apply(ctx, config) {
268
515
  })();
269
516
  return { consume: true };
270
517
  }
271
- // 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
272
521
  // collapsed view is a default, not the only state.
273
- if (matchesKey(data, 'ctrl+o')) {
274
- viewState.expandCards = !viewState.expandCards;
275
- tui.requestRender();
276
- return { consume: true };
277
- }
278
- if (matchesKey(data, 'ctrl+t')) {
279
- viewState.expandReasoning = !viewState.expandReasoning;
522
+ const chorded = keyChord.handle(data);
523
+ if (chorded !== undefined) {
524
+ if (chorded.kind === 'action')
525
+ runSubmission(chorded.binding.submission);
280
526
  tui.requestRender();
281
527
  return { consume: true };
282
528
  }
283
- if (matchesKey(data, 'ctrl+b')) {
284
- if (viewedSession !== activeSession)
285
- void showAgentSession();
286
- 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;
287
533
  }
288
- // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
289
- // decides: stop the work in flight, or leave when there is none.
290
- if (!matchesKey(data, 'ctrl+c'))
291
- return undefined;
292
- if (turnOpen) {
293
- agent?.interrupt();
294
- model.notice('interrupt requested');
295
- tui.requestRender();
296
- return { consume: true };
297
- }
298
- requestExit(0);
299
- return { consume: true };
534
+ return undefined;
300
535
  }));
301
536
  const registry = () => ctx.get('commands');
302
537
  /**
@@ -322,7 +557,7 @@ export function apply(ctx, config) {
322
557
  const askForSession = async (history, sessions) => {
323
558
  const titles = new Map();
324
559
  void loadTitles(history, sessions, titles);
325
- const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
560
+ const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
326
561
  return picked === undefined ? undefined : SessionId(picked);
327
562
  };
328
563
  /** Give the keyboard back to the editor and answer whoever opened the picker. */
@@ -366,7 +601,7 @@ export function apply(ctx, config) {
366
601
  if (agentPresets === undefined)
367
602
  return undefined;
368
603
  presetRows = await agentPresets.list();
369
- return await openPicker(new PresetPicker(() => presetRows, () => currentId));
604
+ return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
370
605
  };
371
606
  /** Title the listed sessions without making the reader wait for the slowest log. */
372
607
  const loadTitles = async (history, sessions, titles) => {
@@ -429,11 +664,31 @@ export function apply(ctx, config) {
429
664
  * materialized; a session this process is not running falls back to storage.
430
665
  */
431
666
  const liveSession = (id) => ctx.get('sessions')?.get?.(id);
667
+ /**
668
+ * The fold's place in the viewed session's durable sequence.
669
+ *
670
+ * A resumed session is folded while its agent's loop is already live, so the
671
+ * same event can reach the surface twice: once on the stream and once from the
672
+ * log the fold is reading. The sequence number every durable event carries is
673
+ * what tells the two apart.
674
+ */
675
+ const foldCursor = new FoldCursor();
676
+ /** Feed one durable event to the model, unless a fold has already folded it. */
677
+ const applyDurable = (event) => {
678
+ if (foldCursor.accept(event))
679
+ applyEvent(event);
680
+ };
432
681
  const foldHistory = async (id) => {
682
+ // Resolved before the fold so every card reads its tool through the scope
683
+ // that actually registered it; a stored session nobody runs has none.
684
+ presentScope = ctx.agents?.get(id);
685
+ // Every fold starts a cleared transcript, so this session's own numbering is
686
+ // where the cursor begins rather than the previous session's.
687
+ foldCursor.reset();
433
688
  const inMemory = liveSession(id)?.snapshotEvents?.();
434
689
  if (inMemory !== undefined) {
435
690
  for (const event of inMemory)
436
- applyEvent(event);
691
+ applyDurable(event);
437
692
  return inMemory.length;
438
693
  }
439
694
  const history = createSessionHistory(ctx);
@@ -441,7 +696,7 @@ export function apply(ctx, config) {
441
696
  return 0;
442
697
  const events = await history.read(id);
443
698
  for (const event of events)
444
- applyEvent(event);
699
+ applyDurable(event);
445
700
  return events.length;
446
701
  };
447
702
  const replayHistory = async (id) => {
@@ -473,9 +728,7 @@ export function apply(ctx, config) {
473
728
  model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
474
729
  }
475
730
  const returned = id === activeSession && previous !== activeSession;
476
- model.marker(returned
477
- ? 'back to the session this terminal drives'
478
- : `viewing ${id}${id === activeSession ? '' : ` — ${LOCAL_KEYS_BACK}`}`);
731
+ model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
479
732
  tui.requestRender();
480
733
  };
481
734
  const showAgentSession = async () => {
@@ -507,13 +760,13 @@ export function apply(ctx, config) {
507
760
  if (agentPresets === undefined)
508
761
  return undefined;
509
762
  if (fork !== undefined)
510
- return agentPresets.current(liveSession(fork.from)) ?? seat;
763
+ return agentPresets.current(liveSession(fork.from)) ?? seatMode();
511
764
  if (!resume)
512
- return seat;
765
+ return seatMode();
513
766
  const history = createSessionHistory(ctx);
514
767
  const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
515
768
  if (stored === undefined)
516
- return seat;
769
+ return seatMode();
517
770
  const resolvedStored = await resolveStored(id, stored);
518
771
  if (resolvedStored === undefined) {
519
772
  // The composition this session recorded is gone. Naming one explicitly is
@@ -541,8 +794,6 @@ export function apply(ctx, config) {
541
794
  // Settled before the transcript is touched, so a refusal leaves neither a
542
795
  // half-replayed session nor a half-composed agent behind.
543
796
  const preset = await presetFor(id, resume, fork);
544
- if (resume)
545
- await replayHistory(id);
546
797
  const handle = await startAgent(ctx, {
547
798
  sessionId: id,
548
799
  resume,
@@ -561,6 +812,16 @@ export function apply(ctx, config) {
561
812
  activeSession = id;
562
813
  viewedSession = id;
563
814
  agent = handle;
815
+ // A session with no history to fold still has to present its first live
816
+ // card through the right scope, so the scope is set before any event can.
817
+ presentScope = handle.agent;
818
+ // Replayed only once the agent exists, because the fold reads every card
819
+ // through the scope the preset mounted; a fold before that scope existed
820
+ // degraded each replayed card to a bare generic row. The agent's loop is
821
+ // live by now, so the fold and the stream race over the same events; the
822
+ // durable sequence number is what keeps one event from landing twice.
823
+ if (resume)
824
+ await replayHistory(id);
564
825
  // A branch inherits the conversation the reader was already reading, so it
565
826
  // opens on that history rather than on an empty screen.
566
827
  if (fork !== undefined)
@@ -576,6 +837,9 @@ export function apply(ctx, config) {
576
837
  const switchSession = async (id) => {
577
838
  const previous = agent;
578
839
  agent = undefined;
840
+ // Drop the outgoing scope before its agent is disposed, so no card folded
841
+ // during the transition can read a torn-down world.
842
+ presentScope = undefined;
579
843
  turnOpen = false;
580
844
  model.reset();
581
845
  work.reset();
@@ -824,19 +1088,11 @@ export function apply(ctx, config) {
824
1088
  }
825
1089
  const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
826
1090
  switch (command.kind) {
827
- case 'current': {
828
- const facts = statusFacts();
829
- const current = modelSwitch.current();
830
- const route = current === undefined
831
- // Without a choice of its own the surface reports what the next step
832
- // would actually use, not that it has no opinion.
833
- ? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
834
- : `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
835
- const providers = catalog.providers().map(provider => provider.id);
836
- model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model> switches, /model <provider> lists its models`);
837
- 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();
838
1095
  return;
839
- }
840
1096
  case 'list-models':
841
1097
  void catalog.models(command.provider).then(entries => {
842
1098
  model.notice(entries.length === 0
@@ -848,17 +1104,213 @@ export function apply(ctx, config) {
848
1104
  tui.requestRender();
849
1105
  });
850
1106
  return;
851
- case 'switch':
852
- modelSwitch.choose(command.choice);
853
- model.notice(`model set to ${command.choice.provider}/${command.choice.model} for the next step`);
854
- tui.requestRender();
1107
+ case 'switch': {
1108
+ const choice = command.choice;
1109
+ if (choice.reasoningEffort === undefined) {
1110
+ modelSwitch.choose(choice);
1111
+ model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
1112
+ tui.requestRender();
1113
+ return;
1114
+ }
1115
+ // The route decides which efforts exist, so an explicit one is checked
1116
+ // against the adapter before it is put in force: a typo must not become
1117
+ // a request the provider rejects.
1118
+ void (async () => {
1119
+ try {
1120
+ const info = await catalog.efforts(choice.provider, choice.model);
1121
+ const efforts = info?.efforts ?? [];
1122
+ if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
1123
+ model.notice(efforts.length === 0
1124
+ ? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
1125
+ : `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
1126
+ tui.requestRender();
1127
+ return;
1128
+ }
1129
+ modelSwitch.choose(choice);
1130
+ model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
1131
+ }
1132
+ catch (error) {
1133
+ model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1134
+ }
1135
+ tui.requestRender();
1136
+ })();
855
1137
  return;
1138
+ }
856
1139
  case 'invalid':
857
1140
  model.notice(`/model: ${command.reason}`);
858
1141
  tui.requestRender();
859
1142
  return;
860
1143
  }
861
1144
  };
1145
+ /** Whether a route's effort list is being read, so a second key cannot race it. */
1146
+ let openingEfforts = false;
1147
+ /** Put the reader's effort choice in force for the next step. */
1148
+ const applyEffort = (provider, modelId, effortId) => {
1149
+ modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
1150
+ ? { provider, model: modelId }
1151
+ : { provider, model: modelId, reasoningEffort: effortId });
1152
+ model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
1153
+ tui.requestRender();
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
+ };
1273
+ /**
1274
+ * Offer the efforts the route in force advertises.
1275
+ *
1276
+ * The list is read before the picker opens because the rows are the route's
1277
+ * own metadata; a menu painted before that arrived could offer a level the
1278
+ * request would then be refused for.
1279
+ */
1280
+ const openEffortPicker = async () => {
1281
+ if (catalog === undefined) {
1282
+ model.notice('this profile has no llm service, so reasoning efforts cannot be read');
1283
+ tui.requestRender();
1284
+ return;
1285
+ }
1286
+ const facts = statusFacts();
1287
+ if (facts.provider === undefined || facts.model === undefined) {
1288
+ model.notice('no model route is in use; /model <provider>/<model> picks one first');
1289
+ tui.requestRender();
1290
+ return;
1291
+ }
1292
+ if (openingEfforts)
1293
+ return;
1294
+ openingEfforts = true;
1295
+ try {
1296
+ const info = await catalog.efforts(facts.provider, facts.model);
1297
+ const efforts = info?.efforts ?? [];
1298
+ if (efforts.length === 0) {
1299
+ model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
1300
+ return;
1301
+ }
1302
+ const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
1303
+ if (picked !== undefined)
1304
+ applyEffort(facts.provider, facts.model, picked);
1305
+ }
1306
+ catch (error) {
1307
+ model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1308
+ }
1309
+ finally {
1310
+ openingEfforts = false;
1311
+ tui.requestRender();
1312
+ }
1313
+ };
862
1314
  /**
863
1315
  * Show or choose the mode this session runs.
864
1316
  *
@@ -916,8 +1368,26 @@ export function apply(ctx, config) {
916
1368
  ? []
917
1369
  : registry()?.list(current).map(command => `/${command.name}`) ?? [];
918
1370
  const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
919
- 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);
920
1389
  };
1390
+ const planActive = () => planSelectedActive(planState(), work.state().planMode);
921
1391
  const runCommand = (name, line) => {
922
1392
  const current = agent;
923
1393
  const commands = registry();
@@ -943,8 +1413,33 @@ export function apply(ctx, config) {
943
1413
  tui.requestRender();
944
1414
  });
945
1415
  };
946
- editor.onSubmit = text => {
947
- const submission = classifySubmission(text);
1416
+ /**
1417
+ * Put the deployment default in force before the first turn.
1418
+ *
1419
+ * The agent is created before the settings file has been read, so the route
1420
+ * its loop captured is the composition placeholder; the reader's default —
1421
+ * effort included — only exists by the time they can type. Adopting it here
1422
+ * is what makes the status line's route the one the request actually uses.
1423
+ */
1424
+ const adoptDefaultRoute = () => {
1425
+ if (modelSwitch.current() !== undefined)
1426
+ return;
1427
+ const facts = statusFacts();
1428
+ if (facts.provider === undefined || facts.model === undefined)
1429
+ return;
1430
+ modelSwitch.adopt({
1431
+ provider: facts.provider,
1432
+ model: facts.model,
1433
+ ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1434
+ });
1435
+ };
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) => {
948
1443
  switch (submission.kind) {
949
1444
  case 'empty':
950
1445
  return;
@@ -978,6 +1473,23 @@ export function apply(ctx, config) {
978
1473
  case 'todo':
979
1474
  runTodoCommand();
980
1475
  return;
1476
+ case 'theme':
1477
+ for (const line of renderThemeTable(toOverrides(readSection())))
1478
+ model.notice(line);
1479
+ tui.requestRender();
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
+ }
981
1493
  case 'copy':
982
1494
  runCopyCommand();
983
1495
  return;
@@ -1018,6 +1530,11 @@ export function apply(ctx, config) {
1018
1530
  tui.requestRender();
1019
1531
  });
1020
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;
1021
1538
  case 'command':
1022
1539
  runCommand(submission.name, submission.line);
1023
1540
  return;
@@ -1030,10 +1547,13 @@ export function apply(ctx, config) {
1030
1547
  // While a turn is running the human is steering it, not opening another.
1031
1548
  if (turnOpen)
1032
1549
  agent.steer(submission.text);
1033
- else
1550
+ else {
1551
+ adoptDefaultRoute();
1034
1552
  agent.submit(submission.text);
1553
+ }
1035
1554
  }
1036
1555
  };
1556
+ editor.onSubmit = text => runSubmission(classifySubmission(text));
1037
1557
  disposers.push(ctx.on('session/event', (session, event) => {
1038
1558
  // The surface state — activity, timer, title, bell, job board — belongs to
1039
1559
  // the agent this terminal drives, even while a child is on screen.
@@ -1054,10 +1574,15 @@ export function apply(ctx, config) {
1054
1574
  // something else, and nothing else refreshes a live board.
1055
1575
  refreshJobs();
1056
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();
1057
1581
  }
1058
1582
  if (session.id !== viewedSession)
1059
1583
  return;
1060
- applyEvent(event);
1584
+ presentScope = ctx.agents?.get(session.id);
1585
+ applyDurable(event);
1061
1586
  tui.requestRender();
1062
1587
  }));
1063
1588
  /**
@@ -1090,7 +1615,7 @@ export function apply(ctx, config) {
1090
1615
  if (request.agent.id !== activeSession)
1091
1616
  return next();
1092
1617
  return new Promise(resolve => {
1093
- const gate = new ApprovalGate(request.toolName, request.reason);
1618
+ const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
1094
1619
  request.signal?.addEventListener('abort', () => {
1095
1620
  gate.cancel();
1096
1621
  if (pending?.gate === gate)
@@ -1108,20 +1633,33 @@ export function apply(ctx, config) {
1108
1633
  if (questions.length === 0)
1109
1634
  return next();
1110
1635
  return new Promise(resolve => {
1111
- const gate = new QuestionGate(questions);
1636
+ const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
1112
1637
  // The seam takes mutable selection arrays and an optional custom field, so
1113
1638
  // the read-only gate answer is copied into that exact shape here.
1114
- openGate({
1115
- kind: 'question',
1116
- gate,
1117
- settle: answers => resolve({
1639
+ const settle = (answers) => {
1640
+ request.signal?.removeEventListener('abort', onAbort);
1641
+ resolve({
1118
1642
  answers: answers.map(answer => ({
1119
1643
  id: answer.id,
1120
1644
  selected: [...answer.selected],
1121
1645
  ...(answer.custom === undefined ? {} : { custom: answer.custom }),
1122
1646
  })),
1123
- }),
1124
- });
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();
1125
1663
  });
1126
1664
  }));
1127
1665
  disposers.push(ctx.on('commands/change', () => installCompletion()));
@@ -1145,6 +1683,24 @@ export function apply(ctx, config) {
1145
1683
  model.applyStreamChunk(payload.frame.chunk);
1146
1684
  tui.requestRender();
1147
1685
  }));
1686
+ /**
1687
+ * Restyle a running session when the reader's section changes.
1688
+ *
1689
+ * The settings document is hot-reloaded by the host, so a reader watching a
1690
+ * shade land never has to leave the session to see it — which is what makes
1691
+ * tuning one bearable instead of a restart per attempt. The event is
1692
+ * namespace-filtered: another surface's preferences are not our repaint.
1693
+ */
1694
+ disposers.push(ctx.on('settings/updated', ns => {
1695
+ if (String(ns) !== TUI_SETTINGS_NAMESPACE)
1696
+ return;
1697
+ applySettings();
1698
+ // Both caches hold rows under the old table, so they have to be told the
1699
+ // table moved; a repaint alone would reuse what they already stored.
1700
+ markdown.invalidate();
1701
+ view.invalidate();
1702
+ tui.requestRender();
1703
+ }));
1148
1704
  const degraded = describeMissingOptional(probe);
1149
1705
  if (degraded !== undefined)
1150
1706
  model.notice(degraded);
@@ -1187,6 +1743,9 @@ export function apply(ctx, config) {
1187
1743
  await presetFor(resolved.sessionId, resolved.resume, undefined);
1188
1744
  tui.start();
1189
1745
  terminal.write(windowTitle(process.cwd(), 'ready'));
1746
+ // A refused settings edit is only visible now that the surface owns the
1747
+ // screen; whatever the scope found before this point prints here instead.
1748
+ settingsNotice.open(message => model.notice(message));
1190
1749
  await boot();
1191
1750
  };
1192
1751
  void start().catch((error) => {