@sagmans/dsh-tui 0.6.0 → 0.8.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 (271) 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 +199 -0
  6. package/.agents/skills/dsh-tui-update-models/references/model-wiring.md +292 -0
  7. package/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs +314 -0
  8. package/README.md +154 -27
  9. package/lib/agent/present.d.ts.map +1 -1
  10. package/lib/agent/present.js +2 -1
  11. package/lib/agent/present.js.map +1 -1
  12. package/lib/cards/composition.d.ts +68 -0
  13. package/lib/cards/composition.d.ts.map +1 -0
  14. package/lib/cards/composition.js +117 -0
  15. package/lib/cards/composition.js.map +1 -0
  16. package/lib/cards/presenter.d.ts +23 -0
  17. package/lib/cards/presenter.d.ts.map +1 -0
  18. package/lib/cards/presenter.js +394 -0
  19. package/lib/cards/presenter.js.map +1 -0
  20. package/lib/cards/preview.d.ts +42 -0
  21. package/lib/cards/preview.d.ts.map +1 -0
  22. package/lib/cards/preview.js +51 -0
  23. package/lib/cards/preview.js.map +1 -0
  24. package/lib/cards.d.ts +12 -120
  25. package/lib/cards.d.ts.map +1 -1
  26. package/lib/cards.js +4 -556
  27. package/lib/cards.js.map +1 -1
  28. package/lib/compat/section.d.ts +29 -0
  29. package/lib/compat/section.d.ts.map +1 -0
  30. package/lib/compat/section.js +82 -0
  31. package/lib/compat/section.js.map +1 -0
  32. package/lib/config.d.ts +27 -2
  33. package/lib/config.d.ts.map +1 -1
  34. package/lib/config.js +61 -1
  35. package/lib/config.js.map +1 -1
  36. package/lib/contracts.d.ts +28 -0
  37. package/lib/contracts.d.ts.map +1 -1
  38. package/lib/fold-cursor.d.ts +8 -0
  39. package/lib/fold-cursor.d.ts.map +1 -1
  40. package/lib/fold-cursor.js +18 -0
  41. package/lib/fold-cursor.js.map +1 -1
  42. package/lib/gates/question-card.d.ts +77 -0
  43. package/lib/gates/question-card.d.ts.map +1 -0
  44. package/lib/gates/question-card.js +113 -0
  45. package/lib/gates/question-card.js.map +1 -0
  46. package/lib/gates/questions.d.ts +127 -0
  47. package/lib/gates/questions.d.ts.map +1 -0
  48. package/lib/gates/questions.js +418 -0
  49. package/lib/gates/questions.js.map +1 -0
  50. package/lib/gates.d.ts +1 -113
  51. package/lib/gates.d.ts.map +1 -1
  52. package/lib/gates.js +1 -484
  53. package/lib/gates.js.map +1 -1
  54. package/lib/herdr/client.d.ts +8 -0
  55. package/lib/herdr/client.d.ts.map +1 -1
  56. package/lib/herdr/client.js +13 -1
  57. package/lib/herdr/client.js.map +1 -1
  58. package/lib/herdr/constants.d.ts +18 -0
  59. package/lib/herdr/constants.d.ts.map +1 -1
  60. package/lib/herdr/constants.js +18 -0
  61. package/lib/herdr/constants.js.map +1 -1
  62. package/lib/herdr/reporter.d.ts +20 -4
  63. package/lib/herdr/reporter.d.ts.map +1 -1
  64. package/lib/herdr/reporter.js +70 -20
  65. package/lib/herdr/reporter.js.map +1 -1
  66. package/lib/herdr/state.d.ts +10 -0
  67. package/lib/herdr/state.d.ts.map +1 -1
  68. package/lib/herdr/state.js +21 -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 +348 -2602
  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 -625
  81. package/lib/input/actions.js.map +1 -1
  82. package/lib/input/completion.d.ts +1 -1
  83. package/lib/input/completion.d.ts.map +1 -1
  84. package/lib/input/completion.js +2 -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 +3 -1
  106. package/lib/input/keymap.js.map +1 -1
  107. package/lib/input/workspace-files.d.ts +10 -0
  108. package/lib/input/workspace-files.d.ts.map +1 -0
  109. package/lib/input/workspace-files.js +359 -0
  110. package/lib/input/workspace-files.js.map +1 -0
  111. package/lib/install-skills.d.ts +39 -0
  112. package/lib/install-skills.d.ts.map +1 -0
  113. package/lib/install-skills.js +273 -0
  114. package/lib/install-skills.js.map +1 -0
  115. package/lib/keys-command.d.ts +2 -1
  116. package/lib/keys-command.d.ts.map +1 -1
  117. package/lib/keys-command.js +2 -1
  118. package/lib/keys-command.js.map +1 -1
  119. package/lib/model-list.d.ts +37 -0
  120. package/lib/model-list.d.ts.map +1 -0
  121. package/lib/model-list.js +60 -0
  122. package/lib/model-list.js.map +1 -0
  123. package/lib/startup.d.ts +8 -0
  124. package/lib/startup.d.ts.map +1 -1
  125. package/lib/startup.js +120 -24
  126. package/lib/startup.js.map +1 -1
  127. package/lib/subagents.d.ts +7 -3
  128. package/lib/subagents.d.ts.map +1 -1
  129. package/lib/subagents.js +28 -4
  130. package/lib/subagents.js.map +1 -1
  131. package/lib/surface/appearance-preferences.d.ts +26 -0
  132. package/lib/surface/appearance-preferences.d.ts.map +1 -0
  133. package/lib/surface/appearance-preferences.js +139 -0
  134. package/lib/surface/appearance-preferences.js.map +1 -0
  135. package/lib/surface/appearance.d.ts +87 -0
  136. package/lib/surface/appearance.d.ts.map +1 -0
  137. package/lib/surface/appearance.js +353 -0
  138. package/lib/surface/appearance.js.map +1 -0
  139. package/lib/surface/background-work.d.ts +51 -0
  140. package/lib/surface/background-work.d.ts.map +1 -0
  141. package/lib/surface/background-work.js +172 -0
  142. package/lib/surface/background-work.js.map +1 -0
  143. package/lib/surface/commands.d.ts +82 -0
  144. package/lib/surface/commands.d.ts.map +1 -0
  145. package/lib/surface/commands.js +370 -0
  146. package/lib/surface/commands.js.map +1 -0
  147. package/lib/surface/modal-input.d.ts +59 -0
  148. package/lib/surface/modal-input.d.ts.map +1 -0
  149. package/lib/surface/modal-input.js +243 -0
  150. package/lib/surface/modal-input.js.map +1 -0
  151. package/lib/surface/model-choice.d.ts +54 -0
  152. package/lib/surface/model-choice.d.ts.map +1 -0
  153. package/lib/surface/model-choice.js +325 -0
  154. package/lib/surface/model-choice.js.map +1 -0
  155. package/lib/surface/preset-choice.d.ts +78 -0
  156. package/lib/surface/preset-choice.d.ts.map +1 -0
  157. package/lib/surface/preset-choice.js +163 -0
  158. package/lib/surface/preset-choice.js.map +1 -0
  159. package/lib/surface/prompt-input.d.ts +97 -0
  160. package/lib/surface/prompt-input.d.ts.map +1 -0
  161. package/lib/surface/prompt-input.js +215 -0
  162. package/lib/surface/prompt-input.js.map +1 -0
  163. package/lib/surface/prompt-memory.d.ts +76 -0
  164. package/lib/surface/prompt-memory.d.ts.map +1 -0
  165. package/lib/surface/prompt-memory.js +167 -0
  166. package/lib/surface/prompt-memory.js.map +1 -0
  167. package/lib/surface/session-lifecycle.d.ts +120 -0
  168. package/lib/surface/session-lifecycle.d.ts.map +1 -0
  169. package/lib/surface/session-lifecycle.js +324 -0
  170. package/lib/surface/session-lifecycle.js.map +1 -0
  171. package/lib/surface/session-picker.d.ts +39 -0
  172. package/lib/surface/session-picker.d.ts.map +1 -0
  173. package/lib/surface/session-picker.js +107 -0
  174. package/lib/surface/session-picker.js.map +1 -0
  175. package/lib/surface/session-view.d.ts +86 -0
  176. package/lib/surface/session-view.d.ts.map +1 -0
  177. package/lib/surface/session-view.js +215 -0
  178. package/lib/surface/session-view.js.map +1 -0
  179. package/lib/surface/staged-turns.d.ts +81 -0
  180. package/lib/surface/staged-turns.d.ts.map +1 -0
  181. package/lib/surface/staged-turns.js +206 -0
  182. package/lib/surface/staged-turns.js.map +1 -0
  183. package/lib/surface/terminal-lifecycle.d.ts +76 -0
  184. package/lib/surface/terminal-lifecycle.d.ts.map +1 -0
  185. package/lib/surface/terminal-lifecycle.js +222 -0
  186. package/lib/surface/terminal-lifecycle.js.map +1 -0
  187. package/lib/terminal-text/scan.d.ts +44 -0
  188. package/lib/terminal-text/scan.d.ts.map +1 -0
  189. package/lib/terminal-text/scan.js +195 -0
  190. package/lib/terminal-text/scan.js.map +1 -0
  191. package/lib/terminal-text/sgr.d.ts +32 -0
  192. package/lib/terminal-text/sgr.d.ts.map +1 -0
  193. package/lib/terminal-text/sgr.js +203 -0
  194. package/lib/terminal-text/sgr.js.map +1 -0
  195. package/lib/terminal-text.d.ts +1 -1
  196. package/lib/terminal-text.d.ts.map +1 -1
  197. package/lib/terminal-text.js +2 -379
  198. package/lib/terminal-text.js.map +1 -1
  199. package/lib/theme-command.js +2 -1
  200. package/lib/theme-command.js.map +1 -1
  201. package/lib/theme-defaults.d.ts +64 -0
  202. package/lib/theme-defaults.d.ts.map +1 -0
  203. package/lib/theme-defaults.js +248 -0
  204. package/lib/theme-defaults.js.map +1 -0
  205. package/lib/theme-files.d.ts +1 -1
  206. package/lib/theme-files.d.ts.map +1 -1
  207. package/lib/theme-files.js +1 -1
  208. package/lib/theme-files.js.map +1 -1
  209. package/lib/theme-resolver.d.ts +36 -0
  210. package/lib/theme-resolver.d.ts.map +1 -0
  211. package/lib/theme-resolver.js +112 -0
  212. package/lib/theme-resolver.js.map +1 -0
  213. package/lib/theme-schema.d.ts.map +1 -1
  214. package/lib/theme-schema.js +3 -2
  215. package/lib/theme-schema.js.map +1 -1
  216. package/lib/theme-settings.d.ts +5 -7
  217. package/lib/theme-settings.d.ts.map +1 -1
  218. package/lib/theme-settings.js +37 -21
  219. package/lib/theme-settings.js.map +1 -1
  220. package/lib/theme-tokens.d.ts +0 -89
  221. package/lib/theme-tokens.d.ts.map +1 -1
  222. package/lib/theme-tokens.js +0 -356
  223. package/lib/theme-tokens.js.map +1 -1
  224. package/lib/theme.d.ts +1 -1
  225. package/lib/theme.d.ts.map +1 -1
  226. package/lib/theme.js +2 -1
  227. package/lib/theme.js.map +1 -1
  228. package/lib/tool-display.d.ts.map +1 -1
  229. package/lib/tool-display.js +2 -1
  230. package/lib/tool-display.js.map +1 -1
  231. package/lib/transcript/message-content.d.ts +21 -0
  232. package/lib/transcript/message-content.d.ts.map +1 -0
  233. package/lib/transcript/message-content.js +61 -0
  234. package/lib/transcript/message-content.js.map +1 -0
  235. package/lib/transcript/tool-calls.d.ts +106 -0
  236. package/lib/transcript/tool-calls.d.ts.map +1 -0
  237. package/lib/transcript/tool-calls.js +254 -0
  238. package/lib/transcript/tool-calls.js.map +1 -0
  239. package/lib/transcript.d.ts +14 -55
  240. package/lib/transcript.d.ts.map +1 -1
  241. package/lib/transcript.js +31 -253
  242. package/lib/transcript.js.map +1 -1
  243. package/lib/ui/dock.d.ts +14 -2
  244. package/lib/ui/dock.d.ts.map +1 -1
  245. package/lib/ui/dock.js +52 -10
  246. package/lib/ui/dock.js.map +1 -1
  247. package/lib/ui/editor.d.ts.map +1 -1
  248. package/lib/ui/editor.js +2 -1
  249. package/lib/ui/editor.js.map +1 -1
  250. package/lib/ui/keymap-picker.d.ts +2 -1
  251. package/lib/ui/keymap-picker.d.ts.map +1 -1
  252. package/lib/ui/keymap-picker.js +1 -0
  253. package/lib/ui/keymap-picker.js.map +1 -1
  254. package/lib/ui/view/gate-card.d.ts +21 -0
  255. package/lib/ui/view/gate-card.d.ts.map +1 -0
  256. package/lib/ui/view/gate-card.js +104 -0
  257. package/lib/ui/view/gate-card.js.map +1 -0
  258. package/lib/ui/view/tool-card.d.ts +149 -0
  259. package/lib/ui/view/tool-card.d.ts.map +1 -0
  260. package/lib/ui/view/tool-card.js +429 -0
  261. package/lib/ui/view/tool-card.js.map +1 -0
  262. package/lib/ui/view/transcript-message.d.ts +62 -0
  263. package/lib/ui/view/transcript-message.d.ts.map +1 -0
  264. package/lib/ui/view/transcript-message.js +158 -0
  265. package/lib/ui/view/transcript-message.js.map +1 -0
  266. package/lib/ui/view.d.ts +105 -223
  267. package/lib/ui/view.d.ts.map +1 -1
  268. package/lib/ui/view.js +132 -679
  269. package/lib/ui/view.js.map +1 -1
  270. package/package.json +3 -1
  271. package/themes/violet-orbit.yaml +7 -5
package/lib/index.js CHANGED
@@ -1,71 +1,37 @@
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 { hiddenTail, NO_UNDO, redoStep, resetUndo, turnsOf, UNDO_TURN_SETTLE_MS, undoStep } from "./agent/undo.js";
12
- 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";
13
4
  import { createStatusFacts } from "./agent/status.js";
14
- import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
15
- import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
16
- import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
17
5
  import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
18
- import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
19
- import { cancelStep, quitStep } from "./input/cancel.js";
20
- import { createAnswerCompletionProvider, createCompletionProvider } from "./input/completion.js";
21
- import { createFileIndex } from "./input/file-search.js";
22
- import { ghostSuffix } from "./input/ghost.js";
23
- import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
24
- import { createDeferredNotice } from "./settings-notice.js";
25
- import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
26
- import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
27
- import { resolveConfig } from "./config.js";
28
- import { FoldCursor } from "./fold-cursor.js";
29
- import { createRestoreRegistry } from "./terminal/restore.js";
30
- import { ExternalEditor } from "./terminal/external-editor.js";
31
- import { installSignalRestore } from "./terminal/signals.js";
32
- import { WarningSafeTui } from "./terminal/warning-screen.js";
33
- import { BELL, shouldRingBell } from "./terminal/bell.js";
34
- import { clipboardSequence } from "./terminal/clipboard.js";
35
- import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
36
- import { driverReportFor, sessionStartReason } from "./herdr/state.js";
37
- import { createHerdrReporter } from "./herdr/reporter.js";
38
- import { defaultExportFile, transcriptToText } from "./export.js";
39
- import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
40
- import { detectColourMode } from "./theme-capability.js";
41
- 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 { describeSkillDrift } from "./install-skills.js";
9
+ import { windowTitle } from "./terminal/title.js";
42
10
  import { toolDisplayFor } from "./tool-display.js";
43
- import { pendingPrompts } from "./queue.js";
44
- import { renderThemeTable } from "./theme-command.js";
45
- import { DEFAULT_THEME, builtinNames, builtinThemesDir, ensureThemesHome, exportTheme, loadThemes, themesHomeDir, watchThemes } from "./theme-files.js";
46
- import { KEYMAP_LAYERS, keymapLayer } from "./keys-command.js";
47
- import { resetSequence } from "./theme-tokens.js";
11
+ import { runModelList } from "./model-list.js";
12
+ import { LIST_MODELS_SERVICE } from "./startup.js";
48
13
  import { SessionId } from '@deepseek-ai/dsh-session';
49
- import { formatTokens } from "./tokens.js";
50
- import { TranscriptModel } from "./transcript.js";
51
- import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
52
- import { cleanCopied } from "./ui/copy.js";
14
+ import { createAppearance } from "./surface/appearance.js";
15
+ import { createBackgroundWork } from "./surface/background-work.js";
16
+ import { createCommands } from "./surface/commands.js";
17
+ import { createModalInput } from "./surface/modal-input.js";
18
+ import { createModelChoice } from "./surface/model-choice.js";
19
+ import { createPromptInput } from "./surface/prompt-input.js";
20
+ import { createPromptMemory } from "./surface/prompt-memory.js";
21
+ import { createPresetChoice } from "./surface/preset-choice.js";
22
+ import { createSessionLifecycle } from "./surface/session-lifecycle.js";
23
+ import { backHint, createSessionView } from "./surface/session-view.js";
24
+ import { createStagedTurns } from "./surface/staged-turns.js";
25
+ import { createTerminalLifecycle } from "./surface/terminal-lifecycle.js";
53
26
  import { WorkDock } from "./ui/dock.js";
54
27
  import { GateInputBar } from "./ui/gate-input.js";
55
- import { HistoryPicker } from "./ui/history-picker.js";
56
28
  import { surfaceLayout } from "./ui/layout.js";
