@sagmans/dsh-tui 0.3.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 (253) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +407 -53
  3. package/cordis.patch.yml +14 -0
  4. package/lib/agent/present.js +1 -1
  5. package/lib/agent/present.js.map +1 -1
  6. package/lib/agent/projections.d.ts +22 -6
  7. package/lib/agent/projections.d.ts.map +1 -1
  8. package/lib/agent/projections.js +21 -10
  9. package/lib/agent/projections.js.map +1 -1
  10. package/lib/agent/prompt-history.d.ts +89 -0
  11. package/lib/agent/prompt-history.d.ts.map +1 -0
  12. package/lib/agent/prompt-history.js +382 -0
  13. package/lib/agent/prompt-history.js.map +1 -0
  14. package/lib/agent/status.d.ts +6 -0
  15. package/lib/agent/status.d.ts.map +1 -1
  16. package/lib/agent/status.js +1 -0
  17. package/lib/agent/status.js.map +1 -1
  18. package/lib/cards.d.ts +83 -13
  19. package/lib/cards.d.ts.map +1 -1
  20. package/lib/cards.js +96 -28
  21. package/lib/cards.js.map +1 -1
  22. package/lib/export.d.ts +3 -3
  23. package/lib/export.d.ts.map +1 -1
  24. package/lib/export.js +23 -13
  25. package/lib/export.js.map +1 -1
  26. package/lib/fold-cursor.d.ts +12 -5
  27. package/lib/fold-cursor.d.ts.map +1 -1
  28. package/lib/fold-cursor.js +16 -5
  29. package/lib/fold-cursor.js.map +1 -1
  30. package/lib/gates.d.ts.map +1 -1
  31. package/lib/gates.js +42 -9
  32. package/lib/gates.js.map +1 -1
  33. package/lib/herdr/client.d.ts +57 -0
  34. package/lib/herdr/client.d.ts.map +1 -0
  35. package/lib/herdr/client.js +218 -0
  36. package/lib/herdr/client.js.map +1 -0
  37. package/lib/herdr/constants.d.ts +93 -0
  38. package/lib/herdr/constants.d.ts.map +1 -0
  39. package/lib/herdr/constants.js +79 -0
  40. package/lib/herdr/constants.js.map +1 -0
  41. package/lib/herdr/reporter.d.ts +60 -0
  42. package/lib/herdr/reporter.d.ts.map +1 -0
  43. package/lib/herdr/reporter.js +217 -0
  44. package/lib/herdr/reporter.js.map +1 -0
  45. package/lib/herdr/state.d.ts +57 -0
  46. package/lib/herdr/state.d.ts.map +1 -0
  47. package/lib/herdr/state.js +62 -0
  48. package/lib/herdr/state.js.map +1 -0
  49. package/lib/index.d.ts.map +1 -1
  50. package/lib/index.js +700 -81
  51. package/lib/index.js.map +1 -1
  52. package/lib/injection.d.ts +9 -0
  53. package/lib/injection.d.ts.map +1 -0
  54. package/lib/injection.js +128 -0
  55. package/lib/injection.js.map +1 -0
  56. package/lib/input/actions.d.ts +19 -2
  57. package/lib/input/actions.d.ts.map +1 -1
  58. package/lib/input/actions.js +60 -11
  59. package/lib/input/actions.js.map +1 -1
  60. package/lib/input/cancel.d.ts +55 -0
  61. package/lib/input/cancel.d.ts.map +1 -0
  62. package/lib/input/cancel.js +47 -0
  63. package/lib/input/cancel.js.map +1 -0
  64. package/lib/input/completion.d.ts +5 -2
  65. package/lib/input/completion.d.ts.map +1 -1
  66. package/lib/input/completion.js +79 -4
  67. package/lib/input/completion.js.map +1 -1
  68. package/lib/input/file-search.d.ts +105 -0
  69. package/lib/input/file-search.d.ts.map +1 -0
  70. package/lib/input/file-search.js +665 -0
  71. package/lib/input/file-search.js.map +1 -0
  72. package/lib/input/fuzzy.d.ts +42 -0
  73. package/lib/input/fuzzy.d.ts.map +1 -0
  74. package/lib/input/fuzzy.js +289 -0
  75. package/lib/input/fuzzy.js.map +1 -0
  76. package/lib/input/ghost.d.ts +46 -0
  77. package/lib/input/ghost.d.ts.map +1 -0
  78. package/lib/input/ghost.js +58 -0
  79. package/lib/input/ghost.js.map +1 -0
  80. package/lib/input/keymap.d.ts +3 -1
  81. package/lib/input/keymap.d.ts.map +1 -1
  82. package/lib/input/keymap.js +16 -3
  83. package/lib/input/keymap.js.map +1 -1
  84. package/lib/input/match.d.ts +12 -4
  85. package/lib/input/match.d.ts.map +1 -1
  86. package/lib/input/match.js +50 -51
  87. package/lib/input/match.js.map +1 -1
  88. package/lib/input/submission.d.ts +34 -1
  89. package/lib/input/submission.d.ts.map +1 -1
  90. package/lib/input/submission.js +33 -5
  91. package/lib/input/submission.js.map +1 -1
  92. package/lib/keys-command.d.ts +19 -3
  93. package/lib/keys-command.d.ts.map +1 -1
  94. package/lib/keys-command.js +34 -31
  95. package/lib/keys-command.js.map +1 -1
  96. package/lib/settings-notice.d.ts +5 -0
  97. package/lib/settings-notice.d.ts.map +1 -1
  98. package/lib/settings-notice.js +9 -7
  99. package/lib/settings-notice.js.map +1 -1
  100. package/lib/stash/lock.d.ts +72 -0
  101. package/lib/stash/lock.d.ts.map +1 -0
  102. package/lib/stash/lock.js +374 -0
  103. package/lib/stash/lock.js.map +1 -0
  104. package/lib/stash/paths.d.ts +22 -0
  105. package/lib/stash/paths.d.ts.map +1 -0
  106. package/lib/stash/paths.js +84 -0
  107. package/lib/stash/paths.js.map +1 -0
  108. package/lib/stash/private-fs.d.ts +61 -0
  109. package/lib/stash/private-fs.d.ts.map +1 -0
  110. package/lib/stash/private-fs.js +379 -0
  111. package/lib/stash/private-fs.js.map +1 -0
  112. package/lib/stash/schema.d.ts +56 -0
  113. package/lib/stash/schema.d.ts.map +1 -0
  114. package/lib/stash/schema.js +108 -0
  115. package/lib/stash/schema.js.map +1 -0
  116. package/lib/stash/store.d.ts +92 -0
  117. package/lib/stash/store.d.ts.map +1 -0
  118. package/lib/stash/store.js +262 -0
  119. package/lib/stash/store.js.map +1 -0
  120. package/lib/stash.d.ts +122 -0
  121. package/lib/stash.d.ts.map +1 -0
  122. package/lib/stash.js +353 -0
  123. package/lib/stash.js.map +1 -0
  124. package/lib/terminal/external-editor.d.ts +93 -0
  125. package/lib/terminal/external-editor.d.ts.map +1 -0
  126. package/lib/terminal/external-editor.js +263 -0
  127. package/lib/terminal/external-editor.js.map +1 -0
  128. package/lib/terminal/host-writes.d.ts +39 -0
  129. package/lib/terminal/host-writes.d.ts.map +1 -0
  130. package/lib/terminal/host-writes.js +136 -0
  131. package/lib/terminal/host-writes.js.map +1 -0
  132. package/lib/terminal/signals.d.ts +25 -0
  133. package/lib/terminal/signals.d.ts.map +1 -0
  134. package/lib/terminal/signals.js +39 -0
  135. package/lib/terminal/signals.js.map +1 -0
  136. package/lib/terminal/warning-screen.d.ts +19 -1
  137. package/lib/terminal/warning-screen.d.ts.map +1 -1
  138. package/lib/terminal/warning-screen.js +37 -1
  139. package/lib/terminal/warning-screen.js.map +1 -1
  140. package/lib/terminal-text.d.ts +72 -0
  141. package/lib/terminal-text.d.ts.map +1 -0
  142. package/lib/terminal-text.js +603 -0
  143. package/lib/terminal-text.js.map +1 -0
  144. package/lib/text.d.ts +45 -6
  145. package/lib/text.d.ts.map +1 -1
  146. package/lib/text.js +106 -46
  147. package/lib/text.js.map +1 -1
  148. package/lib/theme-command.d.ts +7 -4
  149. package/lib/theme-command.d.ts.map +1 -1
  150. package/lib/theme-command.js +32 -12
  151. package/lib/theme-command.js.map +1 -1
  152. package/lib/theme-files.d.ts +110 -0
  153. package/lib/theme-files.d.ts.map +1 -0
  154. package/lib/theme-files.js +352 -0
  155. package/lib/theme-files.js.map +1 -0
  156. package/lib/theme-schema.d.ts +121 -0
  157. package/lib/theme-schema.d.ts.map +1 -0
  158. package/lib/theme-schema.js +87 -0
  159. package/lib/theme-schema.js.map +1 -0
  160. package/lib/theme-settings.d.ts +72 -10
  161. package/lib/theme-settings.d.ts.map +1 -1
  162. package/lib/theme-settings.js +162 -45
  163. package/lib/theme-settings.js.map +1 -1
  164. package/lib/theme-tokens.d.ts +39 -7
  165. package/lib/theme-tokens.d.ts.map +1 -1
  166. package/lib/theme-tokens.js +104 -13
  167. package/lib/theme-tokens.js.map +1 -1
  168. package/lib/theme.d.ts +24 -2
  169. package/lib/theme.d.ts.map +1 -1
  170. package/lib/theme.js +49 -6
  171. package/lib/theme.js.map +1 -1
  172. package/lib/todo-guard.d.ts +116 -0
  173. package/lib/todo-guard.d.ts.map +1 -0
  174. package/lib/todo-guard.js +259 -0
  175. package/lib/todo-guard.js.map +1 -0
  176. package/lib/tool-display.d.ts +57 -0
  177. package/lib/tool-display.d.ts.map +1 -0
  178. package/lib/tool-display.js +51 -0
  179. package/lib/tool-display.js.map +1 -0
  180. package/lib/transcript.d.ts +28 -0
  181. package/lib/transcript.d.ts.map +1 -1
  182. package/lib/transcript.js +126 -37
  183. package/lib/transcript.js.map +1 -1
  184. package/lib/ui/diff.d.ts +49 -0
  185. package/lib/ui/diff.d.ts.map +1 -0
  186. package/lib/ui/diff.js +208 -0
  187. package/lib/ui/diff.js.map +1 -0
  188. package/lib/ui/dock.d.ts.map +1 -1
  189. package/lib/ui/dock.js +9 -6
  190. package/lib/ui/dock.js.map +1 -1
  191. package/lib/ui/editor.d.ts +41 -1
  192. package/lib/ui/editor.d.ts.map +1 -1
  193. package/lib/ui/editor.js +100 -4
  194. package/lib/ui/editor.js.map +1 -1
  195. package/lib/ui/frame.d.ts +16 -5
  196. package/lib/ui/frame.d.ts.map +1 -1
  197. package/lib/ui/frame.js +32 -13
  198. package/lib/ui/frame.js.map +1 -1
  199. package/lib/ui/history-picker.d.ts +15 -0
  200. package/lib/ui/history-picker.d.ts.map +1 -0
  201. package/lib/ui/history-picker.js +26 -0
  202. package/lib/ui/history-picker.js.map +1 -0
  203. package/lib/ui/keymap-picker.d.ts +16 -0
  204. package/lib/ui/keymap-picker.d.ts.map +1 -0
  205. package/lib/ui/keymap-picker.js +41 -0
  206. package/lib/ui/keymap-picker.js.map +1 -0
  207. package/lib/ui/layout.d.ts +33 -0
  208. package/lib/ui/layout.d.ts.map +1 -0
  209. package/lib/ui/layout.js +33 -0
  210. package/lib/ui/layout.js.map +1 -0
  211. package/lib/ui/markdown.d.ts +25 -3
  212. package/lib/ui/markdown.d.ts.map +1 -1
  213. package/lib/ui/markdown.js +12 -8
  214. package/lib/ui/markdown.js.map +1 -1
  215. package/lib/ui/picker-card.d.ts +42 -0
  216. package/lib/ui/picker-card.d.ts.map +1 -0
  217. package/lib/ui/picker-card.js +148 -0
  218. package/lib/ui/picker-card.js.map +1 -0
  219. package/lib/ui/picker.d.ts +50 -2
  220. package/lib/ui/picker.d.ts.map +1 -1
  221. package/lib/ui/picker.js +66 -7
  222. package/lib/ui/picker.js.map +1 -1
  223. package/lib/ui/prompt.d.ts +12 -2
  224. package/lib/ui/prompt.d.ts.map +1 -1
  225. package/lib/ui/prompt.js +25 -2
  226. package/lib/ui/prompt.js.map +1 -1
  227. package/lib/ui/rows.d.ts +8 -6
  228. package/lib/ui/rows.d.ts.map +1 -1
  229. package/lib/ui/rows.js +10 -18
  230. package/lib/ui/rows.js.map +1 -1
  231. package/lib/ui/stash-picker.d.ts +43 -0
  232. package/lib/ui/stash-picker.d.ts.map +1 -0
  233. package/lib/ui/stash-picker.js +78 -0
  234. package/lib/ui/stash-picker.js.map +1 -0
  235. package/lib/ui/status.d.ts +2 -0
  236. package/lib/ui/status.d.ts.map +1 -1
  237. package/lib/ui/status.js +25 -16
  238. package/lib/ui/status.js.map +1 -1
  239. package/lib/ui/theme-picker.d.ts +18 -0
  240. package/lib/ui/theme-picker.d.ts.map +1 -0
  241. package/lib/ui/theme-picker.js +68 -0
  242. package/lib/ui/theme-picker.js.map +1 -0
  243. package/lib/ui/view.d.ts +146 -22
  244. package/lib/ui/view.d.ts.map +1 -1
  245. package/lib/ui/view.js +461 -154
  246. package/lib/ui/view.js.map +1 -1
  247. package/lib/work.d.ts +3 -1
  248. package/lib/work.d.ts.map +1 -1
  249. package/lib/work.js +9 -1
  250. package/lib/work.js.map +1 -1
  251. package/package.json +7 -1
  252. package/themes/deepseek-blue.yaml +183 -0
  253. package/themes/violet-orbit.yaml +178 -0
