@sagmans/dsh-tui 0.5.2 → 0.7.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 (285) hide show
  1. package/.agents/skills/dsh-tui-dogfood/SKILL.md +83 -0
  2. package/.agents/skills/dsh-tui-dogfood/references/home-state.md +78 -0
  3. package/.agents/skills/dsh-tui-dogfood/scripts/clone-links.mjs +133 -0
  4. package/.agents/skills/dsh-tui-dogfood/scripts/run-plugin-from-worktree.sh +417 -0
  5. package/.agents/skills/dsh-tui-update-models/SKILL.md +121 -0
  6. package/.agents/skills/dsh-tui-update-models/references/model-wiring.md +162 -0
  7. package/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs +178 -0
  8. package/README.md +131 -17
  9. package/lib/agent/host.d.ts +11 -0
  10. package/lib/agent/host.d.ts.map +1 -1
  11. package/lib/agent/host.js +16 -2
  12. package/lib/agent/host.js.map +1 -1
  13. package/lib/agent/present.d.ts.map +1 -1
  14. package/lib/agent/present.js +2 -1
  15. package/lib/agent/present.js.map +1 -1
  16. package/lib/agent/undo.d.ts +52 -0
  17. package/lib/agent/undo.d.ts.map +1 -0
  18. package/lib/agent/undo.js +106 -0
  19. package/lib/agent/undo.js.map +1 -0
  20. package/lib/cards/composition.d.ts +68 -0
  21. package/lib/cards/composition.d.ts.map +1 -0
  22. package/lib/cards/composition.js +117 -0
  23. package/lib/cards/composition.js.map +1 -0
  24. package/lib/cards/presenter.d.ts +23 -0
  25. package/lib/cards/presenter.d.ts.map +1 -0
  26. package/lib/cards/presenter.js +394 -0
  27. package/lib/cards/presenter.js.map +1 -0
  28. package/lib/cards/preview.d.ts +42 -0
  29. package/lib/cards/preview.d.ts.map +1 -0
  30. package/lib/cards/preview.js +51 -0
  31. package/lib/cards/preview.js.map +1 -0
  32. package/lib/cards.d.ts +42 -95
  33. package/lib/cards.d.ts.map +1 -1
  34. package/lib/cards.js +4 -526
  35. package/lib/cards.js.map +1 -1
  36. package/lib/compat/section.d.ts +29 -0
  37. package/lib/compat/section.d.ts.map +1 -0
  38. package/lib/compat/section.js +82 -0
  39. package/lib/compat/section.js.map +1 -0
  40. package/lib/config.d.ts +27 -2
  41. package/lib/config.d.ts.map +1 -1
  42. package/lib/config.js +61 -1
  43. package/lib/config.js.map +1 -1
  44. package/lib/contracts.d.ts +28 -0
  45. package/lib/contracts.d.ts.map +1 -1
  46. package/lib/fold-cursor.d.ts +8 -0
  47. package/lib/fold-cursor.d.ts.map +1 -1
  48. package/lib/fold-cursor.js +18 -0
  49. package/lib/fold-cursor.js.map +1 -1
  50. package/lib/gates/question-card.d.ts +77 -0
  51. package/lib/gates/question-card.d.ts.map +1 -0
  52. package/lib/gates/question-card.js +113 -0
  53. package/lib/gates/question-card.js.map +1 -0
  54. package/lib/gates/questions.d.ts +127 -0
  55. package/lib/gates/questions.d.ts.map +1 -0
  56. package/lib/gates/questions.js +418 -0
  57. package/lib/gates/questions.js.map +1 -0
  58. package/lib/gates.d.ts +1 -113
  59. package/lib/gates.d.ts.map +1 -1
  60. package/lib/gates.js +1 -484
  61. package/lib/gates.js.map +1 -1
  62. package/lib/herdr/reporter.d.ts +9 -4
  63. package/lib/herdr/reporter.d.ts.map +1 -1
  64. package/lib/herdr/reporter.js +5 -9
  65. package/lib/herdr/reporter.js.map +1 -1
  66. package/lib/herdr/state.d.ts +35 -4
  67. package/lib/herdr/state.d.ts.map +1 -1
  68. package/lib/herdr/state.js +37 -4
  69. package/lib/herdr/state.js.map +1 -1
  70. package/lib/index.d.ts +1 -0
  71. package/lib/index.d.ts.map +1 -1
  72. package/lib/index.js +343 -2267
  73. package/lib/index.js.map +1 -1
  74. package/lib/input/action-catalog.d.ts +74 -0
  75. package/lib/input/action-catalog.d.ts.map +1 -0
  76. package/lib/input/action-catalog.js +137 -0
  77. package/lib/input/action-catalog.js.map +1 -0
  78. package/lib/input/actions.d.ts +5 -81
  79. package/lib/input/actions.d.ts.map +1 -1
  80. package/lib/input/actions.js +4 -621
  81. package/lib/input/actions.js.map +1 -1
  82. package/lib/input/completion.d.ts +11 -1
  83. package/lib/input/completion.d.ts.map +1 -1
  84. package/lib/input/completion.js +17 -1
  85. package/lib/input/completion.js.map +1 -1
  86. package/lib/input/file-index.d.ts +40 -0
  87. package/lib/input/file-index.d.ts.map +1 -0
  88. package/lib/input/file-index.js +141 -0
  89. package/lib/input/file-index.js.map +1 -0
  90. package/lib/input/file-search.d.ts +1 -38
  91. package/lib/input/file-search.d.ts.map +1 -1
  92. package/lib/input/file-search.js +1 -487
  93. package/lib/input/file-search.js.map +1 -1
  94. package/lib/input/key-press.d.ts +57 -0
  95. package/lib/input/key-press.d.ts.map +1 -0
  96. package/lib/input/key-press.js +218 -0
  97. package/lib/input/key-press.js.map +1 -0
  98. package/lib/input/keymap-conflicts.d.ts +59 -0
  99. package/lib/input/keymap-conflicts.d.ts.map +1 -0
  100. package/lib/input/keymap-conflicts.js +292 -0
  101. package/lib/input/keymap-conflicts.js.map +1 -0
  102. package/lib/input/keymap-settings.js +1 -1
  103. package/lib/input/keymap-settings.js.map +1 -1
  104. package/lib/input/keymap.d.ts.map +1 -1
  105. package/lib/input/keymap.js +5 -1
  106. package/lib/input/keymap.js.map +1 -1
  107. package/lib/input/submission.d.ts +11 -1
  108. package/lib/input/submission.d.ts.map +1 -1
  109. package/lib/input/submission.js +10 -1
  110. package/lib/input/submission.js.map +1 -1
  111. package/lib/input/workspace-files.d.ts +10 -0
  112. package/lib/input/workspace-files.d.ts.map +1 -0
  113. package/lib/input/workspace-files.js +359 -0
  114. package/lib/input/workspace-files.js.map +1 -0
  115. package/lib/install-skills.d.ts +20 -0
  116. package/lib/install-skills.d.ts.map +1 -0
  117. package/lib/install-skills.js +198 -0
  118. package/lib/install-skills.js.map +1 -0
  119. package/lib/keys-command.d.ts +2 -1
  120. package/lib/keys-command.d.ts.map +1 -1
  121. package/lib/keys-command.js +2 -1
  122. package/lib/keys-command.js.map +1 -1
  123. package/lib/model-list.d.ts +37 -0
  124. package/lib/model-list.d.ts.map +1 -0
  125. package/lib/model-list.js +60 -0
  126. package/lib/model-list.js.map +1 -0
  127. package/lib/startup.d.ts +8 -0
  128. package/lib/startup.d.ts.map +1 -1
  129. package/lib/startup.js +120 -24
  130. package/lib/startup.js.map +1 -1
  131. package/lib/subagents.d.ts +7 -3
  132. package/lib/subagents.d.ts.map +1 -1
  133. package/lib/subagents.js +28 -4
  134. package/lib/subagents.js.map +1 -1
  135. package/lib/surface/appearance-preferences.d.ts +26 -0
  136. package/lib/surface/appearance-preferences.d.ts.map +1 -0
  137. package/lib/surface/appearance-preferences.js +139 -0
  138. package/lib/surface/appearance-preferences.js.map +1 -0
  139. package/lib/surface/appearance.d.ts +87 -0
  140. package/lib/surface/appearance.d.ts.map +1 -0
  141. package/lib/surface/appearance.js +353 -0
  142. package/lib/surface/appearance.js.map +1 -0
  143. package/lib/surface/background-work.d.ts +49 -0
  144. package/lib/surface/background-work.d.ts.map +1 -0
  145. package/lib/surface/background-work.js +146 -0
  146. package/lib/surface/background-work.js.map +1 -0
  147. package/lib/surface/commands.d.ts +80 -0
  148. package/lib/surface/commands.d.ts.map +1 -0
  149. package/lib/surface/commands.js +364 -0
  150. package/lib/surface/commands.js.map +1 -0
  151. package/lib/surface/modal-input.d.ts +58 -0
  152. package/lib/surface/modal-input.d.ts.map +1 -0
  153. package/lib/surface/modal-input.js +237 -0
  154. package/lib/surface/modal-input.js.map +1 -0
  155. package/lib/surface/model-choice.d.ts +54 -0
  156. package/lib/surface/model-choice.d.ts.map +1 -0
  157. package/lib/surface/model-choice.js +325 -0
  158. package/lib/surface/model-choice.js.map +1 -0
  159. package/lib/surface/preset-choice.d.ts +78 -0
  160. package/lib/surface/preset-choice.d.ts.map +1 -0
  161. package/lib/surface/preset-choice.js +163 -0
  162. package/lib/surface/preset-choice.js.map +1 -0
  163. package/lib/surface/prompt-input.d.ts +97 -0
  164. package/lib/surface/prompt-input.d.ts.map +1 -0
  165. package/lib/surface/prompt-input.js +215 -0
  166. package/lib/surface/prompt-input.js.map +1 -0
  167. package/lib/surface/prompt-memory.d.ts +76 -0
  168. package/lib/surface/prompt-memory.d.ts.map +1 -0
  169. package/lib/surface/prompt-memory.js +167 -0
  170. package/lib/surface/prompt-memory.js.map +1 -0
  171. package/lib/surface/session-lifecycle.d.ts +120 -0
  172. package/lib/surface/session-lifecycle.d.ts.map +1 -0
  173. package/lib/surface/session-lifecycle.js +321 -0
  174. package/lib/surface/session-lifecycle.js.map +1 -0
  175. package/lib/surface/session-picker.d.ts +39 -0
  176. package/lib/surface/session-picker.d.ts.map +1 -0
  177. package/lib/surface/session-picker.js +107 -0
  178. package/lib/surface/session-picker.js.map +1 -0
  179. package/lib/surface/session-view.d.ts +86 -0
  180. package/lib/surface/session-view.d.ts.map +1 -0
  181. package/lib/surface/session-view.js +215 -0
  182. package/lib/surface/session-view.js.map +1 -0
  183. package/lib/surface/staged-turns.d.ts +81 -0
  184. package/lib/surface/staged-turns.d.ts.map +1 -0
  185. package/lib/surface/staged-turns.js +206 -0
  186. package/lib/surface/staged-turns.js.map +1 -0
  187. package/lib/surface/terminal-lifecycle.d.ts +76 -0
  188. package/lib/surface/terminal-lifecycle.d.ts.map +1 -0
  189. package/lib/surface/terminal-lifecycle.js +222 -0
  190. package/lib/surface/terminal-lifecycle.js.map +1 -0
  191. package/lib/terminal/warning-screen.d.ts +8 -1
  192. package/lib/terminal/warning-screen.d.ts.map +1 -1
  193. package/lib/terminal/warning-screen.js +9 -0
  194. package/lib/terminal/warning-screen.js.map +1 -1
  195. package/lib/terminal-text/scan.d.ts +44 -0
  196. package/lib/terminal-text/scan.d.ts.map +1 -0
  197. package/lib/terminal-text/scan.js +195 -0
  198. package/lib/terminal-text/scan.js.map +1 -0
  199. package/lib/terminal-text/sgr.d.ts +32 -0
  200. package/lib/terminal-text/sgr.d.ts.map +1 -0
  201. package/lib/terminal-text/sgr.js +203 -0
  202. package/lib/terminal-text/sgr.js.map +1 -0
  203. package/lib/terminal-text.d.ts +1 -1
  204. package/lib/terminal-text.d.ts.map +1 -1
  205. package/lib/terminal-text.js +2 -379
  206. package/lib/terminal-text.js.map +1 -1
  207. package/lib/theme-command.js +2 -1
  208. package/lib/theme-command.js.map +1 -1
  209. package/lib/theme-defaults.d.ts +64 -0
  210. package/lib/theme-defaults.d.ts.map +1 -0
  211. package/lib/theme-defaults.js +248 -0
  212. package/lib/theme-defaults.js.map +1 -0
  213. package/lib/theme-resolver.d.ts +36 -0
  214. package/lib/theme-resolver.d.ts.map +1 -0
  215. package/lib/theme-resolver.js +112 -0
  216. package/lib/theme-resolver.js.map +1 -0
  217. package/lib/theme-schema.d.ts +6 -6
  218. package/lib/theme-schema.d.ts.map +1 -1
  219. package/lib/theme-schema.js +3 -2
  220. package/lib/theme-schema.js.map +1 -1
  221. package/lib/theme-settings.d.ts +13 -15
  222. package/lib/theme-settings.d.ts.map +1 -1
  223. package/lib/theme-settings.js +27 -19
  224. package/lib/theme-settings.js.map +1 -1
  225. package/lib/theme-tokens.d.ts +1 -90
  226. package/lib/theme-tokens.d.ts.map +1 -1
  227. package/lib/theme-tokens.js +11 -341
  228. package/lib/theme-tokens.js.map +1 -1
  229. package/lib/theme.d.ts +1 -1
  230. package/lib/theme.d.ts.map +1 -1
  231. package/lib/theme.js +2 -1
  232. package/lib/theme.js.map +1 -1
  233. package/lib/tool-display.d.ts.map +1 -1
  234. package/lib/tool-display.js +2 -1
  235. package/lib/tool-display.js.map +1 -1
  236. package/lib/transcript/message-content.d.ts +21 -0
  237. package/lib/transcript/message-content.d.ts.map +1 -0
  238. package/lib/transcript/message-content.js +61 -0
  239. package/lib/transcript/message-content.js.map +1 -0
  240. package/lib/transcript/tool-calls.d.ts +106 -0
  241. package/lib/transcript/tool-calls.d.ts.map +1 -0
  242. package/lib/transcript/tool-calls.js +254 -0
  243. package/lib/transcript/tool-calls.js.map +1 -0
  244. package/lib/transcript.d.ts +38 -43
  245. package/lib/transcript.d.ts.map +1 -1
  246. package/lib/transcript.js +58 -245
  247. package/lib/transcript.js.map +1 -1
  248. package/lib/ui/copy.d.ts +17 -0
  249. package/lib/ui/copy.d.ts.map +1 -0
  250. package/lib/ui/copy.js +104 -0
  251. package/lib/ui/copy.js.map +1 -0
  252. package/lib/ui/dock.d.ts +35 -3
  253. package/lib/ui/dock.d.ts.map +1 -1
  254. package/lib/ui/dock.js +124 -23
  255. package/lib/ui/dock.js.map +1 -1
  256. package/lib/ui/editor.d.ts.map +1 -1
  257. package/lib/ui/editor.js +2 -1
  258. package/lib/ui/editor.js.map +1 -1
  259. package/lib/ui/frame.d.ts +31 -0
  260. package/lib/ui/frame.d.ts.map +1 -1
  261. package/lib/ui/frame.js +27 -4
  262. package/lib/ui/frame.js.map +1 -1
  263. package/lib/ui/keymap-picker.d.ts +2 -1
  264. package/lib/ui/keymap-picker.d.ts.map +1 -1
  265. package/lib/ui/keymap-picker.js +1 -0
  266. package/lib/ui/keymap-picker.js.map +1 -1
  267. package/lib/ui/view/gate-card.d.ts +21 -0
  268. package/lib/ui/view/gate-card.d.ts.map +1 -0
  269. package/lib/ui/view/gate-card.js +104 -0
  270. package/lib/ui/view/gate-card.js.map +1 -0
  271. package/lib/ui/view/tool-card.d.ts +138 -0
  272. package/lib/ui/view/tool-card.d.ts.map +1 -0
  273. package/lib/ui/view/tool-card.js +413 -0
  274. package/lib/ui/view/tool-card.js.map +1 -0
  275. package/lib/ui/view/transcript-message.d.ts +62 -0
  276. package/lib/ui/view/transcript-message.d.ts.map +1 -0
  277. package/lib/ui/view/transcript-message.js +158 -0
  278. package/lib/ui/view/transcript-message.js.map +1 -0
  279. package/lib/ui/view.d.ts +87 -156
  280. package/lib/ui/view.d.ts.map +1 -1
  281. package/lib/ui/view.js +122 -556
  282. package/lib/ui/view.js.map +1 -1
  283. package/package.json +3 -1
  284. package/themes/deepseek-blue.yaml +9 -0
  285. package/themes/violet-orbit.yaml +7 -0