57
- import { KeymapPicker } from "./ui/keymap-picker.js";
58
- import { PickerPopup, POPUP_MAX_HEIGHT, popupWidth } from "./ui/picker-card.js";
59
29
  import { PromptBar } from "./ui/prompt.js";
60
30
  import { MarkdownRenderer } from "./ui/markdown.js";
61
31
  import { createMermaidTransform } from "./ui/mermaid.js";
62
- import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
63
32
  import { QueueBar } from "./ui/queue.js";
64
33
  import { StatusBar } from "./ui/status.js";
65
- import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
66
- import { PromptStash } from "./stash.js";
67
- import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
68
- import { ThemePicker } from "./ui/theme-picker.js";
34
+ import { TranscriptView } from "./ui/view.js";
69
35
  export const name = 'tui';
70
36
  /**
71
37
  * Services the surface cannot run without.
@@ -75,21 +41,6 @@ export const name = 'tui';
75
41
  * every card to a bare generic row.
76
42
  */
77
43
  export const inject = ['agents', 'tools'];
78
- /** One second in the unit a chord window is scheduled in. */
79
- const MS_PER_SECOND = 1000;
80
- /** What the back hint names when the reader has unbound the key it would advertise. */
81
- const BACK_HINT_FALLBACK = 'ctrl+b';
82
- /** What the reader presses to leave a view they did not open. */
83
- const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
84
- /** Stored sessions titled at once when the picker opens. */
85
- const TITLE_CONCURRENCY = 4;
86
- /** How often the running-state clock repaints while a turn is open. */
87
- const STATUS_TICK_MS = 1000;
88
- /** Reverse video for the cell the cursor occupies, so a ghost keeps the cursor visible. */
89
- const GHOST_CURSOR_PREFIX = '\u001b[7m';
90
- function asRecord(value) {
91
- return typeof value === 'object' && value !== null ? value : undefined;
92
- }
93
44
  /**
94
45
  * Refuse to run without a real terminal.
95
46
  *
@@ -112,6 +63,24 @@ export function assertInteractiveTerminal(input = {}) {
112
63
  * the composition rows around it.
113
64
  */