package/lib/index.js CHANGED
@@ -1,9 +1,10 @@
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
+ import { createPromptHistory } from "./agent/prompt-history.js";
7
8
  import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
8
9
  import { createToolPresenter } from "./agent/present.js";
9
10
  import { forkPoint } from "./agent/fork.js";
@@ -14,7 +15,9 @@ import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } f
14
15
  import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
15
16
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
16
17
  import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
18
+ import { cancelStep, quitStep } from "./input/cancel.js";
17
19
  import { createCompletionProvider } from "./input/completion.js";
20
+ import { ghostSuffix } from "./input/ghost.js";
18
21
  import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
19
22
  import { createDeferredNotice } from "./settings-notice.js";
20
23
  import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
@@ -22,23 +25,34 @@ import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
22
25
  import { resolveConfig } from "./config.js";
23
26
  import { FoldCursor } from "./fold-cursor.js";
24
27
  import { createRestoreRegistry } from "./terminal/restore.js";
28
+ import { ExternalEditor } from "./terminal/external-editor.js";
29
+ import { installSignalRestore } from "./terminal/signals.js";
25
30
  import { WarningSafeTui } from "./terminal/warning-screen.js";
26
31
  import { BELL, shouldRingBell } from "./terminal/bell.js";
27
32
  import { clipboardSequence } from "./terminal/clipboard.js";
