@agi-fans/dsh-tui 0.20.0 → 0.22.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 (319) hide show
  1. package/lib/CHANGELOG.md +249 -3
  2. package/lib/chrome/box.d.ts +47 -16
  3. package/lib/chrome/box.js +172 -130
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/code-highlight.d.ts +15 -0
  6. package/lib/chrome/code-highlight.js +60 -4
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/command-output.d.ts +29 -2
  9. package/lib/chrome/command-output.js +100 -4
  10. package/lib/chrome/command-output.js.map +1 -1
  11. package/lib/chrome/main-screen-renderer.d.ts +18 -0
  12. package/lib/chrome/main-screen-renderer.js +58 -5
  13. package/lib/chrome/main-screen-renderer.js.map +1 -1
  14. package/lib/chrome/markdown.js +30 -10
  15. package/lib/chrome/markdown.js.map +1 -1
  16. package/lib/chrome/relative-time.d.ts +17 -0
  17. package/lib/chrome/relative-time.js +36 -0
  18. package/lib/chrome/relative-time.js.map +1 -1
  19. package/lib/chrome/renderer.d.ts +9 -0
  20. package/lib/chrome/renderer.js +1 -1
  21. package/lib/chrome/renderer.js.map +1 -1
  22. package/lib/chrome/secret-mask.d.ts +35 -0
  23. package/lib/chrome/secret-mask.js +180 -0
  24. package/lib/chrome/secret-mask.js.map +1 -0
  25. package/lib/chrome/status-config.d.ts +161 -51
  26. package/lib/chrome/status-config.js +237 -126
  27. package/lib/chrome/status-config.js.map +1 -1
  28. package/lib/chrome/status-line.d.ts +3 -2
  29. package/lib/chrome/status-line.js +105 -63
  30. package/lib/chrome/status-line.js.map +1 -1
  31. package/lib/chrome/terminal-colors.d.ts +85 -0
  32. package/lib/chrome/terminal-colors.js +230 -0
  33. package/lib/chrome/terminal-colors.js.map +1 -0
  34. package/lib/chrome/terminal-images.d.ts +9 -1
  35. package/lib/chrome/terminal-images.js +27 -4
  36. package/lib/chrome/terminal-images.js.map +1 -1
  37. package/lib/chrome/theme.d.ts +32 -9
  38. package/lib/chrome/theme.js +616 -520
  39. package/lib/chrome/theme.js.map +1 -1
  40. package/lib/chrome/tool-args.d.ts +1 -1
  41. package/lib/chrome/tool-args.js +3 -1
  42. package/lib/chrome/tool-args.js.map +1 -1
  43. package/lib/chrome/tool-renderers.d.ts +2 -0
  44. package/lib/chrome/tool-renderers.js +69 -3
  45. package/lib/chrome/tool-renderers.js.map +1 -1
  46. package/lib/chrome/tools-list.d.ts +1 -4
  47. package/lib/chrome/tools-list.js +1 -41
  48. package/lib/chrome/tools-list.js.map +1 -1
  49. package/lib/chrome/welcome-tips.d.ts +20 -7
  50. package/lib/chrome/welcome-tips.js +22 -41
  51. package/lib/chrome/welcome-tips.js.map +1 -1
  52. package/lib/commands/auth-ids.d.ts +25 -0
  53. package/lib/commands/auth-ids.js +27 -0
  54. package/lib/commands/auth-ids.js.map +1 -0
  55. package/lib/commands/auth-input.d.ts +34 -0
  56. package/lib/commands/auth-input.js +96 -0
  57. package/lib/commands/auth-input.js.map +1 -0
  58. package/lib/commands/auth-providers.d.ts +39 -0
  59. package/lib/commands/auth-providers.js +125 -0
  60. package/lib/commands/auth-providers.js.map +1 -0
  61. package/lib/commands/auth.d.ts +16 -22
  62. package/lib/commands/auth.js +346 -299
  63. package/lib/commands/auth.js.map +1 -1
  64. package/lib/commands/btw.d.ts +27 -0
  65. package/lib/commands/btw.js +59 -0
  66. package/lib/commands/btw.js.map +1 -0
  67. package/lib/commands/diff.js +30 -5
  68. package/lib/commands/diff.js.map +1 -1
  69. package/lib/commands/export.d.ts +1 -1
  70. package/lib/commands/export.js +5 -3
  71. package/lib/commands/export.js.map +1 -1
  72. package/lib/commands/files.d.ts +17 -1
  73. package/lib/commands/files.js +69 -61
  74. package/lib/commands/files.js.map +1 -1
  75. package/lib/commands/integrations.js +18 -6
  76. package/lib/commands/integrations.js.map +1 -1
  77. package/lib/commands/jobs.d.ts +19 -4
  78. package/lib/commands/jobs.js +50 -7
  79. package/lib/commands/jobs.js.map +1 -1
  80. package/lib/commands/model.js +109 -76
  81. package/lib/commands/model.js.map +1 -1
  82. package/lib/commands/permission.js +46 -13
  83. package/lib/commands/permission.js.map +1 -1
  84. package/lib/commands/plugins.js +196 -42
  85. package/lib/commands/plugins.js.map +1 -1
  86. package/lib/commands/schedule.d.ts +29 -0
  87. package/lib/commands/schedule.js +228 -0
  88. package/lib/commands/schedule.js.map +1 -0
  89. package/lib/commands/session-configuration.d.ts +1 -1
  90. package/lib/commands/session-configuration.js +29 -28
  91. package/lib/commands/session-configuration.js.map +1 -1
  92. package/lib/commands/session.js +75 -80
  93. package/lib/commands/session.js.map +1 -1
  94. package/lib/commands/terminal.d.ts +24 -4
  95. package/lib/commands/terminal.js +38 -94
  96. package/lib/commands/terminal.js.map +1 -1
  97. package/lib/commands/trajectory.js +4 -1
  98. package/lib/commands/trajectory.js.map +1 -1
  99. package/lib/definition.d.ts +150 -11
  100. package/lib/index.d.ts +1 -0
  101. package/lib/index.js +1 -0
  102. package/lib/index.js.map +1 -1
  103. package/lib/input/actions.d.ts +51 -0
  104. package/lib/input/actions.js +120 -0
  105. package/lib/input/actions.js.map +1 -0
  106. package/lib/input/editor-discovery.js +2 -2
  107. package/lib/input/editor-discovery.js.map +1 -1
  108. package/lib/input/editor.d.ts +0 -10
  109. package/lib/input/editor.js +11 -9
  110. package/lib/input/editor.js.map +1 -1
  111. package/lib/input/image-paste.d.ts +19 -1
  112. package/lib/input/image-paste.js +82 -1
  113. package/lib/input/image-paste.js.map +1 -1
  114. package/lib/input/keybindings-config.d.ts +15 -4
  115. package/lib/input/keybindings-config.js +57 -35
  116. package/lib/input/keybindings-config.js.map +1 -1
  117. package/lib/input/keys.d.ts +18 -0
  118. package/lib/input/keys.js +48 -3
  119. package/lib/input/keys.js.map +1 -1
  120. package/lib/input/wheel-scroll.d.ts +46 -0
  121. package/lib/input/wheel-scroll.js +69 -0
  122. package/lib/input/wheel-scroll.js.map +1 -0
  123. package/lib/prompt-intents.d.ts +182 -0
  124. package/lib/prompt-intents.js +127 -0
  125. package/lib/prompt-intents.js.map +1 -0
  126. package/lib/runtime/agent-profile.d.ts +5 -0
  127. package/lib/runtime/agent-profile.js +12 -0
  128. package/lib/runtime/agent-profile.js.map +1 -1
  129. package/lib/runtime/approval-rules.d.ts +59 -0
  130. package/lib/runtime/approval-rules.js +179 -0
  131. package/lib/runtime/approval-rules.js.map +1 -0
  132. package/lib/runtime/btw-policy.d.ts +7 -0
  133. package/lib/runtime/btw-policy.js +18 -0
  134. package/lib/runtime/btw-policy.js.map +1 -0
  135. package/lib/runtime/btw.d.ts +47 -0
  136. package/lib/runtime/btw.js +200 -0
  137. package/lib/runtime/btw.js.map +1 -0
  138. package/lib/runtime/cancellable.d.ts +75 -0
  139. package/lib/runtime/cancellable.js +76 -0
  140. package/lib/runtime/cancellable.js.map +1 -0
  141. package/lib/runtime/composer-images.js +5 -4
  142. package/lib/runtime/composer-images.js.map +1 -1
  143. package/lib/runtime/composer-shell.d.ts +30 -0
  144. package/lib/runtime/composer-shell.js +150 -0
  145. package/lib/runtime/composer-shell.js.map +1 -0
  146. package/lib/runtime/file-recovery-local.d.ts +73 -0
  147. package/lib/runtime/file-recovery-local.js +351 -0
  148. package/lib/runtime/file-recovery-local.js.map +1 -0
  149. package/lib/runtime/file-recovery-recording.d.ts +23 -0
  150. package/lib/runtime/file-recovery-recording.js +131 -0
  151. package/lib/runtime/file-recovery-recording.js.map +1 -0
  152. package/lib/runtime/file-recovery-review.d.ts +3 -0
  153. package/lib/runtime/file-recovery-review.js +130 -0
  154. package/lib/runtime/file-recovery-review.js.map +1 -0
  155. package/lib/runtime/file-recovery-store.d.ts +83 -0
  156. package/lib/runtime/file-recovery-store.js +329 -0
  157. package/lib/runtime/file-recovery-store.js.map +1 -0
  158. package/lib/runtime/file-recovery-worker.d.ts +22 -0
  159. package/lib/runtime/file-recovery-worker.js +153 -0
  160. package/lib/runtime/file-recovery-worker.js.map +1 -0
  161. package/lib/runtime/file-review.d.ts +6 -3
  162. package/lib/runtime/file-review.js +44 -7
  163. package/lib/runtime/file-review.js.map +1 -1
  164. package/lib/runtime/herdr-agent.d.ts +4 -13
  165. package/lib/runtime/herdr-agent.js +19 -63
  166. package/lib/runtime/herdr-agent.js.map +1 -1
  167. package/lib/runtime/plain-tui.d.ts +2 -0
  168. package/lib/runtime/plain-tui.js +7 -1
  169. package/lib/runtime/plain-tui.js.map +1 -1
  170. package/lib/runtime/plugin-settings.d.ts +25 -2
  171. package/lib/runtime/plugin-settings.js +329 -31
  172. package/lib/runtime/plugin-settings.js.map +1 -1
  173. package/lib/runtime/provider-local.d.ts +81 -24
  174. package/lib/runtime/provider-local.js +1807 -608
  175. package/lib/runtime/provider-local.js.map +1 -1
  176. package/lib/runtime/runner.js +52 -12
  177. package/lib/runtime/runner.js.map +1 -1
  178. package/lib/runtime/session-archive.d.ts +6 -0
  179. package/lib/runtime/session-archive.js +129 -3
  180. package/lib/runtime/session-archive.js.map +1 -1
  181. package/lib/runtime/session-runtime.js +1 -1
  182. package/lib/runtime/session-runtime.js.map +1 -1
  183. package/lib/runtime/settings-preferences.d.ts +5 -0
  184. package/lib/runtime/settings-preferences.js +34 -0
  185. package/lib/runtime/settings-preferences.js.map +1 -0
  186. package/lib/runtime/startup-notices.js +2 -0
  187. package/lib/runtime/startup-notices.js.map +1 -1
  188. package/lib/runtime/terminal-modes.d.ts +47 -0
  189. package/lib/runtime/terminal-modes.js +86 -0
  190. package/lib/runtime/terminal-modes.js.map +1 -0
  191. package/lib/runtime/terminal-notifications.d.ts +3 -5
  192. package/lib/runtime/terminal-notifications.js +4 -8
  193. package/lib/runtime/terminal-notifications.js.map +1 -1
  194. package/lib/runtime/terminal-probe.d.ts +36 -0
  195. package/lib/runtime/terminal-probe.js +37 -0
  196. package/lib/runtime/terminal-probe.js.map +1 -0
  197. package/lib/runtime/terminal-session.d.ts +57 -0
  198. package/lib/runtime/terminal-session.js +164 -0
  199. package/lib/runtime/terminal-session.js.map +1 -0
  200. package/lib/runtime/terminal-state.d.ts +29 -0
  201. package/lib/runtime/terminal-state.js +62 -0
  202. package/lib/runtime/terminal-state.js.map +1 -0
  203. package/lib/runtime/tool-presentation.js +50 -14
  204. package/lib/runtime/tool-presentation.js.map +1 -1
  205. package/lib/session/external-agents.d.ts +5 -0
  206. package/lib/session/external-agents.js +27 -0
  207. package/lib/session/external-agents.js.map +1 -0
  208. package/lib/session/feature-toggles.d.ts +1 -1
  209. package/lib/session/feature-toggles.js +1 -1
  210. package/lib/session/interaction-adapter.d.ts +1 -6
  211. package/lib/session/interaction-adapter.js +95 -18
  212. package/lib/session/interaction-adapter.js.map +1 -1
  213. package/lib/session/message-queue.js +1 -1
  214. package/lib/session/message-queue.js.map +1 -1
  215. package/lib/session/model-readiness.d.ts +16 -0
  216. package/lib/session/model-readiness.js +43 -0
  217. package/lib/session/model-readiness.js.map +1 -0
  218. package/lib/session/package-metadata.d.ts +8 -0
  219. package/lib/session/package-metadata.js +8 -0
  220. package/lib/session/package-metadata.js.map +1 -1
  221. package/lib/session/project-context.d.ts +5 -0
  222. package/lib/session/project-context.js +36 -0
  223. package/lib/session/project-context.js.map +1 -1
  224. package/lib/session/session-controller.d.ts +10 -13
  225. package/lib/session/session-controller.js +183 -98
  226. package/lib/session/session-controller.js.map +1 -1
  227. package/lib/session/session-library.js +2 -0
  228. package/lib/session/session-library.js.map +1 -1
  229. package/lib/session/session-tree.d.ts +3 -0
  230. package/lib/session/session-tree.js +3 -1
  231. package/lib/session/session-tree.js.map +1 -1
  232. package/lib/session/subagent-roster.d.ts +2 -1
  233. package/lib/session/subagent-roster.js +24 -3
  234. package/lib/session/subagent-roster.js.map +1 -1
  235. package/lib/session/subagent-tracker.d.ts +6 -2
  236. package/lib/session/subagent-tracker.js +49 -2
  237. package/lib/session/subagent-tracker.js.map +1 -1
  238. package/lib/session/tui-settings.d.ts +47 -13
  239. package/lib/session/tui-settings.js +76 -34
  240. package/lib/session/tui-settings.js.map +1 -1
  241. package/lib/session/working-directory.d.ts +4 -0
  242. package/lib/session/working-directory.js +4 -0
  243. package/lib/session/working-directory.js.map +1 -0
  244. package/lib/session/worktrees.d.ts +13 -0
  245. package/lib/session/worktrees.js +49 -0
  246. package/lib/session/worktrees.js.map +1 -0
  247. package/lib/views/approval-rule.d.ts +3 -0
  248. package/lib/views/approval-rule.js +4 -0
  249. package/lib/views/approval-rule.js.map +1 -0
  250. package/lib/views/at-complete.d.ts +8 -0
  251. package/lib/views/at-complete.js +64 -2
  252. package/lib/views/at-complete.js.map +1 -1
  253. package/lib/views/autocomplete.d.ts +22 -2
  254. package/lib/views/autocomplete.js +70 -40
  255. package/lib/views/autocomplete.js.map +1 -1
  256. package/lib/views/copy-targets.d.ts +13 -14
  257. package/lib/views/copy-targets.js +42 -54
  258. package/lib/views/copy-targets.js.map +1 -1
  259. package/lib/views/document-reader.js +18 -5
  260. package/lib/views/document-reader.js.map +1 -1
  261. package/lib/views/event-views.d.ts +1 -1
  262. package/lib/views/event-views.js +60 -6
  263. package/lib/views/event-views.js.map +1 -1
  264. package/lib/views/file-actions.d.ts +12 -0
  265. package/lib/views/file-actions.js +72 -0
  266. package/lib/views/file-actions.js.map +1 -0
  267. package/lib/views/history-search.js +15 -21
  268. package/lib/views/history-search.js.map +1 -1
  269. package/lib/views/hotkeys.d.ts +8 -1
  270. package/lib/views/hotkeys.js +53 -26
  271. package/lib/views/hotkeys.js.map +1 -1
  272. package/lib/views/list-navigation.d.ts +62 -0
  273. package/lib/views/list-navigation.js +73 -0
  274. package/lib/views/list-navigation.js.map +1 -0
  275. package/lib/views/list-view.d.ts +99 -0
  276. package/lib/views/list-view.js +165 -0
  277. package/lib/views/list-view.js.map +1 -0
  278. package/lib/views/prompt-selector.d.ts +34 -4
  279. package/lib/views/prompt-selector.js +270 -134
  280. package/lib/views/prompt-selector.js.map +1 -1
  281. package/lib/views/session-files.d.ts +1 -2
  282. package/lib/views/session-files.js +1 -11
  283. package/lib/views/session-files.js.map +1 -1
  284. package/lib/views/settings-list.d.ts +51 -10
  285. package/lib/views/settings-list.js +267 -315
  286. package/lib/views/settings-list.js.map +1 -1
  287. package/lib/views/trajectory-timeline.d.ts +72 -0
  288. package/lib/views/trajectory-timeline.js +133 -0
  289. package/lib/views/trajectory-timeline.js.map +1 -0
  290. package/lib/views/trajectory.d.ts +15 -4
  291. package/lib/views/trajectory.js +557 -123
  292. package/lib/views/trajectory.js.map +1 -1
  293. package/lib/views/transcript-export.js +9 -0
  294. package/lib/views/transcript-export.js.map +1 -1
  295. package/lib/views/transcript-render.d.ts +97 -25
  296. package/lib/views/transcript-render.js +542 -170
  297. package/lib/views/transcript-render.js.map +1 -1
  298. package/lib/views/transcript-search.d.ts +39 -6
  299. package/lib/views/transcript-search.js +46 -11
  300. package/lib/views/transcript-search.js.map +1 -1
  301. package/lib/views/transcript-types.d.ts +63 -5
  302. package/lib/views/transcript-types.js +23 -11
  303. package/lib/views/transcript-types.js.map +1 -1
  304. package/package.json +95 -65
  305. package/lib/commands/loop.d.ts +0 -52
  306. package/lib/commands/loop.js +0 -307
  307. package/lib/commands/loop.js.map +0 -1
  308. package/lib/commands/steer.d.ts +0 -5
  309. package/lib/commands/steer.js +0 -24
  310. package/lib/commands/steer.js.map +0 -1
  311. package/lib/runtime/terminal-reader.d.ts +0 -11
  312. package/lib/runtime/terminal-reader.js +0 -87
  313. package/lib/runtime/terminal-reader.js.map +0 -1
  314. package/lib/views/agent-hub.d.ts +0 -32
  315. package/lib/views/agent-hub.js +0 -206
  316. package/lib/views/agent-hub.js.map +0 -1
  317. package/lib/views/copy-selector.d.ts +0 -51
  318. package/lib/views/copy-selector.js +0 -139
  319. package/lib/views/copy-selector.js.map +0 -1
@@ -2,7 +2,7 @@
2
2
  * TUI capability seam — local terminal provider.
3
3
 
4
4
  * Owns the tty: raw-mode key input (editing, history, slash/tab
5
- * autocomplete, /settings overlay, /copy picker, Ctrl-R history search, PgUp/PgDn
5
+ * autocomplete, /settings overlay, Ctrl-R history search, PgUp/PgDn
6
6
  * and Shift+Up/Down transcript scroll, Ctrl-O tool
7
7
  * expand, bracketed paste, double-Escape conversation rewind, double Ctrl-C exit, Ctrl-D quit),
8
8
  * SIGWINCH reflow, and the differential renderer. In non-tty mode
@@ -10,7 +10,11 @@
10
10
  * printing of settled blocks.
11
11
  * @module @agi-fans/dsh-tui
12
12
  */
13
- import { TerminalNotificationController, TerminalNotificationQueue, terminalNotificationSequence, terminalProgressSequence, } from "./terminal-notifications.js";
13
+ import { TerminalNotificationController, TerminalNotificationQueue, terminalNotificationSequence, } from "./terminal-notifications.js";
14
+ import { PROGRAM_STATUS_QUERY, programStatusSequence, terminalTitle } from "./terminal-state.js";
15
+ import { TerminalSession } from "./terminal-session.js";
16
+ import { BRACKETED_PASTE, FOCUS_REPORTING, MOUSE_TRACKING, } from "./terminal-modes.js";
17
+ import { IDLE_WHEEL, terminalAcceleratesWheel, wheelStep } from "../input/wheel-scroll.js";
14
18
  import { HerdrAgentReporter } from "./herdr-agent.js";
15
19
  import { imageProtocol } from "../chrome/terminal-images.js";
16
20
  import { previewImages } from "./image-preview.js";
@@ -31,43 +35,65 @@ import { TUI_SERVICE, } from "../definition.js";
31
35
  import { applySlashCompletion, formatHelpText, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
32
36
  import { applyPathCompletion, defaultPathSource, editorLineAt, findPathToken, parsePathPrefix, pathSuggestions, searchPathSuggestions, } from "../views/path-complete.js";
33
37
  import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar';
34
- import { nextSelectableAutocompleteIndex, searchAtSuggestions, } from "../views/at-complete.js";
38
+ import { matchesAtShortcutQuery, nextSelectableAutocompleteIndex, searchAtSuggestions, } from "../views/at-complete.js";
35
39
  import { readTmuxMouseEnabled } from "../input/mouse-policy.js";
36
40
  import { TextSelection } from "../input/text-selection.js";
37
41
  import { copyToClipboard, readFromClipboard } from "../input/clipboard.js";
38
- import { applyCopySelectorEvent, createCopySelector, } from "../views/copy-selector.js";
39
- import { buildCopyTargets, extractCopyTarget, parseCopyKind } from "../views/copy-targets.js";
42
+ import { buildCopyTargets } from "../views/copy-targets.js";
40
43
  import { applyHistorySearchEvent, createHistorySearch, } from "../views/history-search.js";
41
44
  import { InputEditor, lineEnd, lineStart } from "../input/editor.js";
42
- import { applySettingsEvent, createSettings, } from "../views/settings-list.js";
43
- import { editPluginSettings, pluginSettingsEntries } from "./plugin-settings.js";
45
+ import { applySettingsEvent, createSettings, refreshSettings, tuiSettingItems, } from "../views/settings-list.js";
46
+ import { editSettingsEntry, pluginSettingsEntries, resetSettingsControl, setSettingsEntry, showSettingsError } from "./plugin-settings.js";
47
+ import { preferenceChanges } from "./settings-preferences.js";
44
48
  import { appendTrajectoryEvent, applyTrajectoryEvent, createTrajectory, trajectoryDetailMetrics, trajectoryListMetrics, } from "../views/trajectory.js";
45
- import { applyAgentHubEvent, createAgentHub, updateAgentHub, } from "../views/agent-hub.js";
46
- import { applyEvent, applyStreamChunk, blockSearchText, initialTranscript, replayEvents, renderView, turnGroups, transcriptSeam, TRANSCRIPT_FAST_SCROLL, withWorkspaceSummary, } from "../views/event-views.js";
47
- import { applyTranscriptSearchEvent, createTranscriptSearch, searchBlockIndexes, } from "../views/transcript-search.js";
49
+ import { applyEvent, applyStreamChunk, renderedBlocks, formatAgentElapsed, subagentPreviewStart, initialTranscript, replayEvents, renderView, turnGroups, transcriptSeam, TRANSCRIPT_FAST_SCROLL, withWorkspaceSummary, } from "../views/event-views.js";
50
+ import { applyTranscriptSearchEvent, createTranscriptSearch, searchMatches, } from "../views/transcript-search.js";
48
51
  import { nextRevealStep, revealStreamingAssistant, streamingAssistantKey, streamingAssistantUnits, } from "../views/streaming-reveal.js";
49
52
  import { flushPending, parseKeys } from "../input/keys.js";
53
+ import { sanitizeDisplayLine } from "../chrome/renderer.js";
54
+ import { blockFileAction } from "../views/file-actions.js";
55
+ import { fileDocument, reviewFiles } from "./file-review.js";
56
+ import { stripAnsi } from "../chrome/width.js";
50
57
  import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
51
- import { colorDisabledByEnv, createTheme, detectTrueColor } from "../chrome/theme.js";
58
+ import { colorDisabledByEnv, createTheme, detectColorDepth } from "../chrome/theme.js";
59
+ import { formatToolsText } from "../chrome/tools-list.js";
52
60
  import { renderTool } from "../chrome/tool-renderers.js";
53
- import { TUI_SETTINGS_ENTRY, TUI_SETTINGS_FIELDS } from "../session/tui-settings.js";
54
- import { defaultStatusBarConfig, resolveStatusBarConfig } from "../chrome/status-config.js";
61
+ import { NOTIFICATION_THRESHOLD_MS, resolveNotificationMode, TUI_SETTINGS_ENTRY, TUI_SETTINGS_FIELDS, } from "../session/tui-settings.js";
62
+ import { DEFAULT_STATUS_DENSITY, resolveStatusBarConfig, statusBarDocument } from "../chrome/status-config.js";
55
63
  import { HistoryStore } from "../session/history-store.js";
56
64
  import { PasteBurst } from "../input/paste-burst.js";
57
- import { loadKeybindings } from "../input/keybindings-config.js";
65
+ import { DEFAULT_KEYBINDINGS, loadKeybindings, saveKeybindings, TUI_ACTION_LABELS, TUI_ACTIONS } from "../input/keybindings-config.js";
58
66
  import { discoverEditors } from "../input/editor-discovery.js";
59
67
  import { editExternally, editFileExternally } from "../input/external-editor.js";
60
68
  import z from '@deepseek-ai/schemastery';
61
- import { movePromptSelection, movePromptTree, searchPromptSelection, filteredPromptOptions, refreshPromptOptions, promptTreePageSize, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, } from "../views/prompt-selector.js";
62
- import { refreshProjectContext, resolveProjectContext } from "../session/project-context.js";
63
- import { pickWelcomeTips } from "../chrome/welcome-tips.js";
69
+ import { movePromptSelection, movePromptTree, searchPromptSelection, filteredPromptOptions, refreshPromptOptions, promptTreePageSize, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, documentRows, findInRows, } from "../views/prompt-selector.js";
70
+ import { edgeIndex, listNavigation, navigateIndex } from "../views/list-navigation.js";
71
+ import { refreshProjectContext, resolveGitStatusFiles, resolveProjectContext } from "../session/project-context.js";
72
+ import { modelRouteReady } from "../session/model-readiness.js";
64
73
  import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount, keysForAction } from "../views/hotkeys.js";