114
65
  export function apply(ctx, config) {
66
+ const appExit = ctx.get('appExit');
67
+ if (appExit === undefined) {
68
+ throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
69
+ }
70
+ // The listing is a pipeable dump, not a surface: it answers before the
71
+ // config, the composition probe, and the TTY gate, claims no terminal, and
72
+ // leaves the exit code to the command that owns the process.
73
+ if (ctx.get(LIST_MODELS_SERVICE) === true) {
74
+ // Provider rows mount after this one, so an immediate read would report an
75
+ // empty catalog; the launcher's committed startup is the first moment the
76
+ // composed adapters exist, and a failed boot must not print a listing.
77
+ const appReady = ctx.get('appReady');
78
+ if (appReady === undefined) {
79
+ throw new Error('dsh-tui: the dsh launcher must provide appReady; start this surface with dsh --profile tui');
80
+ }
81
+ appReady.onReady(() => { void runModelList(ctx, appExit); });
82
+ return;
83
+ }
115
84
  const resolved = resolveConfig(config);
116
85
  assertInteractiveTerminal();
117
86
  // Fail before taking the screen over: a missing row is a composition mistake,
@@ -119,441 +88,306 @@ export function apply(ctx, config) {
119
88
  const probe = probeComposition(service => ctx.get(service) !== undefined);
120
89
  if (probe.missingRequired.length > 0)
121
90
  throw new Error(describeMissingRequired(probe));
122
- const appExit = ctx.get('appExit');
123
- if (appExit === undefined) {
124
- throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
125
- }
126
- /**
127
- * The surface's appearance, rebuilt whenever the reader's settings change.
128
- *
129
- * Renderers hold this object for the life of the session, so the current
130
- * theme is swapped *behind* a stable delegate rather than reassigned: every
131
- * row then reads one whole table, and a repaint can never observe a
132
- * half-applied one. `--no-color` still outranks anything configured.
133
- */
134
- const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
135
- /**
136
- * The reader's section, or nothing when the service is not mounted.
137
- *
138
- * The service is only readable inside an `inject` scope — asking for it
139
- * outside one is a composition error, not a missing value — so this stays a
140
- * late-bound read that the injection point and the change event both use.
141
- */
142
- let readSection = () => defaultSettings();
143
- /**
144
- * The themes this session can draw.
145
- *
146
- * Read from disk rather than compiled in, so a file the reader saves is a theme
147
- * the moment the save lands. Held beside the settings rather than inside them
148
- * because the two answer different questions: the section says which name the
149
- * reader chose, and this says which names exist — including the one they typed
150
- * into the directory a second ago.
151
- */
152
- const themesHome = themesHomeDir();
153
- let themeLibrary = loadThemes(themesHome, builtinThemesDir());
154
- /**
155
- * Persist a theme choice, replaced once the section is registered.
156
- *
157
- * A theme picked mid-session has to outlive it, so the choice is written
158
- * through the same scope the reader's document is read from instead of kept
159
- * in memory: the host persists it, and the change comes back through
160
- * `settings/updated` like any other edit — which is what restyles the screen.
161
- */
162
- let chooseTheme = (_name) => { };
163
- /**
164
- * A refused settings edit, kept until the surface can show it: stderr is
165
- * behind the alt screen, and the section is read on a schedule of its own.
166
- */
167
- const settingsNotice = createDeferredNotice();
168
- let current = createTheme(themeMode());
169
- /**
170
- * The section as it was last read.
171
- *
172
- * Held so a preview can rebuild the table without reading the document again:
173
- * the picker repaints on every arrow key, and a read there would report a
174
- * refused section once per press.
175
- */
176
- let appliedSection;
177
- /**
178
- * The theme the picker's cursor is on, while its list is open.
179
- *
180
- * A preview is a name and nothing else — the document is not written — so
181
- * leaving the list is one more rebuild from the section, and a session that
182
- * ends mid-preview has still persisted only what the reader chose.
183
- */
184
- let previewTheme;
185
- const applyTheme = (section) => {
186
- // The row under the cursor outranks the document while a list is open, so a
187
- // theme is judged on the reader's own transcript before it is taken.
188
- current = createTheme(themeMode(), toOverrides({ ...section, theme: previewTheme ?? section.theme }, themeLibrary));
189
- };
190
- /**
191
- * Show a theme without choosing it.
192
- *
193
- * The list's cursor is the preview: every row paints the surface and writes
194
- * nothing, so two themes are compared on the reader's own transcript rather
195
- * than on a name. Clearing the name puts back what the document says, which is
196
- * what cancelling the list has to leave behind.
197
- */
198
- const showTheme = (name) => {
199
- previewTheme = name;
200
- // Nothing to rebuild from before the first read, and nothing to show either.
201
- if (appliedSection === undefined)
202
- return;
203
- applyTheme(appliedSection);
204
- markdown.invalidate();
205
- view.invalidate();
206
- tui.requestRender();
207
- };
208
- const theme = {
209
- get revision() { return current.revision; },
210
- get color() { return current.color; },
211
- style: (token, text) => current.style(token, text),
212
- rich: (raw, options) => current.rich(raw, options),
213
- cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
214
- glyph: token => current.glyph(token),
215
- visible: token => current.visible(token),
216
- // Forwarded rather than read, because the editor and the markdown view keep
217
- // the theme object they were built with: a settings change has to reach them
218
- // through a stable delegate or they would keep the boot appearance.
219
- editor: forwardEditorTheme(() => current.editor),
220
- markdown: forwardMarkdownTheme(() => current.markdown),
221
- };
222
- /**
223
- * Rows the reader has opened by key. The model stays untouched; only the view
224
- * reads this, and a click on one message overrides it there. A thought starts
225
- * folded: it is the longest, least scannable row in the transcript, so leaving
226
- * it open pushes the answer a reader came for off the screen, and a folded row
227
- * still names itself and its key. Cards start from the reader's own `tools:`
228
- * settings, and a PTC card's calls start open for the opposite reason: each is
229
- * one clipped line under a header that already names the program.
230
- */
231
- const viewState = { ...DEFAULT_VIEW_STATE };
232
- /**
233
- * How a reply's mermaid fences draw, seeded from the reader's section.
234
- *
235
- * The transform reads this per render instead of capturing it, because the
236
- * settings document is hot-reloaded and a session already on screen has to
237
- * follow the edit.
238
- */
239
- let mermaidMode = defaultSettings().mermaid;
240
- /** How each tool's cards draw; the settings document owns it and the view reads it live. */
241
- let toolDisplay = defaultSettings().tools;
242
- /** The keys that start a chord, and how long one waits; the settings document owns all of it. */
243
- let prefixKeys = DEFAULT_PREFIX_KEYS;
244
- let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
245
- /** Every action's keys in force; the settings document owns it and a press reads it live. */
246
- let keymap = defaultKeymap();
247
- /** Whether prompts are recorded and offered, and the cap on how many; the settings document owns all three. */
248
- let historyEnabled = defaultSettings().history.enabled;
249
- let historyGhost = defaultSettings().history.ghost;
250
- let historyMaxEntries = defaultSettings().history.maxEntries;
251
- /**
252
- * The chord between a prefix and the action that follows it.
253
- *
254
- * Built here, before the terminal exists, because the settings scope applies
255
- * first and has to be able to end a chord armed under the keymap it replaced.
256
- * The repaint the window also wants is late-bound: only a key press reaches
257
- * it, and no key can arrive before the surface has started.
258
- */
259
- const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
260
- /**
261
- * Seed the display the reader configured.
262
- *
263
- * The key toggles nested calls for one session, but a settings edit is a
264
- * deliberate act, so it re-seeds and becomes the new starting point; the
265
- * mermaid mode and the per-tool card fold have no key of their own and only
266
- * ever come from the document.
267
- */
268
- const applyDisplay = (section) => {
269
- viewState.expandSubCalls = section.subcalls === 'inline';
270
- mermaidMode = section.mermaid;
271
- toolDisplay = section.tools;
272
- prefixKeys = section.prefixes;
273
- prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
274
- keymap = section.keymap;
275
- // Installed where the library reads it, so a remap lands on the next press
276
- // rather than at the next restart.
277
- installKeybindings(keymap);
278
- historyEnabled = section.history.enabled;
279
- historyGhost = section.history.ghost;
280
- historyMaxEntries = section.history.maxEntries;
281
- // A chord armed under the keymap the reader just replaced is not their chord.
282
- keyChord.disarm();
283
- };
284
- /**
285
- * Read the reader's section once and apply everything it configures.
286
- *
287
- * One read per change, because a refused section is reported on the way past:
288
- * reading it once per field would show the reader the same refusal twice.
289
- */
290
- const applySettings = () => {
291
- const section = readSection();
292
- appliedSection = section;
293
- // A settings edit ends any preview: what the document says is now the choice,
294
- // and a name left over from a list would outrank it.
295
- previewTheme = undefined;
296
- reportMissingTheme(section);
297
- applyTheme(section);
298
- applyDisplay(section);
299
- };
300
- /**
301
- * Say so when the reader named a theme that nothing answers to.
302
- *
303
- * The schema cannot refuse the name: a theme is a file, so the set of names is
304
- * known to the directory rather than to this build, and one can stop answering
305
- * between two reads. Falling back to the default without a word would leave the
306
- * reader looking at shades they did not choose, so the refusal lands here
307
- * instead — beside the read that found it, and alongside the rest of the
308
- * section, which is still theirs.
309
- */
310
- const reportMissingTheme = (section) => {
311
- const name = section.theme;
312
- if (name === undefined || themeLibrary.get(name) !== undefined)
313
- return;
314
- settingsNotice.post(`dsh-tui theme "${name}" is not a theme · themes: ${themeLibrary.names().join(' · ')} · the default, ${DEFAULT_THEME}, is drawn instead`);
315
- };
316
91
  /**
317
- * Own the section, so the harness validates and persists it for the reader.
92
+ * The reader's appearance, built first because everything this surface draws
93
+ * holds its theme delegate and reads its preferences live.
318
94
  *
319
- * Registration is how the document learns the section exists at all; without
320
- * it a hand-written `dsh-tui:` block would be dropped on the next save. The
321
- * first read happens here too, because this is the only scope the service
322
- * may be touched in.
95
+ * The settings scope is registered further down, because it re-seeds the prompt
96
+ * bar's own keys and may only apply once that owner exists.
323
97
  */
324
- ctx.inject(['settings'], settingsCtx => {
325
- // Registration parses the document against the schema, so a section the
326
- // schema itself refuses throws here — inside a fiber whose failure the
327
- // screen never shows. Reporting it through the same holder keeps a typo
328
- // from costing the reader every setting they wrote, silently.
329
- let scope;
330
- try {
331
- scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
332
- }
333
- catch (error) {
334
- // Nothing registered means nothing to read, so the reader's switch cannot
335
- // be confirmed: recording stays off rather than falling back to on.
336
- historyEnabled = false;
337
- settingsNotice.post(settingsProblemMessage(error) + ' · prompt history stays off until the section parses');
338
- return;
339
- }
340
- readSection = () => readScope(scope, message => settingsNotice.post(message));
341
- chooseTheme = name => {
342
- void scope.update({ theme: name }).then(() => model.notice(`theme · ${name} · written to the settings document`), (error) => settingsNotice.post(settingsProblemMessage(error)));
343
- };
344
- applySettings();
98
+ const appearance = createAppearance(ctx, {
99
+ color: () => resolved.color,
100
+ // The row's own theme is the layer a profile patch can pin on a harness
101
+ // that keeps settings per row, so it is read here beside the section.
102
+ rowTheme: resolved.theme,
103
+ rowSettings: () => readRowSettings(config),
104
+ rowSettingsLive: hasLiveRowSettings(config),
105
+ notice: message => sessionView.notice(message),
106
+ render: () => tui.requestRender(),
107
+ invalidateMarkdown: () => markdown.invalidate(),
108
+ invalidateView: () => view.invalidate(),
109
+ // The chord a settings edit has to end is armed in the bar's own owner, so it
110
+ // is reached through the thing that holds it rather than copied here.
111
+ keybindings: () => promptInput,
112
+ openPicker: picker => modalInput.openPicker(picker),
345
113
  });
114
+ const { theme } = appearance;
115
+ /**
116
+ * The prompt bar's own presses, built here rather than beside the bar.
117
+ *
118
+ * The settings scope below applies first and has to be able to end a chord
119
+ * armed under the keymap it replaced, so this owner exists before the terminal
120
+ * or the editor do; each port reads them at call time for that reason.
121
+ */
122
+ const promptInput = createPromptInput(ctx, {
123
+ keymap: appearance.keymap,
124
+ prefixKeys: appearance.prefixKeys,
125
+ prefixWindowMs: appearance.prefixWindowMs,
126
+ tui: () => tui,
127
+ editor: () => editor,
128
+ promptBar: () => promptBar,
129
+ modalHandleKey: data => modalInput.handleKey(data),
130
+ toggleCards: appearance.toggleCards,
131
+ toggleSubCalls: appearance.toggleSubCalls,
132
+ toggleReasoning: appearance.toggleReasoning,
133
+ openEffortPicker: () => {
134
+ void modelChoice.openEffortPicker();
135
+ },
136
+ openHistoryPicker: () => {
137
+ void promptMemory.openHistoryPicker();
138
+ },
139
+ back: () => {
140
+ if (sessionView.viewingChild())
141
+ void sessionView.showDriven();
142
+ },
143
+ queuedPrompts: () => sessionLifecycle.queuedPrompts(),
144
+ turnRunning: () => sessionLifecycle.turnRunning(),
145
+ viewingChild: () => sessionView.viewingChild(),
146
+ interrupt: () => {
147
+ sessionLifecycle.drivingAgent()?.interrupt();
148
+ },
149
+ notice: message => sessionView.notice(message),
150
+ requestExit: code => requestExit(code),
151
+ recordPrompt: text => promptMemory.record(text),
152
+ runSubmission: submission => commands.runSubmission(submission),
153
+ drivenAgent: () => sessionLifecycle.drivingAgent()?.agent,
154
+ registeredCommands: target => commands.registeredCommands(target),
155
+ });
156
+ appearance.registerSection();
346
157
  /**
347
- * The agent scope the tool presenter resolves against.
158
+ * The transcript on screen, and the fold that feeds it.
348
159
  *
349
- * Tools are registered in the scoped world the session's preset mounts, so a
350
- * card can only read its tool's own render intent while this names that
351
- * agent. It follows whatever session the transcript is folding, because a
352
- * child on screen reads through the child's scope, not the parent's.
160
+ * Built before anything that draws it, and before the session it opens on is
161
+ * driven: every read it takes of the driven identity, the staged cutoff, and
162
+ * the render target is a port taken at call time, because all three move as
163
+ * the reader switches sessions.
353
164
  */
354
- let presentScope;
355
- const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
165
+ const sessionView = createSessionView(ctx, {
166
+ initialSession: resolved.sessionId,
167
+ drivenSession: () => sessionLifecycle.activeSession(),
168
+ stagedCutoff: () => stagedTurns.stagedCutoff(),
169
+ agentScope: id => ctx.agents?.get(id),
170
+ liveEvents: id => liveSession(id)?.snapshotEvents?.(),
171
+ render: () => tui.requestRender(),
172
+ });
356
173
  /**
357
- * The reader's prompt history: global across projects, recorded from every
358
- * submitted line, and offered back as they type. Built before the bar so its
359
- * first load cannot race the first suggestion.
360
- */
361
- const promptHistory = createPromptHistory({
362
- cap: () => historyMaxEntries,
363
- warn: message => {
364
- model.notice(message);
174
+ * The prompt's own memory, constructed where the box that draws its ghost
175
+ * already exists; the settings readers stay live because the document is
176
+ * hot-reloaded.
177
+ */
178
+ const promptMemory = createPromptMemory({
179
+ historyEnabled: appearance.historyEnabled,
180
+ historyGhost: appearance.historyGhost,
181
+ historyMaxEntries: appearance.historyMaxEntries,
182
+ theme,
183
+ keymap: appearance.keymap,
184
+ notice: message => sessionView.notice(message),
185
+ render: () => tui.requestRender(),
186
+ editorText: () => editor.getExpandedText(),
187
+ setEditorText: text => {
188
+ editor.setText(text);
365
189
  tui.requestRender();
366
190
  },
191
+ // A question answers in this editor, so a draft written into a borrowed bar
192
+ // would become somebody's answer instead of a parked prompt. An editor
193
+ // holding the draft in another program owns it just as firmly: a pop that
194
+ // landed then would be deleted from the bank and then overwritten.
195
+ editorAvailable: () => !promptBar.isBorrowed() && !terminalLifecycle.handedOver(),
196
+ activeSession: () => sessionLifecycle.activeSession(),
197
+ // The keyboard's lifetime is the modal owner's, so the list is handed over
198
+ // rather than driven here.
199
+ openPicker: picker => modalInput.openPicker(picker),
367
200
  });
201
+ const ghostBrush = promptMemory.ghostBrush;
368
202
  /**
369
- * The dimmed completion drawn from recorded prompts.
370
- *
371
- * Colour is the affordance: with styling off the suggestion would be
372
- * unreadable text the reader could still accept, which is worse than none.
373
- * The cursor cell is also reversed so the cursor stays visible on the ghost.
203
+ * The route this session runs with, built before the status facts that report
204
+ * it and read through ports because the screen, the keyboard and the settings
205
+ * document that supplies the default all belong to the surface around it.
374
206
  */
375
- const ghostBrush = {
376
- enabled: () => historyEnabled && historyGhost && theme.color && theme.visible('editor.ghost'),
377
- suggestion: input => ghostSuffix({ entries: promptHistory.entries(), ...input }),
378
- paint: (text, cell) => {
379
- const styled = theme.style('editor.ghost', text);
380
- return cell === 'cursor' ? GHOST_CURSOR_PREFIX + styled + resetSequence() : styled;
381
- },
382
- };
383
- const work = new WorkFold();
384
- const modelSwitch = new ModelSwitch();
207
+ const modelChoice = createModelChoice(ctx, {
208
+ statusFacts: () => statusFacts(),
209
+ keymap: appearance.keymap,
210
+ openPicker: picker => modalInput.openPicker(picker),
211
+ notice: message => sessionView.notice(message),
212
+ render: () => tui.requestRender(),
213
+ });
385
214
  const agentPresets = createPresetRoster(ctx);
386
- /** The mode named on the command line, which is the only one that may conflict. */
387
- const requestedPreset = resolved.preset;
388
- /**
389
- * The mode a session the reader starts from here on joins.
390
- *
391
- * A launch flag seeds it and a successful switch updates it, so `/new` and
392
- * `/fork` land in the mode the reader last chose; nothing is written to
393
- * settings, because the mode of a session is not a property of the machine.
394
- * Unset means nobody chose, which is where the roster's default applies.
395
- */
396
- let seat = requestedPreset;
397
- /**
398
- * The mode a session takes its seat in: the reader's latest choice, or the
399
- * roster's default read at this moment.
400
- *
401
- * The default cannot be captured when this row applies: it comes from the
402
- * settings document, which may be read after that, so a captured copy would
403
- * pin every later session to the mode the bundle happened to ship.
404
- */
405
- const seatMode = () => seat ?? agentPresets?.defaultId;
406
- const catalog = createModelCatalog(ctx);
407
- const jobDirectory = createJobDirectory(ctx);
408
- let jobs = [];
409
- const roster = new SubagentRoster();
410
- const subagentControl = createSubagentControl(ctx);
411
- const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
412
- const restore = createRestoreRegistry();
413
- const terminal = new ProcessTerminal();
414
- const tui = new WarningSafeTui(terminal, { copySelection });
415
- // A frame that cannot be drawn leaves the last good screen up, so the failure
416
- // has to reach the transcript: otherwise the surface looks frozen and nothing
417
- // on screen can say why.
418
- tui.onFrameError = error => model.reportError(error);
419
- /** Whether a child process owns the terminal, which is when nothing here may write to it. */
420
- let handedOver = false;
421
- /** Whether the host has unloaded this surface, after which nothing may start it again. */
422
- let disposed = false;
423
- /** An exit asked for while a child owned the terminal, run once the screen is ours again. */
424
- let deferredExit;
425
215
  /**
426
- * Write to the tty, but only while this surface owns it.
427
- *
428
- * An editor the reader opened draws its own screen on the same terminal, so a
429
- * title or a bell from here would land on top of it and, for a bell, sound as
430
- * if the editor had failed. The title is written again when the screen comes
431
- * back; a bell that fell in the gap is dropped rather than rung late.
432
- */
433
- const writeTerminal = (text) => {
434
- if (!handedOver)
435
- terminal.write(text);
436
- };
437
- /**
438
- * Put a copied selection on the clipboard as the words it selected.
439
- *
440
- * A terminal copies the screen, so a drag across a message takes the box the
441
- * message was drawn in along with it. The rows the last frame drew are the
442
- * account of that frame, so the copy is read back through them: the shape the
443
- * surface added comes off, and nothing the reader wrote does.
444
- */
445
- function copySelection(text) {
446
- if (handedOver)
447
- return Promise.resolve(false);
448
- terminal.write(clipboardSequence(cleanCopied(text, view.copyRows())));
449
- return Promise.resolve(true);
450
- }
451
- /** The session this surface drives: commands, approvals, and the bell belong to it. */
452
- let activeSession = resolved.sessionId;
453
- /** The session the transcript is showing, which can be one of its children. */
454
- let viewedSession = resolved.sessionId;
455
- let pending;
456
- let pendingPicker;
457
- /** A pick being vetted owns the list, not the keyboard: filtering stays live while its verdict is read. */
458
- let vetting = false;
459
- const view = new TranscriptView(model, theme, markdown, {
460
- state: () => viewState,
461
- gate: () => pending?.gate.card(),
462
- picker: () => pendingPicker?.card?.(),
463
- keys: () => keymap,
464
- toolDisplay: tool => toolDisplayFor(toolDisplay, tool),
216
+ * Which mode a session takes, read through ports because the open path mounts
217
+ * through the same roster and the screen, the keyboard, the notice, and the
218
+ * session the reader watches all belong to the surface around it.
219
+ */
220
+ const presetChoice = createPresetChoice({
221
+ agentPresets,
222
+ requestedPreset: resolved.preset,
223
+ // The mode a session ran is a property of its own log, which is the same
224
+ // history this surface lists the stored sessions from.
225
+ storedPreset: async (id) => {
226
+ const history = createSessionHistory(ctx);
227
+ return history === undefined ? undefined : await presetOfStoredSession(history, id);
228
+ },
229
+ livePreset: id => agentPresets?.current(liveSession(id)),
230
+ drivingAgent: () => sessionLifecycle.drivingAgent(),
231
+ keymap: appearance.keymap,
232
+ openPicker: picker => modalInput.openPicker(picker),
233
+ notice: message => sessionView.notice(message),
234
+ render: () => tui.requestRender(),
235
+ returnToDrivenSession: async () => {
236
+ if (sessionView.viewingChild())
237
+ await sessionView.show(sessionLifecycle.activeSession());
238
+ },
239
+ });
240
+ const backgroundWork = createBackgroundWork(ctx, {
241
+ drivingAgent: () => sessionLifecycle.drivingAgent(),
242
+ activeSession: () => sessionLifecycle.activeSession(),
243
+ notice: text => sessionView.notice(text),
244
+ marker: text => sessionView.marker(text),
245
+ render: () => tui.requestRender(),
246
+ navigate: id => {
247
+ void sessionView.show(SessionId(id));
248
+ },
249
+ backgroundChanged: running => herdr.background(running),
250
+ });
251
+ const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => appearance.mermaidMode() }));
252
+ const terminalLifecycle = createTerminalLifecycle(ctx, {
253
+ reportFrameError: error => sessionView.reportError(error),
254
+ notice: message => sessionView.notice(message),
255
+ copyRows: () => view.copyRows(),
256
+ activeSession: () => sessionLifecycle.activeSession(),
257
+ sessionOpened: () => sessionLifecycle.sessionOpened(),
258
+ turnRunning: () => sessionLifecycle.turnRunning(),
259
+ stopClock: () => sessionLifecycle.stopClock(),
260
+ exit: appExit,
261
+ draftText: () => editor.getExpandedText(),
262
+ draftBorrowed: () => promptBar.isBorrowed(),
263
+ holdDraft: text => promptBar.replaceHeld(text),
264
+ writeDraft: text => editor.setText(text),
265
+ });
266
+ const { terminal, tui, herdr, disposers, writeTerminal, requestExit, exited } = terminalLifecycle;
267
+ const view = new TranscriptView(sessionView.model, theme, markdown, {
268
+ state: appearance.viewState,
269
+ gate: () => modalInput.gateCard(),
270
+ picker: () => modalInput.pickerCard(),
271
+ keys: appearance.keymap,
272
+ toolDisplay: tool => toolDisplayFor(appearance.toolDisplay(), tool),
465
273
  });
466
274
  // The key map goes in before the bar exists, so no press can be read as the
467
275
  // send the library submits on by default. A settings document read after this
468
276
  // point installs over it, which is why the bar reads the map per press.
469
- installKeybindings(keymap);
470
- const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
277
+ promptInput.installBindings();
278
+ const editor = new GateInputBar(tui, theme.editor, appearance.keymap, ghostBrush);
471
279
  // Answers are written in the reader's own editor, which is why a question
472
280
  // borrows the bar instead of drawing a second one beside it.
473
281
  const promptBar = new PromptBar(editor);
474
- const disposers = [];
475
- // The presenter closure outlives the composition's own teardown, so it must
476
- // not keep an agent alive after its world unwinds.
477
- disposers.push(() => {
478
- presentScope = undefined;
479
- });
480
- let agent;
481
- let turnOpen = false;
482
282
  /**
483
- * The staged undo cursor for the session this terminal drives.
484
- *
485
- * Prompts are never deleted: the cursor chooses how much of the live log the
486
- * transcript shows. A cursor counts the turns of one log, so openAgent resets it.
487
- */
488
- let undoState = NO_UNDO;
489
- /** Event index the transcript must not fold past while turns are hidden. */
490
- let stagedCut;
491
- /** Whether an undo is settling an interrupt, so a second press cannot race it. */
492
- let undoPending = false;
493
- let turnStartedAt;
494
- let exited = false;
495
- /** Whether a session was really opened, which is what an exit hint can name. */
496
- let sessionOpened = false;
497
- /** Re-read the job board; it is live state, so nothing else can fold it. */
498
- const refreshJobs = () => {
499
- jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
500
- tui.requestRender();
501
- };
283
+ * The one modal interaction at a time, and the keyboard it holds.
284
+ *
285
+ * Built before the transcript view so the view can ask it for a card: the
286
+ * card a gate or picker draws is read per paint, never copied.
287
+ */
288
+ const modalInput = createModalInput(ctx, {
289
+ herdr,
290
+ editor,
291
+ tui,
292
+ terminal,
293
+ theme,
294
+ keymap: appearance.keymap,
295
+ promptBar,
296
+ refreshCompletion: () => promptInput.applyCompletion(),
297
+ activeSession: () => sessionLifecycle.activeSession(),
298
+ });
299
+ disposers.push(sessionView.clearPresentScope);
300
+ /**
301
+ * The staged cursor over the session this terminal drives, and the prompts
302
+ * parked behind it.
303
+ *
304
+ * Every port is read at call time — the agent, the session, the draft, the
305
+ * queue all move while an undo settles an interrupt — so this owner can exist
306
+ * before the agent it replaces does.
307
+ */
308
+ const stagedTurns = createStagedTurns({
309
+ viewingChild: () => sessionView.viewingChild(),
310
+ activeSession: () => sessionLifecycle.activeSession(),
311
+ sessionEvents: id => sessionView.sessionEvents(id),
312
+ resetTranscript: () => sessionView.reset(),
313
+ foldTranscript: (id, cutoff) => sessionView.fold(id, cutoff),
314
+ drivingAgent: () => sessionLifecycle.drivingAgent(),
315
+ disposeOutgoing: () => sessionLifecycle.disposeOutgoing(),
316
+ openSession: (id, resume, fork) => sessionLifecycle.openAgent(id, resume, fork),
317
+ queuedPrompts: () => sessionLifecycle.queuedPrompts(),
318
+ canPark: () => stash !== undefined,
319
+ parkPrompt: text => stash?.stashEditor(text) ?? Promise.resolve(),
320
+ turnRunning: () => sessionLifecycle.turnRunning(),
321
+ draft: () => editor.getExpandedText(),
322
+ writeDraft: text => editor.setText(text),
323
+ notice: message => sessionView.notice(message),
324
+ render: () => tui.requestRender(),
325
+ });
502
326
  // The bank is built once the picker exists to answer for it, so the status
503
327
  // source is late-bound: the footer must not read a half-constructed stash.
504
328
  let stash;
505
329
  const statusFacts = createStatusFacts(ctx, {
506
- sessionId: () => activeSession,
507
- activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
508
- override: () => modelSwitch.current(),
330
+ sessionId: () => sessionLifecycle.activeSession(),
331
+ activity: () => sessionLifecycle.activity(),
332
+ override: () => modelChoice.current(),
509
333
  home: process.env.HOME,
510
- chord: () => keyChord.hint(),
334
+ chord: () => promptInput.chordHint(),
511
335
  // Read per paint rather than written into the marker the view left behind:
512
336
  // a hint stored with the transcript would keep naming the key of the day it
513
337
  // was written, and the reader may remap it with the row already on screen.
514
- back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
338
+ back: () => (sessionView.viewingChild() ? backHint(appearance.keymap()) : undefined),
515
339
  stash: () => stash?.entryCount,
516
340
  });
517
341
  const statusBar = new StatusBar(statusFacts, theme);
518
- const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
519
- // The queue is read from the agent this terminal drives rather than from the
520
- // session on screen, because it sits on the editor that submits to that agent.
521
- const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
522
- const queue = new QueueBar(queuedPrompts, theme);
523
- // Only a running turn has anything to say over time, so the clock stops with it.
524
- const statusTicker = setInterval(() => {
525
- if (turnOpen)
526
- tui.requestRender();
527
- }, STATUS_TICK_MS);
528
- disposers.push(() => clearInterval(statusTicker));
529
- // A window that outlived the surface would repaint a screen that is gone.
530
- disposers.push(() => keyChord.disarm());
531
- // A containing Herdr is told what this pane is doing; away from one the
532
- // reporter is inert, so the surface never depends on being multiplexed.
533
- const herdr = createHerdrReporter();
534
- // Kept out of the disposal list on purpose: the row is handed back before
535
- // this stops guarding it, so a host that leaves during the release still
536
- // releases synchronously.
537
- const unregisterExit = herdr.registerExitRelease();
538
- restore.add(() => tui.stop());
539
- ctx.effect(() => () => {
540
- // A surface the host unloads owns no screen and keeps no listeners, so a
541
- // child still running in another process must not be handed a start().
542
- disposed = true;
543
- // The pane stops being an agent before the process that claimed it unwinds:
544
- // a release that ran after the reports were unregistered would race them,
545
- // and one that never ran would leave a row that reads as a live agent. The
546
- // reports already on the wire are settled first, because Herdr ignores a
547
- // release for a pane nothing has claimed yet — the report that followed it
548
- // would otherwise claim the row back. The promise is returned so a host that
549
- // waits for teardown waits for the row too, and the exit listener outlives
550
- // the wait, so one that does not still hands the row back synchronously.
551
- const released = herdr.release().catch(() => undefined);
552
- restore.restore();
553
- for (const dispose of disposers.reverse())
554
- dispose();
555
- return released.finally(unregisterExit);
342
+ const dock = new WorkDock(() => sessionView.workState(), theme, () => backgroundWork.jobs(), () => backgroundWork.roster.list(), undefined, id => {
343
+ void sessionView.show(SessionId(id));
556
344
  });
345
+ const queue = new QueueBar(() => sessionLifecycle.queuedPrompts(), theme);
346
+ /**
347
+ * The agent this terminal drives, and the clock that repaints it while a turn
348
+ * is open.
349
+ *
350
+ * Built after the widgets that draw it and before the layout that mounts them,
351
+ * and every port is read at call time: the readers above exist before this
352
+ * owner does, and the session it drives moves under them.
353
+ */
354
+ const sessionLifecycle = createSessionLifecycle(ctx, {
355
+ initialSession: resolved.sessionId,
356
+ model: resolved.model,
357
+ provider: resolved.provider,
358
+ bell: resolved.bell,
359
+ presetFor: (id, resume, fork) => presetChoice.presetFor(id, resume, fork),
360
+ installModelChoice: agentCtx => modelChoice.setup(agentCtx),
361
+ mountPreset: async (agentCtx, preset) => {
362
+ await agentPresets?.mount(agentCtx, preset);
363
+ },
364
+ liveSession: id => liveSession(id),
365
+ installCompletion: () => promptInput.installCompletion(),
366
+ sessionEvents: id => sessionView.sessionEvents(id),
367
+ setViewed: id => sessionView.setViewed(id),
368
+ setPresentScope: scope => sessionView.setPresentScope(scope),
369
+ clearPresentScope: sessionView.clearPresentScope,
370
+ resetTranscript: () => sessionView.reset(),
371
+ replay: id => sessionView.replay(id),
372
+ fold: id => sessionView.fold(id),
373
+ notice: message => sessionView.notice(message),
374
+ render: () => tui.requestRender(),
375
+ promptMemorySessionOpened: () => promptMemory.sessionOpened(),
376
+ stagedSessionOpened: id => stagedTurns.sessionOpened(id),
377
+ turnSettled: () => stagedTurns.turnSettled(),
378
+ acceptCatalog: info => backgroundWork.acceptCatalog(info),
379
+ resetRoster: () => backgroundWork.resetRoster(),
380
+ refreshJobs: () => backgroundWork.refresh(),
381
+ herdr,
382
+ writeTerminal,
383
+ exiting: exited,
384
+ disposers,
385
+ });
386
+ // A window that outlived the surface would repaint a screen that is gone.
387
+ disposers.push(() => promptInput.disarmChord());
388
+ // Registered here rather than inside the terminal owner: this is the position
389
+ // the one teardown list gave the signal seam before the surface was split.
390
+ disposers.push(terminalLifecycle.signalShutdown());
557
391
  tui.setLayoutRoot(surfaceLayout({
558
392
  transcript: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
559
393
  dock,
@@ -562,2147 +396,59 @@ export function apply(ctx, config) {
562
396
  status: statusBar,
563
397
  }));
564
398
  tui.setFocus(editor);
565
- const requestExit = (code, reason) => {
566
- if (exited)
567
- return;
568
- // A child owns the terminal: restoring it here would leave the reader a
569
- // shell behind an editor that is still running, and would put its tty back
570
- // into cooked mode under it. The exit waits for the screen to come back.
571
- if (handedOver) {
572
- deferredExit = { code, reason };
573
- return;
574
- }
575
- exited = true;
576
- clearInterval(statusTicker);
577
- // The screen goes back at once, so leaving feels like leaving; the row goes
578
- // back behind it. Reports already on the wire are settled first, because
579
- // Herdr ignores the release of a pane nothing has claimed yet — the report
580
- // that followed it would otherwise claim the row back during shutdown.
581
- terminal.write(CLEAR_TITLE);
582
- restore.restore();
583
- void herdr
584
- .release()
585
- // Nobody is left to report a release that failed on the way out, and a
586
- // row that could not be cleared is not a reason to keep the process.
587
- .catch(() => undefined)
588
- .finally(() => {
589
- // Everything below is written after the release: a failure nobody can
590
- // read is not a failure that was reported.
591
- if (reason !== undefined)
592
- terminal.write(`\ndsh-tui: ${reason}\n`);
593
- // The hint is computed here rather than read from the context because
594
- // only the surface knows which session it is leaving: a fork or a
595
- // switch moves it. A run that opened nothing has nothing to offer back:
596
- // the identity it was launched with names no log, so pointing at it
597
- // would send the reader to a conversation that does not exist.
598
- if (sessionOpened)
599
- terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
600
- appExit(code);
601
- });
602
- };
603
- // A signal ends the process from outside the surface, and the default action
604
- // would leave the reader on a screen no shell prompt is drawn in: the same
605
- // shutdown a quit key runs goes to the signals a supervisor sends.
606
- disposers.push(installSignalRestore({ shutdown: code => requestExit(code, 'interrupted') }));
607
- const openGate = (next) => {
608
- pending = next;
609
- // The card's own title names the decision, which is what a reader glancing
610
- // at a wall of panes needs in order to know which one to open.
611
- herdr.block(next.gate.card().title);
612
- // A gate owns the keyboard: the editor must not collect the decision keys.
613
- editor.disableSubmit = true;
614
- tui.setFocus(null);
615
- // A question is answered in this editor, which the gate draws under the row
616
- // being answered, so the hardware cursor belongs to it while it is borrowed.
617
- // It is set after the focus is cleared, which unmarks the component it left.
618
- editor.focused = next.kind === 'question';
619
- tui.requestRender();
620
- };
621
- const closeGate = () => {
622
- pending = undefined;
623
- herdr.unblock();
624
- editor.disableSubmit = false;
625
- // The question is answered or skipped, so the reader gets their prompt back
626
- // in the bar they left it in, with the menu the prompt bar offered.
627
- promptBar.giveBack();
628
- applyCompletion();
629
- tui.setFocus(editor);
630
- tui.requestRender();
631
- };
632
- /**
633
- * Whether the bar holds anything the reader wrote.
634
- *
635
- * Whitespace counts: it is a character the editor holds, and a press that
636
- * clears it is the press the reader asked for. A paste counts expanded,
637
- * because a marker is content rather than an absence of it.
638
- */
639
- const barHasText = () => editor.getExpandedText() !== '';
399
+ disposers.push(promptInput.inputListener());
400
+ stash = promptMemory.buildStash();
640
401
  /**
641
- * What each key the surface answers itself does; false hands the press back.
402
+ * The command plane, wired to the owners every submission is delegated to.
642
403
  *
643
- * Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
644
- * without a handler here fails to compile rather than doing nothing.
404
+ * Built once the bank exists, because the parked-draft commands route to the
405
+ * very bank the footer counts; every other port is a live read, so a command
406
+ * typed after a session switch answers for the session now on screen.
645
407
  */
646
- const surfaceActions = {
647
- toolDetail: () => {
648
- viewState.expandCards = !viewState.expandCards;
649
- tui.requestRender();
650
- return true;
651
- },
652
- subCalls: () => {
653
- viewState.expandSubCalls = !viewState.expandSubCalls;
654
- tui.requestRender();
655
- return true;
656
- },
657
- reasoning: () => {
658
- viewState.expandReasoning = !viewState.expandReasoning;
659
- tui.requestRender();
660
- return true;
408
+ const commands = createCommands(ctx, {
409
+ launch: {
410
+ sessionId: resolved.sessionId,
411
+ resume: resolved.resume,
412
+ resumePicker: resolved.resumePicker,
661
413
  },
662
- effort: () => {
663
- void openEffortPicker();
664
- return true;
665
- },
666
- history: () => {
667
- void openHistoryPicker();
668
- return true;
669
- },
670
- back: () => {
671
- if (viewedSession !== activeSession)
672
- void showAgentSession();
673
- return true;
674
- },
675
- interrupt: () => {
676
- // In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
677
- // decides what one press takes back — and a press with nothing left to
678
- // cancel is handed back rather than spent on leaving.
679
- const queued = queuedPrompts();
680
- const step = cancelStep({
681
- barHasText: barHasText(),
682
- queuedPrompts: queued.length,
683
- turnRunning: turnOpen,
684
- viewingChild: viewedSession !== activeSession,
685
- });
686
- switch (step) {
687
- case 'clear-editor':
688
- editor.setText('');
689
- tui.requestRender();
690
- return true;
691
- case 'reclaim-queued':
692
- // The interrupt drops whatever the agent had not started, so the words
693
- // are read before it is stopped and handed back to the bar: a key that
694
- // means "stop" must not be the key that loses the reader's own prompts.
695
- agent?.interrupt();
696
- editor.setText(queued.join('\n'));
697
- model.notice(`interrupt requested · ${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} back in the bar`);
698
- tui.requestRender();
699
- return true;
700
- case 'interrupt-turn':
701
- agent?.interrupt();
702
- model.notice('interrupt requested');
703
- tui.requestRender();
704
- return true;
705
- case 'leave-child-view':
706
- void showAgentSession();
707
- return true;
708
- case 'hand-back':
709
- return false;
710
- }
711
- },
712
- quit: () => {
713
- // The only key that leaves. Text in the bar keeps it for the editor, so
714
- // the library's delete forward is never taken from a reader who is editing.
715
- switch (quitStep({ barHasText: barHasText(), overlayOpen: tui.hasOverlay(), turnRunning: turnOpen })) {
716
- case 'cancel-then-quit':
717
- // An exit that waits on a tool call is not an exit, so the turn is
718
- // asked to stop and the leave does not wait for the answer.
719
- agent?.interrupt();
720
- requestExit(0);
721
- return true;
722
- case 'quit':
723
- requestExit(0);
724
- return true;
725
- case 'hand-back':
726
- return false;
727
- }
728
- },
729
- };
730
- disposers.push(tui.addInputListener(data => {
731
- // A key arrives as a press and a release once the surface asks the terminal
732
- // to report key events, and this library drops the release only for the
733
- // focused component: a listener sees both halves, so an arrow key that is
734
- // acted on twice steps a picker two rows and answers a question two options
735
- // on. Falls through rather than consuming, which leaves a component that
736
- // asked for releases its own half.
737
- if (isKeyRelease(data))
738
- return undefined;
739
- if (pending !== undefined) {
740
- if (pending.kind === 'approval') {
741
- const outcome = pending.gate.handleKey(data);
742
- if (outcome === undefined)
743
- tui.requestRender();
744
- else {
745
- pending.settle(outcome);
746
- closeGate();
747
- }
748
- }
749
- else {
750
- const answers = pending.gate.handleKey(data);
751
- if (answers === undefined)
752
- tui.requestRender();
753
- else {
754
- pending.settle(answers);
755
- closeGate();
756
- }
757
- }
758
- return { consume: true };
759
- }
760
- if (pendingPicker !== undefined) {
761
- const action = pendingPicker.picker.handleKey(data);
762
- if (action === undefined) {
763
- tui.requestRender();
764
- return { consume: true };
765
- }
766
- if (vetting) {
767
- // A refusal check must not take the keyboard with it: the reader keeps
768
- // filtering and can still leave, while a second pick waits for the
769
- // first verdict rather than racing it.
770
- if (action.kind === 'cancel')
771
- settlePicker(undefined);
772
- return { consume: true };
773
- }
774
- if (action.kind === 'cancel') {
775
- settlePicker(undefined);
776
- return { consume: true };
777
- }
778
- const vet = pendingPicker.vet;
779
- if (vet === undefined) {
780
- settlePicker(action.id);
781
- return { consume: true };
782
- }
783
- vetting = true;
784
- void (async () => {
785
- let reason;
786
- try {
787
- reason = await vet(action.id);
788
- }
789
- finally {
790
- // A check that fails must not take the keyboard with it.
791
- vetting = false;
792
- }
793
- if (pendingPicker === undefined)
794
- return;
795
- if (reason === undefined) {
796
- settlePicker(action.id);
797
- return;
798
- }
799
- pendingPicker.picker.setNote(reason);
800
- tui.requestRender();
801
- })();
802
- return { consume: true };
803
- }
804
- // A chord is the surface's second key: the prefix is consumed and the
805
- // footer names what may follow, while a key that finishes nothing is handed
806
- // on. Detail the reader asked for is always available, even mid-turn: the
807
- // collapsed view is a default, not the only state.
808
- const chorded = keyChord.handle(data);
809
- if (chorded !== undefined) {
810
- if (chorded.kind === 'action')
811
- runSubmission(chorded.binding.submission);
812
- tui.requestRender();
813
- return { consume: true };
814
- }
815
- for (const binding of surfaceBindings(keymap)) {
816
- if (!matchesKey(data, binding.key))
817
- continue;
818
- return surfaceActions[binding.action]() ? { consume: true } : undefined;
819
- }
820
- return undefined;
821
- }));
822
- const registry = () => ctx.get('commands');
823
- /** One scan of the workspace, shared by both menus: the listing is read-only and cached. */
824
- const fileIndex = createFileIndex(process.cwd());
825
- /** The menu the prompt bar offers: the commands this session can run, and the workspace's files. */
826
- let promptCompletion;
827
- /** The menu an answer offers, which is the same menu without the commands. */
828
- let answerCompletion;
829
- /**
830
- * Give the bar the menu its current role calls for.
831
- *
832
- * A question borrows the prompt bar's editor rather than drawing its own, so
833
- * the provider has to follow the borrow: a slash command is a line this
834
- * surface would run, and an answer is text the model reads, so the two roles
835
- * must not share one menu.
836
- */
837
- const applyCompletion = () => {
838
- const chosen = promptBar.isBorrowed() ? answerCompletion : promptCompletion;
839
- if (chosen !== undefined)
840
- editor.setAutocompleteProvider(chosen);
841
- };
842
- /**
843
- * Offer completion for whatever this session can run right now.
844
- *
845
- * The registry is agent-scoped and still empty while the agent starts, so the
846
- * menu is rebuilt when the agent arrives and whenever a package registers
847
- * another command. Both providers are built here because the answer's is the
848
- * prompt bar's own minus the commands, and a rebuild while a question is open
849
- * must not leave the answer menu unreachable.
850
- */
851
- const installCompletion = () => {
852
- const current = agent?.agent;
853
- const commands = registry();
854
- if (current === undefined || commands === undefined)
855
- return;
856
- answerCompletion = createAnswerCompletionProvider(process.cwd(), fileIndex);
857
- promptCompletion = createCompletionProvider(commands.list(current), process.cwd(), fileIndex);
858
- applyCompletion();
859
- };
860
- /**
861
- * Take the keyboard for a picker.
862
- *
863
- * The list is already in hand, so the picker is interactive immediately while
864
- * the titles that make its rows recognizable stream in behind it.
865
- */
866
- const askForSession = async (history, sessions) => {
867
- const titles = new Map();
868
- void loadTitles(history, sessions, titles);
869
- const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
870
- return picked === undefined ? undefined : SessionId(picked);
871
- };
872
- /** Give the keyboard back to the editor and answer whoever opened the picker. */
873
- const settlePicker = (id) => {
874
- const settle = pendingPicker?.settle;
875
- // The screen goes back before the keyboard does: a box left behind a settled
876
- // list would sit over the transcript until something else repainted.
877
- pendingPicker?.release?.();
878
- pendingPicker = undefined;
879
- herdr.unblock();
880
- editor.disableSubmit = false;
881
- tui.setFocus(editor);
882
- settle?.(id);
883
- tui.requestRender();
884
- };
885
- /**
886
- * Why this run cannot open a stored session, or undefined when it can.
887
- *
888
- * A session runs the mode its own log recorded, so a `--preset` that
889
- * disagrees with it can only be refused: the picker asks first so the refusal
890
- * lands in the list rather than after the terminal has been handed back.
891
- */
892
- const refuseReason = async (id) => {
893
- if (requestedPreset === undefined || agentPresets === undefined)
894
- return undefined;
895
- try {
896
- await presetFor(SessionId(id), true, undefined);
897
- return undefined;
898
- }
899
- catch (error) {
900
- return error instanceof Error ? error.message : String(error);
901
- }
902
- };
903
- /**
904
- * Take the keyboard for a picker and answer with the id it settled on.
905
- *
906
- * Where the list is drawn is the caller's decision, because it is a decision
907
- * about the reader's attention. A list that is the destination — a session to
908
- * open, a model to switch to — joins the transcript and is read with it. A
909
- * list that is a reference for the work in front of the reader is drawn over
910
- * that work instead, so looking something up does not cost them their place.
911
- * The keyboard is owned the same way either way.
912
- */
913
- const openPicker = (picker, vet, placement = 'inline') => new Promise(resolve => {
914
- const overlay = placement === 'popup'
915
- ? tui.showOverlay(new PickerPopup(rows => picker.card(rows), () => terminal.rows, theme), {
916
- width: popupWidth(terminal.columns),
917
- maxHeight: POPUP_MAX_HEIGHT,
918
- anchor: 'center',
919
- margin: 1,
920
- // The surface's own listener reads every press before a focused
921
- // component does, so the box needs no focus to be driven; taking it
922
- // would only move focus away from where the reader left it.
923
- nonCapturing: true,
924
- })
925
- : undefined;
926
- pendingPicker = {
927
- picker,
928
- settle: resolve,
929
- vet,
930
- card: overlay === undefined ? () => picker.card() : undefined,
931
- release: overlay === undefined ? undefined : () => overlay.hide(),
932
- };
933
- // A picker owns the keyboard exactly as a gate does: nothing moves until
934
- // the reader chooses, so it is the same kind of wait.
935
- herdr.block(picker.card().title);
936
- editor.disableSubmit = true;
937
- tui.setFocus(null);
938
- tui.requestRender();
939
- });
940
- /**
941
- * Open the key map over the surface.
942
- *
943
- * Nothing here is a pick: a row names an action and the keys reaching it, so
944
- * the id the list settles on is thrown away. What the reader came for is the
945
- * list itself, and the filter that narrows it.
946
- */
947
- const openKeyMap = (layer) => {
948
- void openPicker(new KeymapPicker(() => keymap, layer), undefined, 'popup');
949
- };
950
- /**
951
- * Choose a theme from a list the screen follows.
952
- *
953
- * Enter writes the choice through the settings document, the same path a typed
954
- * name takes, so what lands is what the reader was looking at. Leaving the list
955
- * restores the theme in force, because the document never changed while they
956
- * looked.
957
- */
958
- const openThemePicker = async () => {
959
- const picked = await openPicker(new ThemePicker(() => themeLibrary, () => appliedSection?.theme, () => keymap, theme => showTheme(theme?.name)));
960
- if (picked === undefined) {
961
- showTheme(undefined);
962
- return;
963
- }
964
- // The row stays on screen until the document carries it: restoring first
965
- // would flash the theme the reader just left. The write clears the preview as
966
- // it lands, and a write that fails says so, leaving a theme that is still one
967
- // of theirs rather than shades nothing chose.
968
- chooseTheme(picked);
969
- };
970
- /**
971
- * Reverse search over recorded prompts, seeded with whatever is in the bar.
972
- *
973
- * A pick replaces the draft; a cancel leaves it exactly as it was, because the
974
- * list was opened to look rather than to lose what is already typed.
975
- */
976
- const openHistoryPicker = async () => {
977
- if (!historyEnabled) {
978
- model.notice('prompt history is disabled in ' + TUI_SETTINGS_NAMESPACE + ' settings');
979
- tui.requestRender();
980
- return;
981
- }
982
- if (promptHistory.entries().length === 0) {
983
- const blocked = promptHistory.blockedReason();
984
- model.notice(blocked === undefined ? 'no prompt history yet' : 'prompt history is unavailable: ' + blocked);
985
- tui.requestRender();
986
- return;
987
- }
988
- // The expanded text is what the reader wrote; a large paste sits in the bar
989
- // as a marker, and seeding with it would filter out the prompt it came from.
990
- const picked = await openPicker(new HistoryPicker(() => promptHistory.entries(), editor.getExpandedText(), () => keymap));
991
- if (picked === undefined)
992
- return;
993
- editor.setText(picked);
994
- tui.requestRender();
995
- };
996
- /** The roster as the picker paints it, refreshed when the picker opens. */
997
- let presetRows = [];
998
- /** Choose the mode a session that has not started yet will run. */
999
- const askForPreset = async (currentId) => {
1000
- if (agentPresets === undefined)
1001
- return undefined;
1002
- presetRows = await agentPresets.list();
1003
- return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
1004
- };
1005
- /**
1006
- * The prompt bank for the session this surface drives.
1007
- *
1008
- * The surface owns the editor, the picker, and the notices, so the bank is
1009
- * handed the few things it needs to reach them and nothing else: the commands
1010
- * stay free of terminal state and are exercised without one in the tests.
1011
- */
1012
- stash = new PromptStash({
1013
- getEditorText: () => editor.getExpandedText(),
1014
- setEditorText: text => {
1015
- editor.setText(text);
1016
- tui.requestRender();
1017
- },
1018
- // A question answers in this editor, so a draft written into a borrowed bar
1019
- // would become somebody's answer instead of a parked prompt. An editor
1020
- // holding the draft in another program owns it just as firmly: a pop that
1021
- // landed then would be deleted from the bank and then overwritten.
1022
- editorIsAvailable: () => !promptBar.isBorrowed() && !handedOver,
1023
- notice: message => model.notice(message),
1024
- pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
1025
- confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
414
+ preset: resolved.preset,
415
+ presetRoster: agentPresets,
416
+ missingOptional: () => describeMissingOptional(probe),
417
+ // Read at start rather than at mount: the copy a reader installed can lag
418
+ // this build, and the surface is the only place left that can say so.
419
+ skillDrift: () => describeSkillDrift(),
420
+ session: sessionLifecycle,
421
+ transcript: sessionView,
422
+ route: modelChoice,
423
+ modes: presetChoice,
424
+ work: backgroundWork,
425
+ staged: stagedTurns,
426
+ memory: promptMemory,
427
+ appearance,
428
+ terminal: terminalLifecycle,
429
+ modals: modalInput,
430
+ statusFacts: () => statusFacts(),
431
+ stash: () => stash,
1026
432
  render: () => tui.requestRender(),
1027
- },
1028
- // The bank follows the session this surface drives, not the directory it
1029
- // runs in: two terminals in one checkout keep separate drafts, and a resume
1030
- // finds the ones it parked. Read per command so a switch retargets it.
1031
- { sessionId: () => String(activeSession) });
1032
- /**
1033
- * The reader's own editor, opened over the draft the bar holds.
1034
- *
1035
- * The screen is handed over rather than drawn beside: an editor needs the
1036
- * terminal, so this is the one moment the surface is not the process painting
1037
- * on it. Every failure is reported as a notice and leaves the bar as it was,
1038
- * because the caller is a key press with nowhere to put an error.
1039
- */
1040
- const externalEditor = new ExternalEditor({
1041
- suspend: () => {
1042
- handedOver = true;
1043
- try {
1044
- // The frame is left in place rather than repainted into the normal
1045
- // buffer: the editor is about to paint over that same screen.
1046
- tui.stop({ preserveScreen: true });
1047
- }
1048
- catch (error) {
1049
- // A stop that failed leaves the screen ours; leaving the flag up would
1050
- // suppress every later title and defer every exit for good.
1051
- handedOver = false;
1052
- throw error;
1053
- }
1054
- },
1055
- resume: () => {
1056
- // Whatever the host unloaded is not coming back: starting it again would
1057
- // paint on a terminal this process is done with, into listeners that are gone.
1058
- if (disposed)
1059
- return;
1060
- try {
1061
- tui.start();
1062
- }
1063
- finally {
1064
- handedOver = false;
1065
- }
1066
- // Entering the alternate screen clears it, and any render asked for while
1067
- // the child owned the terminal was dropped after setting the very flag that
1068
- // makes the next ordinary request a no-op: without a forced one the reader
1069
- // would get a blank screen with a working keyboard under it.
1070
- tui.requestRender(true);
1071
- // The title is state this surface owns and the handoff swallowed any change
1072
- // to it, so a turn that ended while the editor was open would leave
1073
- // "working" up until the next turn.
1074
- writeTerminal(windowTitle(process.cwd(), turnOpen ? 'working' : 'ready'));
1075
- },
1076
- notice: message => model.notice(message),
1077
433
  });
1078
- /** Title the listed sessions without making the reader wait for the slowest log. */
1079
- const loadTitles = async (history, sessions, titles) => {
1080
- const queue = [...sessions];
1081
- const worker = async () => {
1082
- for (;;) {
1083
- const next = queue.shift();
1084
- if (next === undefined)
1085
- return;
1086
- try {
1087
- const title = await readSessionTitle(history, next);
1088
- if (title === undefined)
1089
- continue;
1090
- titles.set(next.id, title);
1091
- tui.requestRender();
1092
- }
1093
- catch {
1094
- // A log this build cannot read stays listed by id; the picker is not
1095
- // the place to explain storage, and one bad session is not the list.
1096
- }
1097
- }
1098
- };
1099
- await Promise.all(Array.from({ length: TITLE_CONCURRENCY }, worker));
1100
- };
1101
- const storedSessions = async () => {
1102
- const history = createSessionHistory(ctx);
1103
- if (history === undefined) {
1104
- model.notice('this profile has no session storage, so there is nothing to resume');
1105
- tui.requestRender();
1106
- return undefined;
1107
- }
1108
- try {
1109
- const sessions = await history.list(PICKER_LIMIT);
1110
- if (sessions.length === 0) {
1111
- model.notice('no stored sessions to resume');
1112
- tui.requestRender();
1113
- return undefined;
1114
- }
1115
- return { history, sessions };
1116
- }
1117
- catch (error) {
1118
- model.notice(`could not list stored sessions: ${error instanceof Error ? error.message : String(error)}`);
1119
- tui.requestRender();
1120
- return undefined;
1121
- }
1122
- };
1123
- const chooseSession = async () => {
1124
- const listed = await storedSessions();
1125
- return listed === undefined ? undefined : askForSession(listed.history, listed.sessions);
1126
- };
1127
- /**
1128
- * Replay a stored session so a resumed run opens on the conversation the
1129
- * reader left, not on an empty screen: the durable log is the transcript.
1130
- */
1131
- /**
1132
- * Fold one session's history into the transcript.
1133
- *
1134
- * A live session answers from memory, which is the only source that includes
1135
- * events not yet flushed and the only one that works for a child that has not
1136
- * materialized; a session this process is not running falls back to storage.
1137
- */
434
+ /** The live session this process runs, which is the only source holding an unflushed tail. */
1138
435
  const liveSession = (id) => ctx.get('sessions')?.get?.(id);
1139
- /**
1140
- * The fold's place in the viewed session's durable sequence.
1141
- *
1142
- * A resumed session is folded while its agent's loop is already live, so the
1143
- * same event can reach the surface twice: once on the stream and once from the
1144
- * log the fold is reading. The sequence number every durable event carries is
1145
- * what tells the two apart.
1146
- */
1147
- const foldCursor = new FoldCursor();
1148
- /**
1149
- * Fold one durable event into the view, unless a staged cursor hides it.
1150
- *
1151
- * Undo never deletes, so the log keeps the hidden suffix; this filter is the
1152
- * one place the transcript and the log disagree about where the session ends.
1153
- */
1154
- const applyDurable = (session, event) => {
1155
- if (stagedCut !== undefined && session === activeSession && typeof event.seq === 'number' && event.seq >= stagedCut)
1156
- return;
1157
- if (foldCursor.accept(session, event))
1158
- applyEvent(event);
1159
- };
1160
- const foldHistory = async (id, through) => {
1161
- // Resolved before the fold so every card reads its tool through the scope
1162
- // that actually registered it; a stored session nobody runs has none.
1163
- presentScope = ctx.agents?.get(id);
1164
- // Every fold starts a cleared transcript, so a session already known to the
1165
- // cursor is read from its first event rather than from where it left off.
1166
- foldCursor.reset();
1167
- const inMemory = liveSession(id)?.snapshotEvents?.();
1168
- if (inMemory !== undefined) {
1169
- const events = through === undefined ? inMemory : inMemory.slice(0, through);
1170
- for (const event of events)
1171
- applyDurable(id, event);
1172
- return events.length;
1173
- }
1174
- // Only a live session can be staged: undo acts on the agent this terminal
1175
- // drives, and a stored log has no cursor to hide a suffix from.
1176
- const history = createSessionHistory(ctx);
1177
- if (history === undefined)
1178
- return 0;
1179
- const events = await history.read(id);
1180
- for (const event of events)
1181
- applyDurable(id, event);
1182
- return events.length;
1183
- };
1184
- const replayHistory = async (id) => {
1185
- try {
1186
- const folded = await foldHistory(id);
1187
- if (folded > 0)
1188
- model.notice(`replayed ${folded} events from the stored log`);
1189
- }
1190
- catch (error) {
1191
- model.notice(`could not replay this session: ${error instanceof Error ? error.message : String(error)}`);
1192
- }
1193
- };
1194
- /**
1195
- * Show another session in the transcript without leaving this one.
1196
- *
1197
- * A delegation is an ordinary session, so the reader can read what a child is
1198
- * doing rather than only that it exists; the agent this terminal drives does
1199
- * not change, which keeps commands, approvals, and the bell where they were.
1200
- */
1201
- const showSession = async (id) => {
1202
- const previous = viewedSession;
1203
- model.reset();
1204
- work.reset();
1205
- viewedSession = id;
1206
- try {
1207
- await foldHistory(id);
1208
- }
1209
- catch (error) {
1210
- model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
1211
- }
1212
- const returned = id === activeSession && previous !== activeSession;
1213
- model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
1214
- tui.requestRender();
1215
- };
1216
- const showAgentSession = async () => {
1217
- await showSession(activeSession);
1218
- };
1219
- /** Feed one durable event to everything that folds it. */
1220
- const applyEvent = (event) => {
1221
- model.apply(event);
1222
- work.apply(event);
1223
- };
1224
- /** Every event of a session, from memory when this process runs it. */
1225
- const sessionEvents = async (id) => {
1226
- const inMemory = liveSession(id)?.snapshotEvents?.();
1227
- if (inMemory !== undefined)
1228
- return inMemory;
1229
- const history = createSessionHistory(ctx);
1230
- return history === undefined ? [] : history.read(id);
1231
- };
1232
- /** The closed turns of the session this terminal drives, newest last. */
1233
- const currentTurns = async () => turnsOf(await sessionEvents(activeSession));
1234
- /** Fold the transcript through the staged cut, or the whole log at the tip. */
1235
- const redrawStaged = async () => {
1236
- model.reset();
1237
- work.reset();
1238
- await foldHistory(activeSession, stagedCut);
1239
- };
1240
- /** Park queued prompts one per stash entry, newest first so popping replays queue order. */
1241
- const parkQueued = async (queued) => {
1242
- for (const text of [...queued].reverse())
1243
- await stash?.stashEditor(text);
1244
- };
1245
- /** Resolver for the turn/end an undo is waiting on, set only while waiting. */
1246
- let turnSettled;
1247
- /** Wait for the interrupted turn to close, bounded so undo can give up honestly. */
1248
- const waitForTurnEnd = (timeoutMs) => new Promise(resolve => {
1249
- const timer = setTimeout(() => {
1250
- turnSettled = undefined;
1251
- resolve(false);
1252
- }, timeoutMs);
1253
- turnSettled = () => {
1254
- clearTimeout(timer);
1255
- turnSettled = undefined;
1256
- resolve(true);
1257
- };
1258
- });
1259
- /**
1260
- * Stop the running turn and empty the inbox so the cut lands on a closed
1261
- * turn/end; queued prompts are parked first because cancelling drops them.
1262
- *
1263
- * Returns undefined when the reader's words cannot be kept: a queue with no
1264
- * stash to park it in, or a turn that will not close. Both leave the cursor
1265
- * untouched, so the transcript keeps showing what the model actually saw; a
1266
- * number is how many queued prompts were parked before the turn was stopped.
1267
- */
1268
- const settleForUndo = async () => {
1269
- const queued = queuedPrompts();
1270
- if (!turnOpen && queued.length === 0)
1271
- return 0;
1272
- if (queued.length > 0 && stash === undefined) {
1273
- model.notice('queued prompts have no stash to park in; undo cancelled');
1274
- tui.requestRender();
1275
- return undefined;
1276
- }
1277
- agent?.interrupt();
1278
- if (queued.length > 0)
1279
- await parkQueued(queued);
1280
- if (turnOpen && !(await waitForTurnEnd(UNDO_TURN_SETTLE_MS))) {
1281
- model.notice('could not stop the turn; undo cancelled');
1282
- tui.requestRender();
1283
- return undefined;
1284
- }
1285
- return queued.length;
1286
- };
1287
- const runUndoCommand = () => {
1288
- if (undoPending)
1289
- return;
1290
- undoPending = true;
1291
- void (async () => {
1292
- if (viewedSession !== activeSession) {
1293
- model.notice('undo works on the session this terminal drives · ctrl+b comes back');
1294
- tui.requestRender();
1295
- return;
1296
- }
1297
- const turns = await currentTurns();
1298
- if (undoState.hidden >= turns.length && !turnOpen) {
1299
- model.notice('nothing to undo');
1300
- tui.requestRender();
1301
- return;
1302
- }
1303
- const draft = editor.getExpandedText();
1304
- // Text this state itself restored is not the reader's draft, so undoing
1305
- // again must not park it and end up with two copies.
1306
- const holdsDraft = draft !== '' && draft !== undoState.lastRestored;
1307
- if (holdsDraft && stash === undefined) {
1308
- model.notice('the bar holds a draft and there is no stash to park it in; undo cancelled');
1309
- tui.requestRender();
1310
- return;
1311
- }
1312
- const parked = await settleForUndo();
1313
- if (parked === undefined)
1314
- return;
1315
- const settled = await currentTurns();
1316
- const next = undoStep(undoState, settled);
1317
- if (next === undefined) {
1318
- model.notice('nothing to undo');
1319
- tui.requestRender();
1320
- return;
1321
- }
1322
- if (holdsDraft) {
1323
- await stash?.stashEditor(draft);
1324
- model.notice('the draft in the bar was parked in the stash');
1325
- }
1326
- undoState = next;
1327
- stagedCut = hiddenTail(next, settled)?.seedCount;
1328
- await redrawStaged();
1329
- editor.setText(next.lastRestored);
1330
- // The parking count rides the undo notice: a notice of its own would be
1331
- // replaced before the frame could show it.
1332
- const parkedNote = parked === 0 ? '' : ` · ${parked} queued prompt${parked === 1 ? '' : 's'} parked in the stash`;
1333
- model.notice(`undo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden${parkedNote} · prefix r redo`);
1334
- tui.requestRender();
1335
- })()
1336
- .catch((error) => {
1337
- model.notice(`undo failed: ${error instanceof Error ? error.message : String(error)}`);
1338
- tui.requestRender();
1339
- })
1340
- .finally(() => {
1341
- undoPending = false;
1342
- });
1343
- };
1344
- const runRedoCommand = () => {
1345
- void (async () => {
1346
- if (viewedSession !== activeSession) {
1347
- model.notice('redo works on the session this terminal drives · ctrl+b comes back');
1348
- tui.requestRender();
1349
- return;
1350
- }
1351
- const turns = await currentTurns();
1352
- const before = editor.getExpandedText();
1353
- const restored = undoState.lastRestored;
1354
- const next = redoStep(undoState, turns);
1355
- if (next === undefined) {
1356
- model.notice('nothing to redo');
1357
- tui.requestRender();
1358
- return;
1359
- }
1360
- undoState = next;
1361
- stagedCut = hiddenTail(next, turns)?.seedCount;
1362
- await redrawStaged();
1363
- // A composer the reader edited is theirs; only text this state wrote is replaced.
1364
- if (before === restored)
1365
- editor.setText(next.lastRestored);
1366
- model.notice(next.hidden === 0
1367
- ? 'redo · back at the newest prompt'
1368
- : `redo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden`);
1369
- tui.requestRender();
1370
- })().catch((error) => {
1371
- model.notice(`redo failed: ${error instanceof Error ? error.message : String(error)}`);
1372
- tui.requestRender();
1373
- });
1374
- };
1375
- /**
1376
- * Send the reader's prompt, branching first when the transcript is staged.
1377
- *
1378
- * Context is derived from the log, so a send while turns are hidden has to
1379
- * continue in a child seeded with the visible prefix; submitting in place would
1380
- * show the model the prompts the reader undid. Every send passes through here so
1381
- * the branch has one commit point, not one per caller.
1382
- */
1383
- const commitStagedSend = async (text) => {
1384
- try {
1385
- const previous = agent;
1386
- if (previous === undefined) {
1387
- model.notice('the agent is still starting; try again in a moment');
1388
- tui.requestRender();
1389
- return;
1390
- }
1391
- if (undoState.hidden === 0) {
1392
- previous.submit(text);
1393
- return;
1394
- }
1395
- const source = activeSession;
1396
- const events = await sessionEvents(source);
1397
- const turns = turnsOf(events);
1398
- const tail = hiddenTail(undoState, turns);
1399
- const seed = tail === undefined ? [] : events.slice(0, tail.seedCount);
1400
- const childId = SessionId(`tui-session-${randomUUID()}`);
1401
- agent = undefined;
1402
- turnOpen = false;
1403
- turnStartedAt = undefined;
1404
- presentScope = undefined;
1405
- model.reset();
1406
- work.reset();
1407
- roster.reset();
1408
- await previous.dispose();
1409
- const opened = await openAgent(childId, false, seed.length === 0 ? undefined : { from: source, events: seed });
1410
- model.notice(`continuing in a new branch · ${source} keeps the undone turns`);
1411
- opened.submit(text);
1412
- }
1413
- catch (error) {
1414
- model.notice(`could not send: ${error instanceof Error ? error.message : String(error)}`);
1415
- tui.requestRender();
1416
- }
1417
- };
1418
- /**
1419
- * The mode one agent joins.
1420
- *
1421
- * A resumed session keeps the composition its own log recorded, because
1422
- * swapping it would leave logged tool calls the new composition cannot make —
1423
- * the same reason the harness refuses a switch once a turn has run. A session
1424
- * that recorded none, which is every session written before modes existed,
1425
- * takes the seat; a branch inherits the mode its parent is running.
1426
- */
1427
- const presetFor = async (id, resume, fork) => {
1428
- if (agentPresets === undefined)
1429
- return undefined;
1430
- if (fork !== undefined)
1431
- return agentPresets.current(liveSession(fork.from)) ?? seatMode();
1432
- if (!resume)
1433
- return seatMode();
1434
- const history = createSessionHistory(ctx);
1435
- const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
1436
- if (stored === undefined)
1437
- return seatMode();
1438
- const resolvedStored = await resolveStored(id, stored);
1439
- if (resolvedStored === undefined) {
1440
- // The composition this session recorded is gone. Naming one explicitly is
1441
- // the reader's only way forward, so that is the one case an override is
1442
- // taken for a stored session.
1443
- if (requestedPreset !== undefined)
1444
- return (await agentPresets.resolve(requestedPreset)).id;
1445
- throw new Error(`session ${id} runs mode "${stored}", which this roster no longer offers; name another with --preset`);
1446
- }
1447
- if (requestedPreset !== undefined && requestedPreset !== resolvedStored) {
1448
- throw new Error(`session ${id} runs mode "${resolvedStored}", so --preset ${requestedPreset} does not apply; /preset ${requestedPreset} switches it before its first turn`);
1449
- }
1450
- return resolvedStored;
1451
- };
1452
- /** The stored mode's roster row, or undefined when the roster no longer offers it. */
1453
- const resolveStored = async (id, stored) => {
1454
- try {
1455
- return (await agentPresets?.resolve(stored))?.id;
1456
- }
1457
- catch {
1458
- return undefined;
1459
- }
1460
- };
1461
- const openAgent = async (id, resume, fork) => {
1462
- // Settled before the transcript is touched, so a refusal leaves neither a
1463
- // half-replayed session nor a half-composed agent behind.
1464
- const preset = await presetFor(id, resume, fork);
1465
- const handle = await startAgent(ctx, {
1466
- sessionId: id,
1467
- resume,
1468
- model: resolved.model,
1469
- provider: resolved.provider,
1470
- cwd: process.cwd(),
1471
- preset,
1472
- setup: async (agentCtx) => {
1473
- modelSwitch.install(agentCtx);
1474
- if (preset !== undefined)
1475
- await agentPresets?.mount(agentCtx, preset);
1476
- },
1477
- ...(fork === undefined ? {} : { fork }),
1478
- });
1479
- sessionOpened = true;
1480
- activeSession = id;
1481
- viewedSession = id;
1482
- agent = handle;
1483
- // A cursor counts the turns of one log; the session just opened has its own.
1484
- undoState = resetUndo(id);
1485
- stagedCut = undefined;
1486
- // The bank follows the session, so the footer stops counting the drafts of
1487
- // the session just left and the next command reads this session's file.
1488
- void stash?.open();
1489
- // agent/status is emitted on transitions only, so a driver that was
1490
- // already running when this surface attached — a resume that wakes
1491
- // straight away — would otherwise stay unreported until it stops. The
1492
- // status is read once here and settled before the session identity, so the
1493
- // report that carries the new session id states the driver's real phase
1494
- // rather than the one the previous session left behind.
1495
- herdr.driver(handle.agent.status);
1496
- // The agent's own id is reported rather than the requested one: a resume can
1497
- // be answered by the session the log actually holds.
1498
- herdr.session({
1499
- id: String(handle.sessionId),
1500
- cwd: process.cwd(),
1501
- reason: sessionStartReason({ forked: fork !== undefined, resumed: resume }),
1502
- });
1503
- // A session with no history to fold still has to present its first live
1504
- // card through the right scope, so the scope is set before any event can.
1505
- presentScope = handle.agent;
1506
- // Replayed only once the agent exists, because the fold reads every card
1507
- // through the scope the preset mounted; a fold before that scope existed
1508
- // degraded each replayed card to a bare generic row. The agent's loop is
1509
- // live by now, so the fold and the stream race over the same events; the
1510
- // durable sequence number is what keeps one event from landing twice.
1511
- if (resume)
1512
- await replayHistory(id);
1513
- // A branch inherits the conversation the reader was already reading, so it
1514
- // opens on that history rather than on an empty screen.
1515
- if (fork !== undefined)
1516
- await foldHistory(id);
1517
- disposers.push(() => {
1518
- void handle.dispose();
1519
- });
1520
- installCompletion();
1521
- model.notice(`session ${handle.sessionId}${resume ? ' (resumed)' : ''}`);
1522
- tui.requestRender();
1523
- // Returned so a caller that replaced the handle can send through the new
1524
- // one without a fresh read of state TypeScript can no longer widen.
1525
- return handle;
1526
- };
1527
- /** Move the surface to another stored session without leaving the terminal. */
1528
- const switchSession = async (id) => {
1529
- const previous = agent;
1530
- agent = undefined;
1531
- // Drop the outgoing scope before its agent is disposed, so no card folded
1532
- // during the transition can read a torn-down world.
1533
- presentScope = undefined;
1534
- turnOpen = false;
1535
- // The outgoing turn's clock dies with its agent; leaving it set would time
1536
- // the session being joined by work it never ran.
1537
- turnStartedAt = undefined;
1538
- model.reset();
1539
- work.reset();
1540
- roster.reset();
1541
- if (previous !== undefined)
1542
- await previous.dispose();
1543
- await openAgent(id, true);
1544
- };
1545
- /**
1546
- * Show or choose the route the next step will use.
1547
- *
1548
- * The choice is session-scoped: it changes nothing about the settings a later
1549
- * run reads, and the loop logs its own durable notice when the route a request
1550
- * actually used changes.
1551
- */
1552
- /**
1553
- * Show, read, or kill a background job.
1554
- *
1555
- * A terminal has no second window, so a job started by the model is otherwise
1556
- * invisible: the board is the only place a reader can see what is still
1557
- * running and stop it.
1558
- */
1559
- /**
1560
- * List or stop the delegations this session started.
1561
- *
1562
- * A child runs in the same process as an ordinary agent, so a stop is the
1563
- * cancel a reader's Ctrl+C sends to the parent; nothing here reaches into the
1564
- * child's own session, which keeps its own transcript either way.
1565
- */
1566
- const runSubagentsCommand = (argument) => {
1567
- const command = parseSubagentsArgument(argument);
1568
- switch (command.kind) {
1569
- case 'list':
1570
- model.notice(describeSubagents(roster.list(), Date.now()));
1571
- tui.requestRender();
1572
- return;
1573
- case 'open': {
1574
- const run = resolveRun(roster.list(), command.id);
1575
- if (run === undefined) {
1576
- model.notice(`${command.id}: no single child matches; /subagents lists them`);
1577
- tui.requestRender();
1578
- return;
1579
- }
1580
- void showSession(SessionId(run.id));
1581
- return;
1582
- }
1583
- case 'kill': {
1584
- const stopped = subagentControl?.stop(command.id) ?? false;
1585
- model.notice(stopped
1586
- ? `${command.id}: stop requested`
1587
- : `${command.id}: no live child with that id`);
1588
- tui.requestRender();
1589
- return;
1590
- }
1591
- case 'invalid':
1592
- model.notice(`/subagents: ${command.reason}`);
1593
- tui.requestRender();
1594
- return;
1595
- }
1596
- };
1597
- const runJobsCommand = (argument) => {
1598
- if (jobDirectory === undefined) {
1599
- model.notice('this profile has no job registry, so there is nothing to list');
1600
- tui.requestRender();
1601
- return;
1602
- }
1603
- if (agent === undefined) {
1604
- model.notice('the agent is still starting; try again in a moment');
1605
- tui.requestRender();
1606
- return;
1607
- }
1608
- const command = parseJobsArgument(argument);
1609
- switch (command.kind) {
1610
- case 'list':
1611
- refreshJobs();
1612
- model.notice(describeJobs(jobs, Date.now()));
1613
- tui.requestRender();
1614
- return;
1615
- case 'read': {
1616
- const result = jobDirectory.read(agent.agent, command.id);
1617
- const text = result?.text.trim() ?? '';
1618
- model.notice(text === ''
1619
- ? `${command.id}: no output yet`
1620
- : `${command.id} output\n${text.split('\n').slice(-JOB_READ_LINES).join('\n')}`);
1621
- refreshJobs();
1622
- return;
1623
- }
1624
- case 'kill': {
1625
- const outcome = jobDirectory.kill(agent.agent, command.id);
1626
- model.notice(outcome === undefined
1627
- ? `${command.id}: no such job`
1628
- : outcome === 'requested' ? `${command.id}: stop requested` : `${command.id} had already finished`);
1629
- refreshJobs();
1630
- return;
1631
- }
1632
- case 'invalid':
1633
- model.notice(`/jobs: ${command.reason}`);
1634
- tui.requestRender();
1635
- return;
1636
- }
1637
- };
1638
- /**
1639
- * Give this session a title.
1640
- *
1641
- * The title is what the resume picker shows and what every other surface
1642
- * displays, so a reader who has several sessions can name the one they are in
1643
- * without waiting for the harness to guess.
1644
- */
1645
- /**
1646
- * Write what the reader can see to a file.
1647
- *
1648
- * The base's `/export` downloads the log through the browser, which a
1649
- * terminal has no way to do, so this dumps the transcript the reader is
1650
- * looking at — the thing worth pasting into a message.
1651
- */
1652
- const runExportCommand = (argument) => {
1653
- const path = resolve(argument === '' ? defaultExportFile(String(activeSession)) : argument);
1654
- try {
1655
- writeFileSync(path, transcriptToText(model.entries()), 'utf8');
1656
- model.notice(`transcript written to ${path}`);
1657
- }
1658
- catch (error) {
1659
- model.notice(`could not write ${path}: ${error instanceof Error ? error.message : String(error)}`);
1660
- }
1661
- tui.requestRender();
1662
- };
1663
- /**
1664
- * Branch this conversation and continue in the branch.
1665
- *
1666
- * The branch is a real session with its own identity that inherits a prefix
1667
- * of this one, so a reader can try something without spending the
1668
- * conversation they already had. The cut is anchored to the last completed
1669
- * turn, because half an exchange is not a state to hand a model.
1670
- */
1671
- /** Start a fresh session without leaving the terminal. */
1672
- const runNewCommand = (title) => {
1673
- if (agent === undefined) {
1674
- model.notice('the agent is still starting; try again in a moment');
1675
- tui.requestRender();
1676
- return;
1677
- }
1678
- void (async () => {
1679
- const previous = agent;
1680
- agent = undefined;
1681
- turnOpen = false;
1682
- turnStartedAt = undefined;
1683
- model.reset();
1684
- work.reset();
1685
- roster.reset();
1686
- if (previous !== undefined)
1687
- await previous.dispose();
1688
- await openAgent(SessionId(`tui-session-${randomUUID()}`), false);
1689
- if (title !== '')
1690
- runRenameCommand(title);
1691
- model.notice('started a new session');
1692
- tui.requestRender();
1693
- })().catch((error) => {
1694
- model.notice(`could not start a session: ${error instanceof Error ? error.message : String(error)}`);
1695
- tui.requestRender();
1696
- });
1697
- };
1698
- /**
1699
- * Compose this session's agent again without leaving the conversation.
1700
- *
1701
- * A preset's standing mount only re-reads its composition file for an agent
1702
- * that joins after the file changed, so an edited preset, skill, or prompt
1703
- * file reaches a running session only by joining anew. The durable log is
1704
- * replayed afterwards, so the reader keeps the conversation they were reading.
1705
- */
1706
- const runReloadCommand = () => {
1707
- // A failed reload leaves no agent behind, so the command has to be usable
1708
- // again: the retry is what makes a broken composition file recoverable,
1709
- // while a surface that has opened no session yet is still starting.
1710
- if (agent === undefined && !sessionOpened) {
1711
- model.notice('the agent is still starting; try again in a moment');
1712
- tui.requestRender();
1713
- return;
1714
- }
1715
- const queued = queuedPrompts();
1716
- // Work the reader would lose is a decision, and the interrupt key already
1717
- // owns that decision: it stops the turn and hands queued words back to the
1718
- // bar. Reopening the session drops the inbox, so a reload that would take
1719
- // those words asks for the key instead of asking a question of its own.
1720
- if (turnOpen || queued.length > 0) {
1721
- model.notice(queued.length > 0
1722
- ? `${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} would be dropped — ctrl+c hands them back to the bar, then /reload`
1723
- : 'a turn is running — ctrl+c interrupts it first (delivered text is kept), then /reload');
1724
- tui.requestRender();
1725
- return;
1726
- }
1727
- const id = activeSession;
1728
- void (async () => {
1729
- // The same transition a session switch takes, aimed at the session
1730
- // already open: dispose, then join its preset generation anew.
1731
- await switchSession(id);
1732
- model.notice("reloaded this session's composition; the transcript was replayed");
1733
- tui.requestRender();
1734
- })().catch((error) => {
1735
- const reason = error instanceof Error ? error.message : String(error);
1736
- model.notice(`could not reload: ${reason} — fix the composition and /reload again`);
1737
- tui.requestRender();
1738
- });
1739
- };
1740
- /** Show the todo list the agent has been keeping. */
1741
- const runTodoCommand = () => {
1742
- model.notice(describeTodos(work.state().todos));
1743
- tui.requestRender();
1744
- };
1745
- /**
1746
- * Put the last answer on the reader's clipboard.
1747
- *
1748
- * The clipboard belongs to the terminal, so this asks it through OSC 52 —
1749
- * which is also the only route that works over SSH.
1750
- */
1751
- const runCopyCommand = () => {
1752
- const last = [...model.entries()].reverse().find(entry => entry.kind === 'assistant');
1753
- if (last === undefined || last.kind !== 'assistant') {
1754
- model.notice('nothing to copy yet');
1755
- tui.requestRender();
1756
- return;
1757
- }
1758
- terminal.write(clipboardSequence(last.text));
1759
- model.notice(`copied ${last.text.length} characters through the terminal`);
1760
- tui.requestRender();
1761
- };
1762
- const runForkCommand = (title) => {
1763
- if (agent === undefined) {
1764
- model.notice('the agent is still starting; try again in a moment');
1765
- tui.requestRender();
1766
- return;
1767
- }
1768
- void (async () => {
1769
- const source = activeSession;
1770
- const events = await sessionEvents(source);
1771
- const point = forkPoint(events);
1772
- if (point === undefined) {
1773
- model.notice('nothing to fork yet: this session has no completed turn');
1774
- tui.requestRender();
1775
- return;
1776
- }
1777
- const childId = SessionId(`tui-session-${randomUUID()}`);
1778
- const previous = agent;
1779
- agent = undefined;
1780
- turnOpen = false;
1781
- turnStartedAt = undefined;
1782
- model.reset();
1783
- work.reset();
1784
- roster.reset();
1785
- if (previous !== undefined)
1786
- await previous.dispose();
1787
- await openAgent(childId, false, { from: source, events: events.slice(0, point.inheritedEvents) });
1788
- if (title !== '')
1789
- runRenameCommand(title);
1790
- model.notice(`forked from ${source} at event ${point.boundarySeq} — ${point.inheritedEvents} inherited`);
1791
- tui.requestRender();
1792
- })().catch((error) => {
1793
- model.notice(`could not fork: ${error instanceof Error ? error.message : String(error)}`);
1794
- tui.requestRender();
1795
- });
1796
- };
1797
- const runRenameCommand = (title) => {
1798
- if (title === '') {
1799
- model.notice('use /rename <title>; the title is what the resume picker shows');
1800
- tui.requestRender();
1801
- return;
1802
- }
1803
- const session = ctx.get('sessions')?.get?.(activeSession);
1804
- const titles = ctx.get('sessionTitle');
1805
- if (session === undefined || typeof titles?.rename !== 'function') {
1806
- model.notice('this profile has no session-title service, so this session cannot be renamed');
1807
- tui.requestRender();
1808
- return;
1809
- }
1810
- try {
1811
- const accepted = titles.rename(session, title);
1812
- model.notice(`session renamed to "${typeof accepted?.title === 'string' ? accepted.title : title}"`);
1813
- }
1814
- catch (error) {
1815
- model.notice(`could not rename: ${error instanceof Error ? error.message : String(error)}`);
1816
- }
1817
- tui.requestRender();
1818
- };
1819
- const runModelCommand = (argument) => {
1820
- if (catalog === undefined) {
1821
- model.notice('this profile has no llm service, so models cannot be listed or switched');
1822
- tui.requestRender();
1823
- return;
1824
- }
1825
- const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
1826
- switch (command.kind) {
1827
- case 'current':
1828
- // Choosing by eye is the point of a terminal selector; the picker
1829
- // heads itself with the route the next step will actually use.
1830
- void openModelPicker();
1831
- return;
1832
- case 'list-models':
1833
- void catalog.models(command.provider).then(entries => {
1834
- model.notice(entries.length === 0
1835
- ? `${command.provider} advertises no models; an id may still work`
1836
- : `${command.provider}: ${entries.map(entry => entry.id).join(' ')}`);
1837
- tui.requestRender();
1838
- }).catch((error) => {
1839
- model.notice(`could not list models: ${error instanceof Error ? error.message : String(error)}`);
1840
- tui.requestRender();
1841
- });
1842
- return;
1843
- case 'switch': {
1844
- const choice = command.choice;
1845
- if (choice.reasoningEffort === undefined) {
1846
- modelSwitch.choose(choice);
1847
- model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
1848
- tui.requestRender();
1849
- return;
1850
- }
1851
- // The route decides which efforts exist, so an explicit one is checked
1852
- // against the adapter before it is put in force: a typo must not become
1853
- // a request the provider rejects.
1854
- void (async () => {
1855
- try {
1856
- const info = await catalog.efforts(choice.provider, choice.model);
1857
- const efforts = info?.efforts ?? [];
1858
- if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
1859
- model.notice(efforts.length === 0
1860
- ? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
1861
- : `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
1862
- tui.requestRender();
1863
- return;
1864
- }
1865
- modelSwitch.choose(choice);
1866
- model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
1867
- }
1868
- catch (error) {
1869
- model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1870
- }
1871
- tui.requestRender();
1872
- })();
1873
- return;
1874
- }
1875
- case 'invalid':
1876
- model.notice(`/model: ${command.reason}`);
1877
- tui.requestRender();
1878
- return;
1879
- }
1880
- };
1881
- /** Whether a route's effort list is being read, so a second key cannot race it. */
1882
- let openingEfforts = false;
1883
- /** Put the reader's effort choice in force for the next step. */
1884
- const applyEffort = (provider, modelId, effortId) => {
1885
- modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
1886
- ? { provider, model: modelId }
1887
- : { provider, model: modelId, reasoningEffort: effortId });
1888
- model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
1889
- tui.requestRender();
1890
- };
1891
- /** Whether a model picker's catalog is being read, so a second key cannot race it. */
1892
- let openingModels = false;
1893
- /**
1894
- * The route the next step would actually use.
1895
- *
1896
- * Without a choice of its own the surface reports the composition default,
1897
- * not that it has no opinion: the picker's heading and its marked row have to
1898
- * agree with the status line about the route in force.
1899
- */
1900
- const effectiveRoute = () => {
1901
- const chosen = modelSwitch.current();
1902
- if (chosen !== undefined)
1903
- return chosen;
1904
- const facts = statusFacts();
1905
- if (facts.provider === undefined || facts.model === undefined)
1906
- return undefined;
1907
- return {
1908
- provider: facts.provider,
1909
- model: facts.model,
1910
- ...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
1911
- };
1912
- };
1913
- /** Put the reader's route choice in force for the next step. */
1914
- const applyRoute = (route) => {
1915
- const current = effectiveRoute();
1916
- // The levels belong to the route, so a switch clears an explicit effort
1917
- // while re-picking the route already in force is not a switch.
1918
- const keep = current !== undefined && current.provider === route.provider && current.model === route.model
1919
- ? current.reasoningEffort
1920
- : undefined;
1921
- modelSwitch.choose({
1922
- provider: route.provider,
1923
- model: route.model,
1924
- ...keep === undefined ? {} : { reasoningEffort: keep },
1925
- });
1926
- model.notice(`model set to ${route.provider}/${route.model} for the next step`);
1927
- tui.requestRender();
1928
- };
1929
- /**
1930
- * Offer the levels a route advertises, after the route is already in force.
1931
- *
1932
- * Cancelling the list is a real choice — the reader keeps the model with the
1933
- * provider's own default — which is why the route is applied first. The list
1934
- * is read before the picker opens because the rows are the route's own
1935
- * metadata; a menu painted before that arrived could offer a level the
1936
- * request would then be refused for.
1937
- */
1938
- const offerRouteEfforts = async (route) => {
1939
- if (catalog === undefined)
1940
- return;
1941
- try {
1942
- const info = await catalog.efforts(route.provider, route.model);
1943
- const efforts = info?.efforts ?? [];
1944
- if (efforts.length === 0)
1945
- return;
1946
- const current = effectiveRoute();
1947
- const effective = current !== undefined && current.provider === route.provider && current.model === route.model
1948
- ? current.reasoningEffort
1949
- : undefined;
1950
- const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
1951
- if (picked !== undefined)
1952
- applyEffort(route.provider, route.model, picked);
1953
- }
1954
- catch (error) {
1955
- model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
1956
- }
1957
- tui.requestRender();
1958
- };
1959
- /**
1960
- * Offer every model the configured routes advertise.
1961
- *
1962
- * Rows come from the routes the llm service registered — the providers this
1963
- * deployment configured — and each provider's models join the open list as
1964
- * its catalog resolves, so the picker is filterable before the slowest
1965
- * adapter answers. A route whose catalog cannot be read stays reachable by
1966
- * name through the text form rather than by an explanation in the list.
1967
- */
1968
- const openModelPicker = async () => {
1969
- if (catalog === undefined) {
1970
- model.notice('this profile has no llm service, so models cannot be listed or switched');
1971
- tui.requestRender();
1972
- return;
1973
- }
1974
- const providers = catalog.providers();
1975
- if (providers.length === 0) {
1976
- model.notice('no provider is configured; add one before choosing a model');
1977
- tui.requestRender();
1978
- return;
1979
- }
1980
- if (openingModels)
1981
- return;
1982
- openingModels = true;
1983
- try {
1984
- const routes = [];
1985
- for (const provider of providers) {
1986
- void catalog.models(provider.id).then(entries => {
1987
- if (entries.length === 0)
1988
- return;
1989
- routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
1990
- tui.requestRender();
1991
- }).catch(() => {
1992
- // One adapter's discovery failure is not the list's to explain.
1993
- });
1994
- }
1995
- const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
1996
- if (picked === undefined)
1997
- return;
1998
- const route = readModelRouteKey(picked);
1999
- if (route === undefined)
2000
- return;
2001
- applyRoute(route);
2002
- await offerRouteEfforts(route);
2003
- }
2004
- finally {
2005
- openingModels = false;
2006
- tui.requestRender();
2007
- }
2008
- };
2009
- /**
2010
- * Offer the efforts the route in force advertises.
2011
- *
2012
- * The list is read before the picker opens because the rows are the route's
2013
- * own metadata; a menu painted before that arrived could offer a level the
2014
- * request would then be refused for.
2015
- */
2016
- const openEffortPicker = async () => {
2017
- if (catalog === undefined) {
2018
- model.notice('this profile has no llm service, so reasoning efforts cannot be read');
2019
- tui.requestRender();
2020
- return;
2021
- }
2022
- const facts = statusFacts();
2023
- if (facts.provider === undefined || facts.model === undefined) {
2024
- model.notice('no model route is in use; /model <provider>/<model> picks one first');
2025
- tui.requestRender();
2026
- return;
2027
- }
2028
- if (openingEfforts)
2029
- return;
2030
- openingEfforts = true;
2031
- try {
2032
- const info = await catalog.efforts(facts.provider, facts.model);
2033
- const efforts = info?.efforts ?? [];
2034
- if (efforts.length === 0) {
2035
- model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
2036
- return;
2037
- }
2038
- const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
2039
- if (picked !== undefined)
2040
- applyEffort(facts.provider, facts.model, picked);
2041
- }
2042
- catch (error) {
2043
- model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
2044
- }
2045
- finally {
2046
- openingEfforts = false;
2047
- tui.requestRender();
2048
- }
2049
- };
2050
- /**
2051
- * Show or choose the mode this session runs.
2052
- *
2053
- * The mode decides which tools, prompt sections, and skills exist at all, so
2054
- * it is fixed once a turn has run: the harness refuses the swap and this
2055
- * surface explains why rather than pretending otherwise. Before the first
2056
- * turn the switch is recorded in the log, which is what keeps the transcript
2057
- * honest about the composition later turns ran under.
2058
- */
2059
- const runPresetCommand = (argument) => {
2060
- if (agentPresets === undefined) {
2061
- model.notice('this profile has no agent roster, so there is no mode to choose');
2062
- tui.requestRender();
2063
- return;
2064
- }
2065
- if (agent === undefined) {
2066
- model.notice('the agent is still starting; try again in a moment');
2067
- tui.requestRender();
2068
- return;
2069
- }
2070
- const session = agent.agent.session;
2071
- const current = agentPresets.current(session);
2072
- const command = parsePresetArgument(argument);
2073
- if (command.kind === 'pick') {
2074
- if (agentPresets.started(session)) {
2075
- model.notice(`this session runs ${current === undefined ? 'a mode' : `"${current}"`} and has already started, so its mode is fixed — /new starts a fresh session`);
2076
- tui.requestRender();
2077
- return;
2078
- }
2079
- void askForPreset(current).then(picked => picked === undefined ? undefined : applyPreset(picked));
2080
- return;
2081
- }
2082
- void applyPreset(command.id);
2083
- };
2084
- /** Join this session to another mode and remember it for the next session. */
2085
- const applyPreset = async (id) => {
2086
- if (agentPresets === undefined || agent === undefined)
2087
- return;
2088
- try {
2089
- const chosen = await agentPresets.select(agent.agent, id);
2090
- seat = chosen;
2091
- // The durable selection is folded as a marker on the session it belongs
2092
- // to, so a reader watching a child has to come back to see it.
2093
- if (viewedSession !== activeSession)
2094
- await showSession(activeSession);
2095
- }
2096
- catch (error) {
2097
- model.notice(`could not switch the mode: ${error instanceof Error ? error.message : String(error)}`);
2098
- }
2099
- tui.requestRender();
2100
- };
2101
- const helpText = () => {
2102
- const current = agent?.agent;
2103
- const registered = current === undefined || registry() === undefined
2104
- ? []
2105
- : registry()?.list(current).map(command => `/${command.name}`) ?? [];
2106
- const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
2107
- return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
2108
- };
2109
- /**
2110
- * Whether the agent this surface is driving is in plan mode.
2111
- *
2112
- * The dock's fold is the fallback, for a composition without the plan
2113
- * package: with the controller present its answer is the agent's own state
2114
- * rather than a replay of the events this surface happened to see.
2115
- */
2116
- const planState = () => {
2117
- const current = agent?.agent;
2118
- if (current === undefined)
2119
- return undefined;
2120
- const presets = ctx.get('agentPresets');
2121
- return readPlanState({
2122
- direct: name => ctx.get(name),
2123
- forAgent: (target, name) => presets?.serviceFor(target, name),
2124
- }, current);
2125
- };
2126
- const planActive = () => planSelectedActive(planState(), work.state().planMode);
2127
- const runCommand = (name, line) => {
2128
- const current = agent;
2129
- const commands = registry();
2130
- if (current === undefined) {
2131
- model.notice('the agent is still starting; try again in a moment');
2132
- tui.requestRender();
2133
- return;
2134
- }
2135
- if (commands === undefined || commands.find(current.agent, name) === undefined) {
2136
- model.notice(`unknown command: /${name} — ${helpText()}`);
2137
- tui.requestRender();
2138
- return;
2139
- }
2140
- const controller = new AbortController();
2141
- void commands.execute(current.agent, line, [], controller.signal).then(execution => {
2142
- const result = execution?.result;
2143
- if (result === undefined)
2144
- return;
2145
- model.notice(result.kind === 'error' ? `/${name} failed: ${result.text ?? 'no detail'}` : `/${name} ${result.text ?? 'done'}`);
2146
- tui.requestRender();
2147
- }).catch((error) => {
2148
- model.notice(`/${name} failed: ${error instanceof Error ? error.message : String(error)}`);
2149
- tui.requestRender();
2150
- });
2151
- };
2152
- /**
2153
- * Put the deployment default in force before the first turn.
2154
- *
2155
- * The agent is created before the settings file has been read, so the route
2156
- * its loop captured is the composition placeholder; the reader's default —
2157
- * effort included — only exists by the time they can type. Adopting it here
2158
- * is what makes the status line's route the one the request actually uses.
2159
- */
2160
- const adoptDefaultRoute = () => {
2161
- if (modelSwitch.current() !== undefined)
2162
- return;
2163
- const facts = statusFacts();
2164
- if (facts.provider === undefined || facts.model === undefined)
2165
- return;
2166
- modelSwitch.adopt({
2167
- provider: facts.provider,
2168
- model: facts.model,
2169
- ...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
2170
- });
2171
- };
2172
- /** Show where history is kept, or forget it; the file is global to this machine. */
2173
- const runHistoryCommand = (argument) => {
2174
- // The line that asked for this is recorded before the command runs, but that
2175
- // write rides the store's queue; waiting for it lets the count describe the
2176
- // file the reader has, not the state before their own line landed.
2177
- void promptHistory.flush().then(() => {
2178
- if (argument === '') {
2179
- const count = promptHistory.entries().length;
2180
- const blocked = promptHistory.blockedReason();
2181
- model.notice([
2182
- count + (count === 1 ? ' prompt recorded' : ' prompts recorded'),
2183
- promptHistory.path(),
2184
- blocked === undefined ? undefined : 'writes disabled: ' + blocked,
2185
- ].filter(part => part !== undefined).join(' · '));
2186
- tui.requestRender();
2187
- return;
2188
- }
2189
- if (argument !== 'clear') {
2190
- model.notice('usage: /history shows where history is kept · /history clear forgets every prompt');
2191
- tui.requestRender();
2192
- return;
2193
- }
2194
- // A refused write cannot remove anything, so saying "forgot 0 prompts"
2195
- // would describe a successful clear the file never had.
2196
- const blocked = promptHistory.blockedReason();
2197
- if (blocked !== undefined) {
2198
- model.notice('prompt history is unavailable: ' + blocked);
2199
- tui.requestRender();
2200
- return;
2201
- }
2202
- return promptHistory.clear().then(removed => {
2203
- model.notice('forgot ' + removed + (removed === 1 ? ' prompt' : ' prompts'));
2204
- tui.requestRender();
2205
- });
2206
- }).catch(error => {
2207
- // The store keeps what the file still holds, so the reader is told the
2208
- // clear failed rather than being shown a count that never landed.
2209
- model.notice('could not clear prompt history: ' + (error instanceof Error ? error.message : String(error)));
2210
- tui.requestRender();
2211
- });
2212
- };
2213
- /**
2214
- * Carry out one classified line, wherever it was asked for.
2215
- *
2216
- * A chord asks for the same things the command line does, so both arrive
2217
- * here: a chord cannot behave differently from the command it stands for.
2218
- */
2219
- const runSubmission = (submission) => {
2220
- switch (submission.kind) {
2221
- case 'empty':
2222
- return;
2223
- case 'quit':
2224
- requestExit(0);
2225
- return;
2226
- case 'model':
2227
- runModelCommand(submission.argument);
2228
- return;
2229
- case 'preset':
2230
- runPresetCommand(submission.argument);
2231
- return;
2232
- case 'jobs':
2233
- runJobsCommand(submission.argument);
2234
- return;
2235
- case 'rename':
2236
- runRenameCommand(submission.title);
2237
- return;
2238
- case 'export':
2239
- runExportCommand(submission.path);
2240
- return;
2241
- case 'subagents':
2242
- runSubagentsCommand(submission.argument);
2243
- return;
2244
- case 'fork':
2245
- runForkCommand(submission.title);
2246
- return;
2247
- case 'new':
2248
- runNewCommand(submission.title);
2249
- return;
2250
- case 'reload':
2251
- runReloadCommand();
2252
- return;
2253
- case 'todo':
2254
- runTodoCommand();
2255
- return;
2256
- case 'theme': {
2257
- const argument = submission.argument;
2258
- // A bare command is the list: a theme is judged by looking at it, so
2259
- // choosing one belongs in a list the screen follows rather than in a name
2260
- // the reader has to already know.
2261
- if (argument === '') {
2262
- void openThemePicker();
2263
- return;
2264
- }
2265
- const [head = '', ...rest] = argument.split(/\s+/u);
2266
- // The table answers the other question a theme raises — which layer drew a
2267
- // shade — and stays reachable by name now that the list has the command.
2268
- if (head === 'tokens') {
2269
- for (const line of renderThemeTable(toOverrides(readSection(), themeLibrary), themeLibrary))
2270
- model.notice(line);
2271
- tui.requestRender();
2272
- return;
2273
- }
2274
- // The one way a built-in becomes editable. Its file ships inside the
2275
- // package and the next version replaces it, so a reader who wants to
2276
- // change one needs a copy that is theirs — and the copy is a theme the
2277
- // moment it lands, which is why the table is re-read rather than patched.
2278
- if (head === 'export') {
2279
- const chosen = rest.join(' ').trim();
2280
- if (chosen === '') {
2281
- model.notice(`theme export · which built-in? ${builtinNames(themeLibrary).join(' · ')}`);
2282
- tui.requestRender();
2283
- return;
2284
- }
2285
- const outcome = exportTheme(themeLibrary, chosen);
2286
- if (outcome.ok) {
2287
- themeLibrary = loadThemes(themesHome, builtinThemesDir());
2288
- model.notice(`theme · exported ${chosen} to ${outcome.path} · /theme ${outcome.select} applies it`);
2289
- }
2290
- else {
2291
- model.notice(outcome.problem);
2292
- }
2293
- tui.requestRender();
2294
- return;
2295
- }
2296
- // A name nothing answers to is refused by name, like an unknown key
2297
- // layer: the reader asked for something, so the answer lists names.
2298
- if (themeLibrary.get(head) === undefined) {
2299
- model.notice(`unknown theme "${head}" · themes: ${themeLibrary.names().join(' · ')}`);
2300
- tui.requestRender();
2301
- return;
2302
- }
2303
- chooseTheme(head);
2304
- return;
2305
- }
2306
- case 'keys': {
2307
- const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
2308
- // A layer that does not exist is not a filter that matches nothing: the
2309
- // reader asked for something by name, so the answer names the names.
2310
- if (submission.argument !== '' && layer === undefined) {
2311
- model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
2312
- tui.requestRender();
2313
- return;
2314
- }
2315
- openKeyMap(layer);
2316
- return;
2317
- }
2318
- case 'copy':
2319
- runCopyCommand();
2320
- return;
2321
- case 'history':
2322
- runHistoryCommand(submission.argument);
2323
- return;
2324
- case 'stash':
2325
- // Submitting a command consumes the line it was typed on, so this path
2326
- // can only carry a draft it was given; parking the bar's own draft is
2327
- // what the chord is for.
2328
- if (submission.argument.trim() === '')
2329
- model.notice('usage: /stash <draft>, or ctrl+x then s to park the editor');
2330
- else
2331
- void stash?.stashEditor(submission.argument);
2332
- return;
2333
- case 'stash-draft':
2334
- void stash?.stashEditor();
2335
- return;
2336
- case 'stash-pop':
2337
- void stash?.pop(submission.selector);
2338
- return;
2339
- case 'stash-apply':
2340
- void stash?.apply(submission.selector);
2341
- return;
2342
- case 'stash-list':
2343
- void stash?.list(String(activeSession));
2344
- return;
2345
- case 'stash-drop':
2346
- void stash?.drop(submission.selector);
2347
- return;
2348
- case 'stash-clear':
2349
- void stash?.clear();
2350
- return;
2351
- case 'editor':
2352
- void externalEditor.edit(editor.getExpandedText()).then(text => {
2353
- if (text !== undefined) {
2354
- // A gate can open while the child owns the screen, and the bar then
2355
- // holds somebody's answer: the edited draft waits behind it instead
2356
- // of being written into a question the reader never answered.
2357
- if (promptBar.isBorrowed())
2358
- promptBar.replaceHeld(text);
2359
- else
2360
- editor.setText(text);
2361
- tui.requestRender();
2362
- }
2363
- const pendingExit = deferredExit;
2364
- deferredExit = undefined;
2365
- if (pendingExit !== undefined)
2366
- requestExit(pendingExit.code, pendingExit.reason);
2367
- });
2368
- return;
2369
- case 'status': {
2370
- const facts = statusFacts();
2371
- const context = facts.contextTokens === undefined
2372
- ? undefined
2373
- : `context ${formatTokens(facts.contextTokens)}${facts.contextWindow === undefined ? '' : `/${formatTokens(facts.contextWindow)}`}`;
2374
- model.notice([
2375
- `session ${activeSession}`,
2376
- viewedSession === activeSession ? undefined : `viewing ${viewedSession}`,
2377
- facts.model === undefined
2378
- ? undefined
2379
- : `model ${facts.provider === undefined ? '' : `${facts.provider}/`}${facts.model}${facts.effort === undefined ? '' : ` (${facts.effort})`}`,
2380
- facts.agentPreset === undefined ? undefined : `mode ${facts.agentPreset}`,
2381
- facts.preset === undefined ? undefined : `permissions ${facts.preset}`,
2382
- context,
2383
- facts.uncachedInputTokens === undefined && facts.outputTokens === undefined
2384
- ? undefined
2385
- : `tokens in ${formatTokens(facts.uncachedInputTokens ?? 0)} out ${formatTokens(facts.outputTokens ?? 0)}`,
2386
- `cwd ${facts.cwd}`,
2387
- ].filter(part => part !== undefined).join(' · '));
2388
- tui.requestRender();
2389
- return;
2390
- }
2391
- case 'clear':
2392
- model.reset();
2393
- work.reset();
2394
- tui.requestRender();
2395
- return;
2396
- case 'undo':
2397
- runUndoCommand();
2398
- return;
2399
- case 'redo':
2400
- runRedoCommand();
2401
- return;
2402
- case 'help':
2403
- model.notice(helpText());
2404
- tui.requestRender();
2405
- return;
2406
- case 'resume':
2407
- void chooseSession().then(picked => picked === undefined ? undefined : switchSession(picked)).catch((error) => {
2408
- model.notice(`could not resume: ${error instanceof Error ? error.message : String(error)}`);
2409
- tui.requestRender();
2410
- });
2411
- return;
2412
- // Plan mode is a command the harness owns, so the chord asks the host for
2413
- // the state and names the command for the other one: one key, both ways.
2414
- case 'plan':
2415
- runCommand('plan', planToggleLine(planActive()));
2416
- return;
2417
- case 'command':
2418
- runCommand(submission.name, submission.line);
2419
- return;
2420
- case 'prompt':
2421
- if (agent === undefined) {
2422
- model.notice('the agent is still starting; try again in a moment');
2423
- tui.requestRender();
2424
- return;
2425
- }
2426
- // While a turn is running the human is steering it, not opening another.
2427
- if (turnOpen)
2428
- agent.steer(submission.text);
2429
- else {
2430
- adoptDefaultRoute();
2431
- void commitStagedSend(submission.text);
2432
- }
2433
- }
2434
- };
2435
- editor.onSubmit = text => {
2436
- const submission = classifySubmission(text);
2437
- if (submission.kind === 'empty')
2438
- return;
2439
- // The library's own history feeds the up/down keys; the store below feeds
2440
- // ghost completion and reverse search, and is global rather than per-session.
2441
- editor.addToHistory(text);
2442
- if (historyEnabled)
2443
- promptHistory.record(text);
2444
- runSubmission(submission);
2445
- };
436
+ promptInput.attachSubmit();
2446
437
  disposers.push(ctx.on('session/event', (session, event) => {
2447
- // The surface state — activity, timer, title, bell, job board — belongs to
2448
- // the agent this terminal drives, even while a child is on screen.
2449
- if (session.id === activeSession) {
2450
- if (event.type === 'turn/start') {
2451
- turnOpen = true;
2452
- turnStartedAt = Date.now();
2453
- writeTerminal(windowTitle(process.cwd(), 'working'));
2454
- }
2455
- if (event.type === 'turn/end') {
2456
- const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
2457
- turnOpen = false;
2458
- turnStartedAt = undefined;
2459
- // An undo may be parked waiting for exactly this boundary.
2460
- turnSettled?.();
2461
- writeTerminal(windowTitle(process.cwd(), 'ready'));
2462
- if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
2463
- writeTerminal(BELL);
2464
- // A job the turn started may have settled while the reader was watching
2465
- // something else, and nothing else refreshes a live board.
2466
- refreshJobs();
2467
- }
2468
- // A claim or a discard changes what is queued, and that belongs to this
2469
- // session even while the transcript shows a child's conversation.
2470
- if (event.type === 'agent/inbox/spliced')
2471
- tui.requestRender();
2472
- }
2473
- if (session.id !== viewedSession)
2474
- return;
2475
- presentScope = ctx.agents?.get(session.id);
2476
- applyDurable(session.id, event);
2477
- tui.requestRender();
2478
- }));
2479
- /**
2480
- * Subagent lifecycle arrives as a service event rather than a session event,
2481
- * so it is decoration in the transcript: the durable record of a delegation
2482
- * is the tool call that asked for it. The name is cast so a rename in the
2483
- * harness cannot break compilation of this surface.
2484
- */
2485
- const listenFor = (name, handler) => ctx.on(name, handler);
2486
- // Herdr hears the driver rather than each turn: a run that chains turns
2487
- // through a pending inbox is one stretch of work, and a turn boundary inside
2488
- // it would read as done between two turns of an agent that is still working.
2489
- // The turn listeners above keep the title and the bell, which are about the
2490
- // reader's own conversation.
2491
- disposers.push(listenFor('agent/status', payload => {
2492
- // Read against the driven session at delivery time: the reader can switch
2493
- // sessions between two transitions, and the row must follow the one this
2494
- // terminal now drives.
2495
- const status = driverReportFor(payload, activeSession);
2496
- if (status === undefined)
2497
- return;
2498
- herdr.driver(status);
2499
- }));
2500
- disposers.push(listenFor('subagent/start', info => {
2501
- roster.start(info);
2502
- const record = asRecord(info);
2503
- const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
2504
- const id = typeof record?.id === 'string' ? record.id : '';
2505
- model.marker(`subagent ${provider} started${id === '' ? '' : ` · ${id}`}`);
2506
- tui.requestRender();
2507
- }));
2508
- disposers.push(listenFor('subagent/end', info => {
2509
- roster.end(info);
2510
- const record = asRecord(info);
2511
- const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
2512
- const stop = typeof record?.stopReason === 'string' ? record.stopReason : undefined;
2513
- model.marker(`subagent ${provider} finished${stop === undefined ? '' : ` · ${stop}`}`);
2514
- tui.requestRender();
438
+ sessionLifecycle.observe(session, event);
439
+ sessionView.observe(session.id, event);
2515
440
  }));
2516
- // Answering these two waterfalls is what makes a terminal surface usable at
2517
- // all: without an answerer every gated tool fails closed, and the model's
2518
- // questions never reach the human.
2519
- disposers.push(ctx.on('approval/request', (request, next) => {
2520
- if (request.agent.id !== activeSession)
2521
- return next();
2522
- return new Promise(resolve => {
2523
- const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
2524
- request.signal?.addEventListener('abort', () => {
2525
- gate.cancel();
2526
- if (pending?.gate === gate)
2527
- closeGate();
2528
- resolve('cancelled');
2529
- }, { once: true });
2530
- openGate({ kind: 'approval', gate, settle: resolve });
2531
- });
2532
- }));
2533
- disposers.push(ctx.on('user-questions/request', (request, next) => {
2534
- const agentId = request.agent?.id;
2535
- if (agentId !== undefined && agentId !== activeSession)
2536
- return next();
2537
- const questions = toGateQuestions(request);
2538
- if (questions.length === 0)
2539
- return next();
2540
- return new Promise(resolve => {
2541
- const gate = promptBar.borrow(() => {
2542
- // The bar is an answer's for as long as the gate holds it, so the menu
2543
- // it once offered commands through is replaced before a key can reach it.
2544
- const built = new QuestionGate(questions, editor, () => keymap);
2545
- applyCompletion();
2546
- return built;
2547
- });
2548
- // The seam takes mutable selection arrays and an optional custom field, so
2549
- // the read-only gate answer is copied into that exact shape here.
2550
- const settle = (answers) => {
2551
- request.signal?.removeEventListener('abort', onAbort);
2552
- resolve({
2553
- answers: answers.map(answer => ({
2554
- id: answer.id,
2555
- selected: [...answer.selected],
2556
- ...(answer.custom === undefined ? {} : { custom: answer.custom }),
2557
- })),
2558
- });
2559
- };
2560
- // A question whose caller is gone has no reader, so the gate must release
2561
- // the keyboard instead of collecting an answer the aborted call discards.
2562
- const onAbort = () => {
2563
- gate.cancel();
2564
- if (pending?.gate === gate)
2565
- closeGate();
2566
- settle([]);
2567
- };
2568
- openGate({ kind: 'question', gate, settle });
2569
- request.signal?.addEventListener('abort', onAbort, { once: true });
2570
- // A signal that aborted before the listener existed never emits, so the
2571
- // state has to be read once after subscribing.
2572
- if (request.signal?.aborted === true)
2573
- onAbort();
2574
- });
2575
- }));
2576
- disposers.push(ctx.on('commands/change', () => installCompletion()));
441
+ disposers.push(...sessionLifecycle.driverListeners());
442
+ disposers.push(...backgroundWork.subagentListeners());
443
+ disposers.push(...modalInput.requestListeners());
444
+ disposers.push(...promptInput.commandListeners());
2577
445
  // The board is live state: watch it directly rather than folding events.
2578
- disposers.push(jobDirectory?.watch(owner => {
2579
- if (owner !== undefined && owner.id !== activeSession)
2580
- return;
2581
- refreshJobs();
2582
- }) ?? (() => { }));
2583
- // The transcript belongs to the session on screen, while status, the bell,
2584
- // and the queue stay with the agent this terminal drives. A live delta or a
2585
- // failure folded into the wrong model would print one session's words as
2586
- // another's, and the durable copy that follows would never correct it.
2587
- disposers.push(ctx.on('agent/error', payload => {
2588
- if (payload.agent.id !== viewedSession)
2589
- return;
2590
- model.reportError(payload.error);
2591
- tui.requestRender();
2592
- }));
2593
- disposers.push(ctx.on('agent/assistant-stream', payload => {
2594
- if (payload.agent.id !== viewedSession)
2595
- return;
2596
- if (payload.frame.type !== 'chunk')
2597
- return;
2598
- model.applyStreamChunk(payload.frame.chunk);
2599
- tui.requestRender();
2600
- }));
2601
- /**
2602
- * Restyle a running session when the reader's section changes.
2603
- *
2604
- * The settings document is hot-reloaded by the host, so a reader watching a
2605
- * shade land never has to leave the session to see it — which is what makes
2606
- * tuning one bearable instead of a restart per attempt. The event is
2607
- * namespace-filtered: another surface's preferences are not our repaint.
2608
- */
2609
- disposers.push(ctx.on('settings/updated', ns => {
2610
- if (String(ns) !== TUI_SETTINGS_NAMESPACE)
2611
- return;
2612
- applySettings();
2613
- // Both caches hold rows under the old table, so they have to be told the
2614
- // table moved; a repaint alone would reuse what they already stored.
2615
- markdown.invalidate();
2616
- view.invalidate();
2617
- tui.requestRender();
2618
- }));
2619
- /**
2620
- * The reader's own themes: created, reported, and then watched.
2621
- *
2622
- * Created because a directory that is not there is a command that cannot work —
2623
- * `/theme export` names a path the reader should find where the surface said it
2624
- * would be. Watched because a theme is a file they are editing by hand, so
2625
- * saving one is how they ask for it; a session that needed a restart per shade
2626
- * would make the whole table useless for tuning. Everything unreadable is
2627
- * reported through the deferred notice rather than stderr, which the alternate
2628
- * screen is drawn over.
2629
- */
2630
- let reportedThemes = new Set();
2631
- const reportThemes = () => {
2632
- // Only what is newly wrong: the watcher re-reads the whole directory on every
2633
- // save, so an unfixed file would otherwise repeat its complaint on each one,
2634
- // and a reader who has just been told is not helped by being told again.
2635
- const problems = themeLibrary.problems();
2636
- for (const problem of problems) {
2637
- if (!reportedThemes.has(problem))
2638
- settingsNotice.post(problem);
2639
- }
2640
- reportedThemes = new Set(problems);
2641
- };
2642
- for (const problem of ensureThemesHome(themesHome))
2643
- settingsNotice.post(problem);
2644
- reportThemes();
2645
- disposers.push(watchThemes(themesHome, () => {
2646
- themeLibrary = loadThemes(themesHome, builtinThemesDir());
2647
- reportThemes();
2648
- // The file that was just saved may be the theme already in force, so the
2649
- // table is rebuilt rather than only repainted.
2650
- applySettings();
2651
- markdown.invalidate();
2652
- view.invalidate();
2653
- tui.requestRender();
2654
- }));
2655
- const degraded = describeMissingOptional(probe);
2656
- if (degraded !== undefined)
2657
- model.notice(degraded);
2658
- /**
2659
- * Open the session this run was launched for.
2660
- *
2661
- * A bare `--resume` asks a question only the reader can answer, so the
2662
- * picker runs before anything is created: the agent is then opened on the
2663
- * chosen session rather than swapped afterwards, which would leave the first
2664
- * one's turn half-started.
2665
- */
2666
- const boot = async () => {
2667
- if (!resolved.resumePicker) {
2668
- await openAgent(resolved.sessionId, resolved.resume);
2669
- return;
2670
- }
2671
- const picked = await chooseSession();
2672
- if (picked === undefined) {
2673
- requestExit(0);
2674
- return;
2675
- }
2676
- await openAgent(picked, true);
2677
- };
2678
- /**
2679
- * Take the screen, then open the session this run was launched for.
2680
- *
2681
- * A mode named on the command line is resolved FIRST: the alt screen swallows
2682
- * the launcher's own error output, so a mode this roster does not offer has to
2683
- * be answered while the shell still owns the terminal.
2684
- */
2685
- const start = async () => {
2686
- if (requestedPreset !== undefined && agentPresets !== undefined) {
2687
- seat = (await agentPresets.resolve(requestedPreset)).id;
2688
- }
2689
- // A named mode that disagrees with the one this session recorded is refused
2690
- // here as well as at the open, because the alternate screen closes over
2691
- // whatever was painted on it: the reader would see the failure, not the
2692
- // reason. With a picker the session is not known yet, so it waits for one.
2693
- if (!resolved.resumePicker)
2694
- await presetFor(resolved.sessionId, resolved.resume, undefined);
2695
- tui.start();
2696
- writeTerminal(windowTitle(process.cwd(), 'ready'));
2697
- // Claiming the pane's agent row does not wait for a session: the pane is
2698
- // already on screen and already idle, and a session may still be chosen.
2699
- herdr.publish();
2700
- // A refused settings edit is only visible now that the surface owns the
2701
- // screen; whatever the scope found before this point prints here instead.
2702
- settingsNotice.open(message => model.notice(message));
2703
- await boot();
2704
- };
2705
- void start().catch((error) => {
446
+ disposers.push(backgroundWork.watchJobs());
447
+ disposers.push(...sessionView.agentListeners());
448
+ disposers.push(appearance.settingsListener());
449
+ appearance.createThemesHome();
450
+ disposers.push(appearance.watchThemes());
451
+ void commands.start().catch((error) => {
2706
452
  requestExit(1, error instanceof Error ? error.message : String(error));
2707
453
  });
2708
454
  }