28
33
  import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
34
+ import { sessionStartReason } from "./herdr/state.js";
35
+ import { createHerdrReporter } from "./herdr/reporter.js";
29
36
  import { defaultExportFile, transcriptToText } from "./export.js";
30
37
  import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
31
38
  import { detectColourMode } from "./theme-capability.js";
32
39
  import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
40
+ import { toolDisplayFor } from "./tool-display.js";
33
41
  import { pendingPrompts } from "./queue.js";
34
42
  import { renderThemeTable } from "./theme-command.js";
35
- 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";
45
+ import { resetSequence } from "./theme-tokens.js";
36
46
  import { SessionId } from '@deepseek-ai/dsh-session';
37
47
  import { formatTokens } from "./tokens.js";
38
48
  import { TranscriptModel } from "./transcript.js";
39
49
  import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
40
50
  import { WorkDock } from "./ui/dock.js";
41
51
  import { GateInputBar } from "./ui/gate-input.js";
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";
42
56
  import { PromptBar } from "./ui/prompt.js";
43
57
  import { MarkdownRenderer } from "./ui/markdown.js";
44
58
  import { createMermaidTransform } from "./ui/mermaid.js";
@@ -46,6 +60,9 @@ import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, Se
46
60
  import { QueueBar } from "./ui/queue.js";
47
61
  import { StatusBar } from "./ui/status.js";
48
62
  import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
63
+ import { PromptStash } from "./stash.js";
64
+ import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
65
+ import { ThemePicker } from "./ui/theme-picker.js";
49
66
  export const name = 'tui';