package/lib/index.js CHANGED
@@ -1,68 +1,36 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { writeFileSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
- import { ProcessTerminal, ScrollView, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
5
- import { startAgent } from "./agent/host.js";
6
- import { PICKER_LIMIT, createSessionHistory, presetOfStoredSession, readSessionTitle, } from "./agent/history.js";
7
- import { createPromptHistory } from "./agent/prompt-history.js";
8
- import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
9
- import { createToolPresenter } from "./agent/present.js";
10
- import { forkPoint } from "./agent/fork.js";
11
- import { PROFILE_NAME, resumeHint } from "./identity.js";
1
+ import { ScrollView } from '@earendil-works/pi-tui';
2
+ import { createSessionHistory, presetOfStoredSession } from "./agent/history.js";
3
+ import { createPresetRoster } from "./agent/presets.js";
12
4
  import { createStatusFacts } from "./agent/status.js";
13
- import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
14
- import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
15
- import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
16
5
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
17
- import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
18
- import { cancelStep, quitStep } from "./input/cancel.js";
19
- import { createCompletionProvider } from "./input/completion.js";
20
- import { ghostSuffix } from "./input/ghost.js";
21
- import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
22
- import { createDeferredNotice } from "./settings-notice.js";
23
- import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
24
- import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
25
- import { resolveConfig } from "./config.js";
26
- import { FoldCursor } from "./fold-cursor.js";
27
- import { createRestoreRegistry } from "./terminal/restore.js";
28
- import { ExternalEditor } from "./terminal/external-editor.js";
29
- import { installSignalRestore } from "./terminal/signals.js";
30
- import { WarningSafeTui } from "./terminal/warning-screen.js";
31
- import { BELL, shouldRingBell } from "./terminal/bell.js";
32
- import { clipboardSequence } from "./terminal/clipboard.js";
33
- import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
34
- import { sessionStartReason } from "./herdr/state.js";
35
- import { createHerdrReporter } from "./herdr/reporter.js";
36
- import { defaultExportFile, transcriptToText } from "./export.js";
37
- import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
38
- import { detectColourMode } from "./theme-capability.js";
39
- import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
6
+ import { hasLiveRowSettings, readRowSettings, resolveConfig } from "./config.js";
7
+ export { Config } from "./config.js";
8
+ import { windowTitle } from "./terminal/title.js";
40
9
  import { toolDisplayFor } from "./tool-display.js";
41
- import { pendingPrompts } from "./queue.js";
42
- import { renderThemeTable } from "./theme-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";
10
+ import { runModelList } from "./model-list.js";
11
+ import { LIST_MODELS_SERVICE } from "./startup.js";
46
12
  import { SessionId } from '@deepseek-ai/dsh-session';
47
- import { formatTokens } from "./tokens.js";
48
- import { TranscriptModel } from "./transcript.js";
49
- import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
13
+ import { createAppearance } from "./surface/appearance.js";
14
+ import { createBackgroundWork } from "./surface/background-work.js";
15
+ import { createCommands } from "./surface/commands.js";
16
+ import { createModalInput } from "./surface/modal-input.js";
17
+ import { createModelChoice } from "./surface/model-choice.js";
18
+ import { createPromptInput } from "./surface/prompt-input.js";
19
+ import { createPromptMemory } from "./surface/prompt-memory.js";
20
+ import { createPresetChoice } from "./surface/preset-choice.js";
21
+ import { createSessionLifecycle } from "./surface/session-lifecycle.js";
22
+ import { backHint, createSessionView } from "./surface/session-view.js";
23
+ import { createStagedTurns } from "./surface/staged-turns.js";
24
+ import { createTerminalLifecycle } from "./surface/terminal-lifecycle.js";
50
25
  import { WorkDock } from "./ui/dock.js";
51
26
  import { GateInputBar } from "./ui/gate-input.js";
52
- import { HistoryPicker } from "./ui/history-picker.js";
53
27
  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";
56
28
  import { PromptBar } from "./ui/prompt.js";
57
29
  import { MarkdownRenderer } from "./ui/markdown.js";
58
30
  import { createMermaidTransform } from "./ui/mermaid.js";
59
- import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
60
31
  import { QueueBar } from "./ui/queue.js";
61
32
  import { StatusBar } from "./ui/status.js";
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";
33
+ import { TranscriptView } from "./ui/view.js";
66
34
  export const name = 'tui';
67
35
  /**
68
36
  * Services the surface cannot run without.
@@ -72,21 +40,6 @@ export const name = 'tui';
72
40
  * every card to a bare generic row.
73
41
  */
74
42
  export const inject = ['agents', 'tools'];
75
- /** One second in the unit a chord window is scheduled in. */
76
- const MS_PER_SECOND = 1000;
77
- /** What the back hint names when the reader has unbound the key it would advertise. */
78
- const BACK_HINT_FALLBACK = 'ctrl+b';
79
- /** What the reader presses to leave a view they did not open. */
80
- const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
81
- /** Stored sessions titled at once when the picker opens. */
82
- const TITLE_CONCURRENCY = 4;
83
- /** How often the running-state clock repaints while a turn is open. */
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';
87
- function asRecord(value) {
88
- return typeof value === 'object' && value !== null ? value : undefined;
89
- }
90
43
  /**
91
44
  * Refuse to run without a real terminal.
92
45
  *
@@ -109,6 +62,24 @@ export function assertInteractiveTerminal(input = {}) {
109
62
  * the composition rows around it.
110
63
  */
111
64
  export function apply(ctx, config) {
65
+ const appExit = ctx.get('appExit');
66
+ if (appExit === undefined) {
67
+ throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
68
+ }
69
+ // The listing is a pipeable dump, not a surface: it answers before the
70
+ // config, the composition probe, and the TTY gate, claims no terminal, and
71
+ // leaves the exit code to the command that owns the process.
72
+ if (ctx.get(LIST_MODELS_SERVICE) === true) {
73
+ // Provider rows mount after this one, so an immediate read would report an
74
+ // empty catalog; the launcher's committed startup is the first moment the
75
+ // composed adapters exist, and a failed boot must not print a listing.
76
+ const appReady = ctx.get('appReady');
77
+ if (appReady === undefined) {
78
+ throw new Error('dsh-tui: the dsh launcher must provide appReady; start this surface with dsh --profile tui');
79
+ }
80
+ appReady.onReady(() => { void runModelList(ctx, appExit); });
81
+ return;
82
+ }
112
83
  const resolved = resolveConfig(config);
113
84
  assertInteractiveTerminal();
114
85
  // Fail before taking the screen over: a missing row is a composition mistake,
@@ -116,416 +87,305 @@ export function apply(ctx, config) {
116
87
  const probe = probeComposition(service => ctx.get(service) !== undefined);
117
88
  if (probe.missingRequired.length > 0)
118
89
  throw new Error(describeMissingRequired(probe));
119
- const appExit = ctx.get('appExit');
120
- if (appExit === undefined) {
121
- throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
122
- }
123
- /**
124
- * The surface's appearance, rebuilt whenever the reader's settings change.
125
- *
126
- * Renderers hold this object for the life of the session, so the current
127
- * theme is swapped *behind* a stable delegate rather than reassigned: every
128
- * row then reads one whole table, and a repaint can never observe a
129
- * half-applied one. `--no-color` still outranks anything configured.
130
- */
131
- const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
132
- /**
133
- * The reader's section, or nothing when the service is not mounted.
134
- *
135
- * The service is only readable inside an `inject` scope — asking for it
136
- * outside one is a composition error, not a missing value — so this stays a
137
- * late-bound read that the injection point and the change event both use.
138
- */
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) => { };
160
- /**
161
- * A refused settings edit, kept until the surface can show it: stderr is
162
- * behind the alt screen, and the section is read on a schedule of its own.
163
- */
164
- const settingsNotice = createDeferredNotice();
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;
182
- const applyTheme = (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();
204
- };
205
- const theme = {
206
- get revision() { return current.revision; },
207
- get color() { return current.color; },
208
- style: (token, text) => current.style(token, text),
209
- rich: (raw, options) => current.rich(raw, options),
210
- cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
211
- glyph: token => current.glyph(token),
212
- visible: token => current.visible(token),
213
- // Forwarded rather than read, because the editor and the markdown view keep
214
- // the theme object they were built with: a settings change has to reach them
215
- // through a stable delegate or they would keep the boot appearance.
216
- editor: forwardEditorTheme(() => current.editor),
217
- markdown: forwardMarkdownTheme(() => current.markdown),
218
- };
219
- /**
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.
227
- */
228
- const viewState = { ...DEFAULT_VIEW_STATE };
229
- /**
230
- * How a reply's mermaid fences draw, seeded from the reader's section.
231
- *
232
- * The transform reads this per render instead of capturing it, because the
233
- * settings document is hot-reloaded and a session already on screen has to
234
- * follow the edit.
235
- */
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;
239
- /** The keys that start a chord, and how long one waits; the settings document owns all of it. */
240
- let prefixKeys = DEFAULT_PREFIX_KEYS;
241
- let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
242
- /** Every action's keys in force; the settings document owns it and a press reads it live. */
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;
248
- /**
249
- * The chord between a prefix and the action that follows it.
250
- *
251
- * Built here, before the terminal exists, because the settings scope applies
252
- * first and has to be able to end a chord armed under the keymap it replaced.
253
- * The repaint the window also wants is late-bound: only a key press reaches
254
- * it, and no key can arrive before the surface has started.
255
- */
256
- const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
257
90
  /**
258
- * Seed the display the reader configured.
91
+ * The reader's appearance, built first because everything this surface draws
92
+ * holds its theme delegate and reads its preferences live.
259
93
  *
260
- * The key toggles nested calls for one session, but a settings edit is a
261
- * deliberate act, so it re-seeds and becomes the new starting point; the
262
- * mermaid mode and the per-tool card fold have no key of their own and only
263
- * ever come from the document.
94
+ * The settings scope is registered further down, because it re-seeds the prompt
95
+ * bar's own keys and may only apply once that owner exists.
264
96
  */
265
- const applyDisplay = (section) => {
266
- viewState.expandSubCalls = section.subcalls === 'inline';
267
- mermaidMode = section.mermaid;
268
- toolDisplay = section.tools;
269
- prefixKeys = section.prefixes;
270
- prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
271
- keymap = section.keymap;
272
- // Installed where the library reads it, so a remap lands on the next press
273
- // rather than at the next restart.
274
- installKeybindings(keymap);
275
- historyEnabled = section.history.enabled;
276
- historyGhost = section.history.ghost;
277
- historyMaxEntries = section.history.maxEntries;
278
- // A chord armed under the keymap the reader just replaced is not their chord.
279
- keyChord.disarm();
280
- };
281
- /**
282
- * Read the reader's section once and apply everything it configures.
283
- *
284
- * One read per change, because a refused section is reported on the way past:
285
- * reading it once per field would show the reader the same refusal twice.
286
- */
287
- const applySettings = () => {
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);
294
- applyTheme(section);
295
- applyDisplay(section);
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
- };
313
- /**
314
- * Own the section, so the harness validates and persists it for the reader.
315
- *
316
- * Registration is how the document learns the section exists at all; without
317
- * it a hand-written `dsh-tui:` block would be dropped on the next save. The
318
- * first read happens here too, because this is the only scope the service
319
- * may be touched in.
320
- */
321
- ctx.inject(['settings'], settingsCtx => {
322
- // Registration parses the document against the schema, so a section the
323
- // schema itself refuses throws here — inside a fiber whose failure the
324
- // screen never shows. Reporting it through the same holder keeps a typo
325
- // from costing the reader every setting they wrote, silently.
326
- let scope;
327
- try {
328
- scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
329
- }
330
- catch (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');
335
- return;
336
- }
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
- };
341
- applySettings();
97
+ const appearance = createAppearance(ctx, {
98
+ color: () => resolved.color,
99
+ // The row's own theme is the layer a profile patch can pin on a harness
100
+ // that keeps settings per row, so it is read here beside the section.
101
+ rowTheme: resolved.theme,
102
+ rowSettings: () => readRowSettings(config),
103
+ rowSettingsLive: hasLiveRowSettings(config),
104
+ notice: message => sessionView.notice(message),
105
+ render: () => tui.requestRender(),
106
+ invalidateMarkdown: () => markdown.invalidate(),
107
+ invalidateView: () => view.invalidate(),
108
+ // The chord a settings edit has to end is armed in the bar's own owner, so it
109
+ // is reached through the thing that holds it rather than copied here.
110
+ keybindings: () => promptInput,
111
+ openPicker: picker => modalInput.openPicker(picker),
112
+ });
113
+ const { theme } = appearance;
114
+ /**
115
+ * The prompt bar's own presses, built here rather than beside the bar.
116
+ *
117
+ * The settings scope below applies first and has to be able to end a chord
118
+ * armed under the keymap it replaced, so this owner exists before the terminal
119
+ * or the editor do; each port reads them at call time for that reason.
120
+ */
121
+ const promptInput = createPromptInput(ctx, {
122
+ keymap: appearance.keymap,
123
+ prefixKeys: appearance.prefixKeys,
124
+ prefixWindowMs: appearance.prefixWindowMs,
125
+ tui: () => tui,
126
+ editor: () => editor,
127
+ promptBar: () => promptBar,
128
+ modalHandleKey: data => modalInput.handleKey(data),
129
+ toggleCards: appearance.toggleCards,
130
+ toggleSubCalls: appearance.toggleSubCalls,
131
+ toggleReasoning: appearance.toggleReasoning,
132
+ openEffortPicker: () => {
133
+ void modelChoice.openEffortPicker();
134
+ },
135
+ openHistoryPicker: () => {
136
+ void promptMemory.openHistoryPicker();
137
+ },
138
+ back: () => {
139
+ if (sessionView.viewingChild())
140
+ void sessionView.showDriven();
141
+ },
142
+ queuedPrompts: () => sessionLifecycle.queuedPrompts(),
143
+ turnRunning: () => sessionLifecycle.turnRunning(),
144
+ viewingChild: () => sessionView.viewingChild(),
145
+ interrupt: () => {
146
+ sessionLifecycle.drivingAgent()?.interrupt();
147
+ },
148
+ notice: message => sessionView.notice(message),
149
+ requestExit: code => requestExit(code),
150
+ recordPrompt: text => promptMemory.record(text),
151
+ runSubmission: submission => commands.runSubmission(submission),
152
+ drivenAgent: () => sessionLifecycle.drivingAgent()?.agent,
153
+ registeredCommands: target => commands.registeredCommands(target),
342
154
  });
155
+ appearance.registerSection();
343
156
  /**
344
- * The agent scope the tool presenter resolves against.
157
+ * The transcript on screen, and the fold that feeds it.
345
158
  *
346
- * Tools are registered in the scoped world the session's preset mounts, so a
347
- * card can only read its tool's own render intent while this names that
348
- * agent. It follows whatever session the transcript is folding, because a
349
- * child on screen reads through the child's scope, not the parent's.
159
+ * Built before anything that draws it, and before the session it opens on is
160
+ * driven: every read it takes of the driven identity, the staged cutoff, and
161
+ * the render target is a port taken at call time, because all three move as
162
+ * the reader switches sessions.
350
163
  */
351
- let presentScope;
352
- const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
164
+ const sessionView = createSessionView(ctx, {
165
+ initialSession: resolved.sessionId,
166
+ drivenSession: () => sessionLifecycle.activeSession(),
167
+ stagedCutoff: () => stagedTurns.stagedCutoff(),
168
+ agentScope: id => ctx.agents?.get(id),
169
+ liveEvents: id => liveSession(id)?.snapshotEvents?.(),
170
+ render: () => tui.requestRender(),
171
+ });
353
172
  /**
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);
173
+ * The prompt's own memory, constructed where the box that draws its ghost
174
+ * already exists; the settings readers stay live because the document is
175
+ * hot-reloaded.
176
+ */
177
+ const promptMemory = createPromptMemory({
178
+ historyEnabled: appearance.historyEnabled,
179
+ historyGhost: appearance.historyGhost,
180
+ historyMaxEntries: appearance.historyMaxEntries,
181
+ theme,
182
+ keymap: appearance.keymap,
183
+ notice: message => sessionView.notice(message),
184
+ render: () => tui.requestRender(),
185
+ editorText: () => editor.getExpandedText(),
186
+ setEditorText: text => {
187
+ editor.setText(text);
362
188
  tui.requestRender();
363
189
  },
190
+ // A question answers in this editor, so a draft written into a borrowed bar
191
+ // would become somebody's answer instead of a parked prompt. An editor
192
+ // holding the draft in another program owns it just as firmly: a pop that
193
+ // landed then would be deleted from the bank and then overwritten.
194
+ editorAvailable: () => !promptBar.isBorrowed() && !terminalLifecycle.handedOver(),
195
+ activeSession: () => sessionLifecycle.activeSession(),
196
+ // The keyboard's lifetime is the modal owner's, so the list is handed over
197
+ // rather than driven here.
198
+ openPicker: picker => modalInput.openPicker(picker),
364
199
  });
200
+ const ghostBrush = promptMemory.ghostBrush;
365
201
  /**
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.
202
+ * The route this session runs with, built before the status facts that report
203
+ * it and read through ports because the screen, the keyboard and the settings
204
+ * document that supplies the default all belong to the surface around it.
371
205
  */
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
- };
380
- const work = new WorkFold();
381
- const modelSwitch = new ModelSwitch();
206
+ const modelChoice = createModelChoice(ctx, {
207
+ statusFacts: () => statusFacts(),
208
+ keymap: appearance.keymap,
209
+ openPicker: picker => modalInput.openPicker(picker),
210
+ notice: message => sessionView.notice(message),
211
+ render: () => tui.requestRender(),
212
+ });
382
213
  const agentPresets = createPresetRoster(ctx);
383
- /** The mode named on the command line, which is the only one that may conflict. */
384
- const requestedPreset = resolved.preset;
385
- /**
386
- * The mode a session the reader starts from here on joins.
387
- *
388
- * A launch flag seeds it and a successful switch updates it, so `/new` and
389
- * `/fork` land in the mode the reader last chose; nothing is written to
390
- * settings, because the mode of a session is not a property of the machine.
391
- * Unset means nobody chose, which is where the roster's default applies.
392
- */
393
- let seat = requestedPreset;
394
214
  /**
395
- * The mode a session takes its seat in: the reader's latest choice, or the
396
- * roster's default read at this moment.
397
- *
398
- * The default cannot be captured when this row applies: it comes from the
399
- * settings document, which may be read after that, so a captured copy would
400
- * pin every later session to the mode the bundle happened to ship.
401
- */
402
- const seatMode = () => seat ?? agentPresets?.defaultId;
403
- const catalog = createModelCatalog(ctx);
404
- const jobDirectory = createJobDirectory(ctx);
405
- let jobs = [];
406
- const roster = new SubagentRoster();
407
- const subagentControl = createSubagentControl(ctx);
408
- const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
409
- const restore = createRestoreRegistry();
410
- const terminal = new ProcessTerminal();
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
- };
434
- /** The session this surface drives: commands, approvals, and the bell belong to it. */
435
- let activeSession = resolved.sessionId;
436
- /** The session the transcript is showing, which can be one of its children. */
437
- let viewedSession = resolved.sessionId;
438
- let pending;
439
- let pendingPicker;
440
- /** A pick being vetted owns the list, not the keyboard: filtering stays live while its verdict is read. */
441
- let vetting = false;
442
- const view = new TranscriptView(model, theme, markdown, {
443
- state: () => viewState,
444
- gate: () => pending?.gate.card(),
445
- picker: () => pendingPicker?.card?.(),
446
- keys: () => keymap,
447
- toolDisplay: tool => toolDisplayFor(toolDisplay, tool),
215
+ * Which mode a session takes, read through ports because the open path mounts
216
+ * through the same roster and the screen, the keyboard, the notice, and the
217
+ * session the reader watches all belong to the surface around it.
218
+ */
219
+ const presetChoice = createPresetChoice({
220
+ agentPresets,
221
+ requestedPreset: resolved.preset,
222
+ // The mode a session ran is a property of its own log, which is the same
223
+ // history this surface lists the stored sessions from.
224
+ storedPreset: async (id) => {
225
+ const history = createSessionHistory(ctx);
226
+ return history === undefined ? undefined : await presetOfStoredSession(history, id);
227
+ },
228
+ livePreset: id => agentPresets?.current(liveSession(id)),
229
+ drivingAgent: () => sessionLifecycle.drivingAgent(),
230
+ keymap: appearance.keymap,
231
+ openPicker: picker => modalInput.openPicker(picker),
232
+ notice: message => sessionView.notice(message),
233
+ render: () => tui.requestRender(),
234
+ returnToDrivenSession: async () => {
235
+ if (sessionView.viewingChild())
236
+ await sessionView.show(sessionLifecycle.activeSession());
237
+ },
238
+ });
239
+ const backgroundWork = createBackgroundWork(ctx, {
240
+ drivingAgent: () => sessionLifecycle.drivingAgent(),
241
+ activeSession: () => sessionLifecycle.activeSession(),
242
+ notice: text => sessionView.notice(text),
243
+ marker: text => sessionView.marker(text),
244
+ render: () => tui.requestRender(),
245
+ navigate: id => {
246
+ void sessionView.show(SessionId(id));
247
+ },
248
+ });
249
+ const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => appearance.mermaidMode() }));
250
+ const terminalLifecycle = createTerminalLifecycle(ctx, {
251
+ reportFrameError: error => sessionView.reportError(error),
252
+ notice: message => sessionView.notice(message),
253
+ copyRows: () => view.copyRows(),
254
+ activeSession: () => sessionLifecycle.activeSession(),
255
+ sessionOpened: () => sessionLifecycle.sessionOpened(),
256
+ turnRunning: () => sessionLifecycle.turnRunning(),
257
+ stopClock: () => sessionLifecycle.stopClock(),
258
+ exit: appExit,
259
+ draftText: () => editor.getExpandedText(),
260
+ draftBorrowed: () => promptBar.isBorrowed(),
261
+ holdDraft: text => promptBar.replaceHeld(text),
262
+ writeDraft: text => editor.setText(text),
263
+ });
264
+ const { terminal, tui, herdr, disposers, writeTerminal, requestExit, exited } = terminalLifecycle;
265
+ const view = new TranscriptView(sessionView.model, theme, markdown, {
266
+ state: appearance.viewState,
267
+ gate: () => modalInput.gateCard(),
268
+ picker: () => modalInput.pickerCard(),
269
+ keys: appearance.keymap,
270
+ toolDisplay: tool => toolDisplayFor(appearance.toolDisplay(), tool),
448
271
  });
