@sagmans/dsh-tui 0.4.0 → 0.5.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 (200) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +270 -68
  3. package/lib/agent/present.js +1 -1
  4. package/lib/agent/present.js.map +1 -1
  5. package/lib/agent/prompt-history.d.ts.map +1 -1
  6. package/lib/agent/prompt-history.js +5 -3
  7. package/lib/agent/prompt-history.js.map +1 -1
  8. package/lib/agent/status.d.ts +1 -1
  9. package/lib/cards.d.ts +83 -13
  10. package/lib/cards.d.ts.map +1 -1
  11. package/lib/cards.js +96 -28
  12. package/lib/cards.js.map +1 -1
  13. package/lib/export.d.ts +3 -3
  14. package/lib/export.d.ts.map +1 -1
  15. package/lib/export.js +23 -13
  16. package/lib/export.js.map +1 -1
  17. package/lib/fold-cursor.d.ts +12 -5
  18. package/lib/fold-cursor.d.ts.map +1 -1
  19. package/lib/fold-cursor.js +16 -5
  20. package/lib/fold-cursor.js.map +1 -1
  21. package/lib/gates.d.ts.map +1 -1
  22. package/lib/gates.js +42 -9
  23. package/lib/gates.js.map +1 -1
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +459 -72
  26. package/lib/index.js.map +1 -1
  27. package/lib/injection.d.ts +9 -0
  28. package/lib/injection.d.ts.map +1 -0
  29. package/lib/injection.js +128 -0
  30. package/lib/injection.js.map +1 -0
  31. package/lib/input/actions.d.ts +19 -2
  32. package/lib/input/actions.d.ts.map +1 -1
  33. package/lib/input/actions.js +57 -11
  34. package/lib/input/actions.js.map +1 -1
  35. package/lib/input/cancel.d.ts +55 -0
  36. package/lib/input/cancel.d.ts.map +1 -0
  37. package/lib/input/cancel.js +47 -0
  38. package/lib/input/cancel.js.map +1 -0
  39. package/lib/input/completion.d.ts +5 -2
  40. package/lib/input/completion.d.ts.map +1 -1
  41. package/lib/input/completion.js +79 -4
  42. package/lib/input/completion.js.map +1 -1
  43. package/lib/input/file-search.d.ts +105 -0
  44. package/lib/input/file-search.d.ts.map +1 -0
  45. package/lib/input/file-search.js +665 -0
  46. package/lib/input/file-search.js.map +1 -0
  47. package/lib/input/fuzzy.d.ts +42 -0
  48. package/lib/input/fuzzy.d.ts.map +1 -0
  49. package/lib/input/fuzzy.js +289 -0
  50. package/lib/input/fuzzy.js.map +1 -0
  51. package/lib/input/keymap.d.ts +3 -1
  52. package/lib/input/keymap.d.ts.map +1 -1
  53. package/lib/input/keymap.js +14 -3
  54. package/lib/input/keymap.js.map +1 -1
  55. package/lib/input/match.d.ts +12 -4
  56. package/lib/input/match.d.ts.map +1 -1
  57. package/lib/input/match.js +50 -51
  58. package/lib/input/match.js.map +1 -1
  59. package/lib/input/submission.d.ts +10 -0
  60. package/lib/input/submission.d.ts.map +1 -1
  61. package/lib/input/submission.js +7 -6
  62. package/lib/input/submission.js.map +1 -1
  63. package/lib/keys-command.d.ts +19 -3
  64. package/lib/keys-command.d.ts.map +1 -1
  65. package/lib/keys-command.js +34 -31
  66. package/lib/keys-command.js.map +1 -1
  67. package/lib/settings-notice.d.ts +5 -0
  68. package/lib/settings-notice.d.ts.map +1 -1
  69. package/lib/settings-notice.js +9 -7
  70. package/lib/settings-notice.js.map +1 -1
  71. package/lib/stash/paths.d.ts +8 -8
  72. package/lib/stash/paths.d.ts.map +1 -1
  73. package/lib/stash/paths.js +19 -18
  74. package/lib/stash/paths.js.map +1 -1
  75. package/lib/stash/schema.d.ts +11 -12
  76. package/lib/stash/schema.d.ts.map +1 -1
  77. package/lib/stash/schema.js +10 -29
  78. package/lib/stash/schema.js.map +1 -1
  79. package/lib/stash/store.js +10 -10
  80. package/lib/stash/store.js.map +1 -1
  81. package/lib/stash.d.ts +37 -6
  82. package/lib/stash.d.ts.map +1 -1
  83. package/lib/stash.js +93 -36
  84. package/lib/stash.js.map +1 -1
  85. package/lib/terminal/external-editor.d.ts +93 -0
  86. package/lib/terminal/external-editor.d.ts.map +1 -0
  87. package/lib/terminal/external-editor.js +263 -0
  88. package/lib/terminal/external-editor.js.map +1 -0
  89. package/lib/terminal/host-writes.d.ts +39 -0
  90. package/lib/terminal/host-writes.d.ts.map +1 -0
  91. package/lib/terminal/host-writes.js +136 -0
  92. package/lib/terminal/host-writes.js.map +1 -0
  93. package/lib/terminal/signals.d.ts +25 -0
  94. package/lib/terminal/signals.d.ts.map +1 -0
  95. package/lib/terminal/signals.js +39 -0
  96. package/lib/terminal/signals.js.map +1 -0
  97. package/lib/terminal/warning-screen.d.ts +19 -1
  98. package/lib/terminal/warning-screen.d.ts.map +1 -1
  99. package/lib/terminal/warning-screen.js +37 -1
  100. package/lib/terminal/warning-screen.js.map +1 -1
  101. package/lib/terminal-text.d.ts +72 -0
  102. package/lib/terminal-text.d.ts.map +1 -0
  103. package/lib/terminal-text.js +603 -0
  104. package/lib/terminal-text.js.map +1 -0
  105. package/lib/text.d.ts +45 -6
  106. package/lib/text.d.ts.map +1 -1
  107. package/lib/text.js +106 -46
  108. package/lib/text.js.map +1 -1
  109. package/lib/theme-command.d.ts +7 -4
  110. package/lib/theme-command.d.ts.map +1 -1
  111. package/lib/theme-command.js +32 -12
  112. package/lib/theme-command.js.map +1 -1
  113. package/lib/theme-files.d.ts +110 -0
  114. package/lib/theme-files.d.ts.map +1 -0
  115. package/lib/theme-files.js +352 -0
  116. package/lib/theme-files.js.map +1 -0
  117. package/lib/theme-schema.d.ts +121 -0
  118. package/lib/theme-schema.d.ts.map +1 -0
  119. package/lib/theme-schema.js +87 -0
  120. package/lib/theme-schema.js.map +1 -0
  121. package/lib/theme-settings.d.ts +43 -10
  122. package/lib/theme-settings.d.ts.map +1 -1
  123. package/lib/theme-settings.js +103 -43
  124. package/lib/theme-settings.js.map +1 -1
  125. package/lib/theme-tokens.d.ts +39 -7
  126. package/lib/theme-tokens.d.ts.map +1 -1
  127. package/lib/theme-tokens.js +96 -13
  128. package/lib/theme-tokens.js.map +1 -1
  129. package/lib/theme.d.ts +24 -2
  130. package/lib/theme.d.ts.map +1 -1
  131. package/lib/theme.js +49 -6
  132. package/lib/theme.js.map +1 -1
  133. package/lib/tool-display.d.ts +57 -0
  134. package/lib/tool-display.d.ts.map +1 -0
  135. package/lib/tool-display.js +51 -0
  136. package/lib/tool-display.js.map +1 -0
  137. package/lib/transcript.d.ts +28 -0
  138. package/lib/transcript.d.ts.map +1 -1
  139. package/lib/transcript.js +126 -37
  140. package/lib/transcript.js.map +1 -1
  141. package/lib/ui/diff.d.ts +49 -0
  142. package/lib/ui/diff.d.ts.map +1 -0
  143. package/lib/ui/diff.js +208 -0
  144. package/lib/ui/diff.js.map +1 -0
  145. package/lib/ui/dock.d.ts.map +1 -1
  146. package/lib/ui/dock.js +9 -6
  147. package/lib/ui/dock.js.map +1 -1
  148. package/lib/ui/editor.d.ts.map +1 -1
  149. package/lib/ui/editor.js +4 -2
  150. package/lib/ui/editor.js.map +1 -1
  151. package/lib/ui/frame.d.ts +16 -5
  152. package/lib/ui/frame.d.ts.map +1 -1
  153. package/lib/ui/frame.js +32 -13
  154. package/lib/ui/frame.js.map +1 -1
  155. package/lib/ui/keymap-picker.d.ts +16 -0
  156. package/lib/ui/keymap-picker.d.ts.map +1 -0
  157. package/lib/ui/keymap-picker.js +41 -0
  158. package/lib/ui/keymap-picker.js.map +1 -0
  159. package/lib/ui/layout.d.ts +33 -0
  160. package/lib/ui/layout.d.ts.map +1 -0
  161. package/lib/ui/layout.js +33 -0
  162. package/lib/ui/layout.js.map +1 -0
  163. package/lib/ui/markdown.d.ts +25 -3
  164. package/lib/ui/markdown.d.ts.map +1 -1
  165. package/lib/ui/markdown.js +12 -8
  166. package/lib/ui/markdown.js.map +1 -1
  167. package/lib/ui/picker-card.d.ts +42 -0
  168. package/lib/ui/picker-card.d.ts.map +1 -0
  169. package/lib/ui/picker-card.js +148 -0
  170. package/lib/ui/picker-card.js.map +1 -0
  171. package/lib/ui/picker.d.ts +40 -2
  172. package/lib/ui/picker.d.ts.map +1 -1
  173. package/lib/ui/picker.js +50 -6
  174. package/lib/ui/picker.js.map +1 -1
  175. package/lib/ui/prompt.d.ts +10 -2
  176. package/lib/ui/prompt.d.ts.map +1 -1
  177. package/lib/ui/prompt.js +21 -2
  178. package/lib/ui/prompt.js.map +1 -1
  179. package/lib/ui/rows.d.ts +8 -6
  180. package/lib/ui/rows.d.ts.map +1 -1
  181. package/lib/ui/rows.js +10 -18
  182. package/lib/ui/rows.js.map +1 -1
  183. package/lib/ui/stash-picker.d.ts +2 -2
  184. package/lib/ui/stash-picker.d.ts.map +1 -1
  185. package/lib/ui/stash-picker.js +4 -4
  186. package/lib/ui/stash-picker.js.map +1 -1
  187. package/lib/ui/status.d.ts.map +1 -1
  188. package/lib/ui/status.js +18 -16
  189. package/lib/ui/status.js.map +1 -1
  190. package/lib/ui/theme-picker.d.ts +18 -0
  191. package/lib/ui/theme-picker.d.ts.map +1 -0
  192. package/lib/ui/theme-picker.js +68 -0
  193. package/lib/ui/theme-picker.js.map +1 -0
  194. package/lib/ui/view.d.ts +146 -22
  195. package/lib/ui/view.d.ts.map +1 -1
  196. package/lib/ui/view.js +461 -154
  197. package/lib/ui/view.js.map +1 -1
  198. package/package.json +3 -1
  199. package/themes/deepseek-blue.yaml +183 -0
  200. package/themes/violet-orbit.yaml +178 -0
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 { ProcessTerminal, ScrollView, VStack, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
4
+ import { ProcessTerminal, ScrollView, 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 { createPromptHistory } from "./agent/prompt-history.js";
@@ -15,6 +15,7 @@ import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } f
15
15
  import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
16
16
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
17
17
  import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
18
+ import { cancelStep, quitStep } from "./input/cancel.js";
18
19
  import { createCompletionProvider } from "./input/completion.js";
19
20
  import { ghostSuffix } from "./input/ghost.js";
20
21
  import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
@@ -24,6 +25,8 @@ import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
24
25
  import { resolveConfig } from "./config.js";
25
26
  import { FoldCursor } from "./fold-cursor.js";
26
27
  import { createRestoreRegistry } from "./terminal/restore.js";
28
+ import { ExternalEditor } from "./terminal/external-editor.js";
29
+ import { installSignalRestore } from "./terminal/signals.js";
27
30
  import { WarningSafeTui } from "./terminal/warning-screen.js";
28
31
  import { BELL, shouldRingBell } from "./terminal/bell.js";
29
32
  import { clipboardSequence } from "./terminal/clipboard.js";
@@ -34,9 +37,11 @@ import { defaultExportFile, transcriptToText } from "./export.js";
34
37
  import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
35
38
  import { detectColourMode } from "./theme-capability.js";
36
39
  import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
40
+ import { toolDisplayFor } from "./tool-display.js";
37
41
  import { pendingPrompts } from "./queue.js";
38
42
  import { renderThemeTable } from "./theme-command.js";
39
- import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
43
+ import { DEFAULT_THEME, builtinNames, builtinThemesDir, ensureThemesHome, exportTheme, loadThemes, themesHomeDir, watchThemes } from "./theme-files.js";
44
+ import { KEYMAP_LAYERS, keymapLayer } from "./keys-command.js";
40
45
  import { resetSequence } from "./theme-tokens.js";
41
46
  import { SessionId } from '@deepseek-ai/dsh-session';
42
47
  import { formatTokens } from "./tokens.js";
@@ -45,15 +50,19 @@ import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanSt
45
50
  import { WorkDock } from "./ui/dock.js";
46
51
  import { GateInputBar } from "./ui/gate-input.js";
47
52
  import { HistoryPicker } from "./ui/history-picker.js";
53
+ import { surfaceLayout } from "./ui/layout.js";
54
+ import { KeymapPicker } from "./ui/keymap-picker.js";
55
+ import { PickerPopup, POPUP_MAX_HEIGHT, popupWidth } from "./ui/picker-card.js";
48
56
  import { PromptBar } from "./ui/prompt.js";
49
57
  import { MarkdownRenderer } from "./ui/markdown.js";
50
58
  import { createMermaidTransform } from "./ui/mermaid.js";
51
59
  import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
52
60
  import { QueueBar } from "./ui/queue.js";
53
- import { shortPath, StatusBar } from "./ui/status.js";
61
+ import { StatusBar } from "./ui/status.js";
54
62
  import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
55
63
  import { PromptStash } from "./stash.js";
56
64
  import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
65
+ import { ThemePicker } from "./ui/theme-picker.js";
57
66
  export const name = 'tui';
58
67
  /**
59
68
  * Services the surface cannot run without.
@@ -128,19 +137,76 @@ export function apply(ctx, config) {
128
137
  * late-bound read that the injection point and the change event both use.
129
138
  */
130
139
  let readSection = () => defaultSettings();
140
+ /**
141
+ * The themes this session can draw.
142
+ *
143
+ * Read from disk rather than compiled in, so a file the reader saves is a theme
144
+ * the moment the save lands. Held beside the settings rather than inside them
145
+ * because the two answer different questions: the section says which name the
146
+ * reader chose, and this says which names exist — including the one they typed
147
+ * into the directory a second ago.
148
+ */
149
+ const themesHome = themesHomeDir();
150
+ let themeLibrary = loadThemes(themesHome, builtinThemesDir());
151
+ /**
152
+ * Persist a theme choice, replaced once the section is registered.
153
+ *
154
+ * A theme picked mid-session has to outlive it, so the choice is written
155
+ * through the same scope the reader's document is read from instead of kept
156
+ * in memory: the host persists it, and the change comes back through
157
+ * `settings/updated` like any other edit — which is what restyles the screen.
158
+ */
159
+ let chooseTheme = (_name) => { };
131
160
  /**
132
161
  * A refused settings edit, kept until the surface can show it: stderr is
133
162
  * behind the alt screen, and the section is read on a schedule of its own.
134
163
  */
135
164
  const settingsNotice = createDeferredNotice();
136
165
  let current = createTheme(themeMode());
166
+ /**
167
+ * The section as it was last read.
168
+ *
169
+ * Held so a preview can rebuild the table without reading the document again:
170
+ * the picker repaints on every arrow key, and a read there would report a
171
+ * refused section once per press.
172
+ */
173
+ let appliedSection;
174
+ /**
175
+ * The theme the picker's cursor is on, while its list is open.
176
+ *
177
+ * A preview is a name and nothing else — the document is not written — so
178
+ * leaving the list is one more rebuild from the section, and a session that
179
+ * ends mid-preview has still persisted only what the reader chose.
180
+ */
181
+ let previewTheme;
137
182
  const applyTheme = (section) => {
138
- current = createTheme(themeMode(), toOverrides(section));
183
+ // The row under the cursor outranks the document while a list is open, so a
184
+ // theme is judged on the reader's own transcript before it is taken.
185
+ current = createTheme(themeMode(), toOverrides({ ...section, theme: previewTheme ?? section.theme }, themeLibrary));
186
+ };
187
+ /**
188
+ * Show a theme without choosing it.
189
+ *
190
+ * The list's cursor is the preview: every row paints the surface and writes
191
+ * nothing, so two themes are compared on the reader's own transcript rather
192
+ * than on a name. Clearing the name puts back what the document says, which is
193
+ * what cancelling the list has to leave behind.
194
+ */
195
+ const showTheme = (name) => {
196
+ previewTheme = name;
197
+ // Nothing to rebuild from before the first read, and nothing to show either.
198
+ if (appliedSection === undefined)
199
+ return;
200
+ applyTheme(appliedSection);
201
+ markdown.invalidate();
202
+ view.invalidate();
203
+ tui.requestRender();
139
204
  };
140
205
  const theme = {
141
206
  get revision() { return current.revision; },
142
207
  get color() { return current.color; },
143
208
  style: (token, text) => current.style(token, text),
209
+ rich: (raw, options) => current.rich(raw, options),
144
210
  cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
145
211
  glyph: token => current.glyph(token),
146
212
  visible: token => current.visible(token),
@@ -151,12 +217,13 @@ export function apply(ctx, config) {
151
217
  markdown: forwardMarkdownTheme(() => current.markdown),
152
218
  };
153
219
  /**
154
- * Rows the reader has opened. The model stays untouched; only the view reads
155
- * this. A thought starts folded: it is the longest, least scannable row in the
156
- * transcript, so leaving it open pushes the answer a reader came for off the
157
- * screen, and a folded row still names itself and its key. A PTC card's calls
158
- * start open for the opposite reason: each is one clipped line under a header
159
- * that already names the program.
220
+ * Rows the reader has opened by key. The model stays untouched; only the view
221
+ * reads this, and a click on one message overrides it there. A thought starts
222
+ * folded: it is the longest, least scannable row in the transcript, so leaving
223
+ * it open pushes the answer a reader came for off the screen, and a folded row
224
+ * still names itself and its key. Cards start from the reader's own `tools:`
225
+ * settings, and a PTC card's calls start open for the opposite reason: each is
226
+ * one clipped line under a header that already names the program.
160
227
  */
161
228
  const viewState = { ...DEFAULT_VIEW_STATE };
162
229
  /**
@@ -167,6 +234,8 @@ export function apply(ctx, config) {
167
234
  * follow the edit.
168
235
  */
169
236
  let mermaidMode = defaultSettings().mermaid;
237
+ /** How each tool's cards draw; the settings document owns it and the view reads it live. */
238
+ let toolDisplay = defaultSettings().tools;
170
239
  /** The keys that start a chord, and how long one waits; the settings document owns all of it. */
171
240
  let prefixKeys = DEFAULT_PREFIX_KEYS;
172
241
  let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
@@ -190,11 +259,13 @@ export function apply(ctx, config) {
190
259
  *
191
260
  * The key toggles nested calls for one session, but a settings edit is a
192
261
  * deliberate act, so it re-seeds and becomes the new starting point; the
193
- * mermaid mode has no key of its own and only ever comes from the document.
262
+ * mermaid mode and the per-tool card fold have no key of their own and only
263
+ * ever come from the document.
194
264
  */
195
265
  const applyDisplay = (section) => {
196
266
  viewState.expandSubCalls = section.subcalls === 'inline';
197
267
  mermaidMode = section.mermaid;
268
+ toolDisplay = section.tools;
198
269
  prefixKeys = section.prefixes;
199
270
  prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
200
271
  keymap = section.keymap;
@@ -215,9 +286,30 @@ export function apply(ctx, config) {
215
286
  */
216
287
  const applySettings = () => {
217
288
  const section = readSection();
289
+ appliedSection = section;
290
+ // A settings edit ends any preview: what the document says is now the choice,
291
+ // and a name left over from a list would outrank it.
292
+ previewTheme = undefined;
293
+ reportMissingTheme(section);
218
294
  applyTheme(section);
219
295
  applyDisplay(section);
220
296
  };
297
+ /**
298
+ * Say so when the reader named a theme that nothing answers to.
299
+ *
300
+ * The schema cannot refuse the name: a theme is a file, so the set of names is
301
+ * known to the directory rather than to this build, and one can stop answering
302
+ * between two reads. Falling back to the default without a word would leave the
303
+ * reader looking at shades they did not choose, so the refusal lands here
304
+ * instead — beside the read that found it, and alongside the rest of the
305
+ * section, which is still theirs.
306
+ */
307
+ const reportMissingTheme = (section) => {
308
+ const name = section.theme;
309
+ if (name === undefined || themeLibrary.get(name) !== undefined)
310
+ return;
311
+ settingsNotice.post(`dsh-tui theme "${name}" is not a theme · themes: ${themeLibrary.names().join(' · ')} · the default, ${DEFAULT_THEME}, is drawn instead`);
312
+ };
221
313
  /**
222
314
  * Own the section, so the harness validates and persists it for the reader.
223
315
  *
@@ -243,6 +335,9 @@ export function apply(ctx, config) {
243
335
  return;
244
336
  }
245
337
  readSection = () => readScope(scope, message => settingsNotice.post(message));
338
+ chooseTheme = name => {
339
+ void scope.update({ theme: name }).then(() => model.notice(`theme · ${name} · written to the settings document`), (error) => settingsNotice.post(settingsProblemMessage(error)));
340
+ };
246
341
  applySettings();
247
342
  });
248
343
  /**
@@ -314,6 +409,28 @@ export function apply(ctx, config) {
314
409
  const restore = createRestoreRegistry();
315
410
  const terminal = new ProcessTerminal();
316
411
  const tui = new WarningSafeTui(terminal);
412
+ // A frame that cannot be drawn leaves the last good screen up, so the failure
413
+ // has to reach the transcript: otherwise the surface looks frozen and nothing
414
+ // on screen can say why.
415
+ tui.onFrameError = error => model.reportError(error);
416
+ /** Whether a child process owns the terminal, which is when nothing here may write to it. */
417
+ let handedOver = false;
418
+ /** Whether the host has unloaded this surface, after which nothing may start it again. */
419
+ let disposed = false;
420
+ /** An exit asked for while a child owned the terminal, run once the screen is ours again. */
421
+ let deferredExit;
422
+ /**
423
+ * Write to the tty, but only while this surface owns it.
424
+ *
425
+ * An editor the reader opened draws its own screen on the same terminal, so a
426
+ * title or a bell from here would land on top of it and, for a bell, sound as
427
+ * if the editor had failed. The title is written again when the screen comes
428
+ * back; a bell that fell in the gap is dropped rather than rung late.
429
+ */
430
+ const writeTerminal = (text) => {
431
+ if (!handedOver)
432
+ terminal.write(text);
433
+ };
317
434
  /** The session this surface drives: commands, approvals, and the bell belong to it. */
318
435
  let activeSession = resolved.sessionId;
319
436
  /** The session the transcript is showing, which can be one of its children. */
@@ -325,8 +442,9 @@ export function apply(ctx, config) {
325
442
  const view = new TranscriptView(model, theme, markdown, {
326
443
  state: () => viewState,
327
444
  gate: () => pending?.gate.card(),
328
- picker: () => pendingPicker?.picker.card(),
445
+ picker: () => pendingPicker?.card?.(),
329
446
  keys: () => keymap,
447
+ toolDisplay: tool => toolDisplayFor(toolDisplay, tool),
330
448
  });
331
449
  // The key map goes in before the bar exists, so no press can be read as the
332
450
  // send the library submits on by default. A settings document read after this
@@ -372,7 +490,8 @@ export function apply(ctx, config) {
372
490
  const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
373
491
  // The queue is read from the agent this terminal drives rather than from the
374
492
  // session on screen, because it sits on the editor that submits to that agent.
375
- const queue = new QueueBar(() => pendingPrompts(ctx, liveSession(activeSession)), theme);
493
+ const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
494
+ const queue = new QueueBar(queuedPrompts, theme);
376
495
  // Only a running turn has anything to say over time, so the clock stops with it.
377
496
  const statusTicker = setInterval(() => {
378
497
  if (turnOpen)
@@ -390,6 +509,9 @@ export function apply(ctx, config) {
390
509
  const unregisterExit = herdr.registerExitRelease();
391
510
  restore.add(() => tui.stop());
392
511
  ctx.effect(() => () => {
512
+ // A surface the host unloads owns no screen and keeps no listeners, so a
513
+ // child still running in another process must not be handed a start().
514
+ disposed = true;
393
515
  // The pane stops being an agent before the process that claimed it unwinds:
394
516
  // a release that ran after the reports were unregistered would race them,
395
517
  // and one that never ran would leave a row that reads as a live agent. The
@@ -404,27 +526,24 @@ export function apply(ctx, config) {
404
526
  dispose();
405
527
  return released.finally(unregisterExit);
406
528
  });
407
- tui.setLayoutRoot(new VStack([
408
- {
409
- component: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
410
- basis: 0,
411
- grow: 1,
412
- minSize: 1,
413
- },
414
- // Work state earns rows only when there is some: a dock that always
415
- // occupied a row would cost every conversation one line of transcript.
416
- { component: dock, basis: 'auto', shrink: 0, minSize: 0 },
417
- // Queued input earns rows only while something is waiting, and it gives
418
- // them up before the editor does: the bar being typed in outranks what is
419
- // waiting behind it.
420
- { component: queue, basis: 'auto', shrink: 2, minSize: 0 },
421
- { component: new VStack([{ component: promptBar, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 0 },
422
- { component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
423
- ]));
529
+ tui.setLayoutRoot(surfaceLayout({
530
+ transcript: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
531
+ dock,
532
+ queue,
533
+ prompt: promptBar,
534
+ status: statusBar,
535
+ }));
424
536
  tui.setFocus(editor);
425
537
  const requestExit = (code, reason) => {
426
538
  if (exited)
427
539
  return;
540
+ // A child owns the terminal: restoring it here would leave the reader a
541
+ // shell behind an editor that is still running, and would put its tty back
542
+ // into cooked mode under it. The exit waits for the screen to come back.
543
+ if (handedOver) {
544
+ deferredExit = { code, reason };
545
+ return;
546
+ }
428
547
  exited = true;
429
548
  clearInterval(statusTicker);
430
549
  // The screen goes back at once, so leaving feels like leaving; the row goes
@@ -453,6 +572,10 @@ export function apply(ctx, config) {
453
572
  appExit(code);
454
573
  });
455
574
  };
575
+ // A signal ends the process from outside the surface, and the default action
576
+ // would leave the reader on a screen no shell prompt is drawn in: the same
577
+ // shutdown a quit key runs goes to the signals a supervisor sends.
578
+ disposers.push(installSignalRestore({ shutdown: code => requestExit(code, 'interrupted') }));
456
579
  const openGate = (next) => {
457
580
  pending = next;
458
581
  // The card's own title names the decision, which is what a reader glancing
@@ -477,6 +600,14 @@ export function apply(ctx, config) {
477
600
  tui.setFocus(editor);
478
601
  tui.requestRender();
479
602
  };
603
+ /**
604
+ * Whether the bar holds anything the reader wrote.
605
+ *
606
+ * Whitespace counts: it is a character the editor holds, and a press that
607
+ * clears it is the press the reader asked for. A paste counts expanded,
608
+ * because a marker is content rather than an absence of it.
609
+ */
610
+ const barHasText = () => editor.getExpandedText() !== '';
480
611
  /**
481
612
  * What each key the surface answers itself does; false hands the press back.
482
613
  *
@@ -514,15 +645,57 @@ export function apply(ctx, config) {
514
645
  },
515
646
  interrupt: () => {
516
647
  // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
517
- // decides: stop the work in flight, or leave when there is none.
518
- if (!turnOpen) {
519
- requestExit(0);
520
- return true;
648
+ // decides what one press takes back — and a press with nothing left to
649
+ // cancel is handed back rather than spent on leaving.
650
+ const queued = queuedPrompts();
651
+ const step = cancelStep({
652
+ barHasText: barHasText(),
653
+ queuedPrompts: queued.length,
654
+ turnRunning: turnOpen,
655
+ viewingChild: viewedSession !== activeSession,
656
+ });
657
+ switch (step) {
658
+ case 'clear-editor':
659
+ editor.setText('');
660
+ tui.requestRender();
661
+ return true;
662
+ case 'reclaim-queued':
663
+ // The interrupt drops whatever the agent had not started, so the words
664
+ // are read before it is stopped and handed back to the bar: a key that
665
+ // means "stop" must not be the key that loses the reader's own prompts.
666
+ agent?.interrupt();
667
+ editor.setText(queued.join('\n'));
668
+ model.notice(`interrupt requested · ${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} back in the bar`);
669
+ tui.requestRender();
670
+ return true;
671
+ case 'interrupt-turn':
672
+ agent?.interrupt();
673
+ model.notice('interrupt requested');
674
+ tui.requestRender();
675
+ return true;
676
+ case 'leave-child-view':
677
+ void showAgentSession();
678
+ return true;
679
+ case 'hand-back':
680
+ return false;
681
+ }
682
+ },
683
+ quit: () => {
684
+ // The only key that leaves. Text in the bar keeps it for the editor, so
685
+ // the library's delete forward is never taken from a reader who is editing.
686
+ switch (quitStep({ barHasText: barHasText(), overlayOpen: tui.hasOverlay(), turnRunning: turnOpen })) {
687
+ case 'cancel-then-quit':
688
+ // An exit that waits on a tool call is not an exit, so the turn is
689
+ // asked to stop and the leave does not wait for the answer.
690
+ agent?.interrupt();
691
+ requestExit(0);
692
+ return true;
693
+ case 'quit':
694
+ requestExit(0);
695
+ return true;
696
+ case 'hand-back':
697
+ return false;
521
698
  }
522
- agent?.interrupt();
523
- model.notice('interrupt requested');
524
- tui.requestRender();
525
- return true;
526
699
  },
527
700
  };
528
701
  disposers.push(tui.addInputListener(data => {
@@ -647,6 +820,9 @@ export function apply(ctx, config) {
647
820
  /** Give the keyboard back to the editor and answer whoever opened the picker. */
648
821
  const settlePicker = (id) => {
649
822
  const settle = pendingPicker?.settle;
823
+ // The screen goes back before the keyboard does: a box left behind a settled
824
+ // list would sit over the transcript until something else repainted.
825
+ pendingPicker?.release?.();
650
826
  pendingPicker = undefined;
651
827
  herdr.unblock();
652
828
  editor.disableSubmit = false;
@@ -672,9 +848,36 @@ export function apply(ctx, config) {
672
848
  return error instanceof Error ? error.message : String(error);
673
849
  }
674
850
  };
675
- /** Take the keyboard for a picker and answer with the id it settled on. */
676
- const openPicker = (picker, vet) => new Promise(resolve => {
677
- pendingPicker = { picker, settle: resolve, vet };
851
+ /**
852
+ * Take the keyboard for a picker and answer with the id it settled on.
853
+ *
854
+ * Where the list is drawn is the caller's decision, because it is a decision
855
+ * about the reader's attention. A list that is the destination — a session to
856
+ * open, a model to switch to — joins the transcript and is read with it. A
857
+ * list that is a reference for the work in front of the reader is drawn over
858
+ * that work instead, so looking something up does not cost them their place.
859
+ * The keyboard is owned the same way either way.
860
+ */
861
+ const openPicker = (picker, vet, placement = 'inline') => new Promise(resolve => {
862
+ const overlay = placement === 'popup'
863
+ ? tui.showOverlay(new PickerPopup(rows => picker.card(rows), () => terminal.rows, theme), {
864
+ width: popupWidth(terminal.columns),
865
+ maxHeight: POPUP_MAX_HEIGHT,
866
+ anchor: 'center',
867
+ margin: 1,
868
+ // The surface's own listener reads every press before a focused
869
+ // component does, so the box needs no focus to be driven; taking it
870
+ // would only move focus away from where the reader left it.
871
+ nonCapturing: true,
872
+ })
873
+ : undefined;
874
+ pendingPicker = {
875
+ picker,
876
+ settle: resolve,
877
+ vet,
878
+ card: overlay === undefined ? () => picker.card() : undefined,
879
+ release: overlay === undefined ? undefined : () => overlay.hide(),
880
+ };
678
881
  // A picker owns the keyboard exactly as a gate does: nothing moves until
679
882
  // the reader chooses, so it is the same kind of wait.
680
883
  herdr.block(picker.card().title);
@@ -682,6 +885,36 @@ export function apply(ctx, config) {
682
885
  tui.setFocus(null);
683
886
  tui.requestRender();
684
887
  });
888
+ /**
889
+ * Open the key map over the surface.
890
+ *
891
+ * Nothing here is a pick: a row names an action and the keys reaching it, so
892
+ * the id the list settles on is thrown away. What the reader came for is the
893
+ * list itself, and the filter that narrows it.
894
+ */
895
+ const openKeyMap = (layer) => {
896
+ void openPicker(new KeymapPicker(() => keymap, layer), undefined, 'popup');
897
+ };
898
+ /**
899
+ * Choose a theme from a list the screen follows.
900
+ *
901
+ * Enter writes the choice through the settings document, the same path a typed
902
+ * name takes, so what lands is what the reader was looking at. Leaving the list
903
+ * restores the theme in force, because the document never changed while they
904
+ * looked.
905
+ */
906
+ const openThemePicker = async () => {
907
+ const picked = await openPicker(new ThemePicker(() => themeLibrary, () => appliedSection?.theme, () => keymap, theme => showTheme(theme?.name)));
908
+ if (picked === undefined) {
909
+ showTheme(undefined);
910
+ return;
911
+ }
912
+ // The row stays on screen until the document carries it: restoring first
913
+ // would flash the theme the reader just left. The write clears the preview as
914
+ // it lands, and a write that fails says so, leaving a theme that is still one
915
+ // of theirs rather than shades nothing chose.
916
+ chooseTheme(picked);
917
+ };
685
918
  /**
686
919
  * Reverse search over recorded prompts, seeded with whatever is in the bar.
687
920
  *
@@ -718,13 +951,12 @@ export function apply(ctx, config) {
718
951
  return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
719
952
  };
720
953
  /**
721
- * The prompt bank for this working directory.
954
+ * The prompt bank for the session this surface drives.
722
955
  *
723
956
  * The surface owns the editor, the picker, and the notices, so the bank is
724
957
  * handed the few things it needs to reach them and nothing else: the commands
725
958
  * stay free of terminal state and are exercised without one in the tests.
726
959
  */
727
- const stashCwdLabel = shortPath(process.cwd(), process.env.HOME);
728
960
  stash = new PromptStash({
729
961
  getEditorText: () => editor.getExpandedText(),
730
962
  setEditorText: text => {
@@ -732,13 +964,65 @@ export function apply(ctx, config) {
732
964
  tui.requestRender();
733
965
  },
734
966
  // A question answers in this editor, so a draft written into a borrowed bar
735
- // would become somebody's answer instead of a parked prompt.
736
- editorIsAvailable: () => !promptBar.isBorrowed(),
967
+ // would become somebody's answer instead of a parked prompt. An editor
968
+ // holding the draft in another program owns it just as firmly: a pop that
969
+ // landed then would be deleted from the bank and then overwritten.
970
+ editorIsAvailable: () => !promptBar.isBorrowed() && !handedOver,
737
971
  notice: message => model.notice(message),
738
972
  pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
739
973
  confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
740
974
  render: () => tui.requestRender(),
741
- }, { cwd: process.cwd() });
975
+ },
976
+ // The bank follows the session this surface drives, not the directory it
977
+ // runs in: two terminals in one checkout keep separate drafts, and a resume
978
+ // finds the ones it parked. Read per command so a switch retargets it.
979
+ { sessionId: () => String(activeSession) });
980
+ /**
981
+ * The reader's own editor, opened over the draft the bar holds.
982
+ *
983
+ * The screen is handed over rather than drawn beside: an editor needs the
984
+ * terminal, so this is the one moment the surface is not the process painting
985
+ * on it. Every failure is reported as a notice and leaves the bar as it was,
986
+ * because the caller is a key press with nowhere to put an error.
987
+ */
988
+ const externalEditor = new ExternalEditor({
989
+ suspend: () => {
990
+ handedOver = true;
991
+ try {
992
+ // The frame is left in place rather than repainted into the normal
993
+ // buffer: the editor is about to paint over that same screen.
994
+ tui.stop({ preserveScreen: true });
995
+ }
996
+ catch (error) {
997
+ // A stop that failed leaves the screen ours; leaving the flag up would
998
+ // suppress every later title and defer every exit for good.
999
+ handedOver = false;
1000
+ throw error;
1001
+ }
1002
+ },
1003
+ resume: () => {
1004
+ // Whatever the host unloaded is not coming back: starting it again would
1005
+ // paint on a terminal this process is done with, into listeners that are gone.
1006
+ if (disposed)
1007
+ return;
1008
+ try {
1009
+ tui.start();
1010
+ }
1011
+ finally {
1012
+ handedOver = false;
1013
+ }
1014
+ // Entering the alternate screen clears it, and any render asked for while
1015
+ // the child owned the terminal was dropped after setting the very flag that
1016
+ // makes the next ordinary request a no-op: without a forced one the reader
1017
+ // would get a blank screen with a working keyboard under it.
1018
+ tui.requestRender(true);
1019
+ // The title is state this surface owns and the handoff swallowed any change
1020
+ // to it, so a turn that ended while the editor was open would leave
1021
+ // "working" up until the next turn.
1022
+ writeTerminal(windowTitle(process.cwd(), turnOpen ? 'working' : 'ready'));
1023
+ },
1024
+ notice: message => model.notice(message),
1025
+ });
742
1026
  /** Title the listed sessions without making the reader wait for the slowest log. */
743
1027
  const loadTitles = async (history, sessions, titles) => {
744
1028
  const queue = [...sessions];
@@ -810,21 +1094,21 @@ export function apply(ctx, config) {
810
1094
  */
811
1095
  const foldCursor = new FoldCursor();
812
1096
  /** Feed one durable event to the model, unless a fold has already folded it. */
813
- const applyDurable = (event) => {
814
- if (foldCursor.accept(event))
1097
+ const applyDurable = (session, event) => {
1098
+ if (foldCursor.accept(session, event))
815
1099
  applyEvent(event);
816
1100
  };
817
1101
  const foldHistory = async (id) => {
818
1102
  // Resolved before the fold so every card reads its tool through the scope
819
1103
  // that actually registered it; a stored session nobody runs has none.
820
1104
  presentScope = ctx.agents?.get(id);
821
- // Every fold starts a cleared transcript, so this session's own numbering is
822
- // where the cursor begins rather than the previous session's.
1105
+ // Every fold starts a cleared transcript, so a session already known to the
1106
+ // cursor is read from its first event rather than from where it left off.
823
1107
  foldCursor.reset();
824
1108
  const inMemory = liveSession(id)?.snapshotEvents?.();
825
1109
  if (inMemory !== undefined) {
826
1110
  for (const event of inMemory)
827
- applyDurable(event);
1111
+ applyDurable(id, event);
828
1112
  return inMemory.length;
829
1113
  }
830
1114
  const history = createSessionHistory(ctx);
@@ -832,7 +1116,7 @@ export function apply(ctx, config) {
832
1116
  return 0;
833
1117
  const events = await history.read(id);
834
1118
  for (const event of events)
835
- applyDurable(event);
1119
+ applyDurable(id, event);
836
1120
  return events.length;
837
1121
  };
838
1122
  const replayHistory = async (id) => {
@@ -948,6 +1232,9 @@ export function apply(ctx, config) {
948
1232
  activeSession = id;
949
1233
  viewedSession = id;
950
1234
  agent = handle;
1235
+ // The bank follows the session, so the footer stops counting the drafts of
1236
+ // the session just left and the next command reads this session's file.
1237
+ void stash?.open();
951
1238
  // The agent's own id is reported rather than the requested one: a resume can
952
1239
  // be answered by the session the log actually holds.
953
1240
  herdr.session({
@@ -1657,21 +1944,66 @@ export function apply(ctx, config) {
1657
1944
  case 'todo':
1658
1945
  runTodoCommand();
1659
1946
  return;
1660
- case 'theme':
1661
- for (const line of renderThemeTable(toOverrides(readSection())))
1662
- model.notice(line);
1663
- tui.requestRender();
1947
+ case 'theme': {
1948
+ const argument = submission.argument;
1949
+ // A bare command is the list: a theme is judged by looking at it, so
1950
+ // choosing one belongs in a list the screen follows rather than in a name
1951
+ // the reader has to already know.
1952
+ if (argument === '') {
1953
+ void openThemePicker();
1954
+ return;
1955
+ }
1956
+ const [head = '', ...rest] = argument.split(/\s+/u);
1957
+ // The table answers the other question a theme raises — which layer drew a
1958
+ // shade — and stays reachable by name now that the list has the command.
1959
+ if (head === 'tokens') {
1960
+ for (const line of renderThemeTable(toOverrides(readSection(), themeLibrary), themeLibrary))
1961
+ model.notice(line);
1962
+ tui.requestRender();
1963
+ return;
1964
+ }
1965
+ // The one way a built-in becomes editable. Its file ships inside the
1966
+ // package and the next version replaces it, so a reader who wants to
1967
+ // change one needs a copy that is theirs — and the copy is a theme the
1968
+ // moment it lands, which is why the table is re-read rather than patched.
1969
+ if (head === 'export') {
1970
+ const chosen = rest.join(' ').trim();
1971
+ if (chosen === '') {
1972
+ model.notice(`theme export · which built-in? ${builtinNames(themeLibrary).join(' · ')}`);
1973
+ tui.requestRender();
1974
+ return;
1975
+ }
1976
+ const outcome = exportTheme(themeLibrary, chosen);
1977
+ if (outcome.ok) {
1978
+ themeLibrary = loadThemes(themesHome, builtinThemesDir());
1979
+ model.notice(`theme · exported ${chosen} to ${outcome.path} · /theme ${outcome.select} applies it`);
1980
+ }
1981
+ else {
1982
+ model.notice(outcome.problem);
1983
+ }
1984
+ tui.requestRender();
1985
+ return;
1986
+ }
1987
+ // A name nothing answers to is refused by name, like an unknown key
1988
+ // layer: the reader asked for something, so the answer lists names.
1989
+ if (themeLibrary.get(head) === undefined) {
1990
+ model.notice(`unknown theme "${head}" · themes: ${themeLibrary.names().join(' · ')}`);
1991
+ tui.requestRender();
1992
+ return;
1993
+ }
1994
+ chooseTheme(head);
1664
1995
  return;
1996
+ }
1665
1997
  case 'keys': {
1666
1998
  const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
1999
+ // A layer that does not exist is not a filter that matches nothing: the
2000
+ // reader asked for something by name, so the answer names the names.
1667
2001
  if (submission.argument !== '' && layer === undefined) {
1668
2002
  model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
2003
+ tui.requestRender();
2004
+ return;
1669
2005
  }
1670
- else {
1671
- for (const line of renderKeymap(keymap, layer))
1672
- model.notice(line);
1673
- }
1674
- tui.requestRender();
2006
+ openKeyMap(layer);
1675
2007
  return;
1676
2008
  }
1677
2009
  case 'copy':
@@ -1699,7 +2031,7 @@ export function apply(ctx, config) {
1699
2031
  void stash?.apply(submission.selector);
1700
2032
  return;
1701
2033
  case 'stash-list':
1702
- void stash?.list(stashCwdLabel);
2034
+ void stash?.list(String(activeSession));
1703
2035
  return;
1704
2036
  case 'stash-drop':
1705
2037
  void stash?.drop(submission.selector);
@@ -1707,6 +2039,24 @@ export function apply(ctx, config) {
1707
2039
  case 'stash-clear':
1708
2040
  void stash?.clear();
1709
2041
  return;
2042
+ case 'editor':
2043
+ void externalEditor.edit(editor.getExpandedText()).then(text => {
2044
+ if (text !== undefined) {
2045
+ // A gate can open while the child owns the screen, and the bar then
2046
+ // holds somebody's answer: the edited draft waits behind it instead
2047
+ // of being written into a question the reader never answered.
2048
+ if (promptBar.isBorrowed())
2049
+ promptBar.replaceHeld(text);
2050
+ else
2051
+ editor.setText(text);
2052
+ tui.requestRender();
2053
+ }
2054
+ const pendingExit = deferredExit;
2055
+ deferredExit = undefined;
2056
+ if (pendingExit !== undefined)
2057
+ requestExit(pendingExit.code, pendingExit.reason);
2058
+ });
2059
+ return;
1710
2060
  case 'status': {
1711
2061
  const facts = statusFacts();
1712
2062
  const context = facts.contextTokens === undefined
@@ -1785,17 +2135,17 @@ export function apply(ctx, config) {
1785
2135
  if (event.type === 'turn/start') {
1786
2136
  turnOpen = true;
1787
2137
  turnStartedAt = Date.now();
1788
- terminal.write(windowTitle(process.cwd(), 'working'));
2138
+ writeTerminal(windowTitle(process.cwd(), 'working'));
1789
2139
  herdr.working();
1790
2140
  }
1791
2141
  if (event.type === 'turn/end') {
1792
2142
  const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
1793
2143
  turnOpen = false;
1794
2144
  turnStartedAt = undefined;
1795
- terminal.write(windowTitle(process.cwd(), 'ready'));
2145
+ writeTerminal(windowTitle(process.cwd(), 'ready'));
1796
2146
  herdr.idle();
1797
2147
  if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
1798
- terminal.write(BELL);
2148
+ writeTerminal(BELL);
1799
2149
  // A job the turn started may have settled while the reader was watching
1800
2150
  // something else, and nothing else refreshes a live board.
1801
2151
  refreshJobs();
@@ -1808,7 +2158,7 @@ export function apply(ctx, config) {
1808
2158
  if (session.id !== viewedSession)
1809
2159
  return;
1810
2160
  presentScope = ctx.agents?.get(session.id);
1811
- applyDurable(event);
2161
+ applyDurable(session.id, event);
1812
2162
  tui.requestRender();
1813
2163
  }));
1814
2164
  /**
@@ -1895,14 +2245,18 @@ export function apply(ctx, config) {
1895
2245
  return;
1896
2246
  refreshJobs();
1897
2247
  }) ?? (() => { }));
2248
+ // The transcript belongs to the session on screen, while status, the bell,
2249
+ // and the queue stay with the agent this terminal drives. A live delta or a
2250
+ // failure folded into the wrong model would print one session's words as
2251
+ // another's, and the durable copy that follows would never correct it.
1898
2252
  disposers.push(ctx.on('agent/error', payload => {
1899
- if (payload.agent.id !== activeSession)
2253
+ if (payload.agent.id !== viewedSession)
1900
2254
  return;
1901
2255
  model.reportError(payload.error);
1902
2256
  tui.requestRender();
1903
2257
  }));
1904
2258
  disposers.push(ctx.on('agent/assistant-stream', payload => {
1905
- if (payload.agent.id !== activeSession)
2259
+ if (payload.agent.id !== viewedSession)
1906
2260
  return;
1907
2261
  if (payload.frame.type !== 'chunk')
1908
2262
  return;
@@ -1927,6 +2281,42 @@ export function apply(ctx, config) {
1927
2281
  view.invalidate();
1928
2282
  tui.requestRender();
1929
2283
  }));
2284
+ /**
2285
+ * The reader's own themes: created, reported, and then watched.
2286
+ *
2287
+ * Created because a directory that is not there is a command that cannot work —
2288
+ * `/theme export` names a path the reader should find where the surface said it
2289
+ * would be. Watched because a theme is a file they are editing by hand, so
2290
+ * saving one is how they ask for it; a session that needed a restart per shade
2291
+ * would make the whole table useless for tuning. Everything unreadable is
2292
+ * reported through the deferred notice rather than stderr, which the alternate
2293
+ * screen is drawn over.
2294
+ */
2295
+ let reportedThemes = new Set();
2296
+ const reportThemes = () => {
2297
+ // Only what is newly wrong: the watcher re-reads the whole directory on every
2298
+ // save, so an unfixed file would otherwise repeat its complaint on each one,
2299
+ // and a reader who has just been told is not helped by being told again.
2300
+ const problems = themeLibrary.problems();
2301
+ for (const problem of problems) {
2302
+ if (!reportedThemes.has(problem))
2303
+ settingsNotice.post(problem);
2304
+ }
2305
+ reportedThemes = new Set(problems);
2306
+ };
2307
+ for (const problem of ensureThemesHome(themesHome))
2308
+ settingsNotice.post(problem);
2309
+ reportThemes();
2310
+ disposers.push(watchThemes(themesHome, () => {
2311
+ themeLibrary = loadThemes(themesHome, builtinThemesDir());
2312
+ reportThemes();
2313
+ // The file that was just saved may be the theme already in force, so the
2314
+ // table is rebuilt rather than only repainted.
2315
+ applySettings();
2316
+ markdown.invalidate();
2317
+ view.invalidate();
2318
+ tui.requestRender();
2319
+ }));
1930
2320
  const degraded = describeMissingOptional(probe);
1931
2321
  if (degraded !== undefined)
1932
2322
  model.notice(degraded);
@@ -1968,10 +2358,7 @@ export function apply(ctx, config) {
1968
2358
  if (!resolved.resumePicker)
1969
2359
  await presetFor(resolved.sessionId, resolved.resume, undefined);
1970
2360
  tui.start();
1971
- // Loaded now so the footer's count is real from the first paint, and because
1972
- // a bank that cannot be read should say so before a reader trusts it.
1973
- void stash?.open();
1974
- terminal.write(windowTitle(process.cwd(), 'ready'));
2361
+ writeTerminal(windowTitle(process.cwd(), 'ready'));
1975
2362
  // Claiming the pane's agent row does not wait for a session: the pane is
1976
2363
  // already on screen and already idle, and a session may still be chosen.
1977
2364
  herdr.publish();