50
67
  /**
51
68
  * Services the surface cannot run without.
@@ -65,6 +82,8 @@ const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK
65
82
  const TITLE_CONCURRENCY = 4;
66
83
  /** How often the running-state clock repaints while a turn is open. */
67
84
  const STATUS_TICK_MS = 1000;
85
+ /** Reverse video for the cell the cursor occupies, so a ghost keeps the cursor visible. */
86
+ const GHOST_CURSOR_PREFIX = '\u001b[7m';
68
87
  function asRecord(value) {
69
88
  return typeof value === 'object' && value !== null ? value : undefined;
70
89
  }
@@ -118,19 +137,76 @@ export function apply(ctx, config) {
118
137
  * late-bound read that the injection point and the change event both use.
119
138
  */
120
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) => { };
121
160
  /**
122
161
  * A refused settings edit, kept until the surface can show it: stderr is
123
162
  * behind the alt screen, and the section is read on a schedule of its own.
124
163
  */
125
164
  const settingsNotice = createDeferredNotice();
126
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;
127
182
  const applyTheme = (section) => {
128
- 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();
129
204
  };
130
205
  const theme = {
131
206
  get revision() { return current.revision; },
132
207
  get color() { return current.color; },
133
208
  style: (token, text) => current.style(token, text),
209
+ rich: (raw, options) => current.rich(raw, options),
134
210
  cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
135
211
  glyph: token => current.glyph(token),
136
212
  visible: token => current.visible(token),
@@ -141,12 +217,13 @@ export function apply(ctx, config) {
141
217
  markdown: forwardMarkdownTheme(() => current.markdown),
142
218
  };
143
219
  /**
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.
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.
150
227
  */
151
228
  const viewState = { ...DEFAULT_VIEW_STATE };
152
229
  /**
@@ -157,11 +234,17 @@ export function apply(ctx, config) {
157
234
  * follow the edit.
158
235
  */
159
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;
160
239
  /** The keys that start a chord, and how long one waits; the settings document owns all of it. */
161
240
  let prefixKeys = DEFAULT_PREFIX_KEYS;
162
241
  let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
163
242
  /** Every action's keys in force; the settings document owns it and a press reads it live. */
164
243
  let keymap = defaultKeymap();
244
+ /** Whether prompts are recorded and offered, and the cap on how many; the settings document owns all three. */
245
+ let historyEnabled = defaultSettings().history.enabled;
246
+ let historyGhost = defaultSettings().history.ghost;
247
+ let historyMaxEntries = defaultSettings().history.maxEntries;
165
248
  /**
166
249
  * The chord between a prefix and the action that follows it.
167
250
  *
@@ -176,17 +259,22 @@ export function apply(ctx, config) {
176
259
  *
177
260
  * The key toggles nested calls for one session, but a settings edit is a
178
261
  * 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.
262
+ * mermaid mode and the per-tool card fold have no key of their own and only
263
+ * ever come from the document.
180
264
  */
181
265
  const applyDisplay = (section) => {
182
266
  viewState.expandSubCalls = section.subcalls === 'inline';
183
267
  mermaidMode = section.mermaid;
268
+ toolDisplay = section.tools;
184
269
  prefixKeys = section.prefixes;
185
270
  prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
186
271
  keymap = section.keymap;
187
272
  // Installed where the library reads it, so a remap lands on the next press
188
273
  // rather than at the next restart.
189
274
  installKeybindings(keymap);
275
+ historyEnabled = section.history.enabled;
276
+ historyGhost = section.history.ghost;
277
+ historyMaxEntries = section.history.maxEntries;
190
278
  // A chord armed under the keymap the reader just replaced is not their chord.
191
279
  keyChord.disarm();
192
280
  };
@@ -198,9 +286,30 @@ export function apply(ctx, config) {
198
286
  */
199
287
  const applySettings = () => {
200
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);
201
294
  applyTheme(section);
202
295
  applyDisplay(section);
203
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
+ };
204
313
  /**
205
314
  * Own the section, so the harness validates and persists it for the reader.
206
315
  *
@@ -219,10 +328,16 @@ export function apply(ctx, config) {
219
328
  scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
220
329
  }
221
330
  catch (error) {
222
- settingsNotice.post(settingsProblemMessage(error));
331
+ // Nothing registered means nothing to read, so the reader's switch cannot
332
+ // be confirmed: recording stays off rather than falling back to on.
333
+ historyEnabled = false;
334
+ settingsNotice.post(settingsProblemMessage(error) + ' · prompt history stays off until the section parses');
223
335
  return;
224
336
  }
225
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
+ };
226
341
  applySettings();
227
342
  });
228
343
  /**
@@ -235,6 +350,33 @@ export function apply(ctx, config) {
235
350
  */
236
351
  let presentScope;
237
352
  const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
353
+ /**
354
+ * The reader's prompt history: global across projects, recorded from every
355
+ * submitted line, and offered back as they type. Built before the bar so its
356
+ * first load cannot race the first suggestion.
357
+ */
358
+ const promptHistory = createPromptHistory({
359
+ cap: () => historyMaxEntries,
360
+ warn: message => {
361
+ model.notice(message);
362
+ tui.requestRender();
363
+ },
364
+ });
365
+ /**
366
+ * The dimmed completion drawn from recorded prompts.
367
+ *
368
+ * Colour is the affordance: with styling off the suggestion would be
369
+ * unreadable text the reader could still accept, which is worse than none.
370
+ * The cursor cell is also reversed so the cursor stays visible on the ghost.
371
+ */
372
+ const ghostBrush = {
373
+ enabled: () => historyEnabled && historyGhost && theme.color && theme.visible('editor.ghost'),
374
+ suggestion: input => ghostSuffix({ entries: promptHistory.entries(), ...input }),
375
+ paint: (text, cell) => {
376
+ const styled = theme.style('editor.ghost', text);
377
+ return cell === 'cursor' ? GHOST_CURSOR_PREFIX + styled + resetSequence() : styled;
378
+ },
379
+ };
238
380
  const work = new WorkFold();
239
381
  const modelSwitch = new ModelSwitch();
240
382
  const agentPresets = createPresetRoster(ctx);
@@ -267,6 +409,28 @@ export function apply(ctx, config) {
267
409
  const restore = createRestoreRegistry();
268
410
  const terminal = new ProcessTerminal();
269
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
+ };
270
434
  /** The session this surface drives: commands, approvals, and the bell belong to it. */
271
435
  let activeSession = resolved.sessionId;
272
436
  /** The session the transcript is showing, which can be one of its children. */
@@ -278,14 +442,15 @@ export function apply(ctx, config) {
278
442
  const view = new TranscriptView(model, theme, markdown, {
279
443
  state: () => viewState,
280
444
  gate: () => pending?.gate.card(),
281
- picker: () => pendingPicker?.picker.card(),
445
+ picker: () => pendingPicker?.card?.(),
282
446
  keys: () => keymap,
447
+ toolDisplay: tool => toolDisplayFor(toolDisplay, tool),
283
448
  });
284
449
  // The key map goes in before the bar exists, so no press can be read as the
285
450
  // send the library submits on by default. A settings document read after this
286
451
  // point installs over it, which is why the bar reads the map per press.
287
452
  installKeybindings(keymap);
288
- const editor = new GateInputBar(tui, theme.editor, () => keymap);
453
+ const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
289
454
  // Answers are written in the reader's own editor, which is why a question
290
455
  // borrows the bar instead of drawing a second one beside it.
291
456
  const promptBar = new PromptBar(editor);
@@ -306,6 +471,9 @@ export function apply(ctx, config) {
306
471
  jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
307
472
  tui.requestRender();
308
473
  };
474
+ // The bank is built once the picker exists to answer for it, so the status
475
+ // source is late-bound: the footer must not read a half-constructed stash.
476
+ let stash;
309
477
  const statusFacts = createStatusFacts(ctx, {
310
478
  sessionId: () => activeSession,
311
479
  activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
@@ -316,12 +484,14 @@ export function apply(ctx, config) {
316
484
  // a hint stored with the transcript would keep naming the key of the day it
317
485
  // was written, and the reader may remap it with the row already on screen.
318
486
  back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
487
+ stash: () => stash?.entryCount,
319
488
  });
320
489
  const statusBar = new StatusBar(statusFacts, theme);
321
490
  const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
322
491
  // The queue is read from the agent this terminal drives rather than from the
323
492
  // session on screen, because it sits on the editor that submits to that agent.
324
- const queue = new QueueBar(() => pendingPrompts(ctx, liveSession(activeSession)), theme);
493
+ const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
494
+ const queue = new QueueBar(queuedPrompts, theme);
325
495
  // Only a running turn has anything to say over time, so the clock stops with it.
326
496
  const statusTicker = setInterval(() => {
327
497
  if (turnOpen)
@@ -330,55 +500,87 @@ export function apply(ctx, config) {
330
500
  disposers.push(() => clearInterval(statusTicker));
331
501
  // A window that outlived the surface would repaint a screen that is gone.
332
502
  disposers.push(() => keyChord.disarm());
503
+ // A containing Herdr is told what this pane is doing; away from one the
504
+ // reporter is inert, so the surface never depends on being multiplexed.
505
+ const herdr = createHerdrReporter();
506
+ // Kept out of the disposal list on purpose: the row is handed back before
507
+ // this stops guarding it, so a host that leaves during the release still
508
+ // releases synchronously.
509
+ const unregisterExit = herdr.registerExitRelease();
333
510
  restore.add(() => tui.stop());
334
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;
515
+ // The pane stops being an agent before the process that claimed it unwinds:
516
+ // a release that ran after the reports were unregistered would race them,
517
+ // and one that never ran would leave a row that reads as a live agent. The
518
+ // reports already on the wire are settled first, because Herdr ignores a
519
+ // release for a pane nothing has claimed yet — the report that followed it
520
+ // would otherwise claim the row back. The promise is returned so a host that
521
+ // waits for teardown waits for the row too, and the exit listener outlives
522
+ // the wait, so one that does not still hands the row back synchronously.
523
+ const released = herdr.release().catch(() => undefined);
335
524
  restore.restore();
336
525
  for (const dispose of disposers.reverse())
337
526
  dispose();
527
+ return released.finally(unregisterExit);
338
528
  });
339
- tui.setLayoutRoot(new VStack([
340
- {
341
- component: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
342
- basis: 0,
343
- grow: 1,
344
- minSize: 1,
345
- },
346
- // Work state earns rows only when there is some: a dock that always
347
- // occupied a row would cost every conversation one line of transcript.
348
- { component: dock, basis: 'auto', shrink: 0, minSize: 0 },
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 },
354
- { component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
355
- ]));
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
+ }));
356
536
  tui.setFocus(editor);
357
537
  const requestExit = (code, reason) => {
358
538
  if (exited)
359
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
+ }
360
547
  exited = true;
361
548
  clearInterval(statusTicker);
362
- // Hand the window label back before the screen does, so a shell that sets
363
- // its own title can take over cleanly.
549
+ // The screen goes back at once, so leaving feels like leaving; the row goes
550
+ // back behind it. Reports already on the wire are settled first, because
551
+ // Herdr ignores the release of a pane nothing has claimed yet — the report
552
+ // that followed it would otherwise claim the row back during shutdown.
364
553
  terminal.write(CLEAR_TITLE);
365
554
  restore.restore();
366
- // Everything below is written after the release: the alternate screen closes
367
- // over whatever was painted on it, and a failure nobody can read is not a
368
- // failure that was reported.
369
- if (reason !== undefined)
370
- terminal.write(`\ndsh-tui: ${reason}\n`);
371
- // The hint is computed here rather than read from the context because only
372
- // the surface knows which session it is leaving: a fork or a switch moves it.
373
- // A run that opened nothing has nothing to offer back: the identity it was
374
- // launched with names no log, so pointing at it would send the reader to a
375
- // conversation that does not exist.
376
- if (sessionOpened)
377
- terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
378
- appExit(code);
555
+ void herdr
556
+ .release()
557
+ // Nobody is left to report a release that failed on the way out, and a
558
+ // row that could not be cleared is not a reason to keep the process.
559
+ .catch(() => undefined)
560
+ .finally(() => {
561
+ // Everything below is written after the release: a failure nobody can
562
+ // read is not a failure that was reported.
563
+ if (reason !== undefined)
564
+ terminal.write(`\ndsh-tui: ${reason}\n`);
565
+ // The hint is computed here rather than read from the context because
566
+ // only the surface knows which session it is leaving: a fork or a
567
+ // switch moves it. A run that opened nothing has nothing to offer back:
568
+ // the identity it was launched with names no log, so pointing at it
569
+ // would send the reader to a conversation that does not exist.
570
+ if (sessionOpened)
571
+ terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
572
+ appExit(code);
573
+ });
379
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') }));
380
579
  const openGate = (next) => {
381
580
  pending = next;
581
+ // The card's own title names the decision, which is what a reader glancing
582
+ // at a wall of panes needs in order to know which one to open.
583
+ herdr.block(next.gate.card().title);
382
584
  // A gate owns the keyboard: the editor must not collect the decision keys.
383
585
  editor.disableSubmit = true;
384
586
  tui.setFocus(null);
@@ -390,6 +592,7 @@ export function apply(ctx, config) {
390
592
  };
391
593
  const closeGate = () => {
392
594
  pending = undefined;
595
+ herdr.unblock();
393
596
  editor.disableSubmit = false;
394
597
  // The question is answered or skipped, so the reader gets their prompt back
395
598
  // in the bar they left it in.
@@ -397,10 +600,18 @@ export function apply(ctx, config) {
397
600
  tui.setFocus(editor);
398
601
  tui.requestRender();
399
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() !== '';
400
611
  /**
401
612
  * What each key the surface answers itself does; false hands the press back.
402
613
  *
403
- * Keyed by the table's own ids, so a key added to {@link SURFACE_KEYS}
614
+ * Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
404
615
  * without a handler here fails to compile rather than doing nothing.
405
616
  */
406
617
  const surfaceActions = {
@@ -423,6 +634,10 @@ export function apply(ctx, config) {
423
634
  void openEffortPicker();
424
635
  return true;
425
636
  },
637
+ history: () => {
638
+ void openHistoryPicker();
639
+ return true;
640
+ },
426
641
  back: () => {
427
642
  if (viewedSession !== activeSession)
428
643
  void showAgentSession();
@@ -430,15 +645,57 @@ export function apply(ctx, config) {
430
645
  },
431
646
  interrupt: () => {
432
647
  // 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;
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;
437
698
  }
438
- agent?.interrupt();
439
- model.notice('interrupt requested');
440
- tui.requestRender();
441
- return true;
442
699
  },
443
700
  };
444
701
  disposers.push(tui.addInputListener(data => {
@@ -563,7 +820,11 @@ export function apply(ctx, config) {
563
820
  /** Give the keyboard back to the editor and answer whoever opened the picker. */
564
821
  const settlePicker = (id) => {
565
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?.();
566
826
  pendingPicker = undefined;
827
+ herdr.unblock();
567
828
  editor.disableSubmit = false;
568
829
  tui.setFocus(editor);
569
830
  settle?.(id);
@@ -587,13 +848,99 @@ export function apply(ctx, config) {
587
848
  return error instanceof Error ? error.message : String(error);
588
849
  }
589
850
  };
590
- /** Take the keyboard for a picker and answer with the id it settled on. */
591
- const openPicker = (picker, vet) => new Promise(resolve => {
592
- 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
+ };
881
+ // A picker owns the keyboard exactly as a gate does: nothing moves until
882
+ // the reader chooses, so it is the same kind of wait.
883
+ herdr.block(picker.card().title);
593
884
  editor.disableSubmit = true;
594
885
  tui.setFocus(null);
595
886
  tui.requestRender();
596
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
+ };
918
+ /**
919
+ * Reverse search over recorded prompts, seeded with whatever is in the bar.
920
+ *
921
+ * A pick replaces the draft; a cancel leaves it exactly as it was, because the
922
+ * list was opened to look rather than to lose what is already typed.
923
+ */
924
+ const openHistoryPicker = async () => {
925
+ if (!historyEnabled) {
926
+ model.notice('prompt history is disabled in ' + TUI_SETTINGS_NAMESPACE + ' settings');
927
+ tui.requestRender();
928
+ return;
929
+ }
930
+ if (promptHistory.entries().length === 0) {
931
+ const blocked = promptHistory.blockedReason();
932
+ model.notice(blocked === undefined ? 'no prompt history yet' : 'prompt history is unavailable: ' + blocked);
933
+ tui.requestRender();
934
+ return;
935
+ }
936
+ // The expanded text is what the reader wrote; a large paste sits in the bar
937
+ // as a marker, and seeding with it would filter out the prompt it came from.
938
+ const picked = await openPicker(new HistoryPicker(() => promptHistory.entries(), editor.getExpandedText(), () => keymap));
939
+ if (picked === undefined)
940
+ return;
941
+ editor.setText(picked);
942
+ tui.requestRender();
943
+ };
597
944
  /** The roster as the picker paints it, refreshed when the picker opens. */
598
945
  let presetRows = [];
599
946
  /** Choose the mode a session that has not started yet will run. */
@@ -603,6 +950,79 @@ export function apply(ctx, config) {
603
950
  presetRows = await agentPresets.list();
604
951
  return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
605
952
  };
953
+ /**
954
+ * The prompt bank for the session this surface drives.
955
+ *
956
+ * The surface owns the editor, the picker, and the notices, so the bank is
957
+ * handed the few things it needs to reach them and nothing else: the commands
958
+ * stay free of terminal state and are exercised without one in the tests.
959
+ */
960
+ stash = new PromptStash({
961
+ getEditorText: () => editor.getExpandedText(),
962
+ setEditorText: text => {
963
+ editor.setText(text);
964
+ tui.requestRender();
965
+ },
966
+ // A question answers in this editor, so a draft written into a borrowed bar
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,
971
+ notice: message => model.notice(message),
972
+ pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
973
+ confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
974
+ render: () => tui.requestRender(),
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
+ });
606
1026
  /** Title the listed sessions without making the reader wait for the slowest log. */
607
1027
  const loadTitles = async (history, sessions, titles) => {
608
1028
  const queue = [...sessions];
@@ -674,21 +1094,21 @@ export function apply(ctx, config) {
674
1094
  */
675
1095
  const foldCursor = new FoldCursor();
676
1096
  /** Feed one durable event to the model, unless a fold has already folded it. */
677
- const applyDurable = (event) => {
678
- if (foldCursor.accept(event))
1097
+ const applyDurable = (session, event) => {
1098
+ if (foldCursor.accept(session, event))
679
1099
  applyEvent(event);
680
1100
  };
681
1101
  const foldHistory = async (id) => {
682
1102
  // Resolved before the fold so every card reads its tool through the scope
683
1103
  // that actually registered it; a stored session nobody runs has none.
684
1104
  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.
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.
687
1107
  foldCursor.reset();
688
1108
  const inMemory = liveSession(id)?.snapshotEvents?.();
689
1109
  if (inMemory !== undefined) {
690
1110
  for (const event of inMemory)
691
- applyDurable(event);
1111
+ applyDurable(id, event);
692
1112
  return inMemory.length;
693
1113
  }
694
1114
  const history = createSessionHistory(ctx);
@@ -696,7 +1116,7 @@ export function apply(ctx, config) {
696
1116
  return 0;
697
1117
  const events = await history.read(id);
698
1118
  for (const event of events)
699
- applyDurable(event);
1119
+ applyDurable(id, event);
700
1120
  return events.length;
701
1121
  };
702
1122
  const replayHistory = async (id) => {
@@ -812,6 +1232,16 @@ export function apply(ctx, config) {
812
1232
  activeSession = id;
813
1233
  viewedSession = id;
814
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();
1238
+ // The agent's own id is reported rather than the requested one: a resume can
1239
+ // be answered by the session the log actually holds.
1240
+ herdr.session({
1241
+ id: String(handle.sessionId),
1242
+ cwd: process.cwd(),
1243
+ reason: sessionStartReason({ forked: fork !== undefined, resumed: resume }),
1244
+ });
815
1245
  // A session with no history to fold still has to present its first live
816
1246
  // card through the right scope, so the scope is set before any event can.
817
1247
  presentScope = handle.agent;
@@ -1433,6 +1863,47 @@ export function apply(ctx, config) {
1433
1863
  ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1434
1864
  });
1435
1865
  };
1866
+ /** Show where history is kept, or forget it; the file is global to this machine. */
1867
+ const runHistoryCommand = (argument) => {
1868
+ // The line that asked for this is recorded before the command runs, but that
1869
+ // write rides the store's queue; waiting for it lets the count describe the
1870
+ // file the reader has, not the state before their own line landed.
1871
+ void promptHistory.flush().then(() => {
1872
+ if (argument === '') {
1873
+ const count = promptHistory.entries().length;
1874
+ const blocked = promptHistory.blockedReason();
1875
+ model.notice([
1876
+ count + (count === 1 ? ' prompt recorded' : ' prompts recorded'),
1877
+ promptHistory.path(),
1878
+ blocked === undefined ? undefined : 'writes disabled: ' + blocked,
1879
+ ].filter(part => part !== undefined).join(' · '));
1880
+ tui.requestRender();
1881
+ return;
1882
+ }
1883
+ if (argument !== 'clear') {
1884
+ model.notice('usage: /history shows where history is kept · /history clear forgets every prompt');
1885
+ tui.requestRender();
1886
+ return;
1887
+ }
1888
+ // A refused write cannot remove anything, so saying "forgot 0 prompts"
1889
+ // would describe a successful clear the file never had.
1890
+ const blocked = promptHistory.blockedReason();
1891
+ if (blocked !== undefined) {
1892
+ model.notice('prompt history is unavailable: ' + blocked);
1893
+ tui.requestRender();
1894
+ return;
1895
+ }
1896
+ return promptHistory.clear().then(removed => {
1897
+ model.notice('forgot ' + removed + (removed === 1 ? ' prompt' : ' prompts'));
1898
+ tui.requestRender();
1899
+ });
1900
+ }).catch(error => {
1901
+ // The store keeps what the file still holds, so the reader is told the
1902
+ // clear failed rather than being shown a count that never landed.
1903
+ model.notice('could not clear prompt history: ' + (error instanceof Error ? error.message : String(error)));
1904
+ tui.requestRender();
1905
+ });
1906
+ };
1436
1907
  /**
1437
1908
  * Carry out one classified line, wherever it was asked for.
1438
1909
  *
@@ -1473,26 +1944,119 @@ export function apply(ctx, config) {
1473
1944
  case 'todo':
1474
1945
  runTodoCommand();
1475
1946
  return;
1476
- case 'theme':
1477
- for (const line of renderThemeTable(toOverrides(readSection())))
1478
- model.notice(line);
1479
- 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);
1480
1995
  return;
1996
+ }
1481
1997
  case 'keys': {
1482
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.
1483
2001
  if (submission.argument !== '' && layer === undefined) {
1484
2002
  model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
2003
+ tui.requestRender();
2004
+ return;
1485
2005
  }
1486
- else {
1487
- for (const line of renderKeymap(keymap, layer))
1488
- model.notice(line);
1489
- }
1490
- tui.requestRender();
2006
+ openKeyMap(layer);
1491
2007
  return;
1492
2008
  }
1493
2009
  case 'copy':
1494
2010
  runCopyCommand();
1495
2011
  return;
2012
+ case 'history':
2013
+ runHistoryCommand(submission.argument);
2014
+ return;
2015
+ case 'stash':
2016
+ // Submitting a command consumes the line it was typed on, so this path
2017
+ // can only carry a draft it was given; parking the bar's own draft is
2018
+ // what the chord is for.
2019
+ if (submission.argument.trim() === '')
2020
+ model.notice('usage: /stash <draft>, or ctrl+x then s to park the editor');
2021
+ else
2022
+ void stash?.stashEditor(submission.argument);
2023
+ return;
2024
+ case 'stash-draft':
2025
+ void stash?.stashEditor();
2026
+ return;
2027
+ case 'stash-pop':
2028
+ void stash?.pop(submission.selector);
2029
+ return;
2030
+ case 'stash-apply':
2031
+ void stash?.apply(submission.selector);
2032
+ return;
2033
+ case 'stash-list':
2034
+ void stash?.list(String(activeSession));
2035
+ return;
2036
+ case 'stash-drop':
2037
+ void stash?.drop(submission.selector);
2038
+ return;
2039
+ case 'stash-clear':
2040
+ void stash?.clear();
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;
1496
2060
  case 'status': {
1497
2061
  const facts = statusFacts();
1498
2062
  const context = facts.contextTokens === undefined
@@ -1553,7 +2117,17 @@ export function apply(ctx, config) {
1553
2117
  }
1554
2118
  }
1555
2119
  };
1556
- editor.onSubmit = text => runSubmission(classifySubmission(text));
2120
+ editor.onSubmit = text => {
2121
+ const submission = classifySubmission(text);
2122
+ if (submission.kind === 'empty')
2123
+ return;
2124
+ // The library's own history feeds the up/down keys; the store below feeds
2125
+ // ghost completion and reverse search, and is global rather than per-session.
2126
+ editor.addToHistory(text);
2127
+ if (historyEnabled)
2128
+ promptHistory.record(text);
2129
+ runSubmission(submission);
2130
+ };
1557
2131
  disposers.push(ctx.on('session/event', (session, event) => {
1558
2132
  // The surface state — activity, timer, title, bell, job board — belongs to
1559
2133
  // the agent this terminal drives, even while a child is on screen.
@@ -1561,15 +2135,17 @@ export function apply(ctx, config) {
1561
2135
  if (event.type === 'turn/start') {
1562
2136
  turnOpen = true;
1563
2137
  turnStartedAt = Date.now();
1564
- terminal.write(windowTitle(process.cwd(), 'working'));
2138
+ writeTerminal(windowTitle(process.cwd(), 'working'));
2139
+ herdr.working();
1565
2140
  }
1566
2141
  if (event.type === 'turn/end') {
1567
2142
  const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
1568
2143
  turnOpen = false;
1569
2144
  turnStartedAt = undefined;
1570
- terminal.write(windowTitle(process.cwd(), 'ready'));
2145
+ writeTerminal(windowTitle(process.cwd(), 'ready'));
2146
+ herdr.idle();
1571
2147
  if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
1572
- terminal.write(BELL);
2148
+ writeTerminal(BELL);
1573
2149
  // A job the turn started may have settled while the reader was watching
1574
2150
  // something else, and nothing else refreshes a live board.
1575
2151
  refreshJobs();
@@ -1582,7 +2158,7 @@ export function apply(ctx, config) {
1582
2158
  if (session.id !== viewedSession)
1583
2159
  return;
1584
2160
  presentScope = ctx.agents?.get(session.id);
1585
- applyDurable(event);
2161
+ applyDurable(session.id, event);
1586
2162
  tui.requestRender();
1587
2163
  }));
1588
2164
  /**
@@ -1669,14 +2245,18 @@ export function apply(ctx, config) {
1669
2245
  return;
1670
2246
  refreshJobs();
1671
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.
1672
2252
  disposers.push(ctx.on('agent/error', payload => {
1673
- if (payload.agent.id !== activeSession)
2253
+ if (payload.agent.id !== viewedSession)
1674
2254
  return;
1675
2255
  model.reportError(payload.error);
1676
2256
  tui.requestRender();
1677
2257
  }));
1678
2258
  disposers.push(ctx.on('agent/assistant-stream', payload => {
1679
- if (payload.agent.id !== activeSession)
2259
+ if (payload.agent.id !== viewedSession)
1680
2260
  return;
1681
2261
  if (payload.frame.type !== 'chunk')
1682
2262
  return;
@@ -1701,6 +2281,42 @@ export function apply(ctx, config) {
1701
2281
  view.invalidate();
1702
2282
  tui.requestRender();
1703
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
+ }));
1704
2320
  const degraded = describeMissingOptional(probe);
1705
2321
  if (degraded !== undefined)
1706
2322
  model.notice(degraded);
@@ -1742,7 +2358,10 @@ export function apply(ctx, config) {
1742
2358
  if (!resolved.resumePicker)
1743
2359
  await presetFor(resolved.sessionId, resolved.resume, undefined);
1744
2360
  tui.start();
1745
- terminal.write(windowTitle(process.cwd(), 'ready'));
2361
+ writeTerminal(windowTitle(process.cwd(), 'ready'));
2362
+ // Claiming the pane's agent row does not wait for a session: the pane is
2363
+ // already on screen and already idle, and a session may still be chosen.
2364
+ herdr.publish();
1746
2365
  // A refused settings edit is only visible now that the surface owns the
1747
2366
  // screen; whatever the scope found before this point prints here instead.
1748
2367
  settingsNotice.open(message => model.notice(message));