449
272
  // The key map goes in before the bar exists, so no press can be read as the
450
273
  // send the library submits on by default. A settings document read after this
451
274
  // point installs over it, which is why the bar reads the map per press.
452
- installKeybindings(keymap);
453
- const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
275
+ promptInput.installBindings();
276
+ const editor = new GateInputBar(tui, theme.editor, appearance.keymap, ghostBrush);
454
277
  // Answers are written in the reader's own editor, which is why a question
455
278
  // borrows the bar instead of drawing a second one beside it.
456
279
  const promptBar = new PromptBar(editor);
457
- const disposers = [];
458
- // The presenter closure outlives the composition's own teardown, so it must
459
- // not keep an agent alive after its world unwinds.
460
- disposers.push(() => {
461
- presentScope = undefined;
280
+ /**
281
+ * The one modal interaction at a time, and the keyboard it holds.
282
+ *
283
+ * Built before the transcript view so the view can ask it for a card: the
284
+ * card a gate or picker draws is read per paint, never copied.
285
+ */
286
+ const modalInput = createModalInput(ctx, {
287
+ herdr,
288
+ editor,
289
+ tui,
290
+ terminal,
291
+ theme,
292
+ keymap: appearance.keymap,
293
+ promptBar,
294
+ refreshCompletion: () => promptInput.applyCompletion(),
295
+ activeSession: () => sessionLifecycle.activeSession(),
296
+ });
297
+ disposers.push(sessionView.clearPresentScope);
298
+ /**
299
+ * The staged cursor over the session this terminal drives, and the prompts
300
+ * parked behind it.
301
+ *
302
+ * Every port is read at call time — the agent, the session, the draft, the
303
+ * queue all move while an undo settles an interrupt — so this owner can exist
304
+ * before the agent it replaces does.
305
+ */
306
+ const stagedTurns = createStagedTurns({
307
+ viewingChild: () => sessionView.viewingChild(),
308
+ activeSession: () => sessionLifecycle.activeSession(),
309
+ sessionEvents: id => sessionView.sessionEvents(id),
310
+ resetTranscript: () => sessionView.reset(),
311
+ foldTranscript: (id, cutoff) => sessionView.fold(id, cutoff),
312
+ drivingAgent: () => sessionLifecycle.drivingAgent(),
313
+ disposeOutgoing: () => sessionLifecycle.disposeOutgoing(),
314
+ openSession: (id, resume, fork) => sessionLifecycle.openAgent(id, resume, fork),
315
+ queuedPrompts: () => sessionLifecycle.queuedPrompts(),
316
+ canPark: () => stash !== undefined,
317
+ parkPrompt: text => stash?.stashEditor(text) ?? Promise.resolve(),
318
+ turnRunning: () => sessionLifecycle.turnRunning(),
319
+ draft: () => editor.getExpandedText(),
320
+ writeDraft: text => editor.setText(text),
321
+ notice: message => sessionView.notice(message),
322
+ render: () => tui.requestRender(),
462
323
  });
463
- let agent;
464
- let turnOpen = false;
465
- let turnStartedAt;
466
- let exited = false;
467
- /** Whether a session was really opened, which is what an exit hint can name. */
468
- let sessionOpened = false;
469
- /** Re-read the job board; it is live state, so nothing else can fold it. */
470
- const refreshJobs = () => {
471
- jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
472
- tui.requestRender();
473
- };
474
324
  // The bank is built once the picker exists to answer for it, so the status
475
325
  // source is late-bound: the footer must not read a half-constructed stash.
476
326
  let stash;
477
327
  const statusFacts = createStatusFacts(ctx, {
478
- sessionId: () => activeSession,
479
- activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
480
- override: () => modelSwitch.current(),
328
+ sessionId: () => sessionLifecycle.activeSession(),
329
+ activity: () => sessionLifecycle.activity(),
330
+ override: () => modelChoice.current(),
481
331
  home: process.env.HOME,
482
- chord: () => keyChord.hint(),
332
+ chord: () => promptInput.chordHint(),
483
333
  // Read per paint rather than written into the marker the view left behind:
484
334
  // a hint stored with the transcript would keep naming the key of the day it
485
335
  // was written, and the reader may remap it with the row already on screen.
486
- back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
336
+ back: () => (sessionView.viewingChild() ? backHint(appearance.keymap()) : undefined),
487
337
  stash: () => stash?.entryCount,
488
338
  });
489
339
  const statusBar = new StatusBar(statusFacts, theme);
490
- const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
491
- // The queue is read from the agent this terminal drives rather than from the
492
- // session on screen, because it sits on the editor that submits to that agent.
493
- const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
494
- const queue = new QueueBar(queuedPrompts, theme);
495
- // Only a running turn has anything to say over time, so the clock stops with it.
496
- const statusTicker = setInterval(() => {
497
- if (turnOpen)
498
- tui.requestRender();
499
- }, STATUS_TICK_MS);
500
- disposers.push(() => clearInterval(statusTicker));
501
- // A window that outlived the surface would repaint a screen that is gone.
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();
510
- restore.add(() => tui.stop());
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);
524
- restore.restore();
525
- for (const dispose of disposers.reverse())
526
- dispose();
527
- return released.finally(unregisterExit);
340
+ const dock = new WorkDock(() => sessionView.workState(), theme, () => backgroundWork.jobs(), () => backgroundWork.roster.list(), undefined, id => {
341
+ void sessionView.show(SessionId(id));
342
+ });
343
+ const queue = new QueueBar(() => sessionLifecycle.queuedPrompts(), theme);
344
+ /**
345
+ * The agent this terminal drives, and the clock that repaints it while a turn
346
+ * is open.
347
+ *
348
+ * Built after the widgets that draw it and before the layout that mounts them,
349
+ * and every port is read at call time: the readers above exist before this
350
+ * owner does, and the session it drives moves under them.
351
+ */
352
+ const sessionLifecycle = createSessionLifecycle(ctx, {
353
+ initialSession: resolved.sessionId,
354
+ model: resolved.model,
355
+ provider: resolved.provider,
356
+ bell: resolved.bell,
357
+ presetFor: (id, resume, fork) => presetChoice.presetFor(id, resume, fork),
358
+ installModelChoice: agentCtx => modelChoice.setup(agentCtx),
359
+ mountPreset: async (agentCtx, preset) => {
360
+ await agentPresets?.mount(agentCtx, preset);
361
+ },
362
+ liveSession: id => liveSession(id),
363
+ installCompletion: () => promptInput.installCompletion(),
364
+ sessionEvents: id => sessionView.sessionEvents(id),
365
+ setViewed: id => sessionView.setViewed(id),
366
+ setPresentScope: scope => sessionView.setPresentScope(scope),
367
+ clearPresentScope: sessionView.clearPresentScope,
368
+ resetTranscript: () => sessionView.reset(),
369
+ replay: id => sessionView.replay(id),
370
+ fold: id => sessionView.fold(id),
371
+ notice: message => sessionView.notice(message),
372
+ render: () => tui.requestRender(),
373
+ promptMemorySessionOpened: () => promptMemory.sessionOpened(),
374
+ stagedSessionOpened: id => stagedTurns.sessionOpened(id),
375
+ turnSettled: () => stagedTurns.turnSettled(),
376
+ acceptCatalog: info => backgroundWork.acceptCatalog(info),
377
+ resetRoster: () => backgroundWork.resetRoster(),
378
+ refreshJobs: () => backgroundWork.refresh(),
379
+ herdr,
380
+ writeTerminal,
381
+ exiting: exited,
382
+ disposers,
528
383
  });
384
+ // A window that outlived the surface would repaint a screen that is gone.
385
+ disposers.push(() => promptInput.disarmChord());
386
+ // Registered here rather than inside the terminal owner: this is the position
387
+ // the one teardown list gave the signal seam before the surface was split.
388
+ disposers.push(terminalLifecycle.signalShutdown());
529
389
  tui.setLayoutRoot(surfaceLayout({
530
390
  transcript: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
531
391
  dock,
@@ -534,1840 +394,56 @@ export function apply(ctx, config) {
534
394
  status: statusBar,
535
395
  }));
536
396
  tui.setFocus(editor);
537
- const requestExit = (code, reason) => {
538
- if (exited)
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
- }
547
- exited = true;
548
- clearInterval(statusTicker);
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.
553
- terminal.write(CLEAR_TITLE);
554
- restore.restore();
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
- });
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') }));
579
- const openGate = (next) => {
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);
584
- // A gate owns the keyboard: the editor must not collect the decision keys.
585
- editor.disableSubmit = true;
586
- tui.setFocus(null);
587
- // A question is answered in this editor, which the gate draws under the row
588
- // being answered, so the hardware cursor belongs to it while it is borrowed.
589
- // It is set after the focus is cleared, which unmarks the component it left.
590
- editor.focused = next.kind === 'question';
591
- tui.requestRender();
592
- };
593
- const closeGate = () => {
594
- pending = undefined;
595
- herdr.unblock();
596
- editor.disableSubmit = false;
597
- // The question is answered or skipped, so the reader gets their prompt back
598
- // in the bar they left it in.
599
- promptBar.giveBack();
600
- tui.setFocus(editor);
601
- tui.requestRender();
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() !== '';
397
+ disposers.push(promptInput.inputListener());
398
+ stash = promptMemory.buildStash();
611
399
  /**
612
- * What each key the surface answers itself does; false hands the press back.
400
+ * The command plane, wired to the owners every submission is delegated to.
613
401
  *
614
- * Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
615
- * without a handler here fails to compile rather than doing nothing.
402
+ * Built once the bank exists, because the parked-draft commands route to the
403
+ * very bank the footer counts; every other port is a live read, so a command
404
+ * typed after a session switch answers for the session now on screen.
616
405
  */
617
- const surfaceActions = {
618
- toolDetail: () => {
619
- viewState.expandCards = !viewState.expandCards;
620
- tui.requestRender();
621
- return true;
622
- },
623
- subCalls: () => {
624
- viewState.expandSubCalls = !viewState.expandSubCalls;
625
- tui.requestRender();
626
- return true;
627
- },
628
- reasoning: () => {
629
- viewState.expandReasoning = !viewState.expandReasoning;
630
- tui.requestRender();
631
- return true;
632
- },
633
- effort: () => {
634
- void openEffortPicker();
635
- return true;
636
- },
637
- history: () => {
638
- void openHistoryPicker();
639
- return true;
640
- },
641
- back: () => {
642
- if (viewedSession !== activeSession)
643
- void showAgentSession();
644
- return true;
645
- },
646
- interrupt: () => {
647
- // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
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;
698
- }
406
+ const commands = createCommands(ctx, {
407
+ launch: {
408
+ sessionId: resolved.sessionId,
409
+ resume: resolved.resume,
410
+ resumePicker: resolved.resumePicker,
699
411
  },
700
- };
701
- disposers.push(tui.addInputListener(data => {
702
- // A key arrives as a press and a release once the surface asks the terminal
703
- // to report key events, and this library drops the release only for the
704
- // focused component: a listener sees both halves, so an arrow key that is
705
- // acted on twice steps a picker two rows and answers a question two options
706
- // on. Falls through rather than consuming, which leaves a component that
707
- // asked for releases its own half.
708
- if (isKeyRelease(data))
709
- return undefined;
710
- if (pending !== undefined) {
711
- if (pending.kind === 'approval') {
712
- const outcome = pending.gate.handleKey(data);
713
- if (outcome === undefined)
714
- tui.requestRender();
715
- else {
716
- pending.settle(outcome);
717
- closeGate();
718
- }
719
- }
720
- else {
721
- const answers = pending.gate.handleKey(data);
722
- if (answers === undefined)
723
- tui.requestRender();
724
- else {
725
- pending.settle(answers);
726
- closeGate();
727
- }
728
- }
729
- return { consume: true };
730
- }
731
- if (pendingPicker !== undefined) {
732
- const action = pendingPicker.picker.handleKey(data);
733
- if (action === undefined) {
734
- tui.requestRender();
735
- return { consume: true };
736
- }
737
- if (vetting) {
738
- // A refusal check must not take the keyboard with it: the reader keeps
739
- // filtering and can still leave, while a second pick waits for the
740
- // first verdict rather than racing it.
741
- if (action.kind === 'cancel')
742
- settlePicker(undefined);
743
- return { consume: true };
744
- }
745
- if (action.kind === 'cancel') {
746
- settlePicker(undefined);
747
- return { consume: true };
748
- }
749
- const vet = pendingPicker.vet;
750
- if (vet === undefined) {
751
- settlePicker(action.id);
752
- return { consume: true };
753
- }
754
- vetting = true;
755
- void (async () => {
756
- let reason;
757
- try {
758
- reason = await vet(action.id);
759
- }
760
- finally {
761
- // A check that fails must not take the keyboard with it.
762
- vetting = false;
763
- }
764
- if (pendingPicker === undefined)
765
- return;
766
- if (reason === undefined) {
767
- settlePicker(action.id);
768
- return;
769
- }
770
- pendingPicker.picker.setNote(reason);
771
- tui.requestRender();
772
- })();
773
- return { consume: true };
774
- }
775
- // A chord is the surface's second key: the prefix is consumed and the
776
- // footer names what may follow, while a key that finishes nothing is handed
777
- // on. Detail the reader asked for is always available, even mid-turn: the
778
- // collapsed view is a default, not the only state.
779
- const chorded = keyChord.handle(data);
780
- if (chorded !== undefined) {
781
- if (chorded.kind === 'action')
782
- runSubmission(chorded.binding.submission);
783
- tui.requestRender();
784
- return { consume: true };
785
- }
786
- for (const binding of surfaceBindings(keymap)) {
787
- if (!matchesKey(data, binding.key))
788
- continue;
789
- return surfaceActions[binding.action]() ? { consume: true } : undefined;
790
- }
791
- return undefined;
792
- }));
793
- const registry = () => ctx.get('commands');
794
- /**
795
- * Offer completion for whatever this session can run right now.
796
- *
797
- * The registry is agent-scoped and still empty while the agent starts, so the
798
- * menu is rebuilt when the agent arrives and whenever a package registers
799
- * another command.
800
- */
801
- const installCompletion = () => {
802
- const current = agent?.agent;
803
- const commands = registry();
804
- if (current === undefined || commands === undefined)
805
- return;
806
- editor.setAutocompleteProvider(createCompletionProvider(commands.list(current), process.cwd()));
807
- };
808
- /**
809
- * Take the keyboard for a picker.
810
- *
811
- * The list is already in hand, so the picker is interactive immediately while
812
- * the titles that make its rows recognizable stream in behind it.
813
- */
814
- const askForSession = async (history, sessions) => {
815
- const titles = new Map();
816
- void loadTitles(history, sessions, titles);
817
- const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
818
- return picked === undefined ? undefined : SessionId(picked);
819
- };
820
- /** Give the keyboard back to the editor and answer whoever opened the picker. */
821
- const settlePicker = (id) => {
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?.();
826
- pendingPicker = undefined;
827
- herdr.unblock();
828
- editor.disableSubmit = false;
829
- tui.setFocus(editor);
830
- settle?.(id);
831
- tui.requestRender();
832
- };
833
- /**
834
- * Why this run cannot open a stored session, or undefined when it can.
835
- *
836
- * A session runs the mode its own log recorded, so a `--preset` that
837
- * disagrees with it can only be refused: the picker asks first so the refusal
838
- * lands in the list rather than after the terminal has been handed back.
839
- */
840
- const refuseReason = async (id) => {
841
- if (requestedPreset === undefined || agentPresets === undefined)
842
- return undefined;
843
- try {
844
- await presetFor(SessionId(id), true, undefined);
845
- return undefined;
846
- }
847
- catch (error) {
848
- return error instanceof Error ? error.message : String(error);
849
- }
850
- };
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);
884
- editor.disableSubmit = true;
885
- tui.setFocus(null);
886
- tui.requestRender();
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
- };
944
- /** The roster as the picker paints it, refreshed when the picker opens. */
945
- let presetRows = [];
946
- /** Choose the mode a session that has not started yet will run. */
947
- const askForPreset = async (currentId) => {
948
- if (agentPresets === undefined)
949
- return undefined;
950
- presetRows = await agentPresets.list();
951
- return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
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))),
412
+ preset: resolved.preset,
413
+ presetRoster: agentPresets,
414
+ missingOptional: () => describeMissingOptional(probe),
415
+ session: sessionLifecycle,
416
+ transcript: sessionView,
417
+ route: modelChoice,
418
+ modes: presetChoice,
419
+ work: backgroundWork,
420
+ staged: stagedTurns,
421
+ memory: promptMemory,
422
+ appearance,
423
+ terminal: terminalLifecycle,
424
+ modals: modalInput,
425
+ statusFacts: () => statusFacts(),
426
+ stash: () => stash,
974
427
  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