65
- import { imagePathCandidates, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
66
- import { APP_NAME, APP_VERSION } from "../session/package-metadata.js";
74
+ import { imagePathCandidates, splitComposerImageMarkers, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
75
+ import { APP_DISPLAY_NAME, APP_NAME, APP_VERSION } from "../session/package-metadata.js";
67
76
  const DOUBLE_CTRL_C_MS = 500;
77
+ /**
78
+ * Whether an open completion menu still speaks for the `@` token being typed.
79
+ *
80
+ * A `@` menu is built for a prefix, and the reader extends that prefix one
81
+ * keystroke at a time while the search for the new one is still running. The
82
+ * rows on screen describe a token this one extends, so they stay. Anything else
83
+ * — a new `@`, a different path, a token that was never recorded — describes
84
+ * nothing on screen and must not be reused.
85
+ */
86
+ export function completionDescribes(described, current) {
87
+ if (described === undefined)
88
+ return false;
89
+ return current.startsWith(described) || described.startsWith(current);
90
+ }
91
+ /** How long a confirmation stays on the composer's border before it fades. */
92
+ const TRANSIENT_STATUS_MS = 3_000;
93
+ /** Shortest interval between repaints while a Shell command writes. */
94
+ const SHELL_PAINT_INTERVAL_MS = 80;
68
95
  const DOUBLE_ESCAPE_MS = 500;
69
96
  const STREAMING_REVEAL_MS = 1000 / 30;
70
- const TERMINAL_PROGRESS_KEEPALIVE_MS = 1_000;
71
97
  /**
72
98
  * Durable settlement events that arrive in bursts while a turn runs. Renders
73
99
  * for these are coalesced through the stream-render timer; rarer control
@@ -91,19 +117,6 @@ function shortenPath(cwd) {
91
117
  return '~' + cwd.slice(home.length);
92
118
  return cwd;
93
119
  }
94
- /** Maximum code points written into the OSC 2 window title. */
95
- const WINDOW_TITLE_MAX = 120;
96
- /**
97
- * Make a session title safe for an OSC 2 write: control characters are
98
- * replaced so a title cannot terminate or inject the escape sequence, and the
99
- * payload is bounded without splitting a surrogate pair.
100
- */
101
- function windowTitleText(title) {
102
- if (title === undefined)
103
- return '';
104
- const cleaned = title.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').replace(/\s+/gu, ' ').trim();
105
- return [...cleaned].slice(0, WINDOW_TITLE_MAX).join('');
106
- }
107
120
  export const name = 'omdsh-tui';
108
121
  /**
109
122
  * Row schema. The durable preferences ride as volatile fields, so a settings
@@ -111,7 +124,7 @@ export const name = 'omdsh-tui';
111
124
  * `loader/volatile-update` without remounting the terminal provider.
112
125
  */
113
126
  export const Config = z.object({
114
- model: z.string(),
127
+ model: z.string().default(''),
115
128
  historyPath: z.string(),
116
129
  keybindingsPath: z.string(),
117
130
  dshHome: z.string(),
@@ -163,24 +176,19 @@ export class LocalTui {
163
176
  if (!this.#tty)
164
177
  throw new Error('Opening an editor requires an interactive terminal.');
165
178
  try {
166
- this.#setMouseTracking(false);
167
- this.#term.output.write('\x1b[?1004l');
168
- this.#term.input.setRawMode?.(false);
169
- editFileExternally(path, this.#externalEditor);
179
+ this.#handTerminal(() => { editFileExternally(path, this.#externalEditor); });
170
180
  }
171
181
  finally {
172
- if (!this.#disposed) {
173
- this.#term.input.setRawMode?.(true);
174
- this.#term.output.write('\x1b[?1004h');
175
- this.#renderer.reset();
182
+ if (!this.#disposed)
176
183
  this.#render();
177
- }
178
184
  }
179
185
  }
180
186
  #deferInitialRender = false;
181
187
  #imageProtocol;
182
188
  #imageCellSize = { width: 9, height: 18 };
183
189
  #imagePreviewAbort;
190
+ #fileReviewAbort;
191
+ #fileActionFrame;
184
192
  #imageTemporary = new Set();
185
193
  #terminalProfile;
186
194
  #resizeDebounceMs;
@@ -189,6 +197,8 @@ export class LocalTui {
189
197
  #streamRenderTimer = null;
190
198
  #term;
191
199
  #model;
200
+ /** Optimistic until the credential seams answer; false points at /login. */
201
+ #modelReady = true;
192
202
  #reasoningEffort;
193
203
  #colors;
194
204
  #themeName;
@@ -198,6 +208,21 @@ export class LocalTui {
198
208
  #editor = new InputEditor();
199
209
  #files = new ComposerFiles({ text: () => this.#editor.text,
200
210
  append: marker => { this.#editor.setCursor(this.#editor.text.length); this.#editor.handle({ type: 'text', value: (this.#editor.text === '' ? '' : ' ') + marker }); } });
211
+ insertText(text) {
212
+ if (text === '')
213
+ return;
214
+ const before = this.#editor.text.slice(0, this.#editor.cursor);
215
+ const after = this.#editor.text.slice(this.#editor.cursor);
216
+ const prefix = before !== '' && !/\s$/u.test(before) ? ' ' : '';
217
+ const suffix = (after !== '' && !/^\s/u.test(after)) || (text.startsWith('@') && after === '') ? ' ' : '';
218
+ this.#editor.handle({ type: 'text', value: prefix + text + suffix });
219
+ this.#refreshAutocomplete();
220
+ if (this.#tty)
221
+ this.#render();
222
+ }
223
+ async copyText(text, label = 'text') {
224
+ await this.#copyPicked(text, label);
225
+ }
201
226
  stageFileAttachment(file) {
202
227
  this.#files.add(file);
203
228
  this.#refreshAutocomplete();
@@ -209,6 +234,8 @@ export class LocalTui {
209
234
  #literalInput = false;
210
235
  #draft = '';
211
236
  #historyDraft;
237
+ #clearedDraft;
238
+ #stashedDrafts = new Map();
212
239
  #draftStore;
213
240
  #draftScope;
214
241
  #draftFingerprint = '';
@@ -216,17 +243,19 @@ export class LocalTui {
216
243
  #draftTimer;
217
244
  #unsavedDrafts = new Map();
218
245
  #draftError = false;
246
+ /**
247
+ * The open completion menu. `query` is the `@` prefix the rows were built for,
248
+ * so the next keystroke can tell "that token, extended" from "a different one".
249
+ */
219
250
  #ac = null;
220
251
  #search = null;
221
252
  #transcriptSearch = null;
222
253
  #settings = null;
223
254
  /** Optional-feature state read from the Profile patch this process booted with. */
224
255
  #features;
225
- /** Profile patch the Features section writes; absent hides that section. */
256
+ /** Profile patch the Tools feature controls write; absent hides those controls. */
226
257
  #featurePatch;
227
- #copySelector = null;
228
258
  #trajectory = null;
229
- #agentHub = null;
230
259
  #pending = null;
231
260
  #queuedSubmissions = [];
232
261
  /** Newer queue entries temporarily detached while Up browses backward. */
@@ -289,16 +318,41 @@ export class LocalTui {
289
318
  #focusBlockEdge;
290
319
  #motion = 'full';
291
320
  #externalEditor = 'auto';
292
- #terminalProgress = false;
293
- #terminalProgressActive = false;
294
- #terminalProgressTick = null;
321
+ #terminalTitle = 'status';
322
+ #terminalState = 'auto';
323
+ #programSupported = false;
324
+ #programQuerySent = false;
325
+ /** What the terminal reported about its own colors; empty until (and unless) it answers. */
326
+ get #terminalColors() { return this.#session.colors; }
327
+ /** Capability probe, reported colors, keyboard protocol, and every terminal mode this session turned on. */
328
+ #session = new TerminalSession({
329
+ write: bytes => { this.#term.output.write(bytes); },
330
+ alternateActive: () => this.#renderer?.alternateActive ?? false,
331
+ setAlternateScreenModes: modes => { this.#renderer?.setAlternateScreenModes(modes); },
332
+ deferUntilMainScreen: bytes => { this.#renderer?.deferUntilMainScreen(bytes); },
333
+ colorsChanged: () => { this.#render(); },
334
+ probeSettled: () => {
335
+ this.#probeGate = false;
336
+ if (!this.#disposed && !this.#deferInitialRender)
337
+ this.#render();
338
+ },
339
+ });
340
+ #offExit;
341
+ /** Accumulated wheel gesture, so fast spins move further than one notch. */
342
+ #wheelGesture = IDLE_WHEEL;
343
+ #wheelAccelerates;
344
+ /** When the pending provider retry fires, so the working row can count down. */
345
+ #retryDeadline;
346
+ /** When the running compaction started. */
347
+ #compactionStartedAt;
348
+ #readImageInput;
349
+ #writtenProgramState;
295
350
  #checkUpdates = true;
296
351
  #startupChangelog = 'summary';
297
352
  #notifications = new TerminalNotificationController();
298
353
  #notificationQueue = new TerminalNotificationQueue();
299
354
  #notificationTimer;
300
355
  #terminalFocused;
301
- #notificationFocus = 'unfocused';
302
356
  /**
303
357
  * Herdr lifecycle authority for this pane. The plugin entry injects the
304
358
  * environment-aware reporter; a directly constructed instance is inert so
@@ -306,9 +360,10 @@ export class LocalTui {
306
360
  */
307
361
  #herdr;
308
362
  #notificationPolicy = 'off';
309
- #notificationThreshold = '30s';
310
- #statusBar = defaultStatusBarConfig();
363
+ #statusBar = resolveStatusBarConfig({ density: DEFAULT_STATUS_DENSITY, hidden: [] });
311
364
  #toolsExpanded = false;
365
+ /** Command outputs the reader opened; the rest fold after their preview. */
366
+ #expandedOutputs = new Set();
312
367
  /**
313
368
  * The reader opened something with Ctrl+O or Alt+O and is reading it.
314
369
  *
@@ -327,11 +382,31 @@ export class LocalTui {
327
382
  #copyOnSelect = true;
328
383
  #mathMode = 'auto';
329
384
  #mermaidMode = 'auto';
385
+ /** Transcript collapses every thought to one line; Ctrl+T flips it. */
386
+ #hideThinking = false;
330
387
  #mouseInteraction = 'auto';
331
388
  #tmuxMouseEnabled;
332
389
  #jumpPress;
333
390
  #jumpToLatest;
334
391
  #openedGroups = new Set();
392
+ /** Side-conversation pane; owned here so Esc can close it from any state. */
393
+ #aside;
394
+ /** Set while the pane is open, so closing it stops the work it was showing. */
395
+ #asideAbort;
396
+ /** Thoughts a reader opened individually, addressed by reasoning identity. */
397
+ #expandedReasoning = new Set();
398
+ /**
399
+ * The last action's confirmation, drawn on the composer's bottom border.
400
+ *
401
+ * A status that reports what a key just did is not part of the conversation,
402
+ * so it never enters the block list: it replaces itself, and it is dropped by
403
+ * the next keystroke. Writing it into the transcript would put chatter into
404
+ * native scrollback, which cannot be taken back.
405
+ */
406
+ #transientStatus;
407
+ #transientTimer = null;
408
+ /** Aborts the live Shell card, so Ctrl+C interrupts the command it belongs to. */
409
+ #shellAbort;
335
410
  /** Turn scopes whose tools stay detailed as new calls arrive during inspection. */
336
411
  #detailedTurns = new Set();
337
412
  #tools = [];
@@ -347,8 +422,6 @@ export class LocalTui {
347
422
  * when the prompt settles.
348
423
  */
349
424
  #promptDisplaced = null;
350
- #recentSessions = [];
351
- #welcomeTips;
352
425
  #sessionId;
353
426
  #sessionTitle;
354
427
  #writtenWindowTitle;
@@ -356,7 +429,13 @@ export class LocalTui {
356
429
  #sessionControls;
357
430
  #loopStatus;
358
431
  #subagents;
359
- #subagentLauncherFocused = false;
432
+ /**
433
+ * The roster row the reader is pointing at, or `undefined` when the composer
434
+ * owns the keyboard. `↓` on an empty composer enters the bar; `Enter` opens the
435
+ * child. The bar is the roster now — the full-screen hub it used to open was a
436
+ * second stop on the way to the same transcript.
437
+ */
438
+ #subagentSelection;
360
439
  #inspected;
361
440
  #promptDocument;
362
441
  /**
@@ -364,7 +443,9 @@ export class LocalTui {
364
443
  * preference changes so a session that starts colorless can still enable
365
444
  * hexadecimal palettes without a restart.
366
445
  */
367
- #trueColor = false;
446
+ #colorDepth = 'ansi16';
447
+ /** Hold the first frame until the capability probe settles; scrollback rows are immutable, so the first real paint must use the reported colors. */
448
+ #probeGate = false;
368
449
  #copy;
369
450
  #copyTask = Promise.resolve();
370
451
  #readClipboard;
@@ -372,6 +453,7 @@ export class LocalTui {
372
453
  #readClipboardFiles;
373
454
  #readImagePath;
374
455
  #historyStore;
456
+ #keybindingsPath;
375
457
  #keybindings;
376
458
  #cwd;
377
459
  #projectRoot;
@@ -382,6 +464,10 @@ export class LocalTui {
382
464
  #searchSessions;
383
465
  #autocompleteDebounceMs;
384
466
  #persistPrefs = null;
467
+ #catalogVersion = 0;
468
+ #prefsSaveTask;
469
+ #disposeTask;
470
+ #prefsVersion = 0;
385
471
  #agentBehavior;
386
472
  #updateAgentBehavior;
387
473
  #offAgentBehaviorWatch;
@@ -406,6 +492,7 @@ export class LocalTui {
406
492
  this.#readClipboardFiles = paths.readClipboardFiles ?? readMacClipboardFiles;
407
493
  this.#historyStore = paths.historyPath === undefined ? undefined : new HistoryStore(paths.historyPath);
408
494
  this.#history = this.#historyStore?.load() ?? [];
495
+ this.#keybindingsPath = paths.keybindingsPath;
409
496
  this.#keybindings = loadKeybindings(paths.keybindingsPath);
410
497
  this.#featurePatch = paths.featurePatch;
411
498
  if (paths.featurePatch !== undefined) {
@@ -429,25 +516,27 @@ export class LocalTui {
429
516
  this.#searchFileMentions = paths.searchFileMentions;
430
517
  this.#searchSessions = paths.searchSessions;
431
518
  this.#autocompleteDebounceMs = Math.max(0, paths.autocompleteDebounceMs ?? 100);
432
- this.#welcomeTips = pickWelcomeTips();
433
519
  this.#deferInitialRender = paths.deferInitialRender === true;
434
520
  this.#terminalProfile = paths.terminalProfile ?? detectTerminalProfile();
435
521
  this.#imageProtocol = this.#terminalProfile !== 'direct' || paths.imageProtocol === 'none' ? undefined : paths.imageProtocol ?? imageProtocol(process.env);
522
+ this.#readImageInput = paths.readImageInput;
436
523
  this.#resizeDebounceMs = Math.max(0, paths.resizeDebounceMs ?? 120);
437
524
  this.#streamRenderMs = Math.max(0, paths.streamRenderMs ?? 8);
525
+ this.#wheelAccelerates = paths.wheelAcceleration ?? !terminalAcceleratesWheel();
438
526
  this.#herdr = paths.herdrReporter ?? new HerdrAgentReporter({ env: {} });
439
- this.#syncTrueColor();
440
- this.#tty = term.input.isTTY === true;
527
+ this.#syncColorDepth();
528
+ this.#tty = term.input.isTTY === true && term.output.isTTY === true;
441
529
  this.#draftStore = this.#tty && paths.draftsPath !== undefined ? new ComposerDraftStore(paths.draftsPath) : undefined;
442
530
  this.#plain = new PlainTui({
443
531
  term,
444
532
  blocks: () => this.#state.blocks,
445
533
  mathMode: () => this.#mathMode,
446
534
  mermaidMode: () => this.#mermaidMode,
535
+ hideThinking: () => this.#hideThinking,
447
536
  promptRequest: () => this.#prompt?.request,
448
537
  finishPrompt: answer => { this.#finishPrompt(answer); },
449
538
  });
450
- this.#pwd = shortenPath(project.root);
539
+ this.#pwd = shortenPath(this.#cwd);
451
540
  this.#branch = project.gitLabel;
452
541
  this.#renderer = new MainScreenRenderer({ write: (chunk) => { this.#term.output.write(chunk); } }, {
453
542
  width: this.#term.width(),
@@ -485,24 +574,54 @@ export class LocalTui {
485
574
  if (this.#disposed)
486
575
  return;
487
576
  this.#terminalFocused = undefined;
488
- this.#term.output.write('\x1b[?1004h');
577
+ this.#writtenWindowTitle = undefined;
578
+ this.#writtenProgramState = undefined;
579
+ this.#programQuerySent = false;
580
+ this.#programSupported = false;
581
+ this.#syncWindowTitle();
582
+ this.#session.takeBack();
489
583
  this.#renderer.reset();
490
584
  this.#render();
491
585
  };
492
586
  process.on('SIGCONT', resume);
493
587
  this.#offResume = () => { process.off('SIGCONT', resume); };
494
588
  }
495
- term.output.write('\x1b[?2004h\x1b[?1004h');
589
+ this.#session.setMode('paste', BRACKETED_PASTE, true);
590
+ this.#session.setMode('focus', FOCUS_REPORTING, true);
591
+ this.#probeGate = this.#deferInitialRender;
592
+ // A crash or `process.exit` skips dispose; the shell must still get its
593
+ // terminal back rather than a stuck alternate screen or key encoding.
594
+ const onExit = () => { this.#restoreTerminalOnExit(); };
595
+ process.once('exit', onExit);
596
+ this.#offExit = () => { process.off('exit', onExit); };
597
+ this.#session.start();
496
598
  }
497
599
  this.#render();
498
600
  this.#herdr.start();
601
+ if (paths.deferInitialRender !== true)
602
+ this.#syncWindowTitle();
603
+ }
604
+ /** Follow the viewed Session without replacing its transcript or native history. */
605
+ setWorkingDirectory(cwd) {
606
+ if (cwd === this.#cwd && cwd === this.#projectRoot)
607
+ return;
608
+ this.#cwd = cwd;
609
+ const project = resolveProjectContext(cwd);
610
+ this.#projectRoot = cwd;
611
+ this.#pwd = shortenPath(cwd);
612
+ this.#branch = project.gitLabel;
613
+ this.#refreshAutocomplete();
614
+ this.#syncWindowTitle();
615
+ if (this.#tty)
616
+ this.#render();
499
617
  }
500
- /** Refresh Git workspace metadata after a turn; the footer shows realtime branch/dirty state. */
618
+ /** Refresh Git metadata without changing the Session's effective directory. */
501
619
  #refreshProjectContext() {
502
620
  const branch = refreshProjectContext(this.#cwd).gitLabel;
503
621
  if (branch === this.#branch)
504
622
  return;
505
623
  this.#branch = branch;
624
+ this.#syncWindowTitle();
506
625
  if (this.#tty)
507
626
  this.#render();
508
627
  }
@@ -514,7 +633,20 @@ export class LocalTui {
514
633
  this.#syncStreamingReveal(undefined);
515
634
  this.#render();
516
635
  }
636
+ if (event.type === 'llm/retry')
637
+ this.#retryDeadline = Date.now() + event.data.delayMs;
638
+ if (event.type === 'llm/retry-started' || event.type === 'turn/end')
639
+ this.#retryDeadline = undefined;
517
640
  this.#state = applyEvent(this.#state, event, presentation, workspace);
641
+ const compacting = this.#state.status === 'compacting';
642
+ if (compacting && this.#compactionStartedAt === undefined)
643
+ this.#compactionStartedAt = Date.now();
644
+ else if (!compacting)
645
+ this.#compactionStartedAt = undefined;
646
+ if (event.type === 'turn/end' && this.#inspected === undefined) {
647
+ this.#herdr.settled(event.data.reason.kind);
648
+ this.#syncWindowTitle();
649
+ }
518
650
  this.#emitNotification(this.#notifications.event({
519
651
  type: event.type,
520
652
  time: event.time,
@@ -574,8 +706,12 @@ export class LocalTui {
574
706
  }
575
707
  setStatus(status, options = {}) {
576
708
  // Parent lifecycle remains authoritative while the child owns the visible view.
577
- if (options.root === true || this.#inspected === undefined)
709
+ if (options.root === true || this.#inspected === undefined) {
578
710
  this.#herdr.setRunning(status === 'running');
711
+ if (options.outcome !== undefined)
712
+ this.#herdr.settled(options.outcome);
713
+ this.#syncWindowTitle();
714
+ }
579
715
  if (options.root === true && this.#inspected !== undefined)
580
716
  return;
581
717
  if (this.#state.status === 'compacting')
@@ -585,9 +721,19 @@ export class LocalTui {
585
721
  if (this.#tty)
586
722
  this.#render();
587
723
  }
724
+ /** Update first-run model readiness without touching the model label. */
725
+ setModelReady(ready) {
726
+ if (this.#modelReady === ready)
727
+ return;
728
+ this.#modelReady = ready;
729
+ if (this.#tty)
730
+ this.#render();
731
+ }
588
732
  setModel(model, reasoningEffort) {
589
733
  this.#model = model;
590
734
  this.#reasoningEffort = reasoningEffort;
735
+ this.#syncSettingsActions();
736
+ this.#refreshSettingsCatalog();
591
737
  if (this.#tty)
592
738
  this.#render();
593
739
  }
@@ -609,13 +755,7 @@ export class LocalTui {
609
755
  ? undefined
610
756
  : { agents: roster.agents.map(agent => ({ ...agent, activity: [...agent.activity] })) };
611
757
  if ((this.#subagents?.agents.length ?? 0) === 0)
612
- this.#subagentLauncherFocused = false;
613
- if (this.#agentHub !== null) {
614
- if (this.#subagents === undefined)
615
- this.#agentHub = null;
616
- else
617
- this.#agentHub = updateAgentHub(this.#agentHub, this.#subagents);
618
- }
758
+ this.#subagentSelection = undefined;
619
759
  const inspected = this.#inspected;
620
760
  if (inspected !== undefined) {
621
761
  const current = this.#subagents?.agents.find(agent => agent.id === inspected.id);
@@ -629,7 +769,7 @@ export class LocalTui {
629
769
  };
630
770
  }
631
771
  }
632
- if (!statusChanged && this.#agentHub === null)
772
+ if (!statusChanged)
633
773
  return;
634
774
  this.#syncTick();
635
775
  if (this.#tty)
@@ -644,7 +784,7 @@ export class LocalTui {
644
784
  this.#inspected = inspected === undefined ? undefined : { ...inspected };
645
785
  if (inspected !== undefined) {
646
786
  this.#inspectEscapeFenceUntil = 0;
647
- this.#subagentLauncherFocused = false;
787
+ this.#subagentSelection = undefined;
648
788
  }
649
789
  this.#syncTick();
650
790
  if (this.#tty)
@@ -659,6 +799,7 @@ export class LocalTui {
659
799
  description: command.description,
660
800
  ...(command.inputHint === undefined ? {} : { inputHint: command.inputHint }),
661
801
  }));
802
+ this.#syncSettingsActions();
662
803
  this.#refreshAutocomplete();
663
804
  if (this.#tty)
664
805
  this.#render();
@@ -668,9 +809,7 @@ export class LocalTui {
668
809
  return false;
669
810
  this.#finishPrompt(null);
670
811
  this.#trajectory = createTrajectory(events);
671
- this.#agentHub = null;
672
812
  this.#settings = null;
673
- this.#copySelector = null;
674
813
  this.#search = null;
675
814
  this.#ac = null;
676
815
  this.#render();
@@ -695,9 +834,7 @@ export class LocalTui {
695
834
  const block = this.#state.blocks.findLast(candidate => candidate.kind === 'tool' && String(candidate.callId) === callId);
696
835
  if (block?.kind !== 'tool')
697
836
  return undefined;
698
- // Reuse the card renderer so a decision explains the call the same way the
699
- // transcript already does. The approval prompt renders its detail on one
700
- // line, so the parts are joined here rather than handed over as rows.
837
+ // Keep the provider-owned presentation and input intact in the approval reader.
701
838
  const view = renderTool({
702
839
  name: block.name,
703
840
  arguments: block.args,
@@ -706,15 +843,9 @@ export class LocalTui {
706
843
  expanded: false,
707
844
  ...(block.presentation === undefined ? {} : { presentation: block.presentation }),
708
845
  });
709
- const detail = view.summary === undefined || view.summary === ''
710
- ? view.input.join(' ').replace(/\s+/gu, ' ').trim()
711
- : '';
712
- // A card with a summary already says what the call is. Without one the raw
713
- // argument text is all there is, and its pretty-printed form would reduce to
714
- // a lone brace on a single row, so the whole body is folded into one line.
715
- return [view.title, view.summary, detail === '' ? undefined : detail]
846
+ return [view.title, view.summary, ...view.input]
716
847
  .filter((part) => part !== undefined && part !== '')
717
- .join(' · ');
848
+ .join('\n');
718
849
  }
719
850
  notice(text, options = {}) {
720
851
  const merged = options.job === undefined ? undefined : mergeJobSettlement(this.#state, options.job);
@@ -757,7 +888,202 @@ export class LocalTui {
757
888
  else
758
889
  this.#plain.print();
759
890
  }
891
+ /**
892
+ * Run a Shell line as a live card that streams in place.
893
+ *
894
+ * The card is appended before the command starts and updated as it writes, so
895
+ * the reader watches their own command rather than switching to another
896
+ * surface and coming back. It never waits for an exit gesture: when the
897
+ * command finishes the card is finished too, exactly like a tool call.
898
+ */
899
+ async shellCommand(request) {
900
+ const block = { kind: 'shell', command: request.command, output: '', status: 'running' };
901
+ this.#insertShellBlock(block);
902
+ // Ctrl+C belongs to the running command while its card is alive, the same
903
+ // way it belongs to an active turn. The caller decides what to do with the
904
+ // abort; the provider only routes the key.
905
+ this.#shellAbort = request.abort;
906
+ let lastPaint = 0;
907
+ let pending = '';
908
+ let pendingPaint;
909
+ const cancelPending = () => { if (pendingPaint !== undefined) {
910
+ clearTimeout(pendingPaint);
911
+ pendingPaint = undefined;
912
+ } };
913
+ const paint = () => { if (this.#tty && !this.#disposed)
914
+ this.#render(); };
915
+ // Output can arrive faster than a frame is worth; coalescing keeps a chatty
916
+ // command from spending the whole turn in the renderer.
917
+ const append = (output) => {
918
+ pending = output;
919
+ const now = Date.now();
920
+ if (now - lastPaint < SHELL_PAINT_INTERVAL_MS) {
921
+ cancelPending();
922
+ pendingPaint = setTimeout(paint, SHELL_PAINT_INTERVAL_MS - (now - lastPaint));
923
+ return;
924
+ }
925
+ lastPaint = now;
926
+ this.#updateShellBlock(request.command, current => ({ ...current, output: pending }));
927
+ paint();
928
+ };
929
+ let result;
930
+ try {
931
+ result = await request.run(append, request.signal);
932
+ }
933
+ catch (error) {
934
+ this.#updateShellBlock(request.command, current => ({ ...current, status: 'error' }));
935
+ paint();
936
+ throw error;
937
+ }
938
+ finally {
939
+ cancelPending();
940
+ if (this.#shellAbort === request.abort)
941
+ this.#shellAbort = undefined;
942
+ }
943
+ const failed = result.interrupted === true || (result.code !== undefined && result.code !== 0);
944
+ this.#updateShellBlock(request.command, current => ({
945
+ ...current,
946
+ output: result.output === '' ? current.output : result.output,
947
+ status: failed ? 'error' : 'ok',
948
+ ...(result.code === undefined ? {} : { code: result.code }),
949
+ ...(request.share === true ? { shared: true } : {}),
950
+ }));
951
+ paint();
952
+ return result;
953
+ }
954
+ /**
955
+ * A side question, streamed into its own transcript block.
956
+ *
957
+ * Modelled on `shellCommand` rather than on `commandOutput`: the answer arrives
958
+ * over time, and the reader should watch it arrive where it will stay. Coalesced
959
+ * the same way, because a chatty stream would otherwise spend the turn in the
960
+ * renderer.
961
+ */
962
+ /** Navigation belongs to the pane only when nothing else is claiming it. */
963
+ #asideOwnsKeys() {
964
+ return this.#editor.text === '' && this.#ac === null && this.#prompt === null;
965
+ }
966
+ /**
967
+ * Leaving the pane stops the consultation.
968
+ *
969
+ * Without this the request kept running invisibly and its answer was dropped,
970
+ * so the reader could neither read it nor stop paying for it.
971
+ */
972
+ #closeAside() {
973
+ const aside = this.#aside;
974
+ this.#asideAbort?.abort();
975
+ this.#asideAbort = undefined;
976
+ this.#aside = undefined;
977
+ /**
978
+ * A finished answer collapses into one line rather than disappearing.
979
+ *
980
+ * The pane is above the composer, so closing it was the only way to get the
981
+ * screen height back — and that cost the text. Keeping the exchange as a
982
+ * folded block means the reader can always close the pane. A consultation
983
+ * still running records nothing: the reader left before there was an answer.
984
+ */
985
+ if (aside !== undefined && aside.status !== 'running' && aside.text.trim() !== '') {
986
+ this.#state = { ...this.#state, blocks: [...this.#state.blocks, { kind: 'aside', question: aside.question, text: aside.text }] };
987
+ this.#followTail();
988
+ }
989
+ this.#render();
990
+ }
991
+ /**
992
+ * A side question, streamed into its own pane above the composer.
993
+ *
994
+ * Not a transcript block: the exchange belongs to a conversation that is not
995
+ * this one, so it gets its own pane rather than a row in the conversation the
996
+ * reader is working in. The pane is docked, `Esc` closes it, and the session
997
+ * looks exactly as it did. Coalesced the same way a Shell card is, because a
998
+ * chatty stream would otherwise spend the turn in the renderer.
999
+ */
1000
+ async asideQuestion(request) {
1001
+ this.#asideAbort?.abort();
1002
+ const abort = new AbortController();
1003
+ this.#asideAbort = abort;
1004
+ // Leaving the pane and losing the session both stop the consultation.
1005
+ if (request.signal.aborted)
1006
+ abort.abort();
1007
+ else
1008
+ request.signal.addEventListener('abort', () => abort.abort(), { once: true });
1009
+ this.#aside = { question: request.question, text: '', status: 'running', scroll: 0 };
1010
+ this.#render();
1011
+ let lastPaint = 0;
1012
+ let pending = '';
1013
+ let pendingPaint;
1014
+ const paint = () => { if (this.#tty && !this.#disposed)
1015
+ this.#render(); };
1016
+ const show = (change) => {
1017
+ if (this.#aside !== undefined)
1018
+ this.#aside = { ...this.#aside, ...change };
1019
+ };
1020
+ /**
1021
+ * The pending text is applied by the paint, not merely followed by one: a
1022
+ * chunk that arrives inside the throttle window used to schedule a repaint of
1023
+ * the *old* text, so the last words before a pause never appeared.
1024
+ */
1025
+ const flush = () => {
1026
+ if (pendingPaint !== undefined) {
1027
+ clearTimeout(pendingPaint);
1028
+ pendingPaint = undefined;
1029
+ }
1030
+ lastPaint = Date.now();
1031
+ show({ text: pending });
1032
+ paint();
1033
+ };
1034
+ const stream = (text) => {
1035
+ pending = text;
1036
+ const wait = SHELL_PAINT_INTERVAL_MS - (Date.now() - lastPaint);
1037
+ if (wait <= 0) {
1038
+ flush();
1039
+ return;
1040
+ }
1041
+ if (pendingPaint !== undefined)
1042
+ clearTimeout(pendingPaint);
1043
+ pendingPaint = setTimeout(flush, wait);
1044
+ };
1045
+ try {
1046
+ const result = await request.run(stream, abort.signal);
1047
+ show({
1048
+ text: result.text,
1049
+ status: result.failed === true ? 'error' : 'done',
1050
+ ...(result.detail === undefined ? {} : { detail: result.detail }),
1051
+ });
1052
+ }
1053
+ catch (error) {
1054
+ if (!abort.signal.aborted)
1055
+ show({ status: 'error', detail: error instanceof Error ? error.message : String(error) });
1056
+ }
1057
+ finally {
1058
+ if (pendingPaint !== undefined)
1059
+ clearTimeout(pendingPaint);
1060
+ if (this.#asideAbort === abort)
1061
+ this.#asideAbort = undefined;
1062
+ paint();
1063
+ }
1064
+ }
1065
+ /** Append a Shell card and follow it, so the reader sees it start. */
1066
+ #insertShellBlock(block) {
1067
+ this.#state = { ...this.#state, blocks: [...this.#state.blocks, block] };
1068
+ this.#followTail();
1069
+ if (this.#tty)
1070
+ this.#render();
1071
+ else
1072
+ this.#plain.print();
1073
+ }
1074
+ /** Replace the newest Shell card for `command`; a card is identified by its position. */
1075
+ #updateShellBlock(command, update) {
1076
+ const at = this.#state.blocks.findLastIndex(block => block.kind === 'shell' && block.command === command);
1077
+ if (at < 0)
1078
+ return;
1079
+ const next = this.#state.blocks.slice();
1080
+ next[at] = update(next[at]);
1081
+ this.#state = { ...this.#state, blocks: next };
1082
+ }
760
1083
  prompt(request) {
1084
+ // Background approvals take the terminal from a user-opened file reader.
1085
+ if (this.#fileReviewAbort !== undefined && request.signal !== this.#fileReviewAbort.signal)
1086
+ this.#fileReviewAbort.abort();
761
1087
  if (this.#disposed || request.signal?.aborted === true)
762
1088
  return Promise.resolve(null);
763
1089
  if (this.#prompt !== null) {
@@ -775,18 +1101,21 @@ export class LocalTui {
775
1101
  }
776
1102
  this.#flushPasteBurst(true);
777
1103
  this.#pasteBurst.clear();
778
- if (request.notify !== false)
1104
+ if (request.notify !== false) {
779
1105
  this.#emitNotification(this.#notifications.humanPrompt());
780
- this.#herdr.prompt(request.title);
1106
+ this.#herdr.prompt(request.title);
1107
+ this.#syncWindowTitle();
1108
+ }
781
1109
  if (this.#tty && request.documentImage !== undefined && this.#imageProtocol !== undefined)
782
1110
  this.#term.output.write('\x1b[16t');
783
1111
  const savedInput = this.#editor.snapshot();
784
- this.#editor.setText(request.options?.length || request.allowCustom === false ? '' : request.initialInput ?? '');
1112
+ this.#editor.setText(request.allowCustom === false || request.filterable === true || (request.options?.length && request.initialInput === undefined) ? '' : request.initialInput ?? '');
785
1113
  this.#ac = null;
786
1114
  const displaced = this.#displaceSurface();
787
1115
  return new Promise((resolve) => {
788
1116
  const selected = Math.max(0, filteredPromptOptions(request, '').findIndex(option => (option.value ?? option.label) === request.initialValue) ?? 0);
789
- const pending = { request, selected, checked: new Set(), resolve, savedInput,
1117
+ const pending = { request, selected, checked: new Set((request.options ?? []).flatMap((option, index) => request.initialValues?.includes(option.value ?? option.label) ? [index] : [])), resolve, savedInput,
1118
+ ...(request.armMs !== undefined && request.armMs > 0 ? { armedUntil: Date.now() + request.armMs } : {}),
790
1119
  ...(request.documentPosition === undefined ? {} : { documentAnchor: request.documentPosition, documentQuery: request.documentPosition.query,
791
1120
  documentWordWrap: request.documentPosition.wordWrap, documentColumn: request.documentPosition.column }),
792
1121
  ...(request.documentTail === true && request.documentPosition?.following !== false ? { documentScroll: Number.POSITIVE_INFINITY } : {}) };
@@ -864,11 +1193,13 @@ export class LocalTui {
864
1193
  });
865
1194
  }
866
1195
  replaceSession(events, presentations, status = 'idle', reason = 'open') {
1196
+ if (reason !== 'refresh')
1197
+ this.#fileReviewAbort?.abort();
867
1198
  this.#trajectory = null;
868
1199
  const state = replayEvents(events, presentations);
869
- // Notices and tool catalogs do not turn a blank session into a document
870
- // that /new needs to separate. Refreshes are the same document.
871
- const boundary = reason === 'open' || (reason === 'new' && this.#state.blocks.some(block => block.kind !== 'notice' && block.kind !== 'toolCatalog'));
1200
+ // A notice does not turn a blank session into a document that /new needs
1201
+ // to separate. Refreshes are the same document.
1202
+ const boundary = reason === 'open' || (reason === 'new' && this.#state.blocks.some(block => block.kind !== 'notice'));
872
1203
  const blocks = boundary ? [transcriptSeam('session-opened'), ...state.blocks] : state.blocks;
873
1204
  this.#state = { ...state, status, compactCommandId: undefined, blocks };
874
1205
  this.#plain.resetPrinted();
@@ -881,12 +1212,13 @@ export class LocalTui {
881
1212
  this.#plain.print();
882
1213
  }
883
1214
  setSession(info) {
1215
+ if (info.id !== this.#sessionId)
1216
+ this.#fileReviewAbort?.abort();
884
1217
  this.#switchDraft(this.#inspected?.id ?? info.id);
885
1218
  this.#sessionId = info.id;
886
1219
  this.#herdr.setSession(info.id);
887
1220
  const title = info.title?.trim();
888
1221
  this.#sessionTitle = title === undefined || title === '' ? undefined : title;
889
- this.#recentSessions = info.recent.map((session) => ({ ...session }));
890
1222
  this.#sessionStats = info.stats === undefined ? undefined : { ...info.stats };
891
1223
  this.#sessionControls = info.controls === undefined
892
1224
  ? undefined
@@ -894,6 +1226,7 @@ export class LocalTui {
894
1226
  ...info.controls,
895
1227
  ...(info.controls.plan === undefined ? {} : { plan: { ...info.controls.plan } }),
896
1228
  };
1229
+ this.#syncSettingsActions();
897
1230
  if (!this.#tty)
898
1231
  return;
899
1232
  this.#syncWindowTitle();
@@ -904,30 +1237,50 @@ export class LocalTui {
904
1237
  else
905
1238
  this.#render();
906
1239
  }
907
- /**
908
- * Mirror the folded session title into the terminal's own window/tab title.
909
- * Control characters are stripped so a title can never inject an escape
910
- * sequence, and the payload is bounded to keep tab strips readable.
911
- */
1240
+ /** Only the foreground terminal owner emits title and program-state sequences. */
912
1241
  #syncWindowTitle() {
913
- const title = windowTitleText(this.#sessionTitle);
914
- if (title === this.#writtenWindowTitle)
1242
+ if (!this.#tty || this.#disposed)
1243
+ return;
1244
+ const status = this.#herdr.programStatus;
1245
+ const title = terminalTitle(this.#terminalTitle, status, this.#sessionTitle, this.#cwd, this.#branch);
1246
+ if (title !== this.#writtenWindowTitle && !(this.#terminalTitle === 'off' && this.#writtenWindowTitle === undefined)) {
1247
+ this.#writtenWindowTitle = title;
1248
+ this.#term.output.write(`\x1b]2;${title}\x07`);
1249
+ }
1250
+ if (this.#terminalState === 'auto' && !this.#programQuerySent) {
1251
+ this.#programQuerySent = true;
1252
+ this.#term.output.write(PROGRAM_STATUS_QUERY);
1253
+ }
1254
+ const enabled = this.#terminalState === 'on' || this.#terminalState === 'auto' && this.#programSupported;
1255
+ if (!enabled) {
1256
+ if (this.#writtenProgramState !== undefined)
1257
+ this.#term.output.write(programStatusSequence('clear'));
1258
+ this.#writtenProgramState = undefined;
1259
+ return;
1260
+ }
1261
+ // Only the session name is exported as metadata; prompt bodies and errors stay in the TUI.
1262
+ const sequence = programStatusSequence({ state: status.state, ...(this.#sessionTitle === undefined ? {} : { message: this.#sessionTitle }) });
1263
+ if (sequence === this.#writtenProgramState)
915
1264
  return;
916
- this.#writtenWindowTitle = title;
917
- this.#term.output.write(`\x1b]2;${title}\x07`);
1265
+ this.#writtenProgramState = sequence;
1266
+ this.#term.output.write(sequence);
918
1267
  }
919
1268
  /**
920
- * Re-derive the 24-bit switch from the active color preference. Every path
1269
+ * Re-derive the color depth from the active color preference. Every path
921
1270
  * that changes `#colors` calls this so `/settings` takes effect immediately.
922
1271
  */
923
- #syncTrueColor() {
924
- this.#trueColor = this.#colors && detectTrueColor();
1272
+ #syncColorDepth() {
1273
+ this.#colorDepth = this.#colors ? detectColorDepth() : 'ansi16';
925
1274
  }
926
1275
  /** Apply prefs loaded from this entry's config (does not persist). */
927
1276
  applyStoredPrefs(prefs) {
1277
+ if (this.#disposed)
1278
+ return;
1279
+ this.#prefsVersion++;
928
1280
  this.#applyPrefs(prefs, { persist: false, forceToolsSync: true });
929
1281
  if (this.#settings !== null)
930
1282
  this.#settings = { ...this.#settings, prefs };
1283
+ this.#refreshSettingsCatalog();
931
1284
  if (this.#tty)
932
1285
  this.#render();
933
1286
  }
@@ -939,32 +1292,27 @@ export class LocalTui {
939
1292
  motion: this.#motion,
940
1293
  mathMode: this.#mathMode,
941
1294
  mermaidMode: this.#mermaidMode,
1295
+ hideThinking: this.#hideThinking,
942
1296
  editor: this.#externalEditor,
943
- terminalProgress: this.#terminalProgress,
1297
+ terminalTitle: this.#terminalTitle,
1298
+ terminalState: this.#terminalState,
944
1299
  copyOnSelect: this.#copyOnSelect,
945
1300
  mouseInteraction: this.#mouseInteraction,
946
1301
  pasteProtection: this.#pasteProtection,
947
1302
  checkUpdates: this.#checkUpdates,
948
1303
  startupChangelog: this.#startupChangelog,
949
1304
  notifications: this.#notificationPolicy,
950
- notificationThreshold: this.#notificationThreshold,
951
- notificationFocus: this.#notificationFocus,
952
- statusBar: {
953
- ...this.#statusBar,
954
- groups: [...this.#statusBar.groups],
955
- ...(this.#statusBar.order === undefined ? {} : { order: [...this.#statusBar.order] }),
956
- ...(this.#statusBar.meta === undefined ? {} : { meta: [...this.#statusBar.meta] }),
957
- ...(this.#statusBar.metaOrder === undefined ? {} : { metaOrder: [...this.#statusBar.metaOrder] }),
958
- ...(this.#statusBar.colors === undefined ? {} : { colors: { ...this.#statusBar.colors } }),
959
- ...(this.#statusBar.sides === undefined ? {} : { sides: { ...this.#statusBar.sides } }),
960
- },
1305
+ // The whole document, not the density alone: a projection that dropped the
1306
+ // narrowing made every later re-read of the preferences show the parts the
1307
+ // reader had just hidden.
1308
+ statusBar: statusBarDocument(this.#statusBar),
961
1309
  };
962
1310
  }
963
1311
  /** Called after a live `/settings` change. */
964
1312
  setPrefsPersist(persist) {
965
1313
  this.#persistPrefs = persist;
966
1314
  }
967
- /** Bind the native Plugins section; unbinding cancels and drains its active form. */
1315
+ /** Bind product configuration and Advanced forms; unbinding cancels and drains its active form. */
968
1316
  bindPluginSettings(binding) {
969
1317
  if (this.#pluginSettings !== undefined)
970
1318
  throw new Error('omdsh-tui: Plugin settings are already bound');
@@ -975,8 +1323,7 @@ export class LocalTui {
975
1323
  this.#pluginSettings = undefined;
976
1324
  this.#pluginSettingsAbort?.abort();
977
1325
  await this.#pluginSettingsTask;
978
- if (this.#settings !== null)
979
- this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, undefined, discoverEditors(this.#externalEditor));
1326
+ this.#rebuildSettings([]);
980
1327
  if (this.#tty && !this.#disposed)
981
1328
  this.#render();
982
1329
  };
@@ -998,8 +1345,7 @@ export class LocalTui {
998
1345
  this.#updateAgentBehavior = undefined;
999
1346
  this.#agentBehavior = undefined;
1000
1347
  this.#agentBehaviorPending = false;
1001
- if (this.#settings !== null)
1002
- this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor));
1348
+ this.#rebuildSettings(this.#pluginSettings?.entries() ?? []);
1003
1349
  if (this.#tty && !this.#disposed)
1004
1350
  this.#render();
1005
1351
  };
@@ -1163,14 +1509,31 @@ export class LocalTui {
1163
1509
  return () => { if (this.#steerHandler === handler)
1164
1510
  this.#steerHandler = undefined; };
1165
1511
  }
1512
+ /**
1513
+ * Last-resort restore for a process that exits without disposing: leave the
1514
+ * alternate screen, give every mode back and show the cursor. Writes to a TTY
1515
+ * are synchronous, so this completes before the process ends.
1516
+ */
1517
+ #restoreTerminalOnExit() {
1518
+ if (this.#disposed || !this.#tty)
1519
+ return;
1520
+ try {
1521
+ this.#renderer.finish();
1522
+ }
1523
+ catch { /* best effort while exiting */ }
1524
+ this.#session.release();
1525
+ this.#term.output.write('\x1b[?25h');
1526
+ }
1166
1527
  dispose() {
1167
1528
  if (this.#disposed)
1168
- return this.#herdr.release();
1529
+ return this.#disposeTask ?? this.#herdr.release();
1169
1530
  this.#flushPasteBurst(true);
1170
1531
  this.#flushDraft();
1171
1532
  this.#disposed = true;
1172
1533
  this.#steerHandler = undefined;
1173
1534
  this.#imagePreviewAbort?.abort();
1535
+ this.#fileReviewAbort?.abort();
1536
+ this.#asideAbort?.abort();
1174
1537
  for (const directory of this.#imageTemporary) {
1175
1538
  try {
1176
1539
  rmSync(directory, { recursive: true, force: true });
@@ -1200,7 +1563,6 @@ export class LocalTui {
1200
1563
  this.#tick = null;
1201
1564
  }
1202
1565
  this.#stopRevealTick();
1203
- this.#setTerminalProgress(false);
1204
1566
  if (this.#loopTick !== null) {
1205
1567
  clearInterval(this.#loopTick);
1206
1568
  this.#loopTick = null;
@@ -1220,7 +1582,13 @@ export class LocalTui {
1220
1582
  this.#term.output.write('\x1b]2;\x07');
1221
1583
  this.#writtenWindowTitle = undefined;
1222
1584
  }
1223
- this.#term.output.write('\x1b[?2004l\x1b[?1004l\x1b[?25h\r\n');
1585
+ if (this.#writtenProgramState !== undefined)
1586
+ this.#term.output.write(programStatusSequence('clear'));
1587
+ this.#writtenProgramState = undefined;
1588
+ this.#offExit?.();
1589
+ this.#offExit = undefined;
1590
+ this.#session.dispose();
1591
+ this.#term.output.write('\x1b[?25h\r\n');
1224
1592
  if (this.#resumeHintRequested && this.#sessionId !== undefined) {
1225
1593
  this.#term.output.write(`\r\nResume this session with ${APP_NAME} --resume ${this.#sessionId}\r\n`);
1226
1594
  }
@@ -1230,6 +1598,10 @@ export class LocalTui {
1230
1598
  }
1231
1599
  if (this.#escapeTimer !== null)
1232
1600
  clearTimeout(this.#escapeTimer);
1601
+ if (this.#transientTimer !== null) {
1602
+ clearTimeout(this.#transientTimer);
1603
+ this.#transientTimer = null;
1604
+ }
1233
1605
  if (this.#autocompleteTimer !== null)
1234
1606
  clearTimeout(this.#autocompleteTimer);
1235
1607
  this.#autocompleteAbort?.abort();
@@ -1240,7 +1612,8 @@ export class LocalTui {
1240
1612
  this.#offAgentBehaviorWatch = undefined;
1241
1613
  this.#settlePending(null);
1242
1614
  this.#finishPrompt(null);
1243
- return this.#herdr.release();
1615
+ this.#disposeTask = Promise.all([this.#herdr.release(), this.#prefsSaveTask]).then(() => { });
1616
+ return this.#disposeTask;
1244
1617
  }
1245
1618
  /** Settle one pending read, detaching its abort listener. */
1246
1619
  #settlePending(submission) {
@@ -1262,11 +1635,30 @@ export class LocalTui {
1262
1635
  return true;
1263
1636
  return this.#subagents?.agents.some(agent => agent.phase === 'running' || agent.phase === 'starting') === true;
1264
1637
  }
1638
+ /** Countdown shown on the working row while a retry or a compaction is pending. */
1639
+ #workingLabel() {
1640
+ if (this.#retryDeadline !== undefined) {
1641
+ return `Retrying in ${Math.max(0, Math.ceil((this.#retryDeadline - Date.now()) / 1000))}s`;
1642
+ }
1643
+ if (this.#state.status === 'compacting' && this.#compactionStartedAt !== undefined) {
1644
+ return `Compacting ${Math.max(0, Math.floor((Date.now() - this.#compactionStartedAt) / 1000))}s`;
1645
+ }
1646
+ return undefined;
1647
+ }
1648
+ /** The newest image the transcript carries, whichever side produced it. */
1649
+ #latestImageRef() {
1650
+ for (let index = this.#state.blocks.length - 1; index >= 0; index -= 1) {
1651
+ const block = this.#state.blocks[index];
1652
+ const image = block.kind === 'tool' || block.kind === 'user' ? block.image : undefined;
1653
+ if (image !== undefined)
1654
+ return image;
1655
+ }
1656
+ return undefined;
1657
+ }
1265
1658
  #syncTick() {
1266
1659
  if (!this.#tty)
1267
1660
  return;
1268
1661
  const busy = this.#busy();
1269
- this.#setTerminalProgress(this.#terminalProgress && busy);
1270
1662
  if (busy && this.#motion !== 'off') {
1271
1663
  if (this.#tick === null) {
1272
1664
  this.#tick = setInterval(() => {
@@ -1323,31 +1715,6 @@ export class LocalTui {
1323
1715
  clearInterval(this.#revealTick);
1324
1716
  this.#revealTick = null;
1325
1717
  }
1326
- #setTerminalProgress(active) {
1327
- if (!this.#tty)
1328
- return;
1329
- if (active) {
1330
- if (this.#terminalProgressActive)
1331
- return;
1332
- this.#terminalProgressActive = true;
1333
- this.#term.output.write(terminalProgressSequence(true));
1334
- this.#terminalProgressTick = setInterval(() => {
1335
- if (!this.#disposed && this.#terminalProgressActive) {
1336
- this.#term.output.write(terminalProgressSequence(true));
1337
- }
1338
- }, TERMINAL_PROGRESS_KEEPALIVE_MS);
1339
- this.#terminalProgressTick.unref?.();
1340
- return;
1341
- }
1342
- if (this.#terminalProgressTick !== null) {
1343
- clearInterval(this.#terminalProgressTick);
1344
- this.#terminalProgressTick = null;
1345
- }
1346
- if (!this.#terminalProgressActive)
1347
- return;
1348
- this.#terminalProgressActive = false;
1349
- this.#term.output.write(terminalProgressSequence(false));
1350
- }
1351
1718
  #syncLoopTick() {
1352
1719
  if (!this.#tty)
1353
1720
  return;
@@ -1466,10 +1833,14 @@ export class LocalTui {
1466
1833
  }
1467
1834
  if (this.#deferInitialRender)
1468
1835
  return;
1836
+ // Rows committed to scrollback are immutable; the first real paint waits for the probe so it already uses the reported colors.
1837
+ if (this.#probeGate)
1838
+ return;
1469
1839
  const width = this.#term.width();
1470
1840
  const renderState = this.#reveal === undefined
1471
1841
  ? this.#state
1472
1842
  : revealStreamingAssistant(this.#state, this.#reveal.revealed);
1843
+ const workingLabel = this.#workingLabel();
1473
1844
  const frame = this.#tty
1474
1845
  ? renderView(renderState, {
1475
1846
  width,
@@ -1477,6 +1848,7 @@ export class LocalTui {
1477
1848
  imageCellSize: this.#imageCellSize,
1478
1849
  mathMode: this.#mathMode,
1479
1850
  mermaidMode: this.#mermaidMode,
1851
+ hideThinking: this.#hideThinking,
1480
1852
  height: this.#term.height(),
1481
1853
  model: this.#model,
1482
1854
  ...(this.#reasoningEffort === undefined ? {} : { reasoningEffort: this.#reasoningEffort }),
@@ -1492,46 +1864,47 @@ export class LocalTui {
1492
1864
  ...(this.#sessionTitle === undefined ? {} : { sessionTitle: this.#sessionTitle }),
1493
1865
  version: APP_VERSION,
1494
1866
  appName: APP_NAME,
1867
+ displayName: APP_DISPLAY_NAME,
1495
1868
  spinnerFrame: this.#spinner,
1496
1869
  motion: this.#motion,
1497
- trueColor: this.#trueColor,
1870
+ colorDepth: this.#colorDepth,
1498
1871
  themeName: this.#themeName,
1872
+ terminal: this.#terminalColors,
1873
+ ...(workingLabel === undefined ? {} : { workingLabel }),
1874
+ ...(this.#transientStatus === undefined ? {} : { transientStatus: this.#transientStatus }),
1499
1875
  scrollStart: this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart,
1500
1876
  ...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock, focusBlockEdge: this.#focusBlockEdge ?? 'start' }),
1501
1877
  ...(this.#transcriptSearch === null ? {} : {
1502
1878
  transcriptSearch: {
1503
1879
  query: this.#transcriptSearch.query,
1504
- matches: searchBlockIndexes(this.#state.blocks.map(blockSearchText), this.#transcriptSearch.query),
1505
1880
  focus: this.#transcriptSearch.focus,
1506
1881
  editing: this.#transcriptSearch.editing,
1507
1882
  },
1508
1883
  }),
1509
1884
  toolsExpanded: this.#toolsExpanded,
1885
+ expandedOutputs: this.#expandedOutputs,
1510
1886
  expandedTools: this.#detailedToolIds(),
1511
1887
  toolDetailsKey: keysForAction(this.#keybindings, 'toggle-tool-details'),
1512
1888
  turnDetailsKey: keysForAction(this.#keybindings, 'toggle-tools'),
1513
1889
  ...(this.#steerHandler === undefined ? {} : { steerKey: keysForAction(this.#keybindings, 'steer-turn') }),
1514
1890
  openedGroups: this.#openedGroups,
1515
1891
  commands: this.#commands(),
1516
- recentSessions: this.#recentSessions,
1517
- welcomeTips: this.#welcomeTips,
1892
+ modelReady: this.#modelReady,
1518
1893
  ...(this.#sessionStats === undefined ? {} : { sessionStats: this.#sessionStats }),
1519
1894
  ...(this.#sessionControls === undefined ? {} : { sessionControls: this.#sessionControls }),
1520
1895
  ...(this.#loopStatus === undefined ? {} : { loopStatus: this.#loopStatus }),
1521
1896
  ...(this.#subagents === undefined ? {} : { subagents: this.#subagents }),
1522
- subagentLauncherFocused: this.#subagentLauncherFocused,
1897
+ ...(this.#subagentSelection === undefined ? {} : { subagentSelection: this.#subagentSelection }),
1523
1898
  ...(this.#inspected === undefined ? {} : { inspected: this.#inspected }),
1524
1899
  ...(this.#trajectory === null ? {} : { trajectory: this.#trajectory }),
1525
- ...(this.#agentHub === null ? {} : { agentHub: this.#agentHub }),
1900
+ ...(this.#aside === undefined ? {} : { aside: this.#aside }),
1526
1901
  statusBar: this.#statusBar,
1527
1902
  ...(this.#prompt === null ? {} : { promptSelector: this.#prompt }),
1528
1903
  ...(this.#settings !== null
1529
1904
  ? { settings: this.#settings }
1530
- : this.#copySelector !== null
1531
- ? { copySelector: this.#copySelector }
1532
- : this.#search !== null
1533
- ? { historySearch: this.#search }
1534
- : this.#ac !== null ? { autocomplete: this.#ac } : {}),
1905
+ : this.#search !== null
1906
+ ? { historySearch: this.#search }
1907
+ : this.#ac !== null ? { autocomplete: this.#ac } : {}),
1535
1908
  })
1536
1909
  : { lines: [] };
1537
1910
  this.#focusBlock = undefined;
@@ -1542,8 +1915,9 @@ export class LocalTui {
1542
1915
  }
1543
1916
  this.#syncScroll(frame.transcript);
1544
1917
  this.#jumpToLatest = frame.jumpToLatest;
1918
+ this.#fileActionFrame = frame.transcript === undefined || frame.transientSurface === 'overlay' ? undefined : frame;
1545
1919
  this.#setMouseTracking(frame.transcript !== undefined && frame.transientSurface !== 'overlay'
1546
- && (this.#inspecting || this.#maxStart > 0));
1920
+ && (this.#inspecting || this.#maxStart > 0 || (frame.transcript.fileRegions?.length ?? 0) > 0));
1547
1921
  // A run opening or folding reshapes rows above the screen in place; the
1548
1922
  // renderer's frozen boundary is a row index and has to be told, or a fold
1549
1923
  // blanks the screen and a large one replays the transcript into history.
@@ -1634,7 +2008,27 @@ export class LocalTui {
1634
2008
  if (!this.#tty || enabled === this.#mouseTracking)
1635
2009
  return;
1636
2010
  this.#mouseTracking = enabled;
1637
- this.#term.output.write(enabled ? '\x1b[?1000h\x1b[?1002h\x1b[?1006h' : '\x1b[?1006l\x1b[?1002l\x1b[?1000l');
2011
+ this.#session.setMode('mouse', MOUSE_TRACKING, enabled);
2012
+ }
2013
+ /** Give every terminal mode back before the terminal is lent out (Ctrl+Z, external editor). */
2014
+ #handOffTerminal() {
2015
+ this.#setMouseTracking(false);
2016
+ this.#session.handOff();
2017
+ }
2018
+ /** Run `run` with the terminal handed off; take it back and repaint afterwards. */
2019
+ #handTerminal(run) {
2020
+ this.#handOffTerminal();
2021
+ this.#term.input.setRawMode?.(false);
2022
+ try {
2023
+ return run();
2024
+ }
2025
+ finally {
2026
+ if (!this.#disposed) {
2027
+ this.#term.input.setRawMode?.(true);
2028
+ this.#session.takeBack();
2029
+ this.#renderer.reset();
2030
+ }
2031
+ }
1638
2032
  }
1639
2033
  /**
1640
2034
  * After a Ctrl+O toggle: read what is open in a browsing frame at `target`,
@@ -1680,14 +2074,14 @@ export class LocalTui {
1680
2074
  this.#routeInput(event);
1681
2075
  }, 80);
1682
2076
  }
1683
- else if (rest.length > 32) {
2077
+ else if (rest.length > 32 && !rest.startsWith('\x1b]')) {
1684
2078
  this.#pendingKeys = '';
1685
2079
  }
1686
2080
  }
1687
2081
  #routeInputBatch(events) {
1688
2082
  if (this.#pasteProtection && this.#state.status !== 'compacting' && !this.#paste && this.#pasteInFlight === 0
1689
- && this.#prompt === null && this.#settings === null && this.#copySelector === null
1690
- && this.#search === null && this.#trajectory === null && this.#agentHub === null && this.#transcriptSearch === null
2083
+ && this.#prompt === null && this.#settings === null
2084
+ && this.#search === null && this.#trajectory === null && this.#transcriptSearch === null
1691
2085
  && events.every(event => event.type === 'text' || (event.type === 'key' && ['enter', 'ctrl+j', 'tab'].includes(event.id)))) {
1692
2086
  if (this.#pasteBurstTimer !== undefined)
1693
2087
  clearTimeout(this.#pasteBurstTimer);
@@ -1710,8 +2104,8 @@ export class LocalTui {
1710
2104
  this.#dispatch(event);
1711
2105
  return;
1712
2106
  }
1713
- const composer = this.#state.status !== 'compacting' && this.#prompt === null && this.#settings === null && this.#copySelector === null
1714
- && this.#search === null && this.#trajectory === null && this.#agentHub === null && this.#transcriptSearch === null;
2107
+ const composer = this.#state.status !== 'compacting' && this.#prompt === null && this.#settings === null
2108
+ && this.#search === null && this.#trajectory === null && this.#transcriptSearch === null;
1715
2109
  if (this.#paste || this.#pasteInFlight > 0 || !this.#pasteProtection || !composer
1716
2110
  || event.type === 'paste-start' || event.type === 'paste-end') {
1717
2111
  this.#flushPasteBurst(true);
@@ -1728,6 +2122,7 @@ export class LocalTui {
1728
2122
  }
1729
2123
  #deliverPasteEmission(event) {
1730
2124
  if (event.type === 'paste') {
2125
+ this.#literalInput ||= this.#editor.cursor === 0 && event.value.startsWith('!');
1731
2126
  this.#editor.paste(event.value.replaceAll('\r\n', '\n').replaceAll('\r', '\n'));
1732
2127
  this.#refreshAutocomplete();
1733
2128
  this.#render();
@@ -1763,7 +2158,7 @@ export class LocalTui {
1763
2158
  async #acceptPastedText(text) {
1764
2159
  if (this.#prompt !== null) {
1765
2160
  const prompt = this.#prompt;
1766
- if (prompt.request.documentSource !== undefined && prompt.documentInput !== undefined) {
2161
+ if (prompt.documentInput !== undefined) {
1767
2162
  this.#handleDocumentReader({ type: 'text', value: text }, prompt);
1768
2163
  return;
1769
2164
  }
@@ -1776,7 +2171,7 @@ export class LocalTui {
1776
2171
  this.#render();
1777
2172
  return;
1778
2173
  }
1779
- if (this.#settings !== null || this.#copySelector !== null)
2174
+ if (this.#settings !== null)
1780
2175
  return;
1781
2176
  if (this.#search !== null) {
1782
2177
  this.#applySearch({ type: 'text', value: text });
@@ -1810,12 +2205,13 @@ export class LocalTui {
1810
2205
  return;
1811
2206
  }
1812
2207
  }
2208
+ this.#literalInput ||= this.#editor.cursor === 0 && text.startsWith('!');
1813
2209
  this.#editor.paste(text);
1814
2210
  this.#refreshAutocomplete();
1815
2211
  this.#render();
1816
2212
  }
1817
2213
  async #pasteClipboard() {
1818
- if (this.#search === null && this.#settings === null && this.#copySelector === null) {
2214
+ if (this.#search === null && this.#settings === null) {
1819
2215
  const image = await this.#readClipboardImage();
1820
2216
  if (image !== null) {
1821
2217
  if (await this.#images.admit(image)) {
@@ -1863,8 +2259,27 @@ export class LocalTui {
1863
2259
  return;
1864
2260
  const inside = (target) => event.row === target.row && event.column >= target.column && event.column < target.column + target.width;
1865
2261
  if (event.button === 'right') {
1866
- if (event.action === 'press')
1867
- void this.#copySelection();
2262
+ if (event.action === 'press') {
2263
+ if (this.#selection.active)
2264
+ void this.#copySelection();
2265
+ else {
2266
+ const document = this.#renderer.documentRowAt(event.row);
2267
+ const frame = this.#fileActionFrame;
2268
+ const source = frame?.documentRows;
2269
+ const region = document === undefined ? undefined : frame?.transcript?.fileRegions?.find(region => document >= region.start && document < region.end);
2270
+ const expected = document === undefined || source === undefined || source === null ? undefined
2271
+ : frame?.lines[source.frameStart + document - source.documentStart];
2272
+ const actual = this.#renderer.viewport()[event.row];
2273
+ // Native-history snapshots may differ from the current document. Do
2274
+ // not reinterpret their old text as another file after a reflow.
2275
+ if (region !== undefined && expected !== undefined && actual !== undefined && stripAnsi(sanitizeDisplayLine(expected)).trimEnd() === stripAnsi(actual).trimEnd()) {
2276
+ const block = this.#state.blocks[region.block];
2277
+ const action = block === undefined ? undefined : blockFileAction(block);
2278
+ if (action !== undefined)
2279
+ this.#openFileAction(action, region.file);
2280
+ }
2281
+ }
2282
+ }
1868
2283
  return;
1869
2284
  }
1870
2285
  if (event.action === 'press') {
@@ -1921,16 +2336,45 @@ export class LocalTui {
1921
2336
  this.notice('Could not copy selection to the clipboard.', { level: 'error' });
1922
2337
  }
1923
2338
  }
2339
+ #openFileAction(action, initial) {
2340
+ if (!this.#tty || this.#disposed || this.#prompt !== null || this.#fileReviewAbort !== undefined)
2341
+ return;
2342
+ const abort = new AbortController();
2343
+ this.#fileReviewAbort = abort;
2344
+ this.#selection.clear();
2345
+ const surface = {
2346
+ prompt: (request) => this.prompt({ ...request, onSuperseded: () => { abort.abort(); } }),
2347
+ notice: (text, options) => { this.notice(text, options); },
2348
+ openFileInEditor: (path) => { this.openFileInEditor(path); },
2349
+ insertText: (text) => { this.insertText(text); },
2350
+ };
2351
+ void reviewFiles(surface, action.title, action.cwd, action.files, index => fileDocument(action.files[index].path, abort.signal), abort.signal, initial ?? (action.files.length === 1 ? 0 : undefined), false)
2352
+ .catch(error => { if (!abort.signal.aborted && !this.#disposed)
2353
+ this.notice(`Could not preview file: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' }); })
2354
+ .finally(() => { if (this.#fileReviewAbort === abort)
2355
+ this.#fileReviewAbort = undefined; });
2356
+ }
1924
2357
  #dispatch(event) {
2358
+ // Any input makes the last confirmation stale; a note that survives typing
2359
+ // starts competing with the command the reader is writing.
2360
+ if (event.type === 'key' || event.type === 'text')
2361
+ this.#clearTransientStatus();
1925
2362
  if (event.type === 'cell-size') {
1926
2363
  this.#imageCellSize = { width: event.width, height: event.height };
1927
2364
  if (this.#prompt?.request.documentImage !== undefined)
1928
2365
  this.#render();
1929
2366
  return;
1930
2367
  }
2368
+ if (event.type === 'program-status-support') {
2369
+ this.#programSupported = true;
2370
+ this.#syncWindowTitle();
2371
+ return;
2372
+ }
2373
+ if (this.#session.handle(event))
2374
+ return;
1931
2375
  if (event.type === 'focus') {
1932
2376
  this.#terminalFocused = event.focused;
1933
- if (event.focused && this.#notificationFocus === 'unfocused')
2377
+ if (event.focused && this.#notificationPolicy === 'unfocused')
1934
2378
  this.#notificationQueue.clear();
1935
2379
  return;
1936
2380
  }
@@ -1954,7 +2398,10 @@ export class LocalTui {
1954
2398
  if (event.type === 'wheel') {
1955
2399
  this.#jumpPress = undefined;
1956
2400
  if (this.#mouseTracking && !this.#paste && this.#pasteInFlight === 0) {
1957
- this.#scrollBy(event.direction === 'up' ? -3 : 3);
2401
+ const step = wheelStep(this.#wheelGesture, Date.now(), event.direction === 'up' ? -1 : 1, !this.#wheelAccelerates);
2402
+ this.#wheelGesture = step.gesture;
2403
+ if (step.lines !== 0)
2404
+ this.#scrollBy(step.lines);
1958
2405
  }
1959
2406
  return;
1960
2407
  }
@@ -2020,7 +2467,7 @@ export class LocalTui {
2020
2467
  if (this.#handlePrompt(event))
2021
2468
  return;
2022
2469
  if (this.#trajectory !== null) {
2023
- const { pageSize } = trajectoryListMetrics(this.#trajectory, this.#term.height());
2470
+ const { pageSize } = trajectoryListMetrics(this.#trajectory, this.#term.height(), this.#term.width());
2024
2471
  const { pageLines } = trajectoryDetailMetrics(this.#trajectory, this.#term.height());
2025
2472
  const command = applyTrajectoryEvent(this.#trajectory, event, { pageSize, detailPageLines: pageLines });
2026
2473
  if ('close' in command)
@@ -2030,25 +2477,12 @@ export class LocalTui {
2030
2477
  this.#render();
2031
2478
  return;
2032
2479
  }
2033
- if (this.#agentHub !== null) {
2034
- const command = applyAgentHubEvent(this.#agentHub, event);
2035
- if ('close' in command)
2036
- this.#agentHub = null;
2037
- else if ('open' in command) {
2038
- this.#agentHub = null;
2039
- this.#openInspect(command.open);
2040
- }
2041
- else
2042
- this.#agentHub = command.state;
2043
- this.#render();
2044
- return;
2045
- }
2046
2480
  if (this.#transcriptSearch !== null) {
2047
2481
  this.#applyTranscriptSearch(event);
2048
2482
  return;
2049
2483
  }
2050
2484
  if (event.type === 'key' && event.id === 'end' && this.#jumpToLatest !== undefined
2051
- && this.#settings === null && this.#copySelector === null && this.#search === null) {
2485
+ && this.#settings === null && this.#search === null) {
2052
2486
  this.#followTail();
2053
2487
  this.#render();
2054
2488
  return;
@@ -2059,60 +2493,31 @@ export class LocalTui {
2059
2493
  return;
2060
2494
  }
2061
2495
  if (this.#inspected !== undefined && this.#inspected.writable !== true && event.type === 'text'
2062
- && this.#prompt === null && this.#settings === null && this.#copySelector === null && this.#search === null) {
2063
- return;
2064
- }
2065
- if (event.type === 'key' && event.id === 'ctrl+c') {
2066
- if (this.#prompt !== null) {
2067
- this.#finishPrompt(null);
2068
- this.#editor.setText('');
2069
- this.#render();
2070
- return;
2071
- }
2072
- if (this.#settings !== null) {
2073
- this.#settings = null;
2074
- this.#render();
2075
- return;
2076
- }
2077
- if (this.#copySelector !== null) {
2078
- this.#copySelector = null;
2079
- this.#render();
2080
- return;
2081
- }
2082
- if (this.#search !== null) {
2083
- this.#search = null;
2084
- this.#render();
2085
- return;
2086
- }
2087
- const now = Date.now();
2088
- if (now - this.#lastSigintTime < DOUBLE_CTRL_C_MS) {
2089
- this.#lastSigintTime = 0;
2090
- this.#quit();
2091
- return;
2092
- }
2093
- this.#lastSigintTime = now;
2094
- if (this.#state.status !== 'idle') {
2095
- for (const listener of this.#interrupts)
2096
- listener();
2097
- }
2098
- else {
2099
- this.#literalInput = false;
2100
- this.#editor.clear();
2101
- this.#images.clear();
2102
- this.#files.clear();
2103
- this.#historyIndex = 0;
2104
- this.#clearDraft();
2105
- this.#ac = null;
2106
- this.#render();
2107
- }
2496
+ && this.#prompt === null && this.#settings === null && this.#search === null) {
2108
2497
  return;
2109
2498
  }
2110
2499
  if (this.#settings !== null) {
2111
2500
  this.#applySettings(event);
2112
2501
  return;
2113
2502
  }
2114
- if (this.#copySelector !== null) {
2115
- this.#applyCopySelector(event);
2503
+ /**
2504
+ * The side pane is a companion, not a mode: it answers where the reader is
2505
+ * already working, so it must not take keys the composer is using. It reads
2506
+ * navigation only while the composer is empty and nothing else is offering a
2507
+ * choice — a draft, an autocomplete list, or historical recall all keep their
2508
+ * arrows, and Escape keeps closing whatever is actually on top.
2509
+ */
2510
+ if (this.#aside !== undefined && this.#asideOwnsKeys() && event.type === 'key'
2511
+ && (event.id === 'up' || event.id === 'down' || event.id === 'pageUp' || event.id === 'pageDown')) {
2512
+ const step = event.id === 'pageUp' || event.id === 'pageDown' ? 6 : 1;
2513
+ const delta = event.id === 'up' || event.id === 'pageUp' ? -step : step;
2514
+ this.#aside = { ...this.#aside, scroll: Math.max(0, this.#aside.scroll + delta) };
2515
+ this.#render();
2516
+ return;
2517
+ }
2518
+ if (this.#aside !== undefined && event.type === 'key' && event.id === 'escape'
2519
+ && this.#editor.text === '' && this.#ac === null) {
2520
+ this.#closeAside();
2116
2521
  return;
2117
2522
  }
2118
2523
  if (this.#search !== null) {
@@ -2126,92 +2531,210 @@ export class LocalTui {
2126
2531
  }
2127
2532
  if (this.#handleSubagentLauncher(event))
2128
2533
  return;
2129
- if (event.type === 'key' && event.id === 'escape') {
2130
- if (this.#inspected !== undefined) {
2131
- if (this.#inspected.writable === true && (this.#editor.text !== '' || this.#images.count > 0)) {
2132
- this.#editor.clear();
2133
- this.#images.clear();
2134
- this.#files.clear();
2135
- this.#historyIndex = 0;
2136
- this.#literalInput = false;
2137
- this.#clearDraft();
2138
- this.#ac = null;
2139
- this.#render();
2140
- return;
2141
- }
2142
- this.#closeInspect();
2143
- return;
2144
- }
2145
- if (this.#state.status !== 'idle' || this.#pending === null
2146
- || this.#editor.text !== '' || this.#images.count > 0) {
2147
- this.#lastEscapeTime = 0;
2148
- }
2149
- else {
2150
- const now = Date.now();
2151
- if (now - this.#lastEscapeTime < DOUBLE_ESCAPE_MS) {
2152
- this.#lastEscapeTime = 0;
2153
- for (const listener of this.#rewinds)
2154
- listener();
2155
- }
2156
- else {
2157
- this.#lastEscapeTime = now;
2158
- }
2159
- return;
2160
- }
2161
- }
2534
+ // Escape is the one key that gives back exactly one layer, so it is resolved
2535
+ // here — after the overlays and the autocomplete have had their turn — and
2536
+ // not with the other actions above.
2537
+ if (event.type === 'key' && this.#boundAction(event.id) === 'interrupt' && this.#runInterrupt())
2538
+ return;
2162
2539
  if (event.type === 'key' && (event.id === 'backspace' || event.id === 'delete')
2163
2540
  && this.#images.removeAtCursor(event.id)) {
2164
2541
  this.#refreshAutocomplete();
2165
2542
  this.#render();
2166
2543
  return;
2167
2544
  }
2545
+ if (event.type === 'key' && (event.id === 'ctrl+-' || event.id === 'ctrl+_')
2546
+ && this.#clearedDraft !== undefined && this.#clearedDraft.revision === this.#editor.revision
2547
+ && !this.#hasComposerContent()) {
2548
+ const cleared = this.#clearedDraft.draft;
2549
+ this.#clearedDraft = undefined;
2550
+ this.#restoreDraft(cleared);
2551
+ this.#historyIndex = 0;
2552
+ this.#refreshAutocomplete();
2553
+ this.#render();
2554
+ return;
2555
+ }
2168
2556
  this.#applyCommand(this.#editor.handle(event));
2169
2557
  }
2170
- #handlePrompt(event) {
2171
- let prompt = this.#prompt;
2172
- if (prompt === null)
2173
- return false;
2174
- if (event.type === 'key' && event.id === 'ctrl+c' && prompt.request.interrupt !== undefined) {
2175
- this.#lastSigintTime = Date.now();
2176
- prompt.request.interrupt();
2558
+ /**
2559
+ * Resolve `interrupt` against whatever currently owns the screen.
2560
+ *
2561
+ * One action, one meaning — cancel what is happening — but the thing that is
2562
+ * happening depends on how deep the reader is. The order runs from the most
2563
+ * transient surface to the least, so Escape always gives back exactly one
2564
+ * layer instead of dropping the reader somewhere unrelated.
2565
+ */
2566
+ #runInterrupt() {
2567
+ if (this.#prompt !== null) {
2568
+ if (this.#prompt.request.presentation === 'progress' || this.#prompt.request.interrupt === undefined) {
2569
+ this.#finishPrompt(null);
2570
+ return true;
2571
+ }
2572
+ const handled = this.#prompt.request.interrupt();
2177
2573
  this.#finishPrompt(null);
2178
- if (this.#state.status !== 'idle')
2574
+ if (handled !== true && this.#state.status !== 'idle')
2179
2575
  for (const listener of this.#interrupts)
2180
2576
  listener();
2181
- this.#render();
2182
2577
  return true;
2183
2578
  }
2184
- if (event.type === 'key' && event.id === 'ctrl+s' && prompt.request.skippable === true) {
2185
- this.#editor.setText('');
2186
- this.#finishPrompt('');
2187
- this.#render();
2579
+ // Escaping a child transcript returns to the parent, and a writable child
2580
+ // with a draft clears it first — the same order the reader expects from
2581
+ // any other depth.
2582
+ if (this.#inspected !== undefined) {
2583
+ if (this.#inspected.writable === true && this.#hasComposerContent()) {
2584
+ this.#clearComposerWithUndo();
2585
+ return true;
2586
+ }
2587
+ this.#closeInspect();
2188
2588
  return true;
2189
2589
  }
2190
- const takeTime = event.type === 'key' && event.id === 'ctrl+t';
2191
- const editing = event.type === 'text' || (event.type === 'key'
2192
- && ['backspace', 'delete', 'up', 'down', 'tab', 'shift+tab'].includes(event.id));
2193
- if (prompt.request.wait !== undefined && prompt.waitHeld !== true && (takeTime || editing)) {
2194
- prompt.request.wait.hold();
2195
- prompt.offTick?.();
2196
- prompt = { ...prompt, waitHeld: true };
2197
- this.#prompt = prompt;
2198
- this.#render();
2199
- }
2200
- if (takeTime && prompt.request.wait !== undefined)
2590
+ if (this.#state.status !== 'idle') {
2591
+ // Aborting a turn would strand whatever the user queued behind it. Pi
2592
+ // hands those drafts back first, so nothing typed during the turn is lost
2593
+ // to the abort that ends it.
2594
+ this.#restoreQueuedBeforeAbort();
2595
+ for (const listener of this.#interrupts)
2596
+ listener();
2201
2597
  return true;
2202
- if (prompt.request.presentation === 'plan-review' || prompt.request.presentation === 'document')
2203
- return this.#handlePlanReview(event, prompt);
2598
+ }
2599
+ if (!this.#hasComposerContent()) {
2600
+ // Nothing to cancel and nothing typed: the second Escape opens the session
2601
+ // tree, which is where a reader goes when they want to leave this point.
2602
+ const now = Date.now();
2603
+ if (now - this.#lastEscapeTime < DOUBLE_ESCAPE_MS) {
2604
+ this.#lastEscapeTime = 0;
2605
+ for (const listener of this.#rewinds)
2606
+ listener();
2607
+ }
2608
+ else {
2609
+ this.#lastEscapeTime = now;
2610
+ }
2611
+ return true;
2612
+ }
2613
+ // Something is typed: Escape is not a no-op, but it also has nothing to
2614
+ // cancel, so the editor keeps the keystroke.
2615
+ return false;
2616
+ }
2617
+ /**
2618
+ * Empty the composer, or exit on a second press.
2619
+ *
2620
+ * Ctrl+C is a composer gesture everywhere else on a terminal; keeping
2621
+ * interrupt on Escape leaves this to do exactly one job.
2622
+ */
2623
+ #runClearComposer() {
2624
+ if (this.#prompt !== null) {
2625
+ this.#finishPrompt(null);
2626
+ this.#editor.setText('');
2627
+ this.#render();
2628
+ return true;
2629
+ }
2630
+ // A running Shell line owns Ctrl+C before the composer does: the reader is
2631
+ // watching a command, and the key they press to stop it is the same one.
2632
+ if (this.#shellAbort !== undefined && !this.#hasComposerContent()) {
2633
+ this.#shellAbort();
2634
+ return true;
2635
+ }
2636
+ if (this.#settings !== null) {
2637
+ this.#settings = null;
2638
+ this.#render();
2639
+ return true;
2640
+ }
2641
+ if (this.#search !== null) {
2642
+ this.#search = null;
2643
+ this.#render();
2644
+ return true;
2645
+ }
2646
+ if (this.#transcriptSearch !== null) {
2647
+ this.#transcriptSearch = null;
2648
+ this.#render();
2649
+ return true;
2650
+ }
2651
+ const now = Date.now();
2652
+ if (now - this.#lastSigintTime < DOUBLE_CTRL_C_MS) {
2653
+ this.#lastSigintTime = 0;
2654
+ this.#quit();
2655
+ return true;
2656
+ }
2657
+ this.#lastSigintTime = now;
2658
+ // A running turn keeps Ctrl+C as the familiar abort while the composer is
2659
+ // empty; Escape already covers it, and refusing the reflex would be worse.
2660
+ if (this.#state.status !== 'idle' && !this.#hasComposerContent()) {
2661
+ this.#restoreQueuedBeforeAbort();
2662
+ for (const listener of this.#interrupts)
2663
+ listener();
2664
+ return true;
2665
+ }
2666
+ this.#clearComposerWithUndo();
2667
+ return true;
2668
+ }
2669
+ /**
2670
+ * Hand durable follow-ups back to the composer before a turn is abandoned.
2671
+ *
2672
+ * The queued messages would otherwise be delivered to a turn that will never
2673
+ * produce the step they were aimed at, which silently loses them.
2674
+ */
2675
+ #restoreQueuedBeforeAbort() {
2676
+ if (!this.#state.nextTurnInbox.some(message => message.source.kind === 'user'))
2677
+ return;
2678
+ if (this.#hasComposerContent())
2679
+ return;
2680
+ for (const listener of this.#queueEdits)
2681
+ listener();
2682
+ }
2683
+ #handlePrompt(event) {
2684
+ let prompt = this.#prompt;
2685
+ if (prompt === null)
2686
+ return false;
2687
+ const disarmed = prompt.armedUntil !== undefined && Date.now() < prompt.armedUntil;
2688
+ if (event.type === 'key' && event.id === 'ctrl+c' && prompt.request.interrupt !== undefined) {
2689
+ this.#lastSigintTime = Date.now();
2690
+ const handled = prompt.request.interrupt();
2691
+ this.#finishPrompt(null);
2692
+ if (handled !== true && this.#state.status !== 'idle')
2693
+ for (const listener of this.#interrupts)
2694
+ listener();
2695
+ this.#render();
2696
+ return true;
2697
+ }
2698
+ if (event.type === 'key' && event.id === 'ctrl+s' && prompt.request.skippable === true) {
2699
+ this.#editor.setText('');
2700
+ this.#finishPrompt('');
2701
+ this.#render();
2702
+ return true;
2703
+ }
2704
+ if (prompt.request.presentation === 'progress') {
2705
+ if (event.type === 'key' && (event.id === 'escape' || event.id === 'ctrl+c')) {
2706
+ this.#finishPrompt(null);
2707
+ this.#render();
2708
+ }
2709
+ return true;
2710
+ }
2711
+ const takeTime = event.type === 'key' && event.id === 'ctrl+t';
2712
+ const editing = event.type === 'text' || (event.type === 'key'
2713
+ && ['backspace', 'delete', 'up', 'down', 'tab', 'shift+tab'].includes(event.id));
2714
+ if (prompt.request.wait !== undefined && prompt.waitHeld !== true && (takeTime || editing)) {
2715
+ prompt.request.wait.hold();
2716
+ prompt.offTick?.();
2717
+ prompt = { ...prompt, waitHeld: true };
2718
+ this.#prompt = prompt;
2719
+ this.#render();
2720
+ }
2721
+ if (takeTime && prompt.request.wait !== undefined)
2722
+ return true;
2723
+ if (prompt.request.presentation === 'plan-review' || prompt.request.presentation === 'document')
2724
+ return this.#handlePlanReview(event, prompt);
2204
2725
  if (event.type === 'text' && this.#editor.text === '') {
2205
2726
  const action = prompt.request.actions?.find(item => item.key === event.value);
2206
2727
  const selected = filteredPromptOptions(prompt.request, '', prompt.collapsed)[prompt.selected];
2207
2728
  if (action !== undefined && (action.scope === 'list' || selected !== undefined)) {
2208
- this.#finishPrompt(action.valuePrefix + (action.scope === 'list' ? '' : selected.value ?? selected.label));
2209
- this.#render();
2729
+ if (!disarmed) {
2730
+ this.#finishPrompt(action.valuePrefix + (action.scope === 'list' ? '' : selected.value ?? selected.label));
2731
+ this.#render();
2732
+ }
2210
2733
  return true;
2211
2734
  }
2212
2735
  }
2213
- if (event.type === 'text' && event.value === ' ' && prompt.request.multiSelect === true && this.#editor.text === '') {
2214
- this.#prompt = togglePromptSelection(prompt);
2736
+ if (event.type === 'text' && event.value === ' ' && prompt.request.multiSelect === true) {
2737
+ this.#prompt = togglePromptSelection(prompt, this.#editor.text);
2215
2738
  this.#render();
2216
2739
  return true;
2217
2740
  }
@@ -2227,38 +2750,56 @@ export class LocalTui {
2227
2750
  return true;
2228
2751
  if (event.type !== 'key')
2229
2752
  return false;
2753
+ if (disarmed && (event.id === 'enter' || event.id === 'ctrl+j'))
2754
+ return true;
2230
2755
  const filtered = filteredPromptOptions(prompt.request, this.#editor.text, prompt.collapsed);
2231
2756
  const count = filtered.length;
2232
- if (prompt.request.presentation === 'fullscreen-tree' || prompt.request.presentation === 'fullscreen-list') {
2233
- const action = prompt.request.actions?.find(item => item.key.toLowerCase() === event.id);
2234
- const selected = filtered[prompt.selected];
2235
- if (action !== undefined) {
2236
- if (action.scope === 'list' || selected !== undefined) {
2237
- this.#finishPrompt(action.valuePrefix + (action.scope === 'list' ? '' : selected.value ?? selected.label));
2757
+ const keyAction = prompt.request.actions?.find(item => item.key.toLowerCase() === event.id.toLowerCase());
2758
+ if (keyAction !== undefined && (keyAction.scope === 'list'
2759
+ || prompt.request.presentation === 'tree'
2760
+ || prompt.request.presentation === 'card')) {
2761
+ if (!disarmed) {
2762
+ const selected = filtered[prompt.selected];
2763
+ if (keyAction.scope === 'list' || selected !== undefined) {
2764
+ this.#editor.setText('');
2765
+ this.#finishPrompt(keyAction.valuePrefix + (keyAction.scope === 'list' ? '' : selected.value ?? selected.label));
2238
2766
  this.#render();
2239
2767
  }
2240
- return true;
2241
2768
  }
2769
+ return true;
2242
2770
  }
2243
2771
  if (event.id === 'escape' || event.id === 'ctrl+c') {
2244
- if (event.id === 'escape' && prompt.request.presentation === 'fullscreen-tree' && this.#editor.text !== '') {
2245
- const selected = filtered[prompt.selected]?.value;
2772
+ /**
2773
+ * Escape gives back one layer: an active filter clears before the card
2774
+ * closes.
2775
+ *
2776
+ * Only the tree used to do this, so a filtered `/model` or `/copy` closed
2777
+ * outright and a reader who wanted their list back had to reopen it.
2778
+ */
2779
+ if (event.id === 'escape' && prompt.request.filterable === true && this.#editor.text !== '') {
2780
+ const selected = filtered[prompt.selected]?.value ?? filtered[prompt.selected]?.label;
2246
2781
  this.#editor.setText('');
2247
- this.#prompt = { ...prompt, collapsed: new Set(), selected: Math.max(0, filteredPromptOptions(prompt.request, '').findIndex(option => option.value === selected)), previewScroll: 0 };
2782
+ const restored = filteredPromptOptions(prompt.request, '', prompt.collapsed);
2783
+ const at = restored.findIndex(option => (option.value ?? option.label) === selected);
2784
+ this.#prompt = {
2785
+ ...prompt,
2786
+ ...(prompt.request.presentation === 'tree' ? { collapsed: new Set() } : {}),
2787
+ selected: Math.max(0, at),
2788
+ previewScroll: 0,
2789
+ };
2248
2790
  this.#render();
2249
2791
  return true;
2250
2792
  }
2251
- this.#editor.setText('');
2252
2793
  this.#finishPrompt(null);
2253
2794
  this.#render();
2254
2795
  return true;
2255
2796
  }
2256
- if (prompt.request.presentation === 'fullscreen-tree' && (event.id === 'left' || event.id === 'right')) {
2797
+ if (prompt.request.presentation === 'tree' && (event.id === 'left' || event.id === 'right')) {
2257
2798
  this.#prompt = movePromptTree(prompt, event.id, this.#editor.text);
2258
2799
  this.#render();
2259
2800
  return true;
2260
2801
  }
2261
- if (prompt.request.presentation === 'fullscreen-tree' && (event.id === 'ctrl+up' || event.id === 'ctrl+down')) {
2802
+ if (prompt.request.presentation === 'tree' && (event.id === 'ctrl+up' || event.id === 'ctrl+down')) {
2262
2803
  const start = this.#promptDocument?.start ?? prompt.previewScroll ?? 0;
2263
2804
  this.#prompt = { ...prompt, previewScroll: Math.max(0, Math.min(start + (event.id === 'ctrl+up' ? -1 : 1), this.#promptDocument?.maxStart ?? 0)) };
2264
2805
  this.#render();
@@ -2276,29 +2817,30 @@ export class LocalTui {
2276
2817
  }
2277
2818
  return true;
2278
2819
  }
2279
- let next;
2280
- const page = prompt.request.presentation === 'fullscreen-tree'
2820
+ // Movement comes from the shared reading, so this selector cannot drift from
2821
+ // the other lists about what `tab` or `pageUp` means. `enter` stays below
2822
+ // because what it submits depends on the editor and on multi-select.
2823
+ const page = prompt.request.presentation === 'tree'
2281
2824
  ? promptTreePageSize(prompt.request, this.#term.width(), this.#term.height()) : 10;
2282
- if (event.id === 'up' || event.id === 'shift+tab')
2283
- next = prompt.selected - 1;
2284
- else if (event.id === 'down' || event.id === 'tab')
2285
- next = prompt.selected + 1;
2286
- else if (event.id === 'pageUp')
2287
- next = prompt.request.presentation === 'fullscreen-tree' ? Math.max(0, prompt.selected - page) : prompt.selected - page;
2288
- else if (event.id === 'pageDown')
2289
- next = prompt.request.presentation === 'fullscreen-tree' ? Math.min(count - 1, prompt.selected + page) : prompt.selected + page;
2290
- else if (event.id === 'home')
2291
- next = 0;
2292
- else if (event.id === 'end')
2293
- next = count - 1;
2294
- if (next !== undefined) {
2295
- this.#prompt = movePromptSelection(prompt, next, count);
2825
+ const navigation = listNavigation(event);
2826
+ if (navigation.kind === 'move' || navigation.kind === 'page') {
2827
+ // The tree clamps: its rows are a structure, and wrapping past the last
2828
+ // branch lands the reader somewhere unrelated to the one they left.
2829
+ const wrap = prompt.request.presentation !== 'tree';
2830
+ this.#prompt = movePromptSelection(prompt, navigateIndex(navigation, prompt.selected, count, page, { wrap }), count);
2831
+ this.#render();
2832
+ return true;
2833
+ }
2834
+ if (navigation.kind === 'edge') {
2835
+ this.#prompt = movePromptSelection(prompt, edgeIndex(navigation, count), count);
2296
2836
  this.#render();
2297
2837
  return true;
2298
2838
  }
2299
- if (event.id === 'enter' && this.#editor.text === '') {
2839
+ if (event.id === 'enter' && (this.#editor.text === '' || prompt.request.multiSelect === true)) {
2300
2840
  const answer = selectedPromptAnswer(prompt);
2301
- if (prompt.request.multiSelect === true && answer === null)
2841
+ // A disabled row and an empty multi-select both swallow Enter: nothing is
2842
+ // being answered, so the surface stays where the reader is.
2843
+ if (answer === null)
2302
2844
  return true;
2303
2845
  this.#finishPrompt(answer);
2304
2846
  this.#render();
@@ -2306,6 +2848,10 @@ export class LocalTui {
2306
2848
  }
2307
2849
  if (event.id === 'enter' && prompt.request.filterable === true) {
2308
2850
  const answer = selectedFilteredPromptAnswer(prompt, this.#editor.text);
2851
+ // A heading under a filter is still a heading: clearing the query and
2852
+ // closing on it would lose both the list and the filter.
2853
+ if (answer === null)
2854
+ return true;
2309
2855
  this.#editor.setText('');
2310
2856
  this.#finishPrompt(answer);
2311
2857
  this.#render();
@@ -2323,19 +2869,25 @@ export class LocalTui {
2323
2869
  }
2324
2870
  #handlePlanReview(event, prompt) {
2325
2871
  const document = prompt.request.presentation === 'document';
2872
+ const disarmed = prompt.armedUntil !== undefined && Date.now() < prompt.armedUntil;
2326
2873
  if (document && prompt.request.documentSource !== undefined && this.#handleDocumentReader(event, prompt))
2327
2874
  return true;
2875
+ if (document && prompt.request.documentSource === undefined && this.#handleRenderedDocument(event, prompt))
2876
+ return true;
2328
2877
  if (document && event.type === 'text') {
2329
2878
  const action = prompt.request.actions?.find(item => item.key.toLowerCase() === event.value.toLowerCase());
2330
- if (action !== undefined) {
2879
+ if (action !== undefined && !disarmed) {
2331
2880
  this.#finishPrompt(action.valuePrefix);
2332
2881
  this.#render();
2333
2882
  }
2334
2883
  return true;
2335
2884
  }
2885
+ if (!document && event.type === 'key' && (event.id === 'ctrl+x' || this.#boundAction(event.id) === 'external-editor')) {
2886
+ this.#openPlanFeedbackEditor(prompt);
2887
+ return true;
2888
+ }
2336
2889
  if (prompt.feedback === true) {
2337
2890
  if (event.type === 'key' && event.id === 'ctrl+c') {
2338
- this.#editor.setText('');
2339
2891
  this.#finishPrompt(null);
2340
2892
  this.#render();
2341
2893
  return true;
@@ -2347,6 +2899,8 @@ export class LocalTui {
2347
2899
  return true;
2348
2900
  }
2349
2901
  if (event.type === 'key' && (event.id === 'enter' || event.id === 'ctrl+j')) {
2902
+ if (disarmed)
2903
+ return true;
2350
2904
  const feedback = this.#editor.text.trim();
2351
2905
  this.#editor.setText('');
2352
2906
  this.#finishPrompt(feedback === '' ? selectedPromptAnswer(prompt) : feedback);
@@ -2362,8 +2916,16 @@ export class LocalTui {
2362
2916
  return true;
2363
2917
  if (event.type !== 'key')
2364
2918
  return false;
2919
+ const keyAction = prompt.request.actions?.find(item => item.key.toLowerCase() === event.id.toLowerCase() && item.scope === 'list');
2920
+ if (keyAction !== undefined) {
2921
+ if (!disarmed) {
2922
+ this.#editor.setText('');
2923
+ this.#finishPrompt(keyAction.valuePrefix);
2924
+ this.#render();
2925
+ }
2926
+ return true;
2927
+ }
2365
2928
  if (event.id === 'escape' || event.id === 'ctrl+c') {
2366
- this.#editor.setText('');
2367
2929
  this.#finishPrompt(null);
2368
2930
  this.#render();
2369
2931
  return true;
@@ -2403,6 +2965,8 @@ export class LocalTui {
2403
2965
  return true;
2404
2966
  }
2405
2967
  if (event.id === 'enter') {
2968
+ if (disarmed)
2969
+ return true;
2406
2970
  const answer = selectedPromptAnswer(prompt);
2407
2971
  if (answer === null)
2408
2972
  return true;
@@ -2420,9 +2984,78 @@ export class LocalTui {
2420
2984
  }
2421
2985
  return true;
2422
2986
  }
2987
+ /**
2988
+ * Find in a rendered document.
2989
+ *
2990
+ * The source reader searches its model; a rendered page is its rows, so the
2991
+ * search runs over exactly what the reader is looking at. Without this, `/help`
2992
+ * — sixty rows of grouped commands — was the one surface with no way to look
2993
+ * something up in it.
2994
+ */
2995
+ #handleRenderedDocument(event, prompt) {
2996
+ const rows = () => documentRows(prompt.request, createTheme(this.#colors, this.#colorDepth, this.#themeName, this.#terminalColors), Math.max(1, this.#term.width() - 6));
2997
+ // Always build on the newest state: typing updates the query while the find
2998
+ // is open, and jumping from a stale copy would drop it again.
2999
+ const jumpTo = (row) => {
3000
+ this.#prompt = { ...(this.#prompt ?? prompt), documentScroll: Math.max(0, row - 1), documentTarget: row, documentAnchor: undefined };
3001
+ };
3002
+ if (prompt.documentInput !== undefined) {
3003
+ if (event.type === 'key' && (event.id === 'escape' || event.id === 'ctrl+c')) {
3004
+ this.#editor.setText('');
3005
+ this.#prompt = { ...prompt, documentInput: undefined, documentQuery: prompt.documentOriginalQuery ?? '', documentError: undefined };
3006
+ this.#render();
3007
+ return true;
3008
+ }
3009
+ if (event.type === 'key' && (event.id === 'enter' || event.id === 'ctrl+j')) {
3010
+ const query = this.#editor.text.trim();
3011
+ this.#prompt = { ...(this.#prompt ?? prompt), documentInput: undefined, documentQuery: query, documentError: undefined };
3012
+ const matches = findInRows(rows(), query);
3013
+ if (matches.length > 0)
3014
+ jumpTo(matches[0].row);
3015
+ else
3016
+ this.#prompt = { ...(this.#prompt ?? prompt), documentTarget: undefined };
3017
+ this.#render();
3018
+ return true;
3019
+ }
3020
+ // Typing narrows the query as it is entered, so the count is live.
3021
+ if (event.type === 'text') {
3022
+ this.#editor.handle(event);
3023
+ this.#prompt = { ...(this.#prompt ?? prompt), documentQuery: this.#editor.text };
3024
+ this.#render();
3025
+ return true;
3026
+ }
3027
+ if (event.type === 'key') {
3028
+ this.#editor.handle(event);
3029
+ this.#prompt = { ...(this.#prompt ?? prompt), documentQuery: this.#editor.text };
3030
+ this.#render();
3031
+ return true;
3032
+ }
3033
+ return false;
3034
+ }
3035
+ const search = event.type === 'key' && event.id === 'ctrl+f' || event.type === 'text' && event.value === '/';
3036
+ if (search) {
3037
+ this.#editor.setText(prompt.documentQuery ?? '');
3038
+ this.#prompt = { ...prompt, documentInput: 'search', documentOriginalQuery: prompt.documentQuery ?? '', documentError: undefined };
3039
+ this.#render();
3040
+ return true;
3041
+ }
3042
+ if (event.type === 'key' && (event.id === 'ctrl+n' || event.id === 'ctrl+p')) {
3043
+ const matches = findInRows(rows(), prompt.documentQuery ?? '');
3044
+ if (matches.length === 0)
3045
+ return true;
3046
+ const current = prompt.documentTarget ?? prompt.documentScroll ?? 0;
3047
+ const from = event.id === 'ctrl+n'
3048
+ ? (matches.findIndex(match => match.row > current) + matches.length) % matches.length
3049
+ : (matches.findLastIndex(match => match.row < current) + matches.length) % matches.length;
3050
+ jumpTo(matches[from].row);
3051
+ this.#render();
3052
+ return true;
3053
+ }
3054
+ return false;
3055
+ }
2423
3056
  #handleDocumentReader(event, prompt) {
2424
3057
  const source = prompt.request.documentSource;
2425
- const layout = documentLayout(source, createTheme(this.#colors, this.#trueColor, this.#themeName), Math.max(1, this.#term.width() - 6), {
3058
+ const layout = documentLayout(source, createTheme(this.#colors, this.#colorDepth, this.#themeName, this.#terminalColors), Math.max(1, this.#term.width() - 6), {
2426
3059
  wordWrap: prompt.documentWordWrap ?? true, column: prompt.documentColumn ?? 0,
2427
3060
  });
2428
3061
  const start = this.#promptDocument?.start ?? 0;
@@ -2504,37 +3137,72 @@ export class LocalTui {
2504
3137
  listener(id);
2505
3138
  }
2506
3139
  #handleSubagentLauncher(event) {
2507
- const hasAgents = (this.#subagents?.agents.length ?? 0) > 0;
2508
- if (this.#subagentLauncherFocused) {
2509
- if (!hasAgents) {
2510
- this.#subagentLauncherFocused = false;
3140
+ const agents = this.#subagents?.agents ?? [];
3141
+ if (this.#subagentSelection !== undefined) {
3142
+ if (agents.length === 0) {
3143
+ this.#subagentSelection = undefined;
2511
3144
  return false;
2512
3145
  }
3146
+ const at = Math.min(this.#subagentSelection, agents.length - 1);
2513
3147
  if (event.type === 'key' && event.id === 'enter') {
2514
- this.#subagentLauncherFocused = false;
2515
- void this.#pickSubagent();
3148
+ this.#subagentSelection = undefined;
3149
+ this.#openSelectedSubagent(agents[at]);
2516
3150
  return true;
2517
3151
  }
2518
- if (event.type === 'key' && (event.id === 'escape' || event.id === 'up')) {
2519
- this.#subagentLauncherFocused = false;
3152
+ if (event.type === 'key' && event.id === 'escape') {
3153
+ this.#subagentSelection = undefined;
2520
3154
  this.#render();
2521
3155
  return true;
2522
3156
  }
2523
- if (event.type === 'key' && event.id === 'down')
3157
+ if (event.type === 'key' && (event.id === 'up' || event.id === 'down')) {
3158
+ const delta = event.id === 'up' ? -1 : 1;
3159
+ // `up` from the first row leaves the bar; `down` stops at the last one.
3160
+ if (at + delta < 0)
3161
+ this.#subagentSelection = undefined;
3162
+ else
3163
+ this.#subagentSelection = Math.min(agents.length - 1, at + delta);
3164
+ this.#render();
2524
3165
  return true;
2525
- this.#subagentLauncherFocused = false;
2526
- return false;
3166
+ }
3167
+ return true;
2527
3168
  }
2528
- if (!hasAgents || event.type !== 'key' || event.id !== 'down')
3169
+ if (agents.length === 0 || event.type !== 'key' || event.id !== 'down')
2529
3170
  return false;
2530
3171
  if (this.#editor.text !== '' || this.#images.count > 0 || this.#historyIndex !== 0 || this.#ac !== null)
2531
3172
  return false;
2532
3173
  if (this.#queuedSubmissions.length > 0 || this.#queueEditNewer !== null || this.#queueEditPending)
2533
3174
  return false;
2534
- this.#subagentLauncherFocused = true;
3175
+ this.#subagentSelection = Math.min(agents.length - 1, Math.max(0, subagentPreviewStart(agents)));
2535
3176
  this.#render();
2536
3177
  return true;
2537
3178
  }
3179
+ /**
3180
+ * Open what the selected row has.
3181
+ *
3182
+ * A local child has a transcript to read, so `Enter` swaps the view. An external
3183
+ * child has none by construction — its results land in the parent session — so
3184
+ * `Enter` prints its status and recent activity as a fold-able transcript
3185
+ * result instead of opening a surface for it.
3186
+ */
3187
+ #openSelectedSubagent(agent) {
3188
+ if (agent.mode === 'external') {
3189
+ this.commandOutput(`Agent · ${agent.label}`, [
3190
+ `${agent.phase === 'completed' ? 'Completed' : agent.phase === 'error' ? 'Failed' : 'Running'} · ${formatAgentElapsed(agent)}`,
3191
+ '',
3192
+ `Mode: ${agent.mode}`,
3193
+ `Session: ${agent.id}`,
3194
+ `Parent: ${agent.parentId ?? 'root session'}`,
3195
+ `Depth: ${agent.depth}`,
3196
+ ...(agent.workflow === undefined ? [] : [`Workflow: ${agent.workflow.name}`, `Phase: ${agent.workflow.phase ?? '—'}`]),
3197
+ ...(agent.activity.length === 0 ? [] : ['', 'Recent activity', ...agent.activity.map(item => `${item.status.padEnd(8)} ${item.text}`)]),
3198
+ '',
3199
+ 'An external agent keeps no local transcript; its results arrive in this session.',
3200
+ ].join('\n'));
3201
+ return;
3202
+ }
3203
+ this.#openInspect(agent.id);
3204
+ this.#render();
3205
+ }
2538
3206
  #closeInspect() {
2539
3207
  if (this.#inspected === undefined)
2540
3208
  return;
@@ -2544,41 +3212,28 @@ export class LocalTui {
2544
3212
  for (const listener of this.#inspectCloses)
2545
3213
  listener();
2546
3214
  }
2547
- #pickSubagent() {
2548
- this.#subagentLauncherFocused = false;
2549
- const agents = this.#subagents?.agents ?? [];
2550
- if (agents.length === 0 && !this.#subagents?.workflows?.length) {
2551
- this.notice('No subagents are available in this session.');
2552
- return;
2553
- }
2554
- this.#prompt = null;
2555
- this.#settings = null;
2556
- this.#copySelector = null;
2557
- this.#search = null;
2558
- this.#trajectory = null;
2559
- this.#ac = null;
2560
- this.#agentHub = createAgentHub(this.#subagents ?? { agents });
2561
- this.#render();
2562
- }
2563
3215
  #applyPrefs(prefs, options) {
3216
+ const previous = options.persist ? this.prefs() : undefined;
3217
+ const version = this.#prefsVersion;
2564
3218
  const previousMotion = this.#motion;
2565
3219
  this.#themeName = prefs.theme;
2566
3220
  this.#colors = prefs.colors;
2567
- this.#syncTrueColor();
3221
+ this.#syncColorDepth();
2568
3222
  this.#motion = prefs.motion ?? 'full';
2569
3223
  this.#flushPasteBurst(true);
2570
3224
  this.#pasteProtection = prefs.pasteProtection !== false;
2571
3225
  this.#externalEditor = prefs.editor ?? 'auto';
2572
- this.#terminalProgress = prefs.terminalProgress ?? false;
3226
+ this.#terminalTitle = prefs.terminalTitle ?? 'status';
3227
+ this.#terminalState = prefs.terminalState ?? 'auto';
3228
+ this.#syncWindowTitle();
2573
3229
  this.#copyOnSelect = prefs.copyOnSelect ?? true;
2574
3230
  this.#mathMode = prefs.mathMode ?? 'auto';
2575
3231
  this.#mermaidMode = prefs.mermaidMode ?? 'auto';
3232
+ this.#hideThinking = prefs.hideThinking === true;
2576
3233
  this.#mouseInteraction = prefs.mouseInteraction ?? 'auto';
2577
3234
  this.#checkUpdates = prefs.checkUpdates ?? true;
2578
3235
  this.#startupChangelog = prefs.startupChangelog ?? 'summary';
2579
- this.#notificationPolicy = prefs.notifications ?? 'off';
2580
- this.#notificationThreshold = prefs.notificationThreshold ?? '30s';
2581
- this.#notificationFocus = prefs.notificationFocus ?? 'unfocused';
3236
+ this.#notificationPolicy = resolveNotificationMode(prefs.notifications, prefs.notificationFocus);
2582
3237
  if (this.#notificationPolicy === 'off')
2583
3238
  this.#notificationQueue.clear();
2584
3239
  this.#configureNotifications();
@@ -2590,8 +3245,33 @@ export class LocalTui {
2590
3245
  this.#reveal = undefined;
2591
3246
  }
2592
3247
  this.#syncTick();
2593
- if (options.persist)
2594
- this.#persistPrefs?.(prefs);
3248
+ if (options.persist && this.#persistPrefs !== null) {
3249
+ const failed = async (error) => {
3250
+ if (this.#disposed)
3251
+ return;
3252
+ if (this.#prefsVersion === version && previous !== undefined)
3253
+ this.applyStoredPrefs(previous);
3254
+ await this.#settingsError(`Settings were not saved: ${error instanceof Error ? error.message : String(error)}`);
3255
+ };
3256
+ try {
3257
+ const changes = previous === undefined ? [] : preferenceChanges(previous, prefs);
3258
+ const result = changes.length === 0 ? undefined : this.#persistPrefs(prefs, changes);
3259
+ if (result !== undefined && result !== null && typeof result === 'object' && typeof result.then === 'function') {
3260
+ this.#prefsSaveTask = Promise.resolve(result).catch(failed).finally(() => { this.#prefsSaveTask = undefined; });
3261
+ }
3262
+ }
3263
+ catch (error) {
3264
+ this.#prefsSaveTask = failed(error).finally(() => { this.#prefsSaveTask = undefined; });
3265
+ }
3266
+ }
3267
+ }
3268
+ async #settingsError(detail) {
3269
+ if (this.#disposed)
3270
+ return;
3271
+ if (this.#settings === null)
3272
+ this.notice(detail, { level: 'error' });
3273
+ else
3274
+ await showSettingsError(this, detail);
2595
3275
  }
2596
3276
  #applyStoredAgentBehavior(next) {
2597
3277
  this.#agentBehavior = { language: next.language };
@@ -2613,7 +3293,7 @@ export class LocalTui {
2613
3293
  if (this.#settings !== null && this.#agentBehavior !== undefined) {
2614
3294
  this.#settings = { ...this.#settings, agent: { ...this.#agentBehavior } };
2615
3295
  }
2616
- this.notice(`Could not save Agent language: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' });
3296
+ await this.#settingsError(`Could not save Agent language: ${error instanceof Error ? error.message : String(error)}`);
2617
3297
  }
2618
3298
  finally {
2619
3299
  this.#agentBehaviorPending = false;
@@ -2622,34 +3302,28 @@ export class LocalTui {
2622
3302
  }
2623
3303
  }
2624
3304
  /**
2625
- * Write the Features section's state into the Profile patch.
3305
+ * Write the Tools feature controls' state into the Profile patch.
2626
3306
  *
2627
3307
  * `disabled` is a Loader option rather than plugin config, so the settings
2628
3308
  * service cannot express it; the Profile patch is the documented surface
2629
3309
  * that can, and the Harness reads it while composing the tree. Only the
2630
3310
  * managed block is rewritten, so hand-authored rows and comments survive.
2631
- * A failure is reported and the overlay keeps the value it shows.
3311
+ * The displayed feature state changes only after the patch is written.
2632
3312
  */
2633
- #writeFeaturePatch() {
3313
+ #writeFeaturePatch(features) {
2634
3314
  const path = this.#featurePatch;
2635
- const features = this.#features;
2636
- if (path === undefined || features === undefined)
3315
+ if (path === undefined)
2637
3316
  return;
3317
+ let current = '';
2638
3318
  try {
2639
- let current = '';
2640
- try {
2641
- current = readFileSync(path, 'utf8');
2642
- }
2643
- catch {
2644
- current = '';
2645
- }
2646
- mkdirSync(dirname(path), { recursive: true });
2647
- writeFileSync(path, replaceFeatureBlock(current, renderFeatureBlock(features)), 'utf8');
3319
+ current = readFileSync(path, 'utf8');
2648
3320
  }
2649
3321
  catch (error) {
2650
- this.notice(`Feature toggles were not saved: ${error instanceof Error ? error.message : String(error)}`);
2651
- this.#render();
3322
+ if (error.code !== 'ENOENT')
3323
+ throw error;
2652
3324
  }
3325
+ mkdirSync(dirname(path), { recursive: true });
3326
+ writeFileSync(path, replaceFeatureBlock(current, renderFeatureBlock(features)), 'utf8');
2653
3327
  }
2654
3328
  #applySettings(event) {
2655
3329
  if (this.#settings === null)
@@ -2657,8 +3331,36 @@ export class LocalTui {
2657
3331
  if (this.#pluginSettingsTask !== undefined)
2658
3332
  return;
2659
3333
  const command = applySettingsEvent(this.#settings, event);
3334
+ if (command.kind === 'open-command') {
3335
+ if (this.#prefsSaveTask !== undefined)
3336
+ return;
3337
+ if (command.name === 'keybindings') {
3338
+ void this.#editKeybindings();
3339
+ return;
3340
+ }
3341
+ this.#runSlash(command.name);
3342
+ return;
3343
+ }
3344
+ if (command.kind === 'reset') {
3345
+ if (this.#prefsSaveTask !== undefined)
3346
+ return;
3347
+ if (this.#pluginSettings?.reset === undefined) {
3348
+ void this.#settingsError('Restoring inheritance requires a writable Profile settings service.');
3349
+ return;
3350
+ }
3351
+ this.#runPluginSettings((binding, signal) => binding.reset(command.id, command.entry, signal, command.label));
3352
+ return;
3353
+ }
2660
3354
  if (command.kind === 'open-plugin') {
2661
- this.#openPluginSettings(command.id);
3355
+ if (this.#prefsSaveTask !== undefined)
3356
+ return;
3357
+ this.#runPluginSettings((binding, signal) => binding.edit(command.id, signal));
3358
+ return;
3359
+ }
3360
+ if (command.kind === 'set-plugin') {
3361
+ if (this.#prefsSaveTask !== undefined)
3362
+ return;
3363
+ this.#runPluginSettings(async (binding, signal) => { await binding.set?.(command.entry, command.value, signal); });
2662
3364
  return;
2663
3365
  }
2664
3366
  if (command.kind === 'update') {
@@ -2667,13 +3369,21 @@ export class LocalTui {
2667
3369
  return;
2668
3370
  }
2669
3371
  if (command.kind === 'apply') {
3372
+ if (this.#prefsSaveTask !== undefined)
3373
+ return;
2670
3374
  if (command.domain === 'features') {
2671
3375
  if (command.state.features === undefined)
2672
3376
  return;
3377
+ try {
3378
+ this.#writeFeaturePatch(command.state.features);
3379
+ }
3380
+ catch (error) {
3381
+ void this.#settingsError(`Feature toggles were not saved: ${error instanceof Error ? error.message : String(error)}`);
3382
+ return;
3383
+ }
2673
3384
  this.#settings = command.state;
2674
3385
  this.#features = command.state.features;
2675
3386
  this.#render();
2676
- this.#writeFeaturePatch();
2677
3387
  return;
2678
3388
  }
2679
3389
  if (command.domain === 'agent') {
@@ -2695,56 +3405,40 @@ export class LocalTui {
2695
3405
  this.#render();
2696
3406
  }
2697
3407
  }
2698
- #openPluginSettings(id) {
3408
+ #runPluginSettings(operation) {
2699
3409
  const binding = this.#pluginSettings;
2700
3410
  if (binding === undefined)
2701
3411
  return;
2702
3412
  const closing = new AbortController();
3413
+ const selected = this.#settings === null ? undefined : tuiSettingItems(this.#settings.prefs, this.#settings.agent, this.#settings.features, this.#settings.plugins, this.#settings.editors, this.#settings.actions)[this.#settings.selected]?.id;
3414
+ this.#catalogVersion++;
2703
3415
  this.#pluginSettingsAbort = closing;
2704
- this.#pluginSettingsTask = Promise.resolve().then(() => binding.edit(id, closing.signal)).catch((error) => {
2705
- if (!closing.signal.aborted && !this.#disposed)
2706
- this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
2707
- }).finally(() => {
3416
+ this.#pluginSettingsTask = Promise.resolve().then(() => operation(binding, closing.signal)).catch(async (error) => {
3417
+ if (closing.signal.aborted || this.#disposed)
3418
+ return;
3419
+ const detail = error instanceof Error ? error.message : String(error);
3420
+ await showSettingsError(this, detail, closing.signal);
3421
+ }).finally(async () => {
3422
+ const entries = await this.#pluginSettings?.refresh?.().catch(() => this.#pluginSettings?.entries() ?? []) ?? this.#pluginSettings?.entries() ?? [];
2708
3423
  this.#pluginSettingsAbort = undefined;
2709
3424
  this.#pluginSettingsTask = undefined;
2710
- if (this.#settings !== null)
2711
- this.#settings = { ...this.#settings, prefs: this.prefs(),
2712
- ...(this.#agentBehavior === undefined ? {} : { agent: this.#agentBehavior }),
2713
- ...(this.#features === undefined ? {} : { features: this.#features }), plugins: this.#pluginSettings?.entries() ?? [] };
3425
+ if (!this.#disposed)
3426
+ this.#rebuildSettings(entries, selected);
2714
3427
  if (this.#tty && !this.#disposed)
2715
3428
  this.#render();
2716
3429
  });
2717
3430
  }
2718
- #applyCopySelector(event) {
2719
- if (this.#copySelector === null)
2720
- return;
2721
- const command = applyCopySelectorEvent(this.#copySelector, event);
2722
- if (command.kind === 'update') {
2723
- this.#copySelector = command.state;
2724
- this.#render();
2725
- return;
2726
- }
2727
- if (command.kind === 'pick') {
2728
- this.#copySelector = null;
2729
- void this.#copyPicked(command.item.text, command.item.copyMessage);
2730
- return;
2731
- }
2732
- if (command.kind === 'close') {
2733
- this.#copySelector = null;
2734
- this.#render();
2735
- }
2736
- }
2737
3431
  async #copyPicked(text, label) {
2738
3432
  try {
2739
3433
  const delivery = await this.#deliverCopy(text);
2740
3434
  if (this.#disposed)
2741
3435
  return;
2742
- this.#notice(delivery === 'requested' ? 'Sent ' + label + ' to the terminal clipboard' : 'Copied ' + label);
3436
+ this.#showTransientStatus(delivery === 'requested' ? 'Sent ' + label + ' to the terminal clipboard' : 'Copied ' + label);
2743
3437
  }
2744
3438
  catch {
2745
3439
  if (this.#disposed)
2746
3440
  return;
2747
- this.#notice('Copy failed');
3441
+ this.#showTransientStatus('Copy failed');
2748
3442
  }
2749
3443
  this.#render();
2750
3444
  }
@@ -2838,13 +3532,26 @@ export class LocalTui {
2838
3532
  const atPrefix = at?.query.replaceAll('\\', '/')
2839
3533
  ?? (token?.kind === 'at' ? parsePathPrefix(token.prefix).raw.replaceAll('\\', '/') : '');
2840
3534
  const fuzzyAt = (at !== undefined || token?.kind === 'at') && atPrefix !== '' && !atPrefix.endsWith('/');
3535
+ const shortcutAt = at !== undefined && matchesAtShortcutQuery(at.query, at.quoted);
2841
3536
  const harnessAt = at !== undefined
2842
3537
  && (this.#searchFileMentions !== undefined || this.#searchSessions !== undefined);
2843
- if (fuzzyAt || harnessAt) {
2844
- if (harnessAt && !fuzzyAt) {
2845
- this.#setAutocomplete(pathSuggestions(this.#editor.text, this.#editor.cursor, pathOptions, commands));
3538
+ if (fuzzyAt || harnessAt || shortcutAt) {
3539
+ // A `@` query is answered by a debounced search, so almost every keystroke
3540
+ // lands before an answer exists. Clearing the menu here emptied the screen
3541
+ // for the whole debounce-plus-search interval and refilled it afterwards —
3542
+ // one flash per character, and the same again on every delete.
3543
+ //
3544
+ // The rows already on screen still describe the token this keystroke is
3545
+ // extending, so they stay until a real result replaces them. A genuinely
3546
+ // different token closes the menu, because those rows no longer apply.
3547
+ if ((harnessAt || shortcutAt) && !fuzzyAt) {
3548
+ const listing = pathSuggestions(this.#editor.text, this.#editor.cursor, pathOptions, commands);
3549
+ if (listing !== null)
3550
+ this.#setAutocomplete(listing, atPrefix);
3551
+ else if (!this.#acDescribes(atPrefix))
3552
+ this.#ac = null;
2846
3553
  }
2847
- else {
3554
+ else if (!this.#acDescribes(atPrefix)) {
2848
3555
  this.#ac = null;
2849
3556
  }
2850
3557
  const text = this.#editor.text;
@@ -2855,7 +3562,7 @@ export class LocalTui {
2855
3562
  return;
2856
3563
  const controller = new AbortController();
2857
3564
  this.#autocompleteAbort = controller;
2858
- const search = this.#searchFileMentions === undefined && this.#searchSessions === undefined
3565
+ const search = this.#searchFileMentions === undefined && this.#searchSessions === undefined && !shortcutAt
2859
3566
  ? searchPathSuggestions(text, cursor, {
2860
3567
  ...pathOptions,
2861
3568
  searchFiles: this.#searchFiles,
@@ -2866,13 +3573,20 @@ export class LocalTui {
2866
3573
  searchFiles: this.#searchFiles,
2867
3574
  ...(this.#searchFileMentions === undefined ? {} : { searchFileMentions: this.#searchFileMentions }),
2868
3575
  ...(this.#searchSessions === undefined ? {} : { searchSessions: this.#searchSessions }),
3576
+ ...(shortcutAt ? { gitContext: resolveGitStatusFiles(this.#cwd) } : {}),
2869
3577
  signal: controller.signal,
2870
3578
  }, commands);
2871
3579
  void search.then((result) => {
2872
3580
  if (this.#disposed || controller.signal.aborted || requestId !== this.#autocompleteRequestId)
2873
3581
  return;
2874
3582
  this.#autocompleteAbort = null;
2875
- this.#setAutocomplete(result);
3583
+ // An empty answer for a `@` query means "nothing matched yet", not
3584
+ // "close the menu": keeping the live list is what stops the flash when
3585
+ // a prefix has no rows of its own.
3586
+ if (result !== null)
3587
+ this.#setAutocomplete(result, atPrefix);
3588
+ else if (!this.#acDescribes(atPrefix))
3589
+ this.#setAutocomplete(null);
2876
3590
  this.#render();
2877
3591
  }).catch((error) => {
2878
3592
  if (controller.signal.aborted || requestId !== this.#autocompleteRequestId)
@@ -2888,7 +3602,16 @@ export class LocalTui {
2888
3602
  const result = pathSuggestions(this.#editor.text, this.#editor.cursor, pathOptions, commands);
2889
3603
  this.#setAutocomplete(result);
2890
3604
  }
2891
- #setAutocomplete(result) {
3605
+ /**
3606
+ * Whether the open menu still speaks for the `@` token being typed.
3607
+ *
3608
+ * A menu with no token recorded belongs to a slash or path completion and is
3609
+ * never reused across an `@` keystroke.
3610
+ */
3611
+ #acDescribes(atPrefix) {
3612
+ return completionDescribes(this.#ac?.query, atPrefix);
3613
+ }
3614
+ #setAutocomplete(result, query) {
2892
3615
  if (result === null) {
2893
3616
  this.#ac = null;
2894
3617
  return;
@@ -2900,7 +3623,7 @@ export class LocalTui {
2900
3623
  if (idx >= 0)
2901
3624
  selected = idx;
2902
3625
  }
2903
- this.#ac = { items: result.items, selected };
3626
+ this.#ac = { items: result.items, selected, ...(query === undefined ? {} : { query }) };
2904
3627
  }
2905
3628
  #moveAutocomplete(dir) {
2906
3629
  if (this.#ac === null || this.#ac.items.length === 0)
@@ -2948,50 +3671,69 @@ export class LocalTui {
2948
3671
  this.#historyNext();
2949
3672
  return;
2950
3673
  }
2951
- if (command.kind === 'interrupt') {
2952
- if (this.#state.status === 'running') {
2953
- for (const listener of this.#interrupts)
2954
- listener();
2955
- }
2956
- return;
2957
- }
2958
- if (command.kind === 'clear') {
2959
- this.#literalInput = false;
2960
- this.#editor.clear();
2961
- this.#images.clear();
2962
- this.#files.clear();
2963
- this.#queueEditNewer = null;
2964
- this.#queueEditPending = false;
2965
- this.#historyIndex = 0;
2966
- this.#clearDraft();
2967
- this.#ac = null;
2968
- this.#search = null;
2969
- this.#render();
2970
- return;
2971
- }
2972
- if (command.kind === 'quit') {
2973
- this.#quit();
3674
+ }
3675
+ /**
3676
+ * Show a one-line confirmation that goes away on its own.
3677
+ *
3678
+ * Replaced, never appended: two presses in a row leave one line saying what
3679
+ * the second one did, which is what the reader is looking for.
3680
+ */
3681
+ #showTransientStatus(text, ttlMs = TRANSIENT_STATUS_MS) {
3682
+ this.#transientStatus = text;
3683
+ if (this.#transientTimer !== null)
3684
+ clearTimeout(this.#transientTimer);
3685
+ this.#transientTimer = setTimeout(() => {
3686
+ this.#transientTimer = null;
3687
+ if (this.#transientStatus !== text)
3688
+ return;
3689
+ this.#transientStatus = undefined;
3690
+ if (!this.#disposed && this.#tty)
3691
+ this.#render();
3692
+ }, ttlMs);
3693
+ this.#transientTimer.unref?.();
3694
+ this.#render();
3695
+ }
3696
+ /** Any keystroke makes the last confirmation stale. */
3697
+ #clearTransientStatus() {
3698
+ if (this.#transientStatus === undefined)
2974
3699
  return;
3700
+ this.#transientStatus = undefined;
3701
+ if (this.#transientTimer !== null) {
3702
+ clearTimeout(this.#transientTimer);
3703
+ this.#transientTimer = null;
2975
3704
  }
2976
- if (command.kind === 'suspend') {
2977
- if (process.platform !== 'win32') {
2978
- if (this.#inspecting) {
2979
- this.#followTail();
2980
- this.#render();
2981
- }
2982
- this.#setMouseTracking(false);
2983
- this.#term.output.write('\x1b[?1004l');
2984
- try {
2985
- process.kill(process.pid, 'SIGTSTP');
2986
- }
2987
- catch { /* no controlling tty */ }
2988
- }
3705
+ }
3706
+ /**
3707
+ * Show or hide thinking across the whole transcript, and remember the choice.
3708
+ *
3709
+ * A revealed transcript is one where no thought is folded, so the per-thought
3710
+ * openings a reader made are dropped at the same time; keeping them would
3711
+ * leave a thought open inside a transcript that claims to show everything.
3712
+ */
3713
+ #toggleThinking() {
3714
+ const next = !this.#hideThinking;
3715
+ this.#hideThinking = next;
3716
+ if (!next)
3717
+ this.#expandedReasoning.clear();
3718
+ const persist = this.#persistPrefs;
3719
+ if (persist !== null)
3720
+ void persist(this.prefs(), [{ op: 'set', path: ['hideThinking'], value: next }]);
3721
+ this.#showTransientStatus(next ? 'Thinking: hidden' : 'Thinking: shown');
3722
+ }
3723
+ /** Hand the terminal to the shell and stop until SIGCONT brings it back. */
3724
+ #suspend() {
3725
+ if (process.platform === 'win32')
2989
3726
  return;
2990
- }
2991
- if (command.kind === 'resetDisplay') {
2992
- this.#renderer.reset();
3727
+ if (this.#inspecting) {
3728
+ this.#followTail();
2993
3729
  this.#render();
2994
3730
  }
3731
+ // The shell that takes over gets every mode back; SIGCONT takes them again.
3732
+ this.#handOffTerminal();
3733
+ try {
3734
+ process.kill(process.pid, 'SIGTSTP');
3735
+ }
3736
+ catch { /* no controlling tty */ }
2995
3737
  }
2996
3738
  #historyPrev() {
2997
3739
  if (this.#images.count > 0)
@@ -3007,7 +3749,7 @@ export class LocalTui {
3007
3749
  this.#refreshAutocomplete();
3008
3750
  this.#render();
3009
3751
  }
3010
- #restoreLatestQueuedSubmission() {
3752
+ #restoreLatestQueuedSubmission(allowNonEmpty = false) {
3011
3753
  if (this.#queueEditPending)
3012
3754
  return true;
3013
3755
  if (this.#queueEditNewer !== null) {
@@ -3025,11 +3767,14 @@ export class LocalTui {
3025
3767
  listener();
3026
3768
  return true;
3027
3769
  }
3028
- if (this.#editor.text !== '' || this.#images.count > 0 || this.#historyIndex !== 0)
3770
+ if (this.#historyIndex !== 0)
3771
+ return false;
3772
+ const hasDraft = this.#hasComposerContent();
3773
+ if (!allowNonEmpty && hasDraft)
3029
3774
  return false;
3030
3775
  const submission = this.#queuedSubmissions.pop();
3031
3776
  if (submission !== undefined) {
3032
- this.#queueEditNewer = [];
3777
+ this.#queueEditNewer = hasDraft ? [this.#currentSubmission()] : [];
3033
3778
  this.#replaceInput(submission);
3034
3779
  return true;
3035
3780
  }
@@ -3069,23 +3814,47 @@ export class LocalTui {
3069
3814
  this.#quitRequested = true;
3070
3815
  }
3071
3816
  }
3817
+ /**
3818
+ * Turn composer text into a submission, shared by the main and inspected
3819
+ * composers.
3820
+ *
3821
+ * The composer marker is a draft affordance, not something the reader said,
3822
+ * so it leaves the text; it still records where the picture sat, so the
3823
+ * segments keep that order.
3824
+ */
3825
+ #buildSubmission(text) {
3826
+ const images = this.#images.copies();
3827
+ const files = this.#files.copies();
3828
+ const typedText = this.#files.text(images.length > 0 ? text.trim() : text);
3829
+ const segments = images.length === 0 ? [] : splitComposerImageMarkers(typedText, images);
3830
+ // Stripping a marker that sat on its own line leaves that line break behind.
3831
+ const submittedText = images.length > 0 ? stripComposerImageMarkers(typedText, images).trim() : typedText;
3832
+ const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
3833
+ const historyText = submittedText.replace(/[ \t]{2,}/gu, ' ').trim();
3834
+ return {
3835
+ submission: {
3836
+ text: submittedText,
3837
+ images,
3838
+ ...(segments.length === 0 ? {} : { segments }),
3839
+ ...(files.length === 0 ? {} : { files }),
3840
+ ...(literal ? { literal: true } : {}),
3841
+ },
3842
+ historyText,
3843
+ };
3844
+ }
3072
3845
  #submit(text) {
3073
3846
  if (this.#prompt !== null) {
3847
+ if (this.#prompt.request.presentation === 'progress')
3848
+ return;
3074
3849
  this.#editor.setText('');
3075
3850
  const answer = this.#prompt.request.preserveWhitespace === true ? text : text.trim();
3076
3851
  this.#finishPrompt(answer.trim() === '' ? null : answer);
3077
3852
  this.#render();
3078
3853
  return;
3079
3854
  }
3080
- const images = this.#images.copies();
3081
- const files = this.#files.copies();
3082
- const submittedText = this.#files.text(images.length > 0 ? text.trim() : text);
3083
- const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
3084
- const submission = { text: submittedText, images, ...(files.length === 0 ? {} : { files }), ...(literal ? { literal: true } : {}) };
3855
+ const { submission, historyText } = this.#buildSubmission(text);
3856
+ const { images, files = [], literal = false, text: submittedText } = submission;
3085
3857
  const queueEditNewer = this.#queueEditNewer;
3086
- const historyText = stripComposerImageMarkers(submittedText, images)
3087
- .replace(/[ \t]{2,}/gu, ' ')
3088
- .trim();
3089
3858
  if (historyText !== '' && this.#history[this.#history.length - 1] !== historyText) {
3090
3859
  this.#history.push(historyText);
3091
3860
  this.#historyStore?.add(historyText);
@@ -3102,9 +3871,7 @@ export class LocalTui {
3102
3871
  this.#ac = null;
3103
3872
  this.#search = null;
3104
3873
  this.#settings = null;
3105
- this.#copySelector = null;
3106
3874
  this.#trajectory = null;
3107
- this.#agentHub = null;
3108
3875
  // Image placeholders are TUI-owned. Mixed image+slash drafts stay a
3109
3876
  // submission so restore can keep the original markers; the runner strips
3110
3877
  // them only to detect and execute Harness commands.
@@ -3131,16 +3898,14 @@ export class LocalTui {
3131
3898
  if (queueEditNewer !== null)
3132
3899
  this.#queuedSubmissions.push(...queueEditNewer);
3133
3900
  }
3901
+ if (submittedText !== '' || images.length > 0 || files.length > 0) {
3902
+ this.#popStashedDraft();
3903
+ }
3134
3904
  this.#render();
3135
3905
  }
3136
3906
  #submitInspect(text) {
3137
- const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
3138
- const images = this.#images.copies();
3139
- const files = this.#files.copies();
3140
- const submittedText = this.#files.text(images.length > 0 ? text.trim() : text);
3141
- const historyText = stripComposerImageMarkers(submittedText, images)
3142
- .replace(/[ \t]{2,}/gu, ' ')
3143
- .trim();
3907
+ const { submission, historyText } = this.#buildSubmission(text);
3908
+ const { images, files = [], literal = false, text: submittedText } = submission;
3144
3909
  if (historyText !== '' && this.#history[this.#history.length - 1] !== historyText) {
3145
3910
  this.#history.push(historyText);
3146
3911
  this.#historyStore?.add(historyText);
@@ -3164,8 +3929,10 @@ export class LocalTui {
3164
3929
  this.#render();
3165
3930
  return;
3166
3931
  }
3932
+ const { literal: _literal, ...inspectSubmission } = submission;
3167
3933
  for (const listener of this.#inspectSubmits)
3168
- listener({ text: submittedText, images, ...(files.length === 0 ? {} : { files }) });
3934
+ listener(inspectSubmission);
3935
+ this.#popStashedDraft();
3169
3936
  this.#render();
3170
3937
  }
3171
3938
  #runSlash(name, args = '') {
@@ -3228,8 +3995,7 @@ export class LocalTui {
3228
3995
  return;
3229
3996
  }
3230
3997
  if (command.name === 'tools') {
3231
- this.#toolCatalog();
3232
- this.#render();
3998
+ this.#runTools();
3233
3999
  return;
3234
4000
  }
3235
4001
  if (args !== '' && args !== 'full') {
@@ -3237,28 +4003,7 @@ export class LocalTui {
3237
4003
  this.#render();
3238
4004
  return;
3239
4005
  }
3240
- const full = args === 'full';
3241
- this.#state = {
3242
- ...this.#state,
3243
- blocks: [...this.#state.blocks, {
3244
- kind: 'commandOutput',
3245
- command: 'help',
3246
- text: [
3247
- formatHelpText(this.#commands()),
3248
- '',
3249
- full
3250
- ? `**Keyboard Shortcuts · ${hotkeyCount(this.#keybindings)} shortcuts**`
3251
- : '**Essential Shortcuts**',
3252
- '',
3253
- full
3254
- ? formatHotkeysText(this.#keybindings)
3255
- : formatEssentialHotkeysText(this.#keybindings),
3256
- ...(full ? [] : ['', 'Use `/help full` to view every keyboard shortcut.']),
3257
- ].join('\n'),
3258
- }],
3259
- };
3260
- this.#focusLatestBlock();
3261
- this.#render();
4006
+ this.#runHelp(args === 'full');
3262
4007
  }
3263
4008
  #commands() {
3264
4009
  const localNames = new Set(BUILTIN_SLASH_COMMANDS.flatMap((command) => [command.name, ...(command.aliases ?? [])]));
@@ -3268,6 +4013,7 @@ export class LocalTui {
3268
4013
  name: command.name,
3269
4014
  description: command.description,
3270
4015
  ...(command.inputHint === undefined ? {} : { inputHint: command.inputHint }),
4016
+ ...(command.complete === undefined ? {} : { complete: command.complete }),
3271
4017
  }));
3272
4018
  return [...BUILTIN_SLASH_COMMANDS, ...runtime];
3273
4019
  }
@@ -3276,10 +4022,24 @@ export class LocalTui {
3276
4022
  if (pending === null)
3277
4023
  return;
3278
4024
  this.#prompt = null;
3279
- this.#herdr.promptResolved();
4025
+ if (pending.request.notify !== false) {
4026
+ this.#herdr.promptResolved();
4027
+ this.#syncWindowTitle();
4028
+ }
3280
4029
  pending.offAbort?.();
3281
4030
  pending.offTick?.();
4031
+ const canceledDraft = answer === null && pending.request.onCancelDraft !== undefined
4032
+ ? this.#editor.expandedText
4033
+ : '';
3282
4034
  this.#editor.restore(pending.savedInput);
4035
+ if (canceledDraft.trim() !== '') {
4036
+ try {
4037
+ pending.request.onCancelDraft?.(canceledDraft);
4038
+ }
4039
+ catch (error) {
4040
+ this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
4041
+ }
4042
+ }
3283
4043
  this.#draftFingerprint = this.#draftKey();
3284
4044
  pending.resolve(answer);
3285
4045
  this.#restoreDisplacedSurface();
@@ -3296,17 +4056,11 @@ export class LocalTui {
3296
4056
  #displaceSurface() {
3297
4057
  const displaced = this.#trajectory !== null
3298
4058
  ? { kind: 'trajectory', state: this.#trajectory }
3299
- : this.#agentHub !== null
3300
- ? { kind: 'agentHub', state: this.#agentHub }
3301
- : this.#settings !== null
3302
- ? { kind: 'settings', state: this.#settings }
3303
- : this.#copySelector !== null
3304
- ? { kind: 'copySelector', state: this.#copySelector }
3305
- : this.#search !== null ? { kind: 'search', state: this.#search } : null;
4059
+ : this.#settings !== null
4060
+ ? { kind: 'settings', state: this.#settings }
4061
+ : this.#search !== null ? { kind: 'search', state: this.#search } : null;
3306
4062
  this.#trajectory = null;
3307
- this.#agentHub = null;
3308
4063
  this.#settings = null;
3309
- this.#copySelector = null;
3310
4064
  this.#search = null;
3311
4065
  return displaced;
3312
4066
  }
@@ -3318,43 +4072,71 @@ export class LocalTui {
3318
4072
  return;
3319
4073
  if (displaced.kind === 'trajectory')
3320
4074
  this.#trajectory = displaced.state;
3321
- else if (displaced.kind === 'agentHub')
3322
- this.#agentHub = displaced.state;
3323
- else if (displaced.kind === 'settings')
4075
+ else if (displaced.kind === 'settings') {
3324
4076
  this.#settings = displaced.state;
3325
- else if (displaced.kind === 'copySelector')
3326
- this.#copySelector = displaced.state;
4077
+ this.#rebuildSettings(this.#pluginSettings?.entries() ?? []);
4078
+ this.#refreshSettingsCatalog();
4079
+ }
3327
4080
  else
3328
4081
  this.#search = displaced.state;
3329
4082
  }
4083
+ /** The pane's answer, offered to `/copy` while it is on screen and finished. */
4084
+ #copyableAside() {
4085
+ if (this.#aside === undefined || this.#aside.status === 'running' || this.#aside.text.trim() === '')
4086
+ return undefined;
4087
+ return { question: this.#aside.question, text: this.#aside.text };
4088
+ }
4089
+ /**
4090
+ * Copy something out of the transcript.
4091
+ *
4092
+ * There used to be four kinds — `text`, `code`, `cmd`, `output` — and each one
4093
+ * asked the reader to remember a word to skip a keystroke in the picker. A kind
4094
+ * that means "the default" is a no-op, and the other three name a row the picker
4095
+ * already shows with its content, its language and its size. One command, one
4096
+ * list: the choice is made by looking, not by recalling.
4097
+ */
3330
4098
  async #runCopy(args) {
3331
- if (args.trim() === '') {
3332
- const items = buildCopyTargets(this.#state.blocks);
3333
- if (items.length === 0) {
3334
- this.#notice('Nothing to copy.');
3335
- this.#render();
3336
- return;
3337
- }
3338
- this.#search = null;
3339
- this.#ac = null;
3340
- this.#settings = null;
3341
- this.#copySelector = createCopySelector(items);
4099
+ if (args.trim() !== '') {
4100
+ this.#notice('Usage: /copy');
3342
4101
  this.#render();
3343
4102
  return;
3344
4103
  }
3345
- const kind = parseCopyKind(args);
3346
- if (kind === undefined) {
3347
- this.#notice('Usage: /copy [code|cmd]');
4104
+ const items = buildCopyTargets(this.#state.blocks, undefined, this.#copyableAside());
4105
+ if (items.length === 0) {
4106
+ this.#notice('Nothing to copy.');
3348
4107
  this.#render();
3349
4108
  return;
3350
4109
  }
3351
- const target = extractCopyTarget(this.#state.blocks, kind);
3352
- if (target === undefined) {
3353
- this.#notice(kind === 'code' ? 'No code block to copy.' : kind === 'cmd' ? 'No command to copy.' : 'Nothing to copy.');
3354
- this.#render();
4110
+ /**
4111
+ * The same card as every other list.
4112
+ *
4113
+ * This used to be a bespoke overlay with its own key model: a filter that
4114
+ * stayed inactive until `/` — so typing a letter did nothing and Space
4115
+ * copied — and a nine-row hotkey table of its own. It was the last surface
4116
+ * that did not answer to the shared list vocabulary, which is exactly the
4117
+ * inconsistency the reader noticed.
4118
+ */
4119
+ const answer = await this.prompt({
4120
+ title: 'Copy',
4121
+ question: items.length === 1 ? 'One thing to copy' : 'Choose what to copy',
4122
+ filterable: true,
4123
+ allowCustom: false,
4124
+ notify: false,
4125
+ options: items.map(item => ({
4126
+ label: item.label,
4127
+ value: item.id,
4128
+ ...(item.hint === '' ? {} : { description: item.hint }),
4129
+ // The preview is the payload: choosing blind is what the row labels
4130
+ // cannot prevent when two replies open with the same words.
4131
+ preview: item.text,
4132
+ })),
4133
+ });
4134
+ if (answer === null)
3355
4135
  return;
3356
- }
3357
- await this.#copyPicked(target.text, target.label);
4136
+ const picked = items.find(item => item.id === answer);
4137
+ if (picked === undefined)
4138
+ return;
4139
+ void this.#copyPicked(picked.text, picked.copyMessage);
3358
4140
  }
3359
4141
  #runSettings(args) {
3360
4142
  if (args.trim() !== '') {
@@ -3364,9 +4146,60 @@ export class LocalTui {
3364
4146
  }
3365
4147
  this.#search = null;
3366
4148
  this.#ac = null;
3367
- this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor));
4149
+ this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor), this.#settingsActions());
4150
+ this.#refreshSettingsCatalog();
3368
4151
  this.#render();
3369
4152
  }
4153
+ #settingsActions() {
4154
+ const runtimeActions = [
4155
+ { id: 'command:model', label: 'Model and reasoning', description: 'Open /model; changes the current selection and saves the default route.', value: [this.#model, this.#reasoningEffort].filter(Boolean).join(' · '), values: [], action: 'model' },
4156
+ { id: 'command:permission', label: 'Access', description: 'Open /permission to choose access for this session.', value: this.#sessionControls?.permission ?? 'Choose', values: [], action: 'permission' },
4157
+ { id: 'command:agent', label: 'Agent preset', description: 'Open /agent to choose the session Agent preset.', value: this.#sessionControls?.agentPreset ?? 'Choose', values: [], action: 'agent' },
4158
+ ].filter(item => this.#runtimeCommands.some(command => command.name === item.action));
4159
+ const customBindings = Object.entries(this.#keybindings).filter(([key, action]) => DEFAULT_KEYBINDINGS[key] !== action).length;
4160
+ return [
4161
+ ...runtimeActions,
4162
+ ...(this.#keybindingsPath === undefined ? [] : [{
4163
+ id: 'action:keybindings',
4164
+ label: 'Keybindings',
4165
+ description: 'Remap keyboard shortcuts and save to keybindings.json.',
4166
+ value: customBindings === 0 ? 'Default' : `${customBindings} custom`,
4167
+ values: [],
4168
+ action: 'keybindings',
4169
+ group: 'Input',
4170
+ }]),
4171
+ ];
4172
+ }
4173
+ #refreshSettingsCatalog() {
4174
+ const binding = this.#pluginSettings;
4175
+ if (this.#settings === null || binding?.refresh === undefined || this.#disposed)
4176
+ return;
4177
+ const version = ++this.#catalogVersion;
4178
+ void binding.refresh().then(entries => {
4179
+ if (version !== this.#catalogVersion || binding !== this.#pluginSettings || this.#settings === null || this.#disposed)
4180
+ return;
4181
+ this.#rebuildSettings(entries);
4182
+ this.#render();
4183
+ }).catch(() => { });
4184
+ }
4185
+ #rebuildSettings(entries, focus) {
4186
+ if (this.#settings === null || this.#disposed)
4187
+ return;
4188
+ this.#settings = refreshSettings(this.#settings, createSettings(this.prefs(), focus, this.#agentBehavior, this.#features, entries, discoverEditors(this.#externalEditor), this.#settingsActions()));
4189
+ }
4190
+ #syncSettingsActions() {
4191
+ if (this.#disposed)
4192
+ return;
4193
+ const entries = this.#pluginSettings?.entries() ?? [];
4194
+ if (this.#settings !== null)
4195
+ this.#rebuildSettings(entries);
4196
+ if (this.#promptDisplaced?.kind === 'settings') {
4197
+ this.#promptDisplaced = {
4198
+ kind: 'settings',
4199
+ state: refreshSettings(this.#promptDisplaced.state, createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, entries, discoverEditors(this.#externalEditor), this.#settingsActions())),
4200
+ };
4201
+ }
4202
+ }
3370
4203
  #notice(text) {
3371
4204
  this.#state = {
3372
4205
  ...this.#state,
@@ -3374,40 +4207,59 @@ export class LocalTui {
3374
4207
  };
3375
4208
  }
3376
4209
  #configureNotifications() {
3377
- const thresholds = { '15s': 15_000, '30s': 30_000, '1m': 60_000, '2m': 120_000 };
3378
4210
  this.#notifications.configure({
3379
4211
  policy: this.#notificationPolicy,
3380
- thresholdMs: thresholds[this.#notificationThreshold],
4212
+ thresholdMs: NOTIFICATION_THRESHOLD_MS,
3381
4213
  });
3382
4214
  }
3383
4215
  #emitNotification(notification) {
3384
4216
  if (notification === undefined || !this.#tty || this.#disposed)
3385
4217
  return;
3386
- if (this.#notificationFocus === 'unfocused' && this.#terminalFocused === true)
4218
+ if (this.#notificationPolicy === 'unfocused' && this.#terminalFocused === true)
3387
4219
  return;
3388
4220
  this.#notificationQueue.offer(notification);
3389
4221
  if (this.#notificationTimer !== undefined)
3390
4222
  return;
3391
4223
  this.#notificationTimer = setTimeout(() => {
3392
4224
  this.#notificationTimer = undefined;
3393
- const pending = this.#notificationQueue.take(this.#notificationFocus, this.#terminalFocused);
4225
+ const pending = this.#notificationQueue.take();
3394
4226
  if (pending === undefined || this.#disposed || this.#notificationPolicy === 'off')
3395
4227
  return;
4228
+ // Re-read focus at delivery time: the reader may have come back inside the
4229
+ // coalescing window, and a turn finishing while they watch is not news.
4230
+ if (this.#notificationPolicy === 'unfocused' && this.#terminalFocused === true)
4231
+ return;
3396
4232
  const sequence = terminalNotificationSequence(pending);
3397
4233
  if (sequence !== '')
3398
4234
  this.#term.output.write(sequence);
3399
4235
  }, 25);
3400
4236
  }
3401
- #toolCatalog() {
3402
- this.#state = {
3403
- ...this.#state,
3404
- blocks: [...this.#state.blocks, { kind: 'toolCatalog', tools: this.#tools }],
3405
- };
3406
- this.#focusLatestBlock();
4237
+ /**
4238
+ * Help is reference, not conversation: 60 rows of it pushed into immutable
4239
+ * scrollback and vanished on resume, so the reader paid for it twice. It opens
4240
+ * in the reading surface instead, where it can be searched and left with Esc.
4241
+ */
4242
+ #runHelp(full) {
4243
+ const text = [
4244
+ formatHelpText(this.#commands()),
4245
+ '',
4246
+ full
4247
+ ? `**Keyboard Shortcuts · ${hotkeyCount(this.#keybindings)} shortcuts**`
4248
+ : '**Essential Shortcuts**',
4249
+ '',
4250
+ full ? formatHotkeysText(this.#keybindings) : formatEssentialHotkeysText(this.#keybindings),
4251
+ ...(full ? [] : ['', 'Use `/help full` to view every keyboard shortcut.']),
4252
+ ].join('\n');
4253
+ this.commandOutput('help', text, { focus: true });
3407
4254
  }
3408
- #focusLatestBlock() {
3409
- this.#follow = false;
3410
- this.#focusBlock = Math.max(0, this.#state.blocks.length - 1);
4255
+ /**
4256
+ * Tools are reference, like the command list: a table of names and
4257
+ * descriptions the reader consults and dismisses, not a step of the work.
4258
+ */
4259
+ #runTools() {
4260
+ // The first row of a command result is its heading, so a table needs one.
4261
+ const heading = `Tools · ${this.#tools.length} active`;
4262
+ this.commandOutput('tools', `${heading}\n\n${formatToolsText(this.#tools)}`, { focus: true });
3411
4263
  }
3412
4264
  /** Resolve a configured action for one key id, tolerating camelCase ids. */
3413
4265
  #boundAction(id) {
@@ -3415,13 +4267,75 @@ export class LocalTui {
3415
4267
  }
3416
4268
  /** Run one configured action; returns true when the event was consumed. */
3417
4269
  #runAction(action) {
3418
- if (this.#prompt !== null || this.#settings !== null || this.#copySelector !== null)
4270
+ // `interrupt` is resolved further down, after every surface that wants to
4271
+ // interpret Escape itself — a settings search clears before the overlay
4272
+ // closes. Claiming it here would skip that layering.
4273
+ if (action === 'interrupt')
4274
+ return false;
4275
+ if (action === 'clear-composer')
4276
+ return this.#runClearComposer();
4277
+ if (action === 'quit') {
4278
+ if (this.#hasComposerContent() || this.#editor.text !== '')
4279
+ return false;
4280
+ this.#quit();
4281
+ return true;
4282
+ }
4283
+ if (action === 'suspend') {
4284
+ this.#suspend();
4285
+ return true;
4286
+ }
4287
+ if (action === 'reset-display') {
4288
+ this.#renderer.reset();
4289
+ this.#render();
4290
+ return true;
4291
+ }
4292
+ if (this.#prompt !== null || this.#settings !== null)
3419
4293
  return false;
4294
+ if (action === 'select-model') {
4295
+ this.#submit('/model');
4296
+ return true;
4297
+ }
4298
+ if (action === 'cycle-thinking') {
4299
+ this.#submit('/model reasoning');
4300
+ return true;
4301
+ }
4302
+ if (action === 'toggle-thinking') {
4303
+ this.#toggleThinking();
4304
+ return true;
4305
+ }
4306
+ if (action === 'dequeue')
4307
+ return this.#restoreLatestQueuedSubmission(true);
4308
+ if (action === 'stash-draft') {
4309
+ if (this.#search !== null)
4310
+ return false;
4311
+ this.#stashOrRestoreDraft();
4312
+ return true;
4313
+ }
3420
4314
  if (action === 'preview-images') {
3421
4315
  if (this.#search !== null || this.#inspected !== undefined)
3422
4316
  return false;
3423
- if (this.#images.count === 0)
3424
- this.notice('Paste an image before opening its preview.');
4317
+ if (this.#images.count === 0) {
4318
+ // No draft image: preview the newest image the conversation carries,
4319
+ // which is the only way a settled card can still show its picture.
4320
+ const ref = this.#latestImageRef();
4321
+ if (ref === undefined)
4322
+ this.notice('Paste an image before opening its preview.');
4323
+ else if (this.#readImageInput === undefined)
4324
+ this.notice('This deployment cannot reopen that image.');
4325
+ else if (this.#imagePreviewAbort === undefined) {
4326
+ const abort = new AbortController();
4327
+ this.#imagePreviewAbort = abort;
4328
+ void this.#readImageInput(ref, abort.signal)
4329
+ .then(image => image === undefined
4330
+ ? this.notice('That image could not be read back for a preview.', { level: 'error' })
4331
+ : previewImages(this, [image], abort.signal))
4332
+ .catch((error) => { if (!abort.signal.aborted && !this.#disposed)
4333
+ this.notice(error instanceof Error ? error.message : String(error), { level: 'error' }); })
4334
+ .finally(() => { if (this.#imagePreviewAbort === abort)
4335
+ this.#imagePreviewAbort = undefined; });
4336
+ }
4337
+ return true;
4338
+ }
3425
4339
  else if (this.#imagePreviewAbort === undefined) {
3426
4340
  const abort = new AbortController();
3427
4341
  const revision = this.#images.revision;
@@ -3496,10 +4410,27 @@ export class LocalTui {
3496
4410
  return true;
3497
4411
  }
3498
4412
  if (action === 'review-changes') {
3499
- const anchor = this.#viewportBlock() ?? this.#state.blocks.length - 1;
3500
- const nextUser = this.#state.blocks.findIndex((block, index) => index > anchor && block.kind === 'user');
3501
- const workspace = this.#state.blocks.slice(0, nextUser < 0 ? undefined : nextUser).findLast(block => block.kind === 'workspace');
3502
- this.#runSlash('diff', workspace?.kind === 'workspace' ? `turn ${workspace.turn}` : '');
4413
+ // The keyboard twin of right-clicking a tool's path: open the files the
4414
+ // tool under the viewport declared. It shares the mouse path's region
4415
+ // lookup, so the two can never disagree about which call is under the
4416
+ // reader's eye.
4417
+ //
4418
+ // This used to also derive the turn under the viewport and forward
4419
+ // `/diff turn N` when no tool was there — twenty lines of anchor
4420
+ // backtracking to reach what `/diff` already asks for on its own. A
4421
+ // reader who wants this turn's changes types `/diff`.
4422
+ const rows = this.#renderer.viewport().map((_, row) => this.#renderer.documentRowAt(row)).filter((row) => row !== undefined);
4423
+ const top = this.#follow ? undefined : rows[0];
4424
+ const regions = this.#fileActionFrame?.transcript?.fileRegions ?? [];
4425
+ const region = (top === undefined ? undefined : regions.find(region => top >= region.start && top < region.end))
4426
+ ?? (this.#inspecting ? regions.find(region => this.#state.blocks[region.block]?.kind === 'tool' && rows.some(row => row >= region.start && row < region.end)) : undefined);
4427
+ const block = this.#state.blocks[region?.block ?? (this.#viewportBlock() ?? this.#state.blocks.length - 1)];
4428
+ const files = block?.kind === 'tool' ? blockFileAction(block) : undefined;
4429
+ if (files === undefined) {
4430
+ this.#showTransientStatus('No tool files here · use /diff', 4_000);
4431
+ return true;
4432
+ }
4433
+ this.#openFileAction(files);
3503
4434
  return true;
3504
4435
  }
3505
4436
  if (action === 'scroll-page-up') {
@@ -3558,10 +4489,6 @@ export class LocalTui {
3558
4489
  this.#render();
3559
4490
  return true;
3560
4491
  }
3561
- if (action === 'inspect-subagent') {
3562
- void this.#pickSubagent();
3563
- return true;
3564
- }
3565
4492
  if (action === 'retry') {
3566
4493
  this.#submit('/retry');
3567
4494
  return true;
@@ -3574,10 +4501,6 @@ export class LocalTui {
3574
4501
  this.#submit('/model previous');
3575
4502
  return true;
3576
4503
  }
3577
- if (action === 'cycle-reasoning') {
3578
- this.#submit('/model reasoning');
3579
- return true;
3580
- }
3581
4504
  if (action === 'copy-prompt') {
3582
4505
  void this.#copyPicked(this.#editor.expandedText, 'current prompt');
3583
4506
  return true;
@@ -3591,29 +4514,212 @@ export class LocalTui {
3591
4514
  this.#startAsyncPaste(this.#pasteClipboard());
3592
4515
  return true;
3593
4516
  }
4517
+ // A queued follow-up is the one action that must still work while the turn
4518
+ // it belongs to is running; Enter is which-turn, this is after-this-turn.
4519
+ if (action === 'follow-up') {
4520
+ if (this.#editor.expandedText.trim() === '')
4521
+ return false;
4522
+ this.#submit(this.#editor.expandedText);
4523
+ return true;
4524
+ }
4525
+ if (action !== 'external-editor')
4526
+ return false;
3594
4527
  try {
3595
- this.#setMouseTracking(false);
3596
- this.#term.output.write('\x1b[?1004l');
3597
- this.#term.input.setRawMode?.(false);
3598
- this.#literalInput ||= this.#editor.startsWithPaste;
3599
- const text = editExternally(this.#editor.expandedText, this.#externalEditor);
3600
- this.#editor.setText(text);
3601
- this.#images.reconcile();
4528
+ this.#handTerminal(() => {
4529
+ this.#literalInput ||= this.#editor.startsWithPaste;
4530
+ const text = editExternally(this.#editor.expandedText, this.#externalEditor);
4531
+ this.#editor.setText(text);
4532
+ this.#images.reconcile();
4533
+ });
3602
4534
  }
3603
4535
  catch (error) {
3604
4536
  this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
3605
4537
  }
3606
4538
  finally {
3607
4539
  if (!this.#disposed) {
3608
- this.#term.input.setRawMode?.(true);
3609
- this.#term.output.write('\x1b[?1004h');
3610
- this.#renderer.reset();
3611
4540
  this.#refreshAutocomplete();
3612
4541
  this.#render();
3613
4542
  }
3614
4543
  }
3615
4544
  return true;
3616
4545
  }
4546
+ #openPlanFeedbackEditor(prompt) {
4547
+ try {
4548
+ this.#handTerminal(() => {
4549
+ const initial = prompt.feedback === true ? this.#editor.expandedText : '';
4550
+ const text = editExternally(initial, this.#externalEditor);
4551
+ this.#editor.setText(text);
4552
+ this.#prompt = { ...prompt, feedback: true };
4553
+ });
4554
+ }
4555
+ catch (error) {
4556
+ this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
4557
+ }
4558
+ finally {
4559
+ if (!this.#disposed)
4560
+ this.#render();
4561
+ }
4562
+ }
4563
+ #hasComposerContent() {
4564
+ return this.#editor.text !== '' || this.#images.count > 0 || this.#files.copies().length > 0;
4565
+ }
4566
+ #stashScope() {
4567
+ return this.#inspected?.id ?? this.#sessionId ?? '__default__';
4568
+ }
4569
+ #clearComposerWithUndo(options = {}) {
4570
+ const hadContent = this.#hasComposerContent();
4571
+ const snapshot = hadContent ? this.#captureDraft() : undefined;
4572
+ this.#literalInput = false;
4573
+ this.#editor.clear();
4574
+ this.#images.clear();
4575
+ this.#files.clear();
4576
+ if (options.clearQueueEdit) {
4577
+ this.#queueEditNewer = null;
4578
+ this.#queueEditPending = false;
4579
+ }
4580
+ if (options.closeSearch)
4581
+ this.#search = null;
4582
+ this.#historyIndex = 0;
4583
+ this.#clearDraft();
4584
+ this.#ac = null;
4585
+ if (snapshot !== undefined) {
4586
+ this.#clearedDraft = { draft: snapshot, revision: this.#editor.revision };
4587
+ }
4588
+ this.#render();
4589
+ }
4590
+ #stashOrRestoreDraft() {
4591
+ const scope = this.#stashScope();
4592
+ const stashed = this.#stashedDrafts.get(scope);
4593
+ if (this.#hasComposerContent()) {
4594
+ const current = this.#captureDraft();
4595
+ this.#stashedDrafts.set(scope, current);
4596
+ if (stashed !== undefined) {
4597
+ this.#restoreDraft(stashed);
4598
+ this.notice('Swapped composer draft with stashed draft.');
4599
+ }
4600
+ else {
4601
+ this.#literalInput = false;
4602
+ this.#editor.setText('');
4603
+ this.#images.clear();
4604
+ this.#files.clear();
4605
+ this.#clearDraft();
4606
+ this.notice('Stashed composer draft. Send a message or press Ctrl+S again to restore.');
4607
+ }
4608
+ this.#historyIndex = 0;
4609
+ this.#ac = null;
4610
+ this.#render();
4611
+ return;
4612
+ }
4613
+ if (stashed !== undefined) {
4614
+ this.#stashedDrafts.delete(scope);
4615
+ this.#restoreDraft(stashed);
4616
+ this.#historyIndex = 0;
4617
+ this.#refreshAutocomplete();
4618
+ this.notice('Restored stashed draft.');
4619
+ this.#render();
4620
+ return;
4621
+ }
4622
+ this.notice('Nothing to stash.');
4623
+ }
4624
+ #popStashedDraft() {
4625
+ if (this.#hasComposerContent())
4626
+ return;
4627
+ const scope = this.#stashScope();
4628
+ const stashed = this.#stashedDrafts.get(scope);
4629
+ if (stashed === undefined)
4630
+ return;
4631
+ this.#stashedDrafts.delete(scope);
4632
+ this.#restoreDraft(stashed);
4633
+ this.#historyIndex = 0;
4634
+ this.#refreshAutocomplete();
4635
+ }
4636
+ async #editKeybindings() {
4637
+ if (this.#keybindingsPath === undefined)
4638
+ return;
4639
+ while (!this.#disposed) {
4640
+ const options = [
4641
+ ...TUI_ACTIONS.map(action => {
4642
+ const keys = keysForAction(this.#keybindings, action);
4643
+ const defaultKeys = keysForAction(DEFAULT_KEYBINDINGS, action);
4644
+ return {
4645
+ label: `${TUI_ACTION_LABELS[action]} · ${keys}`,
4646
+ value: action,
4647
+ description: keys === defaultKeys ? `Action: ${action}` : `Action: ${action} (default: ${defaultKeys})`,
4648
+ };
4649
+ }),
4650
+ {
4651
+ label: 'Reset all keybindings to defaults',
4652
+ value: '__reset_all__',
4653
+ description: 'Restore default shortcuts in keybindings.json',
4654
+ },
4655
+ ];
4656
+ const choice = await this.prompt({
4657
+ title: 'Keybindings',
4658
+ question: 'Choose an action to remap, or Esc to return to /settings.',
4659
+ options,
4660
+ filterable: true,
4661
+ allowCustom: false,
4662
+ notify: false,
4663
+ });
4664
+ if (choice === null || this.#disposed)
4665
+ break;
4666
+ if (choice === '__reset_all__') {
4667
+ try {
4668
+ saveKeybindings(this.#keybindingsPath, DEFAULT_KEYBINDINGS);
4669
+ this.#keybindings = loadKeybindings(this.#keybindingsPath);
4670
+ if (this.#settings !== null)
4671
+ this.#rebuildSettings(this.#pluginSettings?.entries() ?? [], 'action:keybindings');
4672
+ this.notice('Reset keybindings to defaults.');
4673
+ }
4674
+ catch (error) {
4675
+ this.notice(`Could not save keybindings: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' });
4676
+ }
4677
+ continue;
4678
+ }
4679
+ const action = choice;
4680
+ if (!TUI_ACTIONS.includes(action))
4681
+ continue;
4682
+ const currentRawKeys = Object.entries(this.#keybindings).filter(([, bound]) => bound === action).map(([key]) => key);
4683
+ const defaultKeys = keysForAction(DEFAULT_KEYBINDINGS, action);
4684
+ const entered = await this.prompt({
4685
+ title: `Remap ${TUI_ACTION_LABELS[action]}`,
4686
+ question: `Enter key chord (e.g. ctrl+s, alt+k), "default" (${defaultKeys}), or "none" to unbind.`,
4687
+ initialInput: currentRawKeys[0] ?? '',
4688
+ allowCustom: true,
4689
+ notify: false,
4690
+ });
4691
+ if (entered === null || this.#disposed)
4692
+ continue;
4693
+ const normalized = entered.trim().toLowerCase();
4694
+ if (normalized === '')
4695
+ continue;
4696
+ const next = { ...this.#keybindings };
4697
+ for (const [key, bound] of Object.entries(next)) {
4698
+ if (bound === action)
4699
+ delete next[key];
4700
+ }
4701
+ if (normalized === 'default') {
4702
+ for (const [key, bound] of Object.entries(DEFAULT_KEYBINDINGS)) {
4703
+ if (bound === action)
4704
+ next[key] = action;
4705
+ }
4706
+ }
4707
+ else if (normalized !== 'none' && normalized !== 'unbind') {
4708
+ next[normalized] = action;
4709
+ }
4710
+ try {
4711
+ saveKeybindings(this.#keybindingsPath, next);
4712
+ this.#keybindings = loadKeybindings(this.#keybindingsPath);
4713
+ if (this.#settings !== null)
4714
+ this.#rebuildSettings(this.#pluginSettings?.entries() ?? [], 'action:keybindings');
4715
+ }
4716
+ catch (error) {
4717
+ this.notice(`Could not save keybindings: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' });
4718
+ }
4719
+ }
4720
+ if (this.#tty && !this.#disposed)
4721
+ this.#render();
4722
+ }
3617
4723
  /**
3618
4724
  * Block index the reader is looking at: the first one that starts at or below
3619
4725
  * the top of the transcript window. A collapsed row has to stay reachable
@@ -3643,6 +4749,22 @@ export class LocalTui {
3643
4749
  return;
3644
4750
  }
3645
4751
  const anchor = this.#viewportBlock();
4752
+ /**
4753
+ * A long command result and a collapsed side answer fold the same way, and
4754
+ * this is the key that opens either. It is answered before the run lookup
4755
+ * because neither is a run: the reader aimed at those rows, not at the run
4756
+ * above them.
4757
+ */
4758
+ const anchored = anchor === undefined ? undefined : this.#state.blocks[anchor];
4759
+ if (anchor !== undefined && (anchored?.kind === 'commandOutput' || anchored?.kind === 'aside')) {
4760
+ if (this.#expandedOutputs.has(anchored))
4761
+ this.#expandedOutputs.delete(anchored);
4762
+ else
4763
+ this.#expandedOutputs.add(anchored);
4764
+ this.#inspect(anchor);
4765
+ this.#render();
4766
+ return;
4767
+ }
3646
4768
  const groups = turnGroups(this.#state).filter(item => !item.live);
3647
4769
  // The answer, changed-file record, and following notices still address
3648
4770
  // their turn when the reader is following the tail.
@@ -3671,11 +4793,6 @@ export class LocalTui {
3671
4793
  this.#render();
3672
4794
  return;
3673
4795
  }
3674
- if (anchor !== undefined && this.#state.blocks[anchor]?.kind === 'toolCatalog') {
3675
- this.#toolsExpanded = !this.#toolsExpanded;
3676
- this.#inspect(anchor);
3677
- this.#render();
3678
- }
3679
4796
  // A live turn already has the process presentation; Alt+O owns tool details.
3680
4797
  }
3681
4798
  #detailScope(group) {
@@ -3738,20 +4855,47 @@ export class LocalTui {
3738
4855
  else
3739
4856
  set.add(key);
3740
4857
  }
4858
+ /**
4859
+ * Every occurrence of the active query, in reading order.
4860
+ *
4861
+ * Stepping needs the same list the painter uses, so it asks the renderer rather
4862
+ * than scanning source text: a hit inside a folded row or a wrapped Markdown
4863
+ * table exists only in the rendered rows.
4864
+ */
4865
+ #searchOccurrences() {
4866
+ const search = this.#transcriptSearch;
4867
+ if (search === null || search.query.trim() === '')
4868
+ return [];
4869
+ const options = {
4870
+ width: this.#term.width(),
4871
+ colors: this.#colors,
4872
+ themeName: this.#themeName,
4873
+ ...(this.#colorDepth === undefined ? {} : { colorDepth: this.#colorDepth }),
4874
+ mathMode: this.#mathMode,
4875
+ toolsExpanded: this.#toolsExpanded,
4876
+ expandedOutputs: this.#expandedOutputs,
4877
+ hideThinking: this.#hideThinking,
4878
+ expandedTools: this.#detailedToolIds(),
4879
+ ...(this.#openedGroups === undefined ? {} : { openedGroups: this.#openedGroups }),
4880
+ };
4881
+ return searchMatches(renderedBlocks(this.#state, options), search.query);
4882
+ }
3741
4883
  /** Fold one event into the active transcript search. */
3742
4884
  #applyTranscriptSearch(event) {
3743
4885
  const state = this.#transcriptSearch;
3744
4886
  if (state === null)
3745
4887
  return;
3746
- const command = applyTranscriptSearchEvent(state, event, this.#state.blocks.map(blockSearchText));
4888
+ const command = applyTranscriptSearchEvent(state, event, this.#searchOccurrences());
3747
4889
  if (command.kind === 'close') {
3748
4890
  this.#transcriptSearch = null;
3749
4891
  this.#render();
3750
4892
  return;
3751
4893
  }
3752
4894
  this.#transcriptSearch = command.state;
3753
- if (command.kind === 'focus')
3754
- this.#focusBlock = command.block;
4895
+ if (command.kind === 'focus') {
4896
+ this.#focusBlock = command.match.block;
4897
+ this.#focusBlockEdge = 'start';
4898
+ }
3755
4899
  this.#render();
3756
4900
  }
3757
4901
  }
@@ -3775,6 +4919,21 @@ export function apply(ctx, config) {
3775
4919
  };
3776
4920
  const tui = new LocalTui(term, config.model, config.colors.get(), config.theme.get(), copyToClipboard, {
3777
4921
  deferInitialRender: true,
4922
+ readImageInput: async (ref, signal) => {
4923
+ const attachments = ctx.get('attachments');
4924
+ if (attachments === undefined)
4925
+ return undefined;
4926
+ const stored = await attachments.readImage(ref, signal);
4927
+ if (stored === undefined)
4928
+ return undefined;
4929
+ return {
4930
+ data: stored.data,
4931
+ mediaType: stored.ref.mediaType,
4932
+ ...(stored.ref.name === undefined ? {} : { name: stored.ref.name }),
4933
+ width: stored.ref.width,
4934
+ height: stored.ref.height,
4935
+ };
4936
+ },
3778
4937
  terminalProfile,
3779
4938
  alternateScreenOverlays: terminalProfile === 'direct',
3780
4939
  herdrReporter: new HerdrAgentReporter({ interactive: term.input.isTTY === true && term.output.isTTY === true }),
@@ -3788,6 +4947,31 @@ export function apply(ctx, config) {
3788
4947
  });
3789
4948
  ctx.provide(TUI_SERVICE, tui);
3790
4949
  ctx.effect(() => () => tui.dispose());
4950
+ // A missing route must be visible without turning the startup header into a
4951
+ // credential check: the seams answer optimistically when they cannot tell.
4952
+ ctx.inject(['settings', 'credentials'], readyCtx => {
4953
+ const refreshModelReady = () => {
4954
+ void modelRouteReady(readyCtx).then(ready => tui.setModelReady(ready)).catch(() => { });
4955
+ };
4956
+ refreshModelReady();
4957
+ readyCtx.effect(() => {
4958
+ const disposers = [
4959
+ readyCtx.on('credentials/reference-updated', refreshModelReady),
4960
+ readyCtx.on('credentials/record-updated', refreshModelReady),
4961
+ readyCtx.on('authorization/settled', refreshModelReady),
4962
+ ];
4963
+ // The account service and the credential backend may activate just after
4964
+ // this injection; re-read once the composition has settled so an existing
4965
+ // grant is reflected in the live placeholder without a credential event.
4966
+ const timers = [setTimeout(refreshModelReady, 250), setTimeout(refreshModelReady, 1_500)];
4967
+ return () => {
4968
+ for (const dispose of disposers)
4969
+ dispose();
4970
+ for (const timer of timers)
4971
+ clearTimeout(timer);
4972
+ };
4973
+ }, 'omdsh model readiness');
4974
+ });
3791
4975
  // Preferences are this entry's own volatile config: the initial read applies
3792
4976
  // them without persisting, a committed form edit dispatches a volatile update
3793
4977
  // to this fiber only, and an in-app `/settings` change writes back through the
@@ -3800,14 +4984,14 @@ export function apply(ctx, config) {
3800
4984
  mathMode: config.mathMode.get(),
3801
4985
  mermaidMode: config.mermaidMode.get(),
3802
4986
  editor: config.editor.get(),
3803
- terminalProgress: config.terminalProgress.get(),
4987
+ terminalTitle: config.terminalTitle.get(),
4988
+ terminalState: config.terminalState.get(),
3804
4989
  copyOnSelect: config.copyOnSelect.get(),
3805
4990
  mouseInteraction: config.mouseInteraction.get(),
3806
4991
  pasteProtection: config.pasteProtection.get(),
3807
4992
  checkUpdates: config.checkUpdates.get(),
3808
4993
  startupChangelog: config.startupChangelog.get(),
3809
4994
  notifications: config.notifications.get(),
3810
- notificationThreshold: config.notificationThreshold.get(),
3811
4995
  notificationFocus: config.notificationFocus.get(),
3812
4996
  };
3813
4997
  const statusBar = config.statusBar.get();
@@ -3825,17 +5009,32 @@ export function apply(ctx, config) {
3825
5009
  // Persistence needs the settings service; without one the provider still runs
3826
5010
  // and an in-app change stays session-local, as it did before.
3827
5011
  ctx.inject(['settings'], (settingsCtx) => {
3828
- tui.setPrefsPersist((prefs) => {
3829
- const ops = Object.entries(prefs).map(([key, value]) => ({ op: 'set', path: [key], value }));
3830
- void settingsCtx.settings.mutate(TUI_SETTINGS_ENTRY, ops)
3831
- .catch((error) => { settingsCtx.logger.warn(error); });
5012
+ tui.setPrefsPersist((_prefs, changes) => {
5013
+ return settingsCtx.settings.mutate(TUI_SETTINGS_ENTRY, [...changes ?? []])
5014
+ .catch((error) => { tui.applyStoredPrefs(storedPrefs()); throw error; });
3832
5015
  });
3833
5016
  });
3834
5017
  ctx.inject(['settings', 'credentials'], (settingsCtx) => {
3835
- settingsCtx.effect(() => tui.bindPluginSettings({
3836
- entries: () => pluginSettingsEntries(settingsCtx),
3837
- edit: (id, signal) => editPluginSettings(settingsCtx, id, signal),
3838
- }));
5018
+ settingsCtx.effect(() => {
5019
+ let bundles;
5020
+ let inventoryVersion = 0;
5021
+ return tui.bindPluginSettings({
5022
+ entries: () => pluginSettingsEntries(settingsCtx, bundles),
5023
+ refresh: async () => {
5024
+ const version = ++inventoryVersion;
5025
+ const fresh = await settingsCtx.get('pluginManager')?.listBundles();
5026
+ if (version === inventoryVersion)
5027
+ bundles = fresh;
5028
+ return pluginSettingsEntries(settingsCtx, fresh);
5029
+ },
5030
+ edit: (id, signal) => editSettingsEntry(settingsCtx, id, signal),
5031
+ set: (entry, value, signal) => setSettingsEntry(settingsCtx, entry, value, signal),
5032
+ reset: async (id, entry, signal, label) => {
5033
+ await resetSettingsControl(settingsCtx, id, entry, signal, label);
5034
+ tui.applyStoredPrefs(storedPrefs());
5035
+ },
5036
+ });
5037
+ });
3839
5038
  });
3840
5039
  }
3841
5040
  //# sourceMappingURL=provider-local.js.map