428
  });
1026
- /** Title the listed sessions without making the reader wait for the slowest log. */
1027
- const loadTitles = async (history, sessions, titles) => {
1028
- const queue = [...sessions];
1029
- const worker = async () => {
1030
- for (;;) {
1031
- const next = queue.shift();
1032
- if (next === undefined)
1033
- return;
1034
- try {
1035
- const title = await readSessionTitle(history, next);
1036
- if (title === undefined)
1037
- continue;
1038
- titles.set(next.id, title);
1039
- tui.requestRender();
1040
- }
1041
- catch {
1042
- // A log this build cannot read stays listed by id; the picker is not
1043
- // the place to explain storage, and one bad session is not the list.
1044
- }
1045
- }
1046
- };
1047
- await Promise.all(Array.from({ length: TITLE_CONCURRENCY }, worker));
1048
- };
1049
- const storedSessions = async () => {
1050
- const history = createSessionHistory(ctx);
1051
- if (history === undefined) {
1052
- model.notice('this profile has no session storage, so there is nothing to resume');
1053
- tui.requestRender();
1054
- return undefined;
1055
- }
1056
- try {
1057
- const sessions = await history.list(PICKER_LIMIT);
1058
- if (sessions.length === 0) {
1059
- model.notice('no stored sessions to resume');
1060
- tui.requestRender();
1061
- return undefined;
1062
- }
1063
- return { history, sessions };
1064
- }
1065
- catch (error) {
1066
- model.notice(`could not list stored sessions: ${error instanceof Error ? error.message : String(error)}`);
1067
- tui.requestRender();
1068
- return undefined;
1069
- }
1070
- };
1071
- const chooseSession = async () => {
1072
- const listed = await storedSessions();
1073
- return listed === undefined ? undefined : askForSession(listed.history, listed.sessions);
1074
- };
1075
- /**
1076
- * Replay a stored session so a resumed run opens on the conversation the
1077
- * reader left, not on an empty screen: the durable log is the transcript.
1078
- */
1079
- /**
1080
- * Fold one session's history into the transcript.
1081
- *
1082
- * A live session answers from memory, which is the only source that includes
1083
- * events not yet flushed and the only one that works for a child that has not
1084
- * materialized; a session this process is not running falls back to storage.
1085
- */
429
+ /** The live session this process runs, which is the only source holding an unflushed tail. */
1086
430
  const liveSession = (id) => ctx.get('sessions')?.get?.(id);
1087
- /**
1088
- * The fold's place in the viewed session's durable sequence.
1089
- *
1090
- * A resumed session is folded while its agent's loop is already live, so the
1091
- * same event can reach the surface twice: once on the stream and once from the
1092
- * log the fold is reading. The sequence number every durable event carries is
1093
- * what tells the two apart.
1094
- */
1095
- const foldCursor = new FoldCursor();
1096
- /** Feed one durable event to the model, unless a fold has already folded it. */
1097
- const applyDurable = (session, event) => {
1098
- if (foldCursor.accept(session, event))
1099
- applyEvent(event);
1100
- };
1101
- const foldHistory = async (id) => {
1102
- // Resolved before the fold so every card reads its tool through the scope
1103
- // that actually registered it; a stored session nobody runs has none.
1104
- presentScope = ctx.agents?.get(id);
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.
1107
- foldCursor.reset();
1108
- const inMemory = liveSession(id)?.snapshotEvents?.();
1109
- if (inMemory !== undefined) {
1110
- for (const event of inMemory)
1111
- applyDurable(id, event);
1112
- return inMemory.length;
1113
- }
1114
- const history = createSessionHistory(ctx);
1115
- if (history === undefined)
1116
- return 0;
1117
- const events = await history.read(id);
1118
- for (const event of events)
1119
- applyDurable(id, event);
1120
- return events.length;
1121
- };
1122
- const replayHistory = async (id) => {
1123
- try {
1124
- const folded = await foldHistory(id);
1125
- if (folded > 0)
1126
- model.notice(`replayed ${folded} events from the stored log`);
1127
- }
1128
- catch (error) {
1129
- model.notice(`could not replay this session: ${error instanceof Error ? error.message : String(error)}`);
1130
- }
1131
- };
1132
- /**
1133
- * Show another session in the transcript without leaving this one.
1134
- *
1135
- * A delegation is an ordinary session, so the reader can read what a child is
1136
- * doing rather than only that it exists; the agent this terminal drives does
1137
- * not change, which keeps commands, approvals, and the bell where they were.
1138
- */
1139
- const showSession = async (id) => {
1140
- const previous = viewedSession;
1141
- model.reset();
1142
- work.reset();
1143
- viewedSession = id;
1144
- try {
1145
- await foldHistory(id);
1146
- }
1147
- catch (error) {
1148
- model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
1149
- }
1150
- const returned = id === activeSession && previous !== activeSession;
1151
- model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
1152
- tui.requestRender();
1153
- };
1154
- const showAgentSession = async () => {
1155
- await showSession(activeSession);
1156
- };
1157
- /** Feed one durable event to everything that folds it. */
1158
- const applyEvent = (event) => {
1159
- model.apply(event);
1160
- work.apply(event);
1161
- };
1162
- /** Every event of a session, from memory when this process runs it. */
1163
- const sessionEvents = async (id) => {
1164
- const inMemory = liveSession(id)?.snapshotEvents?.();
1165
- if (inMemory !== undefined)
1166
- return inMemory;
1167
- const history = createSessionHistory(ctx);
1168
- return history === undefined ? [] : history.read(id);
1169
- };
1170
- /**
1171
- * The mode one agent joins.
1172
- *
1173
- * A resumed session keeps the composition its own log recorded, because
1174
- * swapping it would leave logged tool calls the new composition cannot make —
1175
- * the same reason the harness refuses a switch once a turn has run. A session
1176
- * that recorded none, which is every session written before modes existed,
1177
- * takes the seat; a branch inherits the mode its parent is running.
1178
- */
1179
- const presetFor = async (id, resume, fork) => {
1180
- if (agentPresets === undefined)
1181
- return undefined;
1182
- if (fork !== undefined)
1183
- return agentPresets.current(liveSession(fork.from)) ?? seatMode();
1184
- if (!resume)
1185
- return seatMode();
1186
- const history = createSessionHistory(ctx);
1187
- const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
1188
- if (stored === undefined)
1189
- return seatMode();
1190
- const resolvedStored = await resolveStored(id, stored);
1191
- if (resolvedStored === undefined) {
1192
- // The composition this session recorded is gone. Naming one explicitly is
1193
- // the reader's only way forward, so that is the one case an override is
1194
- // taken for a stored session.
1195
- if (requestedPreset !== undefined)
1196
- return (await agentPresets.resolve(requestedPreset)).id;
1197
- throw new Error(`session ${id} runs mode "${stored}", which this roster no longer offers; name another with --preset`);
1198
- }
1199
- if (requestedPreset !== undefined && requestedPreset !== resolvedStored) {
1200
- throw new Error(`session ${id} runs mode "${resolvedStored}", so --preset ${requestedPreset} does not apply; /preset ${requestedPreset} switches it before its first turn`);
1201
- }
1202
- return resolvedStored;
1203
- };
1204
- /** The stored mode's roster row, or undefined when the roster no longer offers it. */
1205
- const resolveStored = async (id, stored) => {
1206
- try {
1207
- return (await agentPresets?.resolve(stored))?.id;
1208
- }
1209
- catch {
1210
- return undefined;
1211
- }
1212
- };
1213
- const openAgent = async (id, resume, fork) => {
1214
- // Settled before the transcript is touched, so a refusal leaves neither a
1215
- // half-replayed session nor a half-composed agent behind.
1216
- const preset = await presetFor(id, resume, fork);
1217
- const handle = await startAgent(ctx, {
1218
- sessionId: id,
1219
- resume,
1220
- model: resolved.model,
1221
- provider: resolved.provider,
1222
- cwd: process.cwd(),
1223
- preset,
1224
- setup: async (agentCtx) => {
1225
- modelSwitch.install(agentCtx);
1226
- if (preset !== undefined)
1227
- await agentPresets?.mount(agentCtx, preset);
1228
- },
1229
- ...(fork === undefined ? {} : { fork }),
1230
- });
1231
- sessionOpened = true;
1232
- activeSession = id;
1233
- viewedSession = id;
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
- });
1245
- // A session with no history to fold still has to present its first live
1246
- // card through the right scope, so the scope is set before any event can.
1247
- presentScope = handle.agent;
1248
- // Replayed only once the agent exists, because the fold reads every card
1249
- // through the scope the preset mounted; a fold before that scope existed
1250
- // degraded each replayed card to a bare generic row. The agent's loop is
1251
- // live by now, so the fold and the stream race over the same events; the
1252
- // durable sequence number is what keeps one event from landing twice.
1253
- if (resume)
1254
- await replayHistory(id);
1255
- // A branch inherits the conversation the reader was already reading, so it
1256
- // opens on that history rather than on an empty screen.
1257
- if (fork !== undefined)
1258
- await foldHistory(id);
1259
- disposers.push(() => {
1260
- void handle.dispose();
1261
- });
1262
- installCompletion();
1263
- model.notice(`session ${handle.sessionId}${resume ? ' (resumed)' : ''}`);
1264
- tui.requestRender();
1265
- };
1266
- /** Move the surface to another stored session without leaving the terminal. */
1267
- const switchSession = async (id) => {
1268
- const previous = agent;
1269
- agent = undefined;
1270
- // Drop the outgoing scope before its agent is disposed, so no card folded
1271
- // during the transition can read a torn-down world.
1272
- presentScope = undefined;
1273
- turnOpen = false;
1274
- model.reset();
1275
- work.reset();
1276
- roster.reset();
1277
- if (previous !== undefined)
1278
- await previous.dispose();
1279
- await openAgent(id, true);
1280
- };
1281
- /**
1282
- * Show or choose the route the next step will use.
1283
- *
1284
- * The choice is session-scoped: it changes nothing about the settings a later
1285
- * run reads, and the loop logs its own durable notice when the route a request
1286
- * actually used changes.
1287
- */
1288
- /**
1289
- * Show, read, or kill a background job.
1290
- *
1291
- * A terminal has no second window, so a job started by the model is otherwise
1292
- * invisible: the board is the only place a reader can see what is still
1293
- * running and stop it.
1294
- */
1295
- /**
1296
- * List or stop the delegations this session started.
1297
- *
1298
- * A child runs in the same process as an ordinary agent, so a stop is the
1299
- * cancel a reader's Ctrl+C sends to the parent; nothing here reaches into the
1300
- * child's own session, which keeps its own transcript either way.
1301
- */
1302
- const runSubagentsCommand = (argument) => {
1303
- const command = parseSubagentsArgument(argument);
1304
- switch (command.kind) {
1305
- case 'list':
1306
- model.notice(describeSubagents(roster.list(), Date.now()));
1307
- tui.requestRender();
1308
- return;
1309
- case 'open': {
1310
- const run = resolveRun(roster.list(), command.id);
1311
- if (run === undefined) {
1312
- model.notice(`${command.id}: no single child matches; /subagents lists them`);
1313
- tui.requestRender();
1314
- return;
1315
- }
1316
- void showSession(SessionId(run.id));
1317
- return;
1318
- }
1319
- case 'kill': {
1320
- const stopped = subagentControl?.stop(command.id) ?? false;
1321
- model.notice(stopped
1322
- ? `${command.id}: stop requested`
1323
- : `${command.id}: no live child with that id`);
1324
- tui.requestRender();
1325
- return;
1326
- }
1327
- case 'invalid':
1328
- model.notice(`/subagents: ${command.reason}`);
1329
- tui.requestRender();
1330
- return;
1331
- }
1332
- };
1333
- const runJobsCommand = (argument) => {
1334
- if (jobDirectory === undefined) {
1335
- model.notice('this profile has no job registry, so there is nothing to list');
1336
- tui.requestRender();
1337
- return;
1338
- }
1339
- if (agent === undefined) {
1340
- model.notice('the agent is still starting; try again in a moment');
1341
- tui.requestRender();
1342
- return;
1343
- }
1344
- const command = parseJobsArgument(argument);
1345
- switch (command.kind) {
1346
- case 'list':
1347
- refreshJobs();
1348
- model.notice(describeJobs(jobs, Date.now()));
1349
- tui.requestRender();
1350
- return;
1351
- case 'read': {
1352
- const result = jobDirectory.read(agent.agent, command.id);
1353
- const text = result?.text.trim() ?? '';
1354
- model.notice(text === ''
1355
- ? `${command.id}: no output yet`
1356
- : `${command.id} output\n${text.split('\n').slice(-JOB_READ_LINES).join('\n')}`);
1357
- refreshJobs();
1358
- return;
1359
- }
1360
- case 'kill': {
1361
- const outcome = jobDirectory.kill(agent.agent, command.id);
1362
- model.notice(outcome === undefined
1363
- ? `${command.id}: no such job`
1364
- : outcome === 'requested' ? `${command.id}: stop requested` : `${command.id} had already finished`);
1365
- refreshJobs();
1366
- return;
1367
- }
1368
- case 'invalid':
1369
- model.notice(`/jobs: ${command.reason}`);
1370
- tui.requestRender();
1371
- return;
1372
- }
1373
- };
1374
- /**
1375
- * Give this session a title.
1376
- *
1377
- * The title is what the resume picker shows and what every other surface
1378
- * displays, so a reader who has several sessions can name the one they are in
1379
- * without waiting for the harness to guess.
1380
- */
1381
- /**
1382
- * Write what the reader can see to a file.
1383
- *
1384
- * The base's `/export` downloads the log through the browser, which a
1385
- * terminal has no way to do, so this dumps the transcript the reader is
1386
- * looking at — the thing worth pasting into a message.
1387
- */
1388
- const runExportCommand = (argument) => {
1389
- const path = resolve(argument === '' ? defaultExportFile(String(activeSession)) : argument);
1390
- try {
1391
- writeFileSync(path, transcriptToText(model.entries()), 'utf8');
1392
- model.notice(`transcript written to ${path}`);
1393
- }
1394
- catch (error) {
1395
- model.notice(`could not write ${path}: ${error instanceof Error ? error.message : String(error)}`);
1396
- }
1397
- tui.requestRender();
1398
- };
1399
- /**
1400
- * Branch this conversation and continue in the branch.
1401
- *
1402
- * The branch is a real session with its own identity that inherits a prefix
1403
- * of this one, so a reader can try something without spending the
1404
- * conversation they already had. The cut is anchored to the last completed
1405
- * turn, because half an exchange is not a state to hand a model.
1406
- */
1407
- /** Start a fresh session without leaving the terminal. */
1408
- const runNewCommand = (title) => {
1409
- if (agent === undefined) {
1410
- model.notice('the agent is still starting; try again in a moment');
1411
- tui.requestRender();
1412
- return;
1413
- }
1414
- void (async () => {
1415
- const previous = agent;
1416
- agent = undefined;
1417
- turnOpen = false;
1418
- turnStartedAt = undefined;
1419
- model.reset();
1420
- work.reset();
1421
- roster.reset();
1422
- if (previous !== undefined)
1423
- await previous.dispose();
1424
- await openAgent(SessionId(`tui-session-${randomUUID()}`), false);
1425
- if (title !== '')
1426
- runRenameCommand(title);
1427
- model.notice('started a new session');
1428
- tui.requestRender();
1429
- })().catch((error) => {
1430
- model.notice(`could not start a session: ${error instanceof Error ? error.message : String(error)}`);
1431
- tui.requestRender();
1432
- });
1433
- };
1434
- /** Show the todo list the agent has been keeping. */
1435
- const runTodoCommand = () => {
1436
- model.notice(describeTodos(work.state().todos));
1437
- tui.requestRender();
1438
- };
1439
- /**
1440
- * Put the last answer on the reader's clipboard.
1441
- *
1442
- * The clipboard belongs to the terminal, so this asks it through OSC 52 —
1443
- * which is also the only route that works over SSH.
1444
- */
1445
- const runCopyCommand = () => {
1446
- const last = [...model.entries()].reverse().find(entry => entry.kind === 'assistant');
1447
- if (last === undefined || last.kind !== 'assistant') {
1448
- model.notice('nothing to copy yet');
1449
- tui.requestRender();
1450
- return;
1451
- }
1452
- terminal.write(clipboardSequence(last.text));
1453
- model.notice(`copied ${last.text.length} characters through the terminal`);
1454
- tui.requestRender();
1455
- };
1456
- const runForkCommand = (title) => {
1457
- if (agent === undefined) {
1458
- model.notice('the agent is still starting; try again in a moment');
1459
- tui.requestRender();
1460
- return;
1461
- }
1462
- void (async () => {
1463
- const source = activeSession;
1464
- const events = await sessionEvents(source);
1465
- const point = forkPoint(events);
1466
- if (point === undefined) {
1467
- model.notice('nothing to fork yet: this session has no completed turn');
1468
- tui.requestRender();
1469
- return;
1470
- }
1471
- const childId = SessionId(`tui-session-${randomUUID()}`);
1472
- const previous = agent;
1473
- agent = undefined;
1474
- turnOpen = false;
1475
- turnStartedAt = undefined;
1476
- model.reset();
1477
- work.reset();
1478
- roster.reset();
1479
- if (previous !== undefined)
1480
- await previous.dispose();
1481
- await openAgent(childId, false, { from: source, events: events.slice(0, point.inheritedEvents) });
1482
- if (title !== '')
1483
- runRenameCommand(title);
1484
- model.notice(`forked from ${source} at event ${point.boundarySeq} — ${point.inheritedEvents} inherited`);
1485
- tui.requestRender();
1486
- })().catch((error) => {
1487
- model.notice(`could not fork: ${error instanceof Error ? error.message : String(error)}`);
1488
- tui.requestRender();
1489
- });
1490
- };
1491
- const runRenameCommand = (title) => {
1492
- if (title === '') {
1493
- model.notice('use /rename <title>; the title is what the resume picker shows');
1494
- tui.requestRender();
1495
- return;
1496
- }
1497
- const session = ctx.get('sessions')?.get?.(activeSession);
1498
- const titles = ctx.get('sessionTitle');
1499
- if (session === undefined || typeof titles?.rename !== 'function') {
1500
- model.notice('this profile has no session-title service, so this session cannot be renamed');
1501
- tui.requestRender();
1502
- return;
1503
- }
1504
- try {
1505
- const accepted = titles.rename(session, title);
1506
- model.notice(`session renamed to "${typeof accepted?.title === 'string' ? accepted.title : title}"`);
1507
- }
1508
- catch (error) {
1509
- model.notice(`could not rename: ${error instanceof Error ? error.message : String(error)}`);
1510
- }
1511
- tui.requestRender();
1512
- };
1513
- const runModelCommand = (argument) => {
1514
- if (catalog === undefined) {
1515
- model.notice('this profile has no llm service, so models cannot be listed or switched');
1516
- tui.requestRender();
1517
- return;
1518
- }
1519
- const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
1520
- switch (command.kind) {
1521
- case 'current':
1522
- // Choosing by eye is the point of a terminal selector; the picker
1523
- // heads itself with the route the next step will actually use.
1524
- void openModelPicker();
1525
- return;
1526
- case 'list-models':
1527
- void catalog.models(command.provider).then(entries => {
1528
- model.notice(entries.length === 0
1529
- ? `${command.provider} advertises no models; an id may still work`
1530
- : `${command.provider}: ${entries.map(entry => entry.id).join(' ')}`);
1531
- tui.requestRender();
1532
- }).catch((error) => {
1533
- model.notice(`could not list models: ${error instanceof Error ? error.message : String(error)}`);
1534
- tui.requestRender();
1535
- });
1536
- return;
1537
- case 'switch': {
1538
- const choice = command.choice;
1539
- if (choice.reasoningEffort === undefined) {
1540
- modelSwitch.choose(choice);
1541
- model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
1542
- tui.requestRender();
1543
- return;
1544
- }
1545
- // The route decides which efforts exist, so an explicit one is checked
1546
- // against the adapter before it is put in force: a typo must not become
1547
- // a request the provider rejects.
1548
- void (async () => {
1549
- try {
1550
- const info = await catalog.efforts(choice.provider, choice.model);
1551
- const efforts = info?.efforts ?? [];
1552
- if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
1553
- model.notice(efforts.length === 0
1554
- ? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
1555
- : `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
1556
- tui.requestRender();
1557
- return;
1558
- }
1559
- modelSwitch.choose(choice);
1560
- model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
1561
- }
1562
- catch (error) {
1563
- model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1564
- }
1565
- tui.requestRender();
1566
- })();
1567
- return;
1568
- }
1569
- case 'invalid':
1570
- model.notice(`/model: ${command.reason}`);
1571
- tui.requestRender();
1572
- return;
1573
- }
1574
- };
1575
- /** Whether a route's effort list is being read, so a second key cannot race it. */
1576
- let openingEfforts = false;
1577
- /** Put the reader's effort choice in force for the next step. */
1578
- const applyEffort = (provider, modelId, effortId) => {
1579
- modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
1580
- ? { provider, model: modelId }
1581
- : { provider, model: modelId, reasoningEffort: effortId });
1582
- model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
1583
- tui.requestRender();
1584
- };
1585
- /** Whether a model picker's catalog is being read, so a second key cannot race it. */
1586
- let openingModels = false;
1587
- /**
1588
- * The route the next step would actually use.
1589
- *
1590
- * Without a choice of its own the surface reports the composition default,
1591
- * not that it has no opinion: the picker's heading and its marked row have to
1592
- * agree with the status line about the route in force.
1593
- */
1594
- const effectiveRoute = () => {
1595
- const chosen = modelSwitch.current();
1596
- if (chosen !== undefined)
1597
- return chosen;
1598
- const facts = statusFacts();
1599
- if (facts.provider === undefined || facts.model === undefined)
1600
- return undefined;
1601
- return {
1602
- provider: facts.provider,
1603
- model: facts.model,
1604
- ...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
1605
- };
1606
- };
1607
- /** Put the reader's route choice in force for the next step. */
1608
- const applyRoute = (route) => {
1609
- const current = effectiveRoute();
1610
- // The levels belong to the route, so a switch clears an explicit effort
1611
- // while re-picking the route already in force is not a switch.
1612
- const keep = current !== undefined && current.provider === route.provider && current.model === route.model
1613
- ? current.reasoningEffort
1614
- : undefined;
1615
- modelSwitch.choose({
1616
- provider: route.provider,
1617
- model: route.model,
1618
- ...keep === undefined ? {} : { reasoningEffort: keep },
1619
- });
1620
- model.notice(`model set to ${route.provider}/${route.model} for the next step`);
1621
- tui.requestRender();
1622
- };
1623
- /**
1624
- * Offer the levels a route advertises, after the route is already in force.
1625
- *
1626
- * Cancelling the list is a real choice — the reader keeps the model with the
1627
- * provider's own default — which is why the route is applied first. The list
1628
- * is read before the picker opens because the rows are the route's own
1629
- * metadata; a menu painted before that arrived could offer a level the
1630
- * request would then be refused for.
1631
- */
1632
- const offerRouteEfforts = async (route) => {
1633
- if (catalog === undefined)
1634
- return;
1635
- try {
1636
- const info = await catalog.efforts(route.provider, route.model);
1637
- const efforts = info?.efforts ?? [];
1638
- if (efforts.length === 0)
1639
- return;
1640
- const current = effectiveRoute();
1641
- const effective = current !== undefined && current.provider === route.provider && current.model === route.model
1642
- ? current.reasoningEffort
1643
- : undefined;
1644
- const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
1645
- if (picked !== undefined)
1646
- applyEffort(route.provider, route.model, picked);
1647
- }
1648
- catch (error) {
1649
- model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1650
- }
1651
- tui.requestRender();
1652
- };
1653
- /**
1654
- * Offer every model the configured routes advertise.
1655
- *
1656
- * Rows come from the routes the llm service registered — the providers this
1657
- * deployment configured — and each provider's models join the open list as
1658
- * its catalog resolves, so the picker is filterable before the slowest
1659
- * adapter answers. A route whose catalog cannot be read stays reachable by
1660
- * name through the text form rather than by an explanation in the list.
1661
- */
1662
- const openModelPicker = async () => {
1663
- if (catalog === undefined) {
1664
- model.notice('this profile has no llm service, so models cannot be listed or switched');
1665
- tui.requestRender();
1666
- return;
1667
- }
1668
- const providers = catalog.providers();
1669
- if (providers.length === 0) {
1670
- model.notice('no provider is configured; add one before choosing a model');
1671
- tui.requestRender();
1672
- return;
1673
- }
1674
- if (openingModels)
1675
- return;
1676
- openingModels = true;
1677
- try {
1678
- const routes = [];
1679
- for (const provider of providers) {
1680
- void catalog.models(provider.id).then(entries => {
1681
- if (entries.length === 0)
1682
- return;
1683
- routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
1684
- tui.requestRender();
1685
- }).catch(() => {
1686
- // One adapter's discovery failure is not the list's to explain.
1687
- });
1688
- }
1689
- const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
1690
- if (picked === undefined)
1691
- return;
1692
- const route = readModelRouteKey(picked);
1693
- if (route === undefined)
1694
- return;
1695
- applyRoute(route);
1696
- await offerRouteEfforts(route);
1697
- }
1698
- finally {
1699
- openingModels = false;
1700
- tui.requestRender();
1701
- }
1702
- };
1703
- /**
1704
- * Offer the efforts the route in force advertises.
1705
- *
1706
- * The list is read before the picker opens because the rows are the route's
1707
- * own metadata; a menu painted before that arrived could offer a level the
1708
- * request would then be refused for.
1709
- */
1710
- const openEffortPicker = async () => {
1711
- if (catalog === undefined) {
1712
- model.notice('this profile has no llm service, so reasoning efforts cannot be read');
1713
- tui.requestRender();
1714
- return;
1715
- }
1716
- const facts = statusFacts();
1717
- if (facts.provider === undefined || facts.model === undefined) {
1718
- model.notice('no model route is in use; /model <provider>/<model> picks one first');
1719
- tui.requestRender();
1720
- return;
1721
- }
1722
- if (openingEfforts)
1723
- return;
1724
- openingEfforts = true;
1725
- try {
1726
- const info = await catalog.efforts(facts.provider, facts.model);
1727
- const efforts = info?.efforts ?? [];
1728
- if (efforts.length === 0) {
1729
- model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
1730
- return;
1731
- }
1732
- const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
1733
- if (picked !== undefined)
1734
- applyEffort(facts.provider, facts.model, picked);
1735
- }
1736
- catch (error) {
1737
- model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1738
- }
1739
- finally {
1740
- openingEfforts = false;
1741
- tui.requestRender();
1742
- }
1743
- };
1744
- /**
1745
- * Show or choose the mode this session runs.
1746
- *
1747
- * The mode decides which tools, prompt sections, and skills exist at all, so
1748
- * it is fixed once a turn has run: the harness refuses the swap and this
1749
- * surface explains why rather than pretending otherwise. Before the first
1750
- * turn the switch is recorded in the log, which is what keeps the transcript
1751
- * honest about the composition later turns ran under.
1752
- */
1753
- const runPresetCommand = (argument) => {
1754
- if (agentPresets === undefined) {
1755
- model.notice('this profile has no agent roster, so there is no mode to choose');
1756
- tui.requestRender();
1757
- return;
1758
- }
1759
- if (agent === undefined) {
1760
- model.notice('the agent is still starting; try again in a moment');
1761
- tui.requestRender();
1762
- return;
1763
- }
1764
- const session = agent.agent.session;
1765
- const current = agentPresets.current(session);
1766
- const command = parsePresetArgument(argument);
1767
- if (command.kind === 'pick') {
1768
- if (agentPresets.started(session)) {
1769
- model.notice(`this session runs ${current === undefined ? 'a mode' : `"${current}"`} and has already started, so its mode is fixed — /new starts a fresh session`);
1770
- tui.requestRender();
1771
- return;
1772
- }
1773
- void askForPreset(current).then(picked => picked === undefined ? undefined : applyPreset(picked));
1774
- return;
1775
- }
1776
- void applyPreset(command.id);
1777
- };
1778
- /** Join this session to another mode and remember it for the next session. */
1779
- const applyPreset = async (id) => {
1780
- if (agentPresets === undefined || agent === undefined)
1781
- return;
1782
- try {
1783
- const chosen = await agentPresets.select(agent.agent, id);
1784
- seat = chosen;
1785
- // The durable selection is folded as a marker on the session it belongs
1786
- // to, so a reader watching a child has to come back to see it.
1787
- if (viewedSession !== activeSession)
1788
- await showSession(activeSession);
1789
- }
1790
- catch (error) {
1791
- model.notice(`could not switch the mode: ${error instanceof Error ? error.message : String(error)}`);
1792
- }
1793
- tui.requestRender();
1794
- };
1795
- const helpText = () => {
1796
- const current = agent?.agent;
1797
- const registered = current === undefined || registry() === undefined
1798
- ? []
1799
- : registry()?.list(current).map(command => `/${command.name}`) ?? [];
1800
- const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
1801
- return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
1802
- };
1803
- /**
1804
- * Whether the agent this surface is driving is in plan mode.
1805
- *
1806
- * The dock's fold is the fallback, for a composition without the plan
1807
- * package: with the controller present its answer is the agent's own state
1808
- * rather than a replay of the events this surface happened to see.
1809
- */
1810
- const planState = () => {
1811
- const current = agent?.agent;
1812
- if (current === undefined)
1813
- return undefined;
1814
- const presets = ctx.get('agentPresets');
1815
- return readPlanState({
1816
- direct: name => ctx.get(name),
1817
- forAgent: (target, name) => presets?.serviceFor(target, name),
1818
- }, current);
1819
- };
1820
- const planActive = () => planSelectedActive(planState(), work.state().planMode);
1821
- const runCommand = (name, line) => {
1822
- const current = agent;
1823
- const commands = registry();
1824
- if (current === undefined) {
1825
- model.notice('the agent is still starting; try again in a moment');
1826
- tui.requestRender();
1827
- return;
1828
- }
1829
- if (commands === undefined || commands.find(current.agent, name) === undefined) {
1830
- model.notice(`unknown command: /${name} — ${helpText()}`);
1831
- tui.requestRender();
1832
- return;
1833
- }
1834
- const controller = new AbortController();
1835
- void commands.execute(current.agent, line, [], controller.signal).then(execution => {
1836
- const result = execution?.result;
1837
- if (result === undefined)
1838
- return;
1839
- model.notice(result.kind === 'error' ? `/${name} failed: ${result.text ?? 'no detail'}` : `/${name} ${result.text ?? 'done'}`);
1840
- tui.requestRender();
1841
- }).catch((error) => {
1842
- model.notice(`/${name} failed: ${error instanceof Error ? error.message : String(error)}`);
1843
- tui.requestRender();
1844
- });
1845
- };
1846
- /**
1847
- * Put the deployment default in force before the first turn.
1848
- *
1849
- * The agent is created before the settings file has been read, so the route
1850
- * its loop captured is the composition placeholder; the reader's default —
1851
- * effort included — only exists by the time they can type. Adopting it here
1852
- * is what makes the status line's route the one the request actually uses.
1853
- */
1854
- const adoptDefaultRoute = () => {
1855
- if (modelSwitch.current() !== undefined)
1856
- return;
1857
- const facts = statusFacts();
1858
- if (facts.provider === undefined || facts.model === undefined)
1859
- return;
1860
- modelSwitch.adopt({
1861
- provider: facts.provider,
1862
- model: facts.model,
1863
- ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
1864
- });
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
- };
1907
- /**
1908
- * Carry out one classified line, wherever it was asked for.
1909
- *
1910
- * A chord asks for the same things the command line does, so both arrive
1911
- * here: a chord cannot behave differently from the command it stands for.
1912
- */
1913
- const runSubmission = (submission) => {
1914
- switch (submission.kind) {
1915
- case 'empty':
1916
- return;
1917
- case 'quit':
1918
- requestExit(0);
1919
- return;
1920
- case 'model':
1921
- runModelCommand(submission.argument);
1922
- return;
1923
- case 'preset':
1924
- runPresetCommand(submission.argument);
1925
- return;
1926
- case 'jobs':
1927
- runJobsCommand(submission.argument);
1928
- return;
1929
- case 'rename':
1930
- runRenameCommand(submission.title);
1931
- return;
1932
- case 'export':
1933
- runExportCommand(submission.path);
1934
- return;
1935
- case 'subagents':
1936
- runSubagentsCommand(submission.argument);
1937
- return;
1938
- case 'fork':
1939
- runForkCommand(submission.title);
1940
- return;
1941
- case 'new':
1942
- runNewCommand(submission.title);
1943
- return;
1944
- case 'todo':
1945
- runTodoCommand();
1946
- return;
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);
1995
- return;
1996
- }
1997
- case 'keys': {
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.
2001
- if (submission.argument !== '' && layer === undefined) {
2002
- model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
2003
- tui.requestRender();
2004
- return;
2005
- }
2006
- openKeyMap(layer);
2007
- return;
2008
- }
2009
- case 'copy':
2010
- runCopyCommand();
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;
2060
- case 'status': {
2061
- const facts = statusFacts();
2062
- const context = facts.contextTokens === undefined
2063
- ? undefined
2064
- : `context ${formatTokens(facts.contextTokens)}${facts.contextWindow === undefined ? '' : `/${formatTokens(facts.contextWindow)}`}`;
2065
- model.notice([
2066
- `session ${activeSession}`,
2067
- viewedSession === activeSession ? undefined : `viewing ${viewedSession}`,
2068
- facts.model === undefined
2069
- ? undefined
2070
- : `model ${facts.provider === undefined ? '' : `${facts.provider}/`}${facts.model}${facts.effort === undefined ? '' : ` (${facts.effort})`}`,
2071
- facts.agentPreset === undefined ? undefined : `mode ${facts.agentPreset}`,
2072
- facts.preset === undefined ? undefined : `permissions ${facts.preset}`,
2073
- context,
2074
- facts.uncachedInputTokens === undefined && facts.outputTokens === undefined
2075
- ? undefined
2076
- : `tokens in ${formatTokens(facts.uncachedInputTokens ?? 0)} out ${formatTokens(facts.outputTokens ?? 0)}`,
2077
- `cwd ${facts.cwd}`,
2078
- ].filter(part => part !== undefined).join(' · '));
2079
- tui.requestRender();
2080
- return;
2081
- }
2082
- case 'clear':
2083
- model.reset();
2084
- work.reset();
2085
- tui.requestRender();
2086
- return;
2087
- case 'help':
2088
- model.notice(helpText());
2089
- tui.requestRender();
2090
- return;
2091
- case 'resume':
2092
- void chooseSession().then(picked => picked === undefined ? undefined : switchSession(picked)).catch((error) => {
2093
- model.notice(`could not resume: ${error instanceof Error ? error.message : String(error)}`);
2094
- tui.requestRender();
2095
- });
2096
- return;
2097
- // Plan mode is a command the harness owns, so the chord asks the host for
2098
- // the state and names the command for the other one: one key, both ways.
2099
- case 'plan':
2100
- runCommand('plan', planToggleLine(planActive()));
2101
- return;
2102
- case 'command':
2103
- runCommand(submission.name, submission.line);
2104
- return;
2105
- case 'prompt':
2106
- if (agent === undefined) {
2107
- model.notice('the agent is still starting; try again in a moment');
2108
- tui.requestRender();
2109
- return;
2110
- }
2111
- // While a turn is running the human is steering it, not opening another.
2112
- if (turnOpen)
2113
- agent.steer(submission.text);
2114
- else {
2115
- adoptDefaultRoute();
2116
- agent.submit(submission.text);
2117
- }
2118
- }
2119
- };
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
- };
431
+ promptInput.attachSubmit();
2131
432
  disposers.push(ctx.on('session/event', (session, event) => {
2132
- // The surface state — activity, timer, title, bell, job board — belongs to
2133
- // the agent this terminal drives, even while a child is on screen.
2134
- if (session.id === activeSession) {
2135
- if (event.type === 'turn/start') {
2136
- turnOpen = true;
2137
- turnStartedAt = Date.now();
2138
- writeTerminal(windowTitle(process.cwd(), 'working'));
2139
- herdr.working();
2140
- }
2141
- if (event.type === 'turn/end') {
2142
- const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
2143
- turnOpen = false;
2144
- turnStartedAt = undefined;
2145
- writeTerminal(windowTitle(process.cwd(), 'ready'));
2146
- herdr.idle();
2147
- if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
2148
- writeTerminal(BELL);
2149
- // A job the turn started may have settled while the reader was watching
2150
- // something else, and nothing else refreshes a live board.
2151
- refreshJobs();
2152
- }
2153
- // A claim or a discard changes what is queued, and that belongs to this
2154
- // session even while the transcript shows a child's conversation.
2155
- if (event.type === 'agent/inbox/spliced')
2156
- tui.requestRender();
2157
- }
2158
- if (session.id !== viewedSession)
2159
- return;
2160
- presentScope = ctx.agents?.get(session.id);
2161
- applyDurable(session.id, event);
2162
- tui.requestRender();
2163
- }));
2164
- /**
2165
- * Subagent lifecycle arrives as a service event rather than a session event,
2166
- * so it is decoration in the transcript: the durable record of a delegation
2167
- * is the tool call that asked for it. The name is cast so a rename in the
2168
- * harness cannot break compilation of this surface.
2169
- */
2170
- const listenFor = (name, handler) => ctx.on(name, handler);
2171
- disposers.push(listenFor('subagent/start', info => {
2172
- roster.start(info);
2173
- const record = asRecord(info);
2174
- const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
2175
- const id = typeof record?.id === 'string' ? record.id : '';
2176
- model.marker(`subagent ${provider} started${id === '' ? '' : ` · ${id}`}`);
2177
- tui.requestRender();
2178
- }));
2179
- disposers.push(listenFor('subagent/end', info => {
2180
- roster.end(info);
2181
- const record = asRecord(info);
2182
- const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
2183
- const stop = typeof record?.stopReason === 'string' ? record.stopReason : undefined;
2184
- model.marker(`subagent ${provider} finished${stop === undefined ? '' : ` · ${stop}`}`);
2185
- tui.requestRender();
2186
- }));
2187
- // Answering these two waterfalls is what makes a terminal surface usable at
2188
- // all: without an answerer every gated tool fails closed, and the model's
2189
- // questions never reach the human.
2190
- disposers.push(ctx.on('approval/request', (request, next) => {
2191
- if (request.agent.id !== activeSession)
2192
- return next();
2193
- return new Promise(resolve => {
2194
- const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
2195
- request.signal?.addEventListener('abort', () => {
2196
- gate.cancel();
2197
- if (pending?.gate === gate)
2198
- closeGate();
2199
- resolve('cancelled');
2200
- }, { once: true });
2201
- openGate({ kind: 'approval', gate, settle: resolve });
2202
- });
2203
- }));
2204
- disposers.push(ctx.on('user-questions/request', (request, next) => {
2205
- const agentId = request.agent?.id;
2206
- if (agentId !== undefined && agentId !== activeSession)
2207
- return next();
2208
- const questions = toGateQuestions(request);
2209
- if (questions.length === 0)
2210
- return next();
2211
- return new Promise(resolve => {
2212
- const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
2213
- // The seam takes mutable selection arrays and an optional custom field, so
2214
- // the read-only gate answer is copied into that exact shape here.
2215
- const settle = (answers) => {
2216
- request.signal?.removeEventListener('abort', onAbort);
2217
- resolve({
2218
- answers: answers.map(answer => ({
2219
- id: answer.id,
2220
- selected: [...answer.selected],
2221
- ...(answer.custom === undefined ? {} : { custom: answer.custom }),
2222
- })),
2223
- });
2224
- };
2225
- // A question whose caller is gone has no reader, so the gate must release
2226
- // the keyboard instead of collecting an answer the aborted call discards.
2227
- const onAbort = () => {
2228
- gate.cancel();
2229
- if (pending?.gate === gate)
2230
- closeGate();
2231
- settle([]);
2232
- };
2233
- openGate({ kind: 'question', gate, settle });
2234
- request.signal?.addEventListener('abort', onAbort, { once: true });
2235
- // A signal that aborted before the listener existed never emits, so the
2236
- // state has to be read once after subscribing.
2237
- if (request.signal?.aborted === true)
2238
- onAbort();
2239
- });
433
+ sessionLifecycle.observe(session, event);
434
+ sessionView.observe(session.id, event);
2240
435
  }));
2241
- disposers.push(ctx.on('commands/change', () => installCompletion()));
436
+ disposers.push(...sessionLifecycle.driverListeners());
437
+ disposers.push(...backgroundWork.subagentListeners());
438
+ disposers.push(...modalInput.requestListeners());
439
+ disposers.push(...promptInput.commandListeners());
2242
440
  // The board is live state: watch it directly rather than folding events.
2243
- disposers.push(jobDirectory?.watch(owner => {
2244
- if (owner !== undefined && owner.id !== activeSession)
2245
- return;
2246
- refreshJobs();
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.
2252
- disposers.push(ctx.on('agent/error', payload => {
2253
- if (payload.agent.id !== viewedSession)
2254
- return;
2255
- model.reportError(payload.error);
2256
- tui.requestRender();
2257
- }));
2258
- disposers.push(ctx.on('agent/assistant-stream', payload => {
2259
- if (payload.agent.id !== viewedSession)
2260
- return;
2261
- if (payload.frame.type !== 'chunk')
2262
- return;
2263
- model.applyStreamChunk(payload.frame.chunk);
2264
- tui.requestRender();
2265
- }));
2266
- /**
2267
- * Restyle a running session when the reader's section changes.
2268
- *
2269
- * The settings document is hot-reloaded by the host, so a reader watching a
2270
- * shade land never has to leave the session to see it — which is what makes
2271
- * tuning one bearable instead of a restart per attempt. The event is
2272
- * namespace-filtered: another surface's preferences are not our repaint.
2273
- */
2274
- disposers.push(ctx.on('settings/updated', ns => {
2275
- if (String(ns) !== TUI_SETTINGS_NAMESPACE)
2276
- return;
2277
- applySettings();
2278
- // Both caches hold rows under the old table, so they have to be told the
2279
- // table moved; a repaint alone would reuse what they already stored.
2280
- markdown.invalidate();
2281
- view.invalidate();
2282
- tui.requestRender();
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
- }));
2320
- const degraded = describeMissingOptional(probe);
2321
- if (degraded !== undefined)
2322
- model.notice(degraded);
2323
- /**
2324
- * Open the session this run was launched for.
2325
- *
2326
- * A bare `--resume` asks a question only the reader can answer, so the
2327
- * picker runs before anything is created: the agent is then opened on the
2328
- * chosen session rather than swapped afterwards, which would leave the first
2329
- * one's turn half-started.
2330
- */
2331
- const boot = async () => {
2332
- if (!resolved.resumePicker) {
2333
- await openAgent(resolved.sessionId, resolved.resume);
2334
- return;
2335
- }
2336
- const picked = await chooseSession();
2337
- if (picked === undefined) {
2338
- requestExit(0);
2339
- return;
2340
- }
2341
- await openAgent(picked, true);
2342
- };
2343
- /**
2344
- * Take the screen, then open the session this run was launched for.
2345
- *
2346
- * A mode named on the command line is resolved FIRST: the alt screen swallows
2347
- * the launcher's own error output, so a mode this roster does not offer has to
2348
- * be answered while the shell still owns the terminal.
2349
- */
2350
- const start = async () => {
2351
- if (requestedPreset !== undefined && agentPresets !== undefined) {
2352
- seat = (await agentPresets.resolve(requestedPreset)).id;
2353
- }
2354
- // A named mode that disagrees with the one this session recorded is refused
2355
- // here as well as at the open, because the alternate screen closes over
2356
- // whatever was painted on it: the reader would see the failure, not the
2357
- // reason. With a picker the session is not known yet, so it waits for one.
2358
- if (!resolved.resumePicker)
2359
- await presetFor(resolved.sessionId, resolved.resume, undefined);
2360
- tui.start();
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();
2365
- // A refused settings edit is only visible now that the surface owns the
2366
- // screen; whatever the scope found before this point prints here instead.
2367
- settingsNotice.open(message => model.notice(message));
2368
- await boot();
2369
- };
2370
- void start().catch((error) => {
441
+ disposers.push(backgroundWork.watchJobs());
442
+ disposers.push(...sessionView.agentListeners());
443
+ disposers.push(appearance.settingsListener());
444
+ appearance.createThemesHome();
445
+ disposers.push(appearance.watchThemes());
446
+ void commands.start().catch((error) => {
2371
447
  requestExit(1, error instanceof Error ? error.message : String(error));
2372
448
  });
2373
449
  }