@deepseek-harness-tui/dsh-tui 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (638) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -0
  3. package/bin/dsh-tui.js +95 -0
  4. package/cordis.patch.yml +218 -0
  5. package/cordis.yml +185 -0
  6. package/lib/invariant.js +28 -0
  7. package/lib/types/activityPrefs.d.ts +29 -0
  8. package/lib/types/activityPrefs.d.ts.map +1 -0
  9. package/lib/types/activityPrefs.js +60 -0
  10. package/lib/types/bootstrap/state.d.ts +13 -0
  11. package/lib/types/bootstrap/state.d.ts.map +1 -0
  12. package/lib/types/bootstrap/state.js +12 -0
  13. package/lib/types/cc/cliHighlight.d.ts +20 -0
  14. package/lib/types/cc/cliHighlight.d.ts.map +1 -0
  15. package/lib/types/cc/cliHighlight.js +53 -0
  16. package/lib/types/cc/figures.d.ts +59 -0
  17. package/lib/types/cc/figures.d.ts.map +1 -0
  18. package/lib/types/cc/figures.js +69 -0
  19. package/lib/types/cc/format.d.ts +26 -0
  20. package/lib/types/cc/format.d.ts.map +1 -0
  21. package/lib/types/cc/format.js +55 -0
  22. package/lib/types/cc/hash.d.ts +25 -0
  23. package/lib/types/cc/hash.d.ts.map +1 -0
  24. package/lib/types/cc/hash.js +41 -0
  25. package/lib/types/cc/hyperlink.d.ts +21 -0
  26. package/lib/types/cc/hyperlink.d.ts.map +1 -0
  27. package/lib/types/cc/hyperlink.js +31 -0
  28. package/lib/types/cc/markdown.d.ts +43 -0
  29. package/lib/types/cc/markdown.d.ts.map +1 -0
  30. package/lib/types/cc/markdown.js +345 -0
  31. package/lib/types/cc/spinnerVerbs.d.ts +3 -0
  32. package/lib/types/cc/spinnerVerbs.d.ts.map +1 -0
  33. package/lib/types/cc/spinnerVerbs.js +190 -0
  34. package/lib/types/cc/terminal.d.ts +15 -0
  35. package/lib/types/cc/terminal.d.ts.map +1 -0
  36. package/lib/types/cc/terminal.js +84 -0
  37. package/lib/types/channel.d.ts +672 -0
  38. package/lib/types/channel.d.ts.map +1 -0
  39. package/lib/types/channel.js +2427 -0
  40. package/lib/types/childStderr.d.ts +63 -0
  41. package/lib/types/childStderr.d.ts.map +1 -0
  42. package/lib/types/childStderr.js +161 -0
  43. package/lib/types/commands.d.ts +55 -0
  44. package/lib/types/commands.d.ts.map +1 -0
  45. package/lib/types/commands.js +101 -0
  46. package/lib/types/components/ActivityLine.d.ts +29 -0
  47. package/lib/types/components/ActivityLine.d.ts.map +1 -0
  48. package/lib/types/components/ActivityLine.js +47 -0
  49. package/lib/types/components/ActivityPicker.d.ts +13 -0
  50. package/lib/types/components/ActivityPicker.d.ts.map +1 -0
  51. package/lib/types/components/ActivityPicker.js +24 -0
  52. package/lib/types/components/CommandSuggestions.d.ts +14 -0
  53. package/lib/types/components/CommandSuggestions.d.ts.map +1 -0
  54. package/lib/types/components/CommandSuggestions.js +33 -0
  55. package/lib/types/components/FileSuggestions.d.ts +12 -0
  56. package/lib/types/components/FileSuggestions.d.ts.map +1 -0
  57. package/lib/types/components/FileSuggestions.js +26 -0
  58. package/lib/types/components/GoalTodoPanel.d.ts +13 -0
  59. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -0
  60. package/lib/types/components/GoalTodoPanel.js +57 -0
  61. package/lib/types/components/HelpMenu.d.ts +12 -0
  62. package/lib/types/components/HelpMenu.d.ts.map +1 -0
  63. package/lib/types/components/HelpMenu.js +12 -0
  64. package/lib/types/components/HistorySearchDialog.d.ts +16 -0
  65. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -0
  66. package/lib/types/components/HistorySearchDialog.js +31 -0
  67. package/lib/types/components/InterruptedByUser.d.ts +7 -0
  68. package/lib/types/components/InterruptedByUser.d.ts.map +1 -0
  69. package/lib/types/components/InterruptedByUser.js +9 -0
  70. package/lib/types/components/LoadedContextPanel.d.ts +20 -0
  71. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -0
  72. package/lib/types/components/LoadedContextPanel.js +30 -0
  73. package/lib/types/components/LogoV2.d.ts +23 -0
  74. package/lib/types/components/LogoV2.d.ts.map +1 -0
  75. package/lib/types/components/LogoV2.js +95 -0
  76. package/lib/types/components/Markdown.d.ts +24 -0
  77. package/lib/types/components/Markdown.d.ts.map +1 -0
  78. package/lib/types/components/Markdown.js +106 -0
  79. package/lib/types/components/MarkdownTable.d.ts +17 -0
  80. package/lib/types/components/MarkdownTable.d.ts.map +1 -0
  81. package/lib/types/components/MarkdownTable.js +272 -0
  82. package/lib/types/components/MessageList.d.ts +41 -0
  83. package/lib/types/components/MessageList.d.ts.map +1 -0
  84. package/lib/types/components/MessageList.js +303 -0
  85. package/lib/types/components/ModelPicker.d.ts +14 -0
  86. package/lib/types/components/ModelPicker.d.ts.map +1 -0
  87. package/lib/types/components/ModelPicker.js +15 -0
  88. package/lib/types/components/PresetPicker.d.ts +17 -0
  89. package/lib/types/components/PresetPicker.d.ts.map +1 -0
  90. package/lib/types/components/PresetPicker.js +25 -0
  91. package/lib/types/components/PromptInput.d.ts +67 -0
  92. package/lib/types/components/PromptInput.d.ts.map +1 -0
  93. package/lib/types/components/PromptInput.js +778 -0
  94. package/lib/types/components/ResumePicker.d.ts +15 -0
  95. package/lib/types/components/ResumePicker.d.ts.map +1 -0
  96. package/lib/types/components/ResumePicker.js +34 -0
  97. package/lib/types/components/RewindPicker.d.ts +14 -0
  98. package/lib/types/components/RewindPicker.d.ts.map +1 -0
  99. package/lib/types/components/RewindPicker.js +23 -0
  100. package/lib/types/components/SearchBox.d.ts +20 -0
  101. package/lib/types/components/SearchBox.d.ts.map +1 -0
  102. package/lib/types/components/SearchBox.js +33 -0
  103. package/lib/types/components/Select.d.ts +22 -0
  104. package/lib/types/components/Select.d.ts.map +1 -0
  105. package/lib/types/components/Select.js +19 -0
  106. package/lib/types/components/Spinner/GlimmerMessage.d.ts +19 -0
  107. package/lib/types/components/Spinner/GlimmerMessage.d.ts.map +1 -0
  108. package/lib/types/components/Spinner/GlimmerMessage.js +78 -0
  109. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts +31 -0
  110. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts.map +1 -0
  111. package/lib/types/components/Spinner/SpinnerAnimationRow.js +162 -0
  112. package/lib/types/components/Spinner/SpinnerGlyph.d.ts +16 -0
  113. package/lib/types/components/Spinner/SpinnerGlyph.d.ts.map +1 -0
  114. package/lib/types/components/Spinner/SpinnerGlyph.js +36 -0
  115. package/lib/types/components/Spinner/spinnerMode.d.ts +6 -0
  116. package/lib/types/components/Spinner/spinnerMode.d.ts.map +1 -0
  117. package/lib/types/components/Spinner/spinnerMode.js +1 -0
  118. package/lib/types/components/Spinner/spinnerUtils.d.ts +41 -0
  119. package/lib/types/components/Spinner/spinnerUtils.d.ts.map +1 -0
  120. package/lib/types/components/Spinner/spinnerUtils.js +104 -0
  121. package/lib/types/components/Spinner/useStalledAnimation.d.ts +16 -0
  122. package/lib/types/components/Spinner/useStalledAnimation.d.ts.map +1 -0
  123. package/lib/types/components/Spinner/useStalledAnimation.js +71 -0
  124. package/lib/types/components/StreamingMarkdown.d.ts +12 -0
  125. package/lib/types/components/StreamingMarkdown.d.ts.map +1 -0
  126. package/lib/types/components/StreamingMarkdown.js +39 -0
  127. package/lib/types/components/ThemePicker.d.ts +22 -0
  128. package/lib/types/components/ThemePicker.d.ts.map +1 -0
  129. package/lib/types/components/ThemePicker.js +51 -0
  130. package/lib/types/components/ThinkingToggle.d.ts +17 -0
  131. package/lib/types/components/ThinkingToggle.d.ts.map +1 -0
  132. package/lib/types/components/ThinkingToggle.js +30 -0
  133. package/lib/types/components/ToolUseLoader.d.ts +13 -0
  134. package/lib/types/components/ToolUseLoader.d.ts.map +1 -0
  135. package/lib/types/components/ToolUseLoader.js +17 -0
  136. package/lib/types/components/Whale.d.ts +22 -0
  137. package/lib/types/components/Whale.d.ts.map +1 -0
  138. package/lib/types/components/Whale.js +78 -0
  139. package/lib/types/components/WorkingSpinner.d.ts +26 -0
  140. package/lib/types/components/WorkingSpinner.d.ts.map +1 -0
  141. package/lib/types/components/WorkingSpinner.js +68 -0
  142. package/lib/types/components/activityFrames.d.ts +31 -0
  143. package/lib/types/components/activityFrames.d.ts.map +1 -0
  144. package/lib/types/components/activityFrames.js +83 -0
  145. package/lib/types/components/bigfont.d.ts +28 -0
  146. package/lib/types/components/bigfont.d.ts.map +1 -0
  147. package/lib/types/components/bigfont.js +91 -0
  148. package/lib/types/components/design-system/Byline.d.ts +14 -0
  149. package/lib/types/components/design-system/Byline.d.ts.map +1 -0
  150. package/lib/types/components/design-system/Byline.js +17 -0
  151. package/lib/types/components/design-system/Divider.d.ts +40 -0
  152. package/lib/types/components/design-system/Divider.d.ts.map +1 -0
  153. package/lib/types/components/design-system/Divider.js +24 -0
  154. package/lib/types/components/design-system/KeyboardShortcutHint.d.ts +19 -0
  155. package/lib/types/components/design-system/KeyboardShortcutHint.d.ts.map +1 -0
  156. package/lib/types/components/design-system/KeyboardShortcutHint.js +14 -0
  157. package/lib/types/components/design-system/ListItem.d.ts +36 -0
  158. package/lib/types/components/design-system/ListItem.d.ts.map +1 -0
  159. package/lib/types/components/design-system/ListItem.js +48 -0
  160. package/lib/types/components/design-system/LoadingState.d.ts +19 -0
  161. package/lib/types/components/design-system/LoadingState.d.ts.map +1 -0
  162. package/lib/types/components/design-system/LoadingState.js +22 -0
  163. package/lib/types/components/design-system/Pane.d.ts +17 -0
  164. package/lib/types/components/design-system/Pane.d.ts.map +1 -0
  165. package/lib/types/components/design-system/Pane.js +15 -0
  166. package/lib/types/components/design-system/ProgressBar.d.ts +22 -0
  167. package/lib/types/components/design-system/ProgressBar.d.ts.map +1 -0
  168. package/lib/types/components/design-system/ProgressBar.js +27 -0
  169. package/lib/types/components/design-system/StatusIcon.d.ts +15 -0
  170. package/lib/types/components/design-system/StatusIcon.d.ts.map +1 -0
  171. package/lib/types/components/design-system/StatusIcon.js +20 -0
  172. package/lib/types/components/design-system/ThemeProvider.d.ts +11 -0
  173. package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -0
  174. package/lib/types/components/design-system/ThemeProvider.js +135 -0
  175. package/lib/types/components/design-system/ThemedBox.d.ts +37 -0
  176. package/lib/types/components/design-system/ThemedBox.d.ts.map +1 -0
  177. package/lib/types/components/design-system/ThemedBox.js +26 -0
  178. package/lib/types/components/design-system/ThemedText.d.ts +56 -0
  179. package/lib/types/components/design-system/ThemedText.d.ts.map +1 -0
  180. package/lib/types/components/design-system/ThemedText.js +45 -0
  181. package/lib/types/components/messages/AssistantTextMessage.d.ts +18 -0
  182. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -0
  183. package/lib/types/components/messages/AssistantTextMessage.js +15 -0
  184. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +23 -0
  185. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -0
  186. package/lib/types/components/messages/AssistantThinkingMessage.js +23 -0
  187. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +23 -0
  188. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -0
  189. package/lib/types/components/messages/AssistantToolUseMessage.js +220 -0
  190. package/lib/types/components/messages/MessageMetadata.d.ts +15 -0
  191. package/lib/types/components/messages/MessageMetadata.d.ts.map +1 -0
  192. package/lib/types/components/messages/MessageMetadata.js +20 -0
  193. package/lib/types/components/messages/UserPromptMessage.d.ts +19 -0
  194. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -0
  195. package/lib/types/components/messages/UserPromptMessage.js +15 -0
  196. package/lib/types/components/questions/AskUserQuestionPanel.d.ts +39 -0
  197. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -0
  198. package/lib/types/components/questions/AskUserQuestionPanel.js +228 -0
  199. package/lib/types/components/shimmer.d.ts +35 -0
  200. package/lib/types/components/shimmer.d.ts.map +1 -0
  201. package/lib/types/components/shimmer.js +43 -0
  202. package/lib/types/components/whaleFrames.d.ts +25 -0
  203. package/lib/types/components/whaleFrames.d.ts.map +1 -0
  204. package/lib/types/components/whaleFrames.js +419 -0
  205. package/lib/types/customTheme.d.ts +104 -0
  206. package/lib/types/customTheme.d.ts.map +1 -0
  207. package/lib/types/customTheme.js +268 -0
  208. package/lib/types/effortPrefs.d.ts +22 -0
  209. package/lib/types/effortPrefs.d.ts.map +1 -0
  210. package/lib/types/effortPrefs.js +45 -0
  211. package/lib/types/history.d.ts +24 -0
  212. package/lib/types/history.d.ts.map +1 -0
  213. package/lib/types/history.js +75 -0
  214. package/lib/types/hooks/useBlink.d.ts +11 -0
  215. package/lib/types/hooks/useBlink.d.ts.map +1 -0
  216. package/lib/types/hooks/useBlink.js +20 -0
  217. package/lib/types/i18n.d.ts +926 -0
  218. package/lib/types/i18n.d.ts.map +1 -0
  219. package/lib/types/i18n.js +356 -0
  220. package/lib/types/index.d.ts +70 -0
  221. package/lib/types/index.d.ts.map +1 -0
  222. package/lib/types/index.js +31 -0
  223. package/lib/types/ink/Ansi.d.ts +11 -0
  224. package/lib/types/ink/Ansi.d.ts.map +1 -0
  225. package/lib/types/ink/Ansi.js +269 -0
  226. package/lib/types/ink/bidi.d.ts +18 -0
  227. package/lib/types/ink/bidi.d.ts.map +1 -0
  228. package/lib/types/ink/bidi.js +119 -0
  229. package/lib/types/ink/clearTerminal.d.ts +22 -0
  230. package/lib/types/ink/clearTerminal.d.ts.map +1 -0
  231. package/lib/types/ink/clearTerminal.js +72 -0
  232. package/lib/types/ink/colorize.d.ts +33 -0
  233. package/lib/types/ink/colorize.d.ts.map +1 -0
  234. package/lib/types/ink/colorize.js +213 -0
  235. package/lib/types/ink/components/AlternateScreen.d.ts +22 -0
  236. package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -0
  237. package/lib/types/ink/components/AlternateScreen.js +74 -0
  238. package/lib/types/ink/components/App.d.ts +73 -0
  239. package/lib/types/ink/components/App.d.ts.map +1 -0
  240. package/lib/types/ink/components/App.js +597 -0
  241. package/lib/types/ink/components/AppContext.d.ts +15 -0
  242. package/lib/types/ink/components/AppContext.d.ts.map +1 -0
  243. package/lib/types/ink/components/AppContext.js +11 -0
  244. package/lib/types/ink/components/Box.d.ts +49 -0
  245. package/lib/types/ink/components/Box.d.ts.map +1 -0
  246. package/lib/types/ink/components/Box.js +153 -0
  247. package/lib/types/ink/components/Button.d.ts +37 -0
  248. package/lib/types/ink/components/Button.d.ts.map +1 -0
  249. package/lib/types/ink/components/Button.js +166 -0
  250. package/lib/types/ink/components/ClockContext.d.ts +10 -0
  251. package/lib/types/ink/components/ClockContext.d.ts.map +1 -0
  252. package/lib/types/ink/components/ClockContext.js +108 -0
  253. package/lib/types/ink/components/CursorDeclarationContext.d.ts +30 -0
  254. package/lib/types/ink/components/CursorDeclarationContext.d.ts.map +1 -0
  255. package/lib/types/ink/components/CursorDeclarationContext.js +6 -0
  256. package/lib/types/ink/components/ErrorOverview.d.ts +7 -0
  257. package/lib/types/ink/components/ErrorOverview.d.ts.map +1 -0
  258. package/lib/types/ink/components/ErrorOverview.js +49 -0
  259. package/lib/types/ink/components/Link.d.ts +8 -0
  260. package/lib/types/ink/components/Link.d.ts.map +1 -0
  261. package/lib/types/ink/components/Link.js +33 -0
  262. package/lib/types/ink/components/Newline.d.ts +13 -0
  263. package/lib/types/ink/components/Newline.d.ts.map +1 -0
  264. package/lib/types/ink/components/Newline.js +29 -0
  265. package/lib/types/ink/components/NoSelect.d.ts +20 -0
  266. package/lib/types/ink/components/NoSelect.d.ts.map +1 -0
  267. package/lib/types/ink/components/NoSelect.js +56 -0
  268. package/lib/types/ink/components/RawAnsi.d.ts +17 -0
  269. package/lib/types/ink/components/RawAnsi.d.ts.map +1 -0
  270. package/lib/types/ink/components/RawAnsi.js +45 -0
  271. package/lib/types/ink/components/ScrollBox.d.ts +78 -0
  272. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -0
  273. package/lib/types/ink/components/ScrollBox.js +174 -0
  274. package/lib/types/ink/components/Spacer.d.ts +6 -0
  275. package/lib/types/ink/components/Spacer.d.ts.map +1 -0
  276. package/lib/types/ink/components/Spacer.js +19 -0
  277. package/lib/types/ink/components/StdinContext.d.ts +32 -0
  278. package/lib/types/ink/components/StdinContext.d.ts.map +1 -0
  279. package/lib/types/ink/components/StdinContext.js +16 -0
  280. package/lib/types/ink/components/TerminalFocusContext.d.ts +11 -0
  281. package/lib/types/ink/components/TerminalFocusContext.d.ts.map +1 -0
  282. package/lib/types/ink/components/TerminalFocusContext.js +45 -0
  283. package/lib/types/ink/components/TerminalSizeContext.d.ts +6 -0
  284. package/lib/types/ink/components/TerminalSizeContext.d.ts.map +1 -0
  285. package/lib/types/ink/components/TerminalSizeContext.js +3 -0
  286. package/lib/types/ink/components/Text.d.ts +56 -0
  287. package/lib/types/ink/components/Text.d.ts.map +1 -0
  288. package/lib/types/ink/components/Text.js +194 -0
  289. package/lib/types/ink/constants.d.ts +3 -0
  290. package/lib/types/ink/constants.d.ts.map +1 -0
  291. package/lib/types/ink/constants.js +2 -0
  292. package/lib/types/ink/cursor.d.ts +11 -0
  293. package/lib/types/ink/cursor.d.ts.map +1 -0
  294. package/lib/types/ink/cursor.js +1 -0
  295. package/lib/types/ink/devtools.d.ts +7 -0
  296. package/lib/types/ink/devtools.d.ts.map +1 -0
  297. package/lib/types/ink/devtools.js +1 -0
  298. package/lib/types/ink/dom.d.ts +178 -0
  299. package/lib/types/ink/dom.d.ts.map +1 -0
  300. package/lib/types/ink/dom.js +424 -0
  301. package/lib/types/ink/events/click-event.d.ts +32 -0
  302. package/lib/types/ink/events/click-event.d.ts.map +1 -0
  303. package/lib/types/ink/events/click-event.js +36 -0
  304. package/lib/types/ink/events/dispatcher.d.ts +63 -0
  305. package/lib/types/ink/events/dispatcher.d.ts.map +1 -0
  306. package/lib/types/ink/events/dispatcher.js +194 -0
  307. package/lib/types/ink/events/emitter.d.ts +20 -0
  308. package/lib/types/ink/events/emitter.d.ts.map +1 -0
  309. package/lib/types/ink/events/emitter.js +43 -0
  310. package/lib/types/ink/events/event-handlers.d.ts +47 -0
  311. package/lib/types/ink/events/event-handlers.d.ts.map +1 -0
  312. package/lib/types/ink/events/event-handlers.js +30 -0
  313. package/lib/types/ink/events/event.d.ts +19 -0
  314. package/lib/types/ink/events/event.d.ts.map +1 -0
  315. package/lib/types/ink/events/event.js +22 -0
  316. package/lib/types/ink/events/focus-event.d.ts +19 -0
  317. package/lib/types/ink/events/focus-event.d.ts.map +1 -0
  318. package/lib/types/ink/events/focus-event.js +21 -0
  319. package/lib/types/ink/events/input-event.d.ts +53 -0
  320. package/lib/types/ink/events/input-event.d.ts.map +1 -0
  321. package/lib/types/ink/events/input-event.js +180 -0
  322. package/lib/types/ink/events/keyboard-event.d.ts +40 -0
  323. package/lib/types/ink/events/keyboard-event.d.ts.map +1 -0
  324. package/lib/types/ink/events/keyboard-event.js +65 -0
  325. package/lib/types/ink/events/paste-event.d.ts +11 -0
  326. package/lib/types/ink/events/paste-event.d.ts.map +1 -0
  327. package/lib/types/ink/events/paste-event.js +13 -0
  328. package/lib/types/ink/events/resize-event.d.ts +13 -0
  329. package/lib/types/ink/events/resize-event.d.ts.map +1 -0
  330. package/lib/types/ink/events/resize-event.js +16 -0
  331. package/lib/types/ink/events/terminal-event.d.ts +112 -0
  332. package/lib/types/ink/events/terminal-event.d.ts.map +1 -0
  333. package/lib/types/ink/events/terminal-event.js +131 -0
  334. package/lib/types/ink/events/terminal-focus-event.d.ts +21 -0
  335. package/lib/types/ink/events/terminal-focus-event.d.ts.map +1 -0
  336. package/lib/types/ink/events/terminal-focus-event.js +18 -0
  337. package/lib/types/ink/focus.d.ts +74 -0
  338. package/lib/types/ink/focus.d.ts.map +1 -0
  339. package/lib/types/ink/focus.js +188 -0
  340. package/lib/types/ink/frame.d.ts +119 -0
  341. package/lib/types/ink/frame.d.ts.map +1 -0
  342. package/lib/types/ink/frame.js +43 -0
  343. package/lib/types/ink/get-max-width.d.ts +20 -0
  344. package/lib/types/ink/get-max-width.d.ts.map +1 -0
  345. package/lib/types/ink/get-max-width.js +25 -0
  346. package/lib/types/ink/hit-test.d.ts +47 -0
  347. package/lib/types/ink/hit-test.d.ts.map +1 -0
  348. package/lib/types/ink/hit-test.js +126 -0
  349. package/lib/types/ink/hooks/use-animation-frame.d.ts +27 -0
  350. package/lib/types/ink/hooks/use-animation-frame.d.ts.map +1 -0
  351. package/lib/types/ink/hooks/use-animation-frame.js +48 -0
  352. package/lib/types/ink/hooks/use-app.d.ts +8 -0
  353. package/lib/types/ink/hooks/use-app.d.ts.map +1 -0
  354. package/lib/types/ink/hooks/use-app.js +8 -0
  355. package/lib/types/ink/hooks/use-copy-on-select.d.ts +22 -0
  356. package/lib/types/ink/hooks/use-copy-on-select.d.ts.map +1 -0
  357. package/lib/types/ink/hooks/use-copy-on-select.js +40 -0
  358. package/lib/types/ink/hooks/use-declared-cursor.d.ts +31 -0
  359. package/lib/types/ink/hooks/use-declared-cursor.d.ts.map +1 -0
  360. package/lib/types/ink/hooks/use-declared-cursor.js +65 -0
  361. package/lib/types/ink/hooks/use-input.d.ts +41 -0
  362. package/lib/types/ink/hooks/use-input.d.ts.map +1 -0
  363. package/lib/types/ink/hooks/use-input.js +73 -0
  364. package/lib/types/ink/hooks/use-interval.d.ts +23 -0
  365. package/lib/types/ink/hooks/use-interval.d.ts.map +1 -0
  366. package/lib/types/ink/hooks/use-interval.js +58 -0
  367. package/lib/types/ink/hooks/use-search-highlight.d.ts +34 -0
  368. package/lib/types/ink/hooks/use-search-highlight.d.ts.map +1 -0
  369. package/lib/types/ink/hooks/use-search-highlight.js +35 -0
  370. package/lib/types/ink/hooks/use-selection.d.ts +44 -0
  371. package/lib/types/ink/hooks/use-selection.d.ts.map +1 -0
  372. package/lib/types/ink/hooks/use-selection.js +64 -0
  373. package/lib/types/ink/hooks/use-stdin.d.ts +8 -0
  374. package/lib/types/ink/hooks/use-stdin.d.ts.map +1 -0
  375. package/lib/types/ink/hooks/use-stdin.js +8 -0
  376. package/lib/types/ink/hooks/use-tab-status.d.ts +18 -0
  377. package/lib/types/ink/hooks/use-tab-status.d.ts.map +1 -0
  378. package/lib/types/ink/hooks/use-tab-status.js +58 -0
  379. package/lib/types/ink/hooks/use-terminal-focus.d.ts +11 -0
  380. package/lib/types/ink/hooks/use-terminal-focus.d.ts.map +1 -0
  381. package/lib/types/ink/hooks/use-terminal-focus.js +15 -0
  382. package/lib/types/ink/hooks/use-terminal-size.d.ts +6 -0
  383. package/lib/types/ink/hooks/use-terminal-size.d.ts.map +1 -0
  384. package/lib/types/ink/hooks/use-terminal-size.js +12 -0
  385. package/lib/types/ink/hooks/use-terminal-title.d.ts +14 -0
  386. package/lib/types/ink/hooks/use-terminal-title.d.ts.map +1 -0
  387. package/lib/types/ink/hooks/use-terminal-title.js +30 -0
  388. package/lib/types/ink/hooks/use-terminal-viewport.d.ts +32 -0
  389. package/lib/types/ink/hooks/use-terminal-viewport.d.ts.map +1 -0
  390. package/lib/types/ink/hooks/use-terminal-viewport.js +79 -0
  391. package/lib/types/ink/ink.d.ts +370 -0
  392. package/lib/types/ink/ink.d.ts.map +1 -0
  393. package/lib/types/ink/ink.js +1665 -0
  394. package/lib/types/ink/instances.d.ts +10 -0
  395. package/lib/types/ink/instances.d.ts.map +1 -0
  396. package/lib/types/ink/instances.js +13 -0
  397. package/lib/types/ink/layout/engine.d.ts +7 -0
  398. package/lib/types/ink/layout/engine.d.ts.map +1 -0
  399. package/lib/types/ink/layout/engine.js +8 -0
  400. package/lib/types/ink/layout/geometry.d.ts +86 -0
  401. package/lib/types/ink/layout/geometry.d.ts.map +1 -0
  402. package/lib/types/ink/layout/geometry.js +95 -0
  403. package/lib/types/ink/layout/node.d.ts +151 -0
  404. package/lib/types/ink/layout/node.d.ts.map +1 -0
  405. package/lib/types/ink/layout/node.js +72 -0
  406. package/lib/types/ink/layout/yoga.d.ts +69 -0
  407. package/lib/types/ink/layout/yoga.d.ts.map +1 -0
  408. package/lib/types/ink/layout/yoga.js +249 -0
  409. package/lib/types/ink/line-width-cache.d.ts +9 -0
  410. package/lib/types/ink/line-width-cache.d.ts.map +1 -0
  411. package/lib/types/ink/line-width-cache.js +45 -0
  412. package/lib/types/ink/log-update.d.ts +57 -0
  413. package/lib/types/ink/log-update.d.ts.map +1 -0
  414. package/lib/types/ink/log-update.js +710 -0
  415. package/lib/types/ink/measure-element.d.ts +19 -0
  416. package/lib/types/ink/measure-element.d.ts.map +1 -0
  417. package/lib/types/ink/measure-element.js +10 -0
  418. package/lib/types/ink/measure-text.d.ts +13 -0
  419. package/lib/types/ink/measure-text.d.ts.map +1 -0
  420. package/lib/types/ink/measure-text.js +38 -0
  421. package/lib/types/ink/node-cache.d.ts +33 -0
  422. package/lib/types/ink/node-cache.d.ts.map +1 -0
  423. package/lib/types/ink/node-cache.js +42 -0
  424. package/lib/types/ink/optimizer.d.ts +18 -0
  425. package/lib/types/ink/optimizer.d.ts.map +1 -0
  426. package/lib/types/ink/optimizer.js +83 -0
  427. package/lib/types/ink/output.d.ts +200 -0
  428. package/lib/types/ink/output.d.ts.map +1 -0
  429. package/lib/types/ink/output.js +663 -0
  430. package/lib/types/ink/parse-keypress.d.ts +132 -0
  431. package/lib/types/ink/parse-keypress.d.ts.map +1 -0
  432. package/lib/types/ink/parse-keypress.js +707 -0
  433. package/lib/types/ink/reconciler.d.ts +43 -0
  434. package/lib/types/ink/reconciler.d.ts.map +1 -0
  435. package/lib/types/ink/reconciler.js +413 -0
  436. package/lib/types/ink/render-border.d.ts +36 -0
  437. package/lib/types/ink/render-border.d.ts.map +1 -0
  438. package/lib/types/ink/render-border.js +143 -0
  439. package/lib/types/ink/render-node-to-output.d.ts +93 -0
  440. package/lib/types/ink/render-node-to-output.d.ts.map +1 -0
  441. package/lib/types/ink/render-node-to-output.js +1290 -0
  442. package/lib/types/ink/render-to-screen.d.ts +65 -0
  443. package/lib/types/ink/render-to-screen.d.ts.map +1 -0
  444. package/lib/types/ink/render-to-screen.js +189 -0
  445. package/lib/types/ink/renderer.d.ts +24 -0
  446. package/lib/types/ink/renderer.d.ts.map +1 -0
  447. package/lib/types/ink/renderer.js +136 -0
  448. package/lib/types/ink/root.d.ts +91 -0
  449. package/lib/types/ink/root.d.ts.map +1 -0
  450. package/lib/types/ink/root.js +93 -0
  451. package/lib/types/ink/screen.d.ts +443 -0
  452. package/lib/types/ink/screen.d.ts.map +1 -0
  453. package/lib/types/ink/screen.js +1295 -0
  454. package/lib/types/ink/searchHighlight.d.ts +25 -0
  455. package/lib/types/ink/searchHighlight.d.ts.map +1 -0
  456. package/lib/types/ink/searchHighlight.js +82 -0
  457. package/lib/types/ink/selection.d.ts +323 -0
  458. package/lib/types/ink/selection.d.ts.map +1 -0
  459. package/lib/types/ink/selection.js +881 -0
  460. package/lib/types/ink/squash-text-nodes.d.ts +30 -0
  461. package/lib/types/ink/squash-text-nodes.d.ts.map +1 -0
  462. package/lib/types/ink/squash-text-nodes.js +63 -0
  463. package/lib/types/ink/stringWidth.d.ts +11 -0
  464. package/lib/types/ink/stringWidth.d.ts.map +1 -0
  465. package/lib/types/ink/stringWidth.js +209 -0
  466. package/lib/types/ink/styles.d.ts +324 -0
  467. package/lib/types/ink/styles.d.ts.map +1 -0
  468. package/lib/types/ink/styles.js +307 -0
  469. package/lib/types/ink/supports-hyperlinks.d.ts +20 -0
  470. package/lib/types/ink/supports-hyperlinks.d.ts.map +1 -0
  471. package/lib/types/ink/supports-hyperlinks.js +44 -0
  472. package/lib/types/ink/tabstops.d.ts +9 -0
  473. package/lib/types/ink/tabstops.d.ts.map +1 -0
  474. package/lib/types/ink/tabstops.js +46 -0
  475. package/lib/types/ink/terminal-focus-state.d.ts +35 -0
  476. package/lib/types/ink/terminal-focus-state.d.ts.map +1 -0
  477. package/lib/types/ink/terminal-focus-state.js +57 -0
  478. package/lib/types/ink/terminal-querier.d.ts +161 -0
  479. package/lib/types/ink/terminal-querier.d.ts.map +1 -0
  480. package/lib/types/ink/terminal-querier.js +204 -0
  481. package/lib/types/ink/terminal.d.ts +80 -0
  482. package/lib/types/ink/terminal.d.ts.map +1 -0
  483. package/lib/types/ink/terminal.js +268 -0
  484. package/lib/types/ink/termio/ansi.d.ts +75 -0
  485. package/lib/types/ink/termio/ansi.d.ts.map +1 -0
  486. package/lib/types/ink/termio/ansi.js +79 -0
  487. package/lib/types/ink/termio/csi.d.ts +253 -0
  488. package/lib/types/ink/termio/csi.d.ts.map +1 -0
  489. package/lib/types/ink/termio/csi.js +347 -0
  490. package/lib/types/ink/termio/dec.d.ts +61 -0
  491. package/lib/types/ink/termio/dec.d.ts.map +1 -0
  492. package/lib/types/ink/termio/dec.js +76 -0
  493. package/lib/types/ink/termio/esc.d.ts +15 -0
  494. package/lib/types/ink/termio/esc.d.ts.map +1 -0
  495. package/lib/types/ink/termio/esc.js +57 -0
  496. package/lib/types/ink/termio/osc.d.ts +208 -0
  497. package/lib/types/ink/termio/osc.d.ts.map +1 -0
  498. package/lib/types/ink/termio/osc.js +466 -0
  499. package/lib/types/ink/termio/parser.d.ts +46 -0
  500. package/lib/types/ink/termio/parser.d.ts.map +1 -0
  501. package/lib/types/ink/termio/parser.js +366 -0
  502. package/lib/types/ink/termio/sgr.d.ts +17 -0
  503. package/lib/types/ink/termio/sgr.d.ts.map +1 -0
  504. package/lib/types/ink/termio/sgr.js +300 -0
  505. package/lib/types/ink/termio/tokenize.d.ts +50 -0
  506. package/lib/types/ink/termio/tokenize.d.ts.map +1 -0
  507. package/lib/types/ink/termio/tokenize.js +266 -0
  508. package/lib/types/ink/termio/types.d.ts +211 -0
  509. package/lib/types/ink/termio/types.d.ts.map +1 -0
  510. package/lib/types/ink/termio/types.js +68 -0
  511. package/lib/types/ink/termio.d.ts +25 -0
  512. package/lib/types/ink/termio.d.ts.map +1 -0
  513. package/lib/types/ink/termio.js +24 -0
  514. package/lib/types/ink/truncateToWidth.d.ts +7 -0
  515. package/lib/types/ink/truncateToWidth.d.ts.map +1 -0
  516. package/lib/types/ink/truncateToWidth.js +18 -0
  517. package/lib/types/ink/useTerminalNotification.d.ts +45 -0
  518. package/lib/types/ink/useTerminalNotification.d.ts.map +1 -0
  519. package/lib/types/ink/useTerminalNotification.js +70 -0
  520. package/lib/types/ink/warn.d.ts +7 -0
  521. package/lib/types/ink/warn.d.ts.map +1 -0
  522. package/lib/types/ink/warn.js +15 -0
  523. package/lib/types/ink/widest-line.d.ts +7 -0
  524. package/lib/types/ink/widest-line.d.ts.map +1 -0
  525. package/lib/types/ink/widest-line.js +19 -0
  526. package/lib/types/ink/wrap-text.d.ts +10 -0
  527. package/lib/types/ink/wrap-text.d.ts.map +1 -0
  528. package/lib/types/ink/wrap-text.js +61 -0
  529. package/lib/types/ink/wrapAnsi.d.ts +18 -0
  530. package/lib/types/ink/wrapAnsi.d.ts.map +1 -0
  531. package/lib/types/ink/wrapAnsi.js +16 -0
  532. package/lib/types/invariant.d.ts +20 -0
  533. package/lib/types/invariant.d.ts.map +1 -0
  534. package/lib/types/invariant.js +26 -0
  535. package/lib/types/modelPrefs.d.ts +37 -0
  536. package/lib/types/modelPrefs.d.ts.map +1 -0
  537. package/lib/types/modelPrefs.js +66 -0
  538. package/lib/types/modelRoute.d.ts +79 -0
  539. package/lib/types/modelRoute.d.ts.map +1 -0
  540. package/lib/types/modelRoute.js +96 -0
  541. package/lib/types/native-ts/yoga-layout/enums.d.ts +183 -0
  542. package/lib/types/native-ts/yoga-layout/enums.d.ts.map +1 -0
  543. package/lib/types/native-ts/yoga-layout/enums.js +150 -0
  544. package/lib/types/native-ts/yoga-layout/index.d.ts +788 -0
  545. package/lib/types/native-ts/yoga-layout/index.d.ts.map +1 -0
  546. package/lib/types/native-ts/yoga-layout/index.js +2571 -0
  547. package/lib/types/packaged-skills.d.ts +10 -0
  548. package/lib/types/packaged-skills.d.ts.map +1 -0
  549. package/lib/types/packaged-skills.js +63 -0
  550. package/lib/types/plugin.d.ts +33 -0
  551. package/lib/types/plugin.d.ts.map +1 -0
  552. package/lib/types/plugin.js +454 -0
  553. package/lib/types/presetPrefs.d.ts +29 -0
  554. package/lib/types/presetPrefs.d.ts.map +1 -0
  555. package/lib/types/presetPrefs.js +61 -0
  556. package/lib/types/presets.d.ts +110 -0
  557. package/lib/types/presets.d.ts.map +1 -0
  558. package/lib/types/presets.js +114 -0
  559. package/lib/types/questions.d.ts +97 -0
  560. package/lib/types/questions.d.ts.map +1 -0
  561. package/lib/types/questions.js +201 -0
  562. package/lib/types/screens/Chat.d.ts +23 -0
  563. package/lib/types/screens/Chat.d.ts.map +1 -0
  564. package/lib/types/screens/Chat.js +1263 -0
  565. package/lib/types/screens/StatusLine.d.ts +16 -0
  566. package/lib/types/screens/StatusLine.d.ts.map +1 -0
  567. package/lib/types/screens/StatusLine.js +105 -0
  568. package/lib/types/screens/StatusMetrics.d.ts +87 -0
  569. package/lib/types/screens/StatusMetrics.d.ts.map +1 -0
  570. package/lib/types/screens/StatusMetrics.js +252 -0
  571. package/lib/types/sessionHistory.d.ts +32 -0
  572. package/lib/types/sessionHistory.d.ts.map +1 -0
  573. package/lib/types/sessionHistory.js +87 -0
  574. package/lib/types/theme.d.ts +115 -0
  575. package/lib/types/theme.d.ts.map +1 -0
  576. package/lib/types/theme.js +300 -0
  577. package/lib/types/themePrefs.d.ts +28 -0
  578. package/lib/types/themePrefs.d.ts.map +1 -0
  579. package/lib/types/themePrefs.js +59 -0
  580. package/lib/types/ui.d.ts +28 -0
  581. package/lib/types/ui.d.ts.map +1 -0
  582. package/lib/types/ui.js +25 -0
  583. package/lib/types/update.d.ts +76 -0
  584. package/lib/types/update.d.ts.map +1 -0
  585. package/lib/types/update.js +203 -0
  586. package/lib/types/utils/clipboard.d.ts +43 -0
  587. package/lib/types/utils/clipboard.d.ts.map +1 -0
  588. package/lib/types/utils/clipboard.js +82 -0
  589. package/lib/types/utils/debug.d.ts +8 -0
  590. package/lib/types/utils/debug.d.ts.map +1 -0
  591. package/lib/types/utils/debug.js +12 -0
  592. package/lib/types/utils/earlyInput.d.ts +7 -0
  593. package/lib/types/utils/earlyInput.d.ts.map +1 -0
  594. package/lib/types/utils/earlyInput.js +6 -0
  595. package/lib/types/utils/env.d.ts +10 -0
  596. package/lib/types/utils/env.d.ts.map +1 -0
  597. package/lib/types/utils/env.js +9 -0
  598. package/lib/types/utils/envUtils.d.ts +8 -0
  599. package/lib/types/utils/envUtils.d.ts.map +1 -0
  600. package/lib/types/utils/envUtils.js +13 -0
  601. package/lib/types/utils/execFileNoThrow.d.ts +34 -0
  602. package/lib/types/utils/execFileNoThrow.d.ts.map +1 -0
  603. package/lib/types/utils/execFileNoThrow.js +31 -0
  604. package/lib/types/utils/fullscreen.d.ts +8 -0
  605. package/lib/types/utils/fullscreen.d.ts.map +1 -0
  606. package/lib/types/utils/fullscreen.js +10 -0
  607. package/lib/types/utils/intl.d.ts +7 -0
  608. package/lib/types/utils/intl.d.ts.map +1 -0
  609. package/lib/types/utils/intl.js +9 -0
  610. package/lib/types/utils/loaded-context.d.ts +20 -0
  611. package/lib/types/utils/loaded-context.d.ts.map +1 -0
  612. package/lib/types/utils/loaded-context.js +36 -0
  613. package/lib/types/utils/log.d.ts +7 -0
  614. package/lib/types/utils/log.d.ts.map +1 -0
  615. package/lib/types/utils/log.js +9 -0
  616. package/lib/types/utils/mentions.d.ts +30 -0
  617. package/lib/types/utils/mentions.d.ts.map +1 -0
  618. package/lib/types/utils/mentions.js +72 -0
  619. package/lib/types/utils/narration.d.ts +11 -0
  620. package/lib/types/utils/narration.d.ts.map +1 -0
  621. package/lib/types/utils/narration.js +16 -0
  622. package/lib/types/utils/semver.d.ts +9 -0
  623. package/lib/types/utils/semver.d.ts.map +1 -0
  624. package/lib/types/utils/semver.js +24 -0
  625. package/lib/types/utils/sliceAnsi.d.ts +12 -0
  626. package/lib/types/utils/sliceAnsi.d.ts.map +1 -0
  627. package/lib/types/utils/sliceAnsi.js +79 -0
  628. package/lib/types/working-activity.d.ts +19 -0
  629. package/lib/types/working-activity.d.ts.map +1 -0
  630. package/lib/types/working-activity.js +18 -0
  631. package/package.json +141 -0
  632. package/skills/audit/SKILL.md +24 -0
  633. package/skills/bug/SKILL.md +27 -0
  634. package/skills/pr-comments/SKILL.md +20 -0
  635. package/skills/practice/SKILL.md +22 -0
  636. package/skills/release-notes/SKILL.md +25 -0
  637. package/skills/review/SKILL.md +25 -0
  638. package/skills/vuln-check/SKILL.md +25 -0
@@ -0,0 +1,2427 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agent';
3
+ import { createUserMessage, isTokenDelta, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
4
+ import { SessionId } from '@deepseek-ai/dsh-session';
5
+ import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
6
+ import { discoverBaselineInstructionFiles } from '@deepseek-ai/dsh-agent-instructions';
7
+ import { isAbsolute, join } from 'node:path';
8
+ import { LOCAL_COMMANDS } from './commands.js';
9
+ import { clearResumeTarget, readLastUsed, touchSession, writeResumeTarget } from './sessionHistory.js';
10
+ import { writeActivityFrames } from './activityPrefs.js';
11
+ import { readEffortPref, writeEffortPref } from './effortPrefs.js';
12
+ import { readModelPref, writeModelPref } from './modelPrefs.js';
13
+ import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from './modelRoute.js';
14
+ import { readPresetPref, writePresetPref } from './presetPrefs.js';
15
+ import { composePreset, resolvePersistedPreset, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
16
+ import { isPresetName } from './components/activityFrames.js';
17
+ import { existsSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { logForDebugging } from './utils/debug.js';
20
+ import { extractMentions } from './utils/mentions.js';
21
+ import { t } from './i18n.js';
22
+ const ARGS_PREVIEW_LIMIT = 160;
23
+ const RESULT_PREVIEW_LIMIT = 240;
24
+ /** Local `!`-command output cap (mirrors the result preview limit). */
25
+ const LOCAL_OUTPUT_LIMIT = 240;
26
+ /**
27
+ * In-memory transcript window cap. Older rows beyond this count are FOLDED:
28
+ * their full-text fields (assistant/reasoning text, tool args/results) are
29
+ * dropped and only the preview/status metadata kept, so a long merge/deploy
30
+ * turn cannot grow the TUI's RAM without bound. The session log remains the
31
+ * complete source of truth (`/export` reads it, `/resume` replays it); the
32
+ * folded row keeps its kind/id so scrolling and selection stay stable.
33
+ */
34
+ const MAX_ROWS = 600;
35
+ function preview(text, limit) {
36
+ const flat = text.replace(/\s+/g, ' ').trim();
37
+ return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`;
38
+ }
39
+ /**
40
+ * Fold the oldest rows beyond the transcript window cap: drop each row's
41
+ * full-text fields (assistant/reasoning text, tool args/results) and keep
42
+ * only its preview text, kind, id, and seq. Bounds the TUI's retained text
43
+ * without touching the session log (the source of truth for /export and
44
+ * loadOlder). Small local/notice/interrupt rows are left intact (they hold
45
+ * terminal-local text the log cannot restore). Restored rows are exempt so
46
+ * a loadOlder() restore is not instantly undone. Returns the number of rows
47
+ * folded.
48
+ */
49
+ function foldRows(rows, cap) {
50
+ const excess = rows.length - cap;
51
+ if (excess <= 0)
52
+ return 0;
53
+ let folded = 0;
54
+ for (const row of rows.slice(0, excess)) {
55
+ if (row.folded || row.restored)
56
+ continue;
57
+ if (row.kind !== 'user' && row.kind !== 'assistant' && row.kind !== 'reasoning' && row.kind !== 'tool')
58
+ continue;
59
+ row.folded = true;
60
+ folded += 1;
61
+ if (row.kind === 'tool' && row.tool) {
62
+ row.tool.argsFull = undefined;
63
+ row.tool.resultFull = undefined;
64
+ row.tool.errorText = undefined;
65
+ // Presentation views hold duplicated content strings (diff before/
66
+ // after images, terminal output); the session log re-derives them.
67
+ row.tool.callView = undefined;
68
+ row.tool.resultView = undefined;
69
+ }
70
+ else if (row.text.length > 0) {
71
+ // Keep a short preview so the transcript reads naturally; the full
72
+ // text lives in the session log and is restored by loadOlder().
73
+ row.text = preview(row.text, 200);
74
+ }
75
+ }
76
+ return folded;
77
+ }
78
+ /**
79
+ * Restore folded rows from the session log, newest folded batch first.
80
+ * Rebuilds each folded row's full text from its source events and clears
81
+ * the folded mark, keeping row ids, scroll anchors, and selection stable.
82
+ * `views` re-derives the tool presentation views foldRows dropped (the
83
+ * presenters live on the host plane, so the channel passes them in).
84
+ * Returns the number of rows restored.
85
+ */
86
+ function foldBack(rows, events, views) {
87
+ const folded = rows.filter(row => row.folded);
88
+ if (folded.length === 0)
89
+ return 0;
90
+ const firstFoldedSeq = folded[0]?.seq ?? 0;
91
+ const restoreEvents = events.filter(event => event.seq >= firstFoldedSeq);
92
+ // tool results are matched by callId, not seq, because the result event
93
+ // seq differs from the call event seq that anchored the row.
94
+ const resultsByCall = new Map();
95
+ for (const event of restoreEvents) {
96
+ if (event.type === 'tool/result') {
97
+ resultsByCall.set(event.data.message.source.callId, event);
98
+ }
99
+ }
100
+ let restored = 0;
101
+ for (const row of folded) {
102
+ const rowSeq = row.seq;
103
+ if (rowSeq === undefined)
104
+ continue;
105
+ if (row.kind === 'tool' && row.tool !== undefined) {
106
+ // The tool row is anchored on its tool/call seq; its result text comes
107
+ // from the matching tool/result event.
108
+ const call = restoreEvents.find(event => event.seq === rowSeq && event.type === 'tool/call');
109
+ if (call === undefined || call.type !== 'tool/call')
110
+ continue;
111
+ restoreRowFromEvent(row, call);
112
+ const result = resultsByCall.get(row.tool.callId);
113
+ if (result !== undefined)
114
+ restoreToolResult(row, result);
115
+ row.tool.callView = views?.call(call.data.name, call.data.arguments);
116
+ row.tool.resultView = result !== undefined && result.data.error === undefined
117
+ ? views?.result(call.data.name, call.data.arguments, result.data)
118
+ : undefined;
119
+ row.folded = false;
120
+ restored += 1;
121
+ continue;
122
+ }
123
+ // Text rows are anchored on their first delta chunk; the settled
124
+ // assistant/message at or after that seq carries the full text.
125
+ const message = restoreEvents.find(event => event.seq >= rowSeq && event.type === 'assistant/message');
126
+ if (message === undefined)
127
+ continue;
128
+ restoreRowFromEvent(row, message);
129
+ row.folded = false;
130
+ restored += 1;
131
+ }
132
+ return restored;
133
+ }
134
+ /** Rebuild a folded row's full text from its source session event. */
135
+ function restoreRowFromEvent(row, event) {
136
+ switch (row.kind) {
137
+ case 'user': {
138
+ if (event.type !== 'user/message')
139
+ break;
140
+ const text = event.data.content.map(block => block.type === 'text' ? block.text : '').join('').trim();
141
+ if (text)
142
+ row.text = text;
143
+ break;
144
+ }
145
+ case 'assistant': {
146
+ if (event.type !== 'assistant/message')
147
+ break;
148
+ const text = event.data.message.content.map(block => block.type === 'text' ? block.text : '').join('').trim();
149
+ if (text)
150
+ row.text = text;
151
+ break;
152
+ }
153
+ case 'reasoning': {
154
+ // Thinking text is carried by the assistant/message's reasoning
155
+ // blocks, not the (ephemeral) delta chunks, so the settled message
156
+ // restores it exactly.
157
+ if (event.type !== 'assistant/message')
158
+ break;
159
+ const text = event.data.message.content.map(block => block.type === 'reasoning' ? block.text : '').join('').trim();
160
+ if (text)
161
+ row.text = text;
162
+ break;
163
+ }
164
+ case 'tool': {
165
+ if (event.type !== 'tool/call' || row.tool === undefined)
166
+ break;
167
+ row.tool.argsFull = event.data.arguments;
168
+ break;
169
+ }
170
+ default:
171
+ break;
172
+ }
173
+ }
174
+ /** Restore a folded tool row's result text from its tool/result event. */
175
+ function restoreToolResult(row, event) {
176
+ if (row.tool === undefined)
177
+ return;
178
+ const failure = event.data.error;
179
+ if (failure !== undefined) {
180
+ row.tool.status = 'error';
181
+ row.tool.errorText = `${failure.name}: ${failure.code}`;
182
+ return;
183
+ }
184
+ row.tool.status = 'ok';
185
+ const block = event.data.message.content[0];
186
+ const result = block.content.map(b => b.type === 'text' ? b.text : '').join('').trim();
187
+ row.tool.resultFull = result || undefined;
188
+ }
189
+ /**
190
+ * Coalesce runs of same-type assistant/chunk deltas into single synthetic
191
+ * events for REPLAY only. A streamed turn logs one event per token (~100k
192
+ * events in long sessions); replaying them one at a time costs per-chunk
193
+ * string growth on every row (quadratic in the turn's length). Merging is
194
+ * outcome-identical: ensureStreaming/ensureReasoning only read chunk.type
195
+ * and the concatenated text, and the row's seq comes from the run's FIRST
196
+ * chunk (the fork boundary rewindTo derives from it). Parts join once —
197
+ * no quadratic concat. Live events never go through this.
198
+ */
199
+ function coalesceReplayEvents(events) {
200
+ const out = [];
201
+ let run = null;
202
+ const flush = () => {
203
+ if (run === null)
204
+ return;
205
+ const chunk = run.event.data.chunk;
206
+ out.push({
207
+ ...run.event,
208
+ data: { ...run.event.data, chunk: { ...chunk, text: run.parts.join('') } },
209
+ });
210
+ run = null;
211
+ };
212
+ for (const event of events) {
213
+ if (event.type === 'assistant/chunk' &&
214
+ (event.data.chunk.type === 'text-delta' || event.data.chunk.type === 'reasoning-delta')) {
215
+ if (run !== null && run.type === event.data.chunk.type) {
216
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack text
217
+ run.parts.push(event.data.chunk.text ?? '');
218
+ continue;
219
+ }
220
+ flush();
221
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack text
222
+ run = { event, type: event.data.chunk.type, parts: [event.data.chunk.text ?? ''] };
223
+ continue;
224
+ }
225
+ flush();
226
+ out.push(event);
227
+ }
228
+ flush();
229
+ return out;
230
+ }
231
+ /** Buffer below the context window at which CC warns (autoCompact.ts). */
232
+ const CONTEXT_WARNING_BUFFER_TOKENS = 20_000;
233
+ /** How many newest sessions resolve their title from the first user message
234
+ * (persistence.load reads the whole log — depth caps the picker latency). */
235
+ const SESSION_TITLE_DEPTH = 20;
236
+ /** Picker title cap, in characters. */
237
+ const SESSION_TITLE_LIMIT = 40;
238
+ /** One-line session title: whitespace folded, capped with an ellipsis. */
239
+ function shortenTitle(text) {
240
+ const flat = text.replace(/\s+/g, ' ').trim();
241
+ return flat.length <= SESSION_TITLE_LIMIT
242
+ ? flat
243
+ : `${flat.slice(0, SESSION_TITLE_LIMIT - 1)}…`;
244
+ }
245
+ /** Resolve once a `turn/end` event newer than `fromSeq` lands in the session
246
+ * log (Agent.cancel closes the turn asynchronously), or when the timeout
247
+ * expires. Polling the session log is race-free here: fork reads the same
248
+ * append-only log. */
249
+ async function waitForTurnEnd(session, fromSeq, timeoutMs) {
250
+ const deadline = Date.now() + timeoutMs;
251
+ while (Date.now() < deadline) {
252
+ const last = session.events.at(-1);
253
+ if (last !== undefined && last.type === 'turn/end' && last.seq >= fromSeq) {
254
+ return true;
255
+ }
256
+ await new Promise(resolve => setTimeout(resolve, 200));
257
+ }
258
+ return false;
259
+ }
260
+ /**
261
+ * Create the live channel state for one agent session: replay the durable
262
+ * transcript, subscribe to the agent's events, and expose every TUI action.
263
+ * @internal
264
+ * @param ctx - The plugin context; optional services are resolved via ctx.get.
265
+ * @param initialAgent - The agent whose session the channel renders; rewinds,
266
+ * resumes, and model switches replace it.
267
+ * @param options - Boot options: model route, cwd, provider, and the
268
+ * reasoning-effort / working-activity / agent-handle preferences.
269
+ * @returns The live channel state, subscribed and ready to render.
270
+ */
271
+ export function createChannel(ctx, initialAgent, options) {
272
+ let agent = initialAgent;
273
+ let currentHandle = options.handle;
274
+ // The DSH slash-command registry (optional service): /plan, /goal and
275
+ // friends register here; the TUI merges their descriptors into the slash
276
+ // menu and dispatches through `execute` (which logs the paired
277
+ // command/run + command/done records). Absent the service, only the
278
+ // built-in local commands exist.
279
+ const commandService = ctx.get('commands');
280
+ const listeners = new Set();
281
+ /** True while a frame-aligned stream notification is pending (emitStream). */
282
+ let streamNotifyScheduled = false;
283
+ let nextNotificationId = 1;
284
+ /** One-shot context-low warning per session (CC's TokenWarning). */
285
+ let contextWarned = false;
286
+ const checkContextWarning = () => {
287
+ if (contextWarned || state.contextWindow === undefined)
288
+ return;
289
+ const remaining = state.contextWindow - state.tokens.input;
290
+ if (remaining >= CONTEXT_WARNING_BUFFER_TOKENS)
291
+ return;
292
+ contextWarned = true;
293
+ const percentLeft = Math.max(0, Math.round((remaining / state.contextWindow) * 100));
294
+ state.notify(`Context low (${percentLeft}% remaining) · Run /clear or start a new session`, { color: 'warning', timeoutMs: 8000 });
295
+ };
296
+ /**
297
+ * Register a submitted message as pending and notify the UI. The inbox
298
+ * events (claimed/discarded) retire it; nothing here guesses timing.
299
+ */
300
+ const trackPending = (message, placement) => {
301
+ state.pending = [...state.pending, { id: message.id, text: message.text, placement }];
302
+ state.emit();
303
+ };
304
+ /** Remove one pending entry (rollback on a refused send, steering
305
+ * rejection, or delivery races) and notify only when it existed. */
306
+ const untrackPending = (messageId) => {
307
+ const before = state.pending.length;
308
+ state.pending = state.pending.filter(item => item.id !== messageId);
309
+ if (state.pending.length !== before)
310
+ state.emit();
311
+ };
312
+ /**
313
+ * `@` file mentions (issue #15): expansion reads files asynchronously, so
314
+ * every user-text delivery (submit / steer / interrupt-requeue) funnels
315
+ * through this chain to keep the send order FIFO.
316
+ */
317
+ let sendChain = Promise.resolve();
318
+ /**
319
+ * Expand the text's `@` mentions and deliver ONE user message: the typed
320
+ * text stays the first content block (the transcript bubble renders it —
321
+ * never the file dump) and each resolved reference appends a model-facing
322
+ * attachment block. The pending preview tracks the typed text.
323
+ */
324
+ const deliverUserText = (text, placement) => {
325
+ sendChain = sendChain.then(async () => {
326
+ const expansion = await expandMentions(mentionFs(ctx), state.cwd, text);
327
+ const message = createUserMessage({
328
+ content: expansion.blocks,
329
+ source: { kind: 'user' },
330
+ });
331
+ // Track BEFORE the agent call: a synchronous throw inside
332
+ // followup/steer rolls the preview back; otherwise the inbox events
333
+ // retire it once the message is claimed or discarded.
334
+ trackPending({ id: message.id, text }, placement);
335
+ try {
336
+ if (placement === 'steer')
337
+ agent.steer(message);
338
+ else
339
+ agent.followup(message);
340
+ }
341
+ catch (error) {
342
+ untrackPending(message.id);
343
+ throw error;
344
+ }
345
+ if (expansion.attached.length > 0) {
346
+ state.notify(t('mentions-attached', { count: expansion.attached.length }), { timeoutMs: 2500 });
347
+ }
348
+ if (expansion.missing.length > 0) {
349
+ state.notify(t('mentions-missing', { paths: expansion.missing.map(path => `@${path}`).join(' ') }), {
350
+ color: 'warning',
351
+ timeoutMs: 4000,
352
+ });
353
+ }
354
+ }).catch((error) => {
355
+ // The chain must survive a failed send: log and notify, then continue
356
+ // with the next queued delivery.
357
+ const message = error instanceof Error ? error.message : String(error);
358
+ logForDebugging(`submit: delivery failed (${message})`);
359
+ state.notify(t('send-failed', { err: message }), { color: 'error' });
360
+ });
361
+ };
362
+ /** Monotonic token: only the latest `interruptAndDeliver` re-queues, so a
363
+ * second interrupt while the abort settles cannot double-deliver. */
364
+ let interruptSeq = 0;
365
+ /** The llm runtime seam (dsh-llm LlmRuntime): route metadata resolution. */
366
+ const llmRuntime = ctx.get('llm');
367
+ /** Mutable per-agent model selection (dsh-agent's routing override seam).
368
+ * `current` stays undefined until the user explicitly cycles effort, so
369
+ * default routing (agentOptions on create/fork) is untouched; bindAgent
370
+ * re-couples it to each new agent's prompt assembly + request config. */
371
+ const selection = { current: undefined, assembled: undefined };
372
+ /** The effort chosen this run (or persisted from a previous one); applied
373
+ * to every newly bound agent once validated against its adapter's list. */
374
+ let preferredEffort = options.effort ?? readEffortPref();
375
+ /** Pin `preferredEffort` on the live agent when its route offers it;
376
+ * silent no-op otherwise (the next request/header corrects the display). */
377
+ const applyPreferredEffort = async () => {
378
+ if (preferredEffort === undefined || llmRuntime === undefined)
379
+ return;
380
+ try {
381
+ const info = await llmRuntime.resolveModelInfo(state.provider, state.model);
382
+ if (!info.reasoning?.efforts.some(effort => effort.id === preferredEffort))
383
+ return;
384
+ selection.current = {
385
+ provider: state.provider,
386
+ model: state.model,
387
+ reasoningEffort: ReasoningEffortId(preferredEffort),
388
+ };
389
+ }
390
+ catch {
391
+ // Route metadata resolution is best-effort; a failure just leaves the
392
+ // provider default in effect.
393
+ }
394
+ };
395
+ /** Shift+Tab: cycle the live route's adapter-owned reasoning efforts in
396
+ * the adapter's own display order (dsh parity — deepseek: Off→High→Max).
397
+ * The choice persists to ~/.dsh-cc/effort.json and follows future agents
398
+ * on this channel (resume/model switch re-validate it per route). */
399
+ const cycleEffort = async () => {
400
+ if (llmRuntime === undefined) {
401
+ state.notify(t('effort-unavailable'), { color: 'error' });
402
+ return;
403
+ }
404
+ let efforts;
405
+ let defaultEffort;
406
+ try {
407
+ const info = await llmRuntime.resolveModelInfo(state.provider, state.model);
408
+ efforts = info.reasoning?.efforts ?? [];
409
+ defaultEffort = info.reasoning?.defaultEffort;
410
+ }
411
+ catch (error) {
412
+ state.notify(t('effort-read-failed', { error: error instanceof Error ? error.message : String(error) }), {
413
+ color: 'error',
414
+ timeoutMs: 8000,
415
+ });
416
+ return;
417
+ }
418
+ if (efforts.length <= 1) {
419
+ state.notify(efforts.length === 1
420
+ ? t('effort-single-tier', { name: efforts[0].name })
421
+ : t('effort-unsupported'), { color: 'warning' });
422
+ return;
423
+ }
424
+ // No explicit level yet = the adapter's materialized default is in
425
+ // effect; cycle from THERE, not from the top of the list.
426
+ const currentId = state.reasoningEffort ?? defaultEffort;
427
+ const currentIndex = efforts.findIndex(effort => effort.id === currentId);
428
+ const next = efforts[(currentIndex + 1) % efforts.length];
429
+ selection.current = {
430
+ provider: state.provider,
431
+ model: state.model,
432
+ reasoningEffort: ReasoningEffortId(next.id),
433
+ };
434
+ preferredEffort = next.id;
435
+ state.reasoningEffort = next.id;
436
+ writeEffortPref(next.id);
437
+ state.notify(t('effort-switched', { name: next.name }));
438
+ state.emit();
439
+ };
440
+ const state = {
441
+ version: 0,
442
+ rows: [],
443
+ status: 'starting',
444
+ sessionTitle: '',
445
+ agentId: agent.id,
446
+ model: options.model,
447
+ provider: options.provider,
448
+ tokens: { input: 0, output: 0 },
449
+ cwd: options.cwd,
450
+ gitBranch: undefined,
451
+ working: false,
452
+ spinnerMode: 'requesting',
453
+ responseChars: 0,
454
+ activeToolCount: 0,
455
+ turnStart: 0,
456
+ lastUserText: '',
457
+ notifications: [],
458
+ contextWindow: undefined,
459
+ // Explicit cordis.yml `effort` wins; otherwise the persisted Shift+Tab
460
+ // choice; the first request/header event re-asserts the adapter's truth.
461
+ reasoningEffort: options.effort ?? readEffortPref(),
462
+ workingActivity: undefined,
463
+ activityFrames: options.activityFrames,
464
+ activityEnabled: options.activity !== false,
465
+ contextBarEnabled: options.contextBar !== false,
466
+ agentPreset: options.agentPreset,
467
+ goal: undefined,
468
+ todos: [],
469
+ loadedContext: undefined,
470
+ pending: [],
471
+ commandList: LOCAL_COMMANDS,
472
+ lastUsage: undefined,
473
+ tps: undefined,
474
+ tpsSamples: [],
475
+ contextSegments: {
476
+ system: 0,
477
+ prompt: 0,
478
+ assistant: 0,
479
+ thinking: 0,
480
+ tools: 0,
481
+ },
482
+ subscribe(listener) {
483
+ listeners.add(listener);
484
+ return () => {
485
+ listeners.delete(listener);
486
+ };
487
+ },
488
+ emit() {
489
+ foldRows(state.rows, MAX_ROWS);
490
+ state.version += 1;
491
+ for (const listener of listeners)
492
+ listener();
493
+ },
494
+ // Frame-aligned notification for streaming deltas. LLM chunks arrive at
495
+ // 100-300 events/s (one per token); waking React synchronously per event
496
+ // commits the whole tree per token — the render throttle only gates
497
+ // paint, not commits, so the event loop saturates and output stutters.
498
+ // Data + version stay synchronous (getSnapshot always reads fresh
499
+ // state); only the listener wakeup coalesces to paint cadence.
500
+ emitStream() {
501
+ state.version += 1;
502
+ if (streamNotifyScheduled)
503
+ return;
504
+ streamNotifyScheduled = true;
505
+ const timer = setTimeout(() => {
506
+ streamNotifyScheduled = false;
507
+ foldRows(state.rows, MAX_ROWS);
508
+ for (const listener of listeners)
509
+ listener();
510
+ }, 16);
511
+ // Never hold the process open for a pending UI wakeup.
512
+ timer.unref();
513
+ },
514
+ loadOlder() {
515
+ // Restore folded-away full text from the session log, newest folded
516
+ // batch first, clearing the folded marks. The log is the authoritative
517
+ // source, so restored rows match a fresh replay; live streaming rows
518
+ // are never folded, so nothing here races a running turn.
519
+ const restored = foldBack(state.rows, agent.session.events, { call: presentCallView, result: presentResultView });
520
+ if (restored > 0)
521
+ state.emit();
522
+ return restored;
523
+ },
524
+ submit(text) {
525
+ const trimmed = text.trim();
526
+ if (!trimmed)
527
+ return;
528
+ // Claude Code's `!` mode: `!cmd` runs locally and only shows the
529
+ // output; `!!cmd` additionally sends the output to the model as a
530
+ // user message (CC's <bash-stdout> convention).
531
+ if (trimmed.startsWith('!!')) {
532
+ void runLocalCommand(trimmed.slice(2).trim(), true);
533
+ return;
534
+ }
535
+ if (trimmed.startsWith('!')) {
536
+ void runLocalCommand(trimmed.slice(1).trim(), false);
537
+ return;
538
+ }
539
+ // The current session is being used — move it to the MRU front
540
+ // (/resume sorts by last-used).
541
+ touchSession(state.agentId);
542
+ deliverUserText(trimmed, 'followup');
543
+ },
544
+ /** Steer a message into the RUNNING turn (Codex/pi semantics): it is
545
+ * injected at the next step boundary of the current turn and the agent
546
+ * continues without stopping — faster than followup, never an abort. */
547
+ steer(text) {
548
+ const trimmed = text.trim();
549
+ if (!trimmed)
550
+ return;
551
+ touchSession(state.agentId);
552
+ // Official dsh-agent rc.6: steer() is synchronous void — the message
553
+ // enters the next-step inbox. A rejected step leaves it parked for the
554
+ // next wake; the inbox events below retire the preview (claimed →
555
+ // turn boundary, discarded → cancel).
556
+ deliverUserText(trimmed, 'steer');
557
+ },
558
+ /** Pull a pending message back out of the inbox (Alt+Up): it returns to
559
+ * the input for editing instead of being delivered. */
560
+ removePending(id) {
561
+ const index = state.pending.findIndex(item => item.id === id);
562
+ if (index === -1)
563
+ return false;
564
+ // Official dsh-agent rc.6: withdrawal goes through the agent's inbox
565
+ // projection — `Inbox.remove(messageId)` durably records the
566
+ // cancellation (an `agent/inbox/spliced` session event) and publishes
567
+ // `agent/inbox/discarded`, which retires the preview. Refuse when the
568
+ // message was already claimed (remove returns false) so the UI never
569
+ // pretends a ghost send was pulled back.
570
+ if (!agent.inbox.remove(MessageId(id)))
571
+ return false;
572
+ state.pending = state.pending.filter(item => item.id !== id);
573
+ state.emit();
574
+ return true;
575
+ },
576
+ cancel() {
577
+ // Keep the staged queue: an interrupt aborts the running turn but the
578
+ // queued/steered messages are delivered as the next turn (web parity).
579
+ agent.cancel({ kind: 'user' }, { keepInbox: true });
580
+ },
581
+ interruptAndDeliver(texts) {
582
+ const queued = texts.map(text => text.trim()).filter(text => text !== '');
583
+ if (queued.length === 0)
584
+ return 0;
585
+ // No keepInbox: the parked copies are dropped (their discard events
586
+ // retire the preview), then each text is re-queued as a fresh
587
+ // followup. The harness parks kept inbox work until an unrelated wake
588
+ // (official cancel.spec: "keepInbox parks queued work after an active
589
+ // turn aborts"), and a wake issued while the driver is still aborting
590
+ // is ignored — so the re-queue happens on `whenIdle`, whose own wake
591
+ // starts the new turn.
592
+ agent.cancel({ kind: 'user' });
593
+ const token = ++interruptSeq;
594
+ const whenIdle = agent.whenIdle;
595
+ const deliver = () => {
596
+ // A second interrupt while the abort is still settling must not
597
+ // double-deliver: only the latest request's re-queue runs.
598
+ if (interruptSeq !== token)
599
+ return;
600
+ for (const text of queued) {
601
+ touchSession(state.agentId);
602
+ deliverUserText(text, 'followup');
603
+ }
604
+ };
605
+ if (typeof whenIdle === 'function') {
606
+ void whenIdle.call(agent).then(deliver);
607
+ }
608
+ else {
609
+ // Defensive: a wake while the driver still runs is ignored, so wait
610
+ // for the abort to settle before re-queueing.
611
+ setTimeout(deliver, 200);
612
+ }
613
+ return queued.length;
614
+ },
615
+ async rewindTo(row) {
616
+ if (row.seq === undefined)
617
+ return null;
618
+ const sessions = ctx.get('sessions');
619
+ const agents = ctx.get('agents');
620
+ if (!sessions || !agents) {
621
+ state.notify('Rewind unavailable — session services not loaded', { color: 'error' });
622
+ return null;
623
+ }
624
+ // Stop a running turn first and WAIT for its turn/end to land — fork
625
+ // rejects boundaries inside open turns, and Agent.cancel() closes the
626
+ // turn asynchronously (a long thinking turn can take seconds to settle).
627
+ const wasWorking = state.working;
628
+ const cancelSeq = agent.session.seq;
629
+ if (wasWorking)
630
+ agent.cancel({ kind: 'user' });
631
+ if (wasWorking) {
632
+ const turnSettled = await waitForTurnEnd(agent.session, cancelSeq, 30000);
633
+ if (!turnSettled) {
634
+ state.notify('Cannot rewind — the turn is still settling, try again in a moment', { color: 'error' });
635
+ return null;
636
+ }
637
+ }
638
+ const childId = SessionId(randomUUID());
639
+ // DSH event order is `turn/start → user/message → … → turn/end`, so a
640
+ // message's own seq always sits inside its turn — forking there would
641
+ // hit OPEN_TURN. Rewind to just BEFORE the message's turn/start: the
642
+ // conversation restarts at that point and the message itself comes
643
+ // back into the input for re-editing (CC's rewind semantics).
644
+ const events = agent.session.events;
645
+ let boundary = row.seq;
646
+ for (let i = row.seq; i >= 0; i--) {
647
+ const event = events[i];
648
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: seq may exceed events
649
+ if (event === undefined)
650
+ break;
651
+ if (event.type === 'turn/start') {
652
+ boundary = event.seq - 1;
653
+ break;
654
+ }
655
+ if (event.type === 'turn/end')
656
+ break;
657
+ }
658
+ // Slice the seed ourselves instead of storing a fork: agents.create
659
+ // must own the session (a pre-created fork session would collide on
660
+ // the same id). The create boundary validates the seed (contiguous
661
+ // from seq 0, no open turns), which our boundary already guarantees.
662
+ let seed;
663
+ try {
664
+ if (boundary < 0) {
665
+ throw new Error('cannot rewind to the very first message');
666
+ }
667
+ seed = sessions.fork(agent.session, boundary).events;
668
+ }
669
+ catch (error) {
670
+ const message = error instanceof Error ? error.message : String(error);
671
+ state.notify(`Cannot rewind to this point · ${message}`, { color: 'error' });
672
+ return null;
673
+ }
674
+ let handle;
675
+ // The fork continues under the source session's own preset: switches
676
+ // are blank-only, so every `agent-preset/selected` event predates any
677
+ // rewind boundary and the source log resolves the exact composition.
678
+ // The route likewise stays the live one — a rewind continues the same
679
+ // conversation, so a `/model` switch must survive it (issue #30).
680
+ const rewindComposed = await composePreset(ctx, runningPresetOf(agent.session));
681
+ try {
682
+ handle = await agents.create({
683
+ sessionId: childId,
684
+ seed,
685
+ meta: {
686
+ cwd: options.cwd,
687
+ parentSession: agent.session.id,
688
+ seedLength: seed.length,
689
+ ...(rewindComposed.agentPreset === undefined
690
+ ? {}
691
+ : { agentPreset: rewindComposed.agentPreset }),
692
+ },
693
+ agentOptions: { provider: state.provider, model: state.model },
694
+ ...(rewindComposed.setup === undefined ? {} : { setup: rewindComposed.setup }),
695
+ });
696
+ }
697
+ catch {
698
+ state.notify('Rewind failed — could not create the replacement session', { color: 'error' });
699
+ return null;
700
+ }
701
+ // Replay the forked history into a fresh transcript (tokens/spinner
702
+ // counters land back at the rewind point, matching the fork).
703
+ streaming = undefined;
704
+ reasoning = undefined;
705
+ toolCards.clear();
706
+ nextRowId = 0;
707
+ state.rows.length = 0;
708
+ // Goal/todo/title are session-scoped; the replay re-derives them for
709
+ // the session being entered (or leaves them empty).
710
+ state.todos = [];
711
+ state.goal = undefined;
712
+ state.sessionTitle = '';
713
+ state.tokens = { input: 0, output: 0 };
714
+ state.responseChars = 0;
715
+ state.activeToolCount = 0;
716
+ state.lastUserText = '';
717
+ state.working = false;
718
+ state.spinnerMode = 'requesting';
719
+ state.status = handle.agent.status;
720
+ state.agentId = handle.agent.id;
721
+ state.agentPreset = rewindComposed.agentPreset;
722
+ state.tps = undefined;
723
+ state.tpsSamples = [];
724
+ state.lastUsage = undefined;
725
+ state.workingActivity = undefined;
726
+ state.contextSegments = {
727
+ system: 0,
728
+ prompt: 0,
729
+ assistant: 0,
730
+ thinking: 0,
731
+ tools: 0,
732
+ };
733
+ for (const event of coalesceReplayEvents(seed))
734
+ renderEvent(event);
735
+ // Rebind subscriptions to the new agent, then free the old one.
736
+ const oldHandle = currentHandle;
737
+ agent = handle.agent;
738
+ currentHandle = handle;
739
+ bindAgent();
740
+ refreshCommandList();
741
+ void refreshLoadedContext();
742
+ // The forked session (rewind) becomes the most recently used.
743
+ touchSession(childId);
744
+ state.emit();
745
+ void oldHandle?.dispose().catch(() => { });
746
+ return row.text;
747
+ },
748
+ async resumeTo(sessionId) {
749
+ // Switch the live agent to a persisted session: /resume picker Enter
750
+ // loads the history immediately (the `--resume` launcher path keeps
751
+ // resolving through DSH_CC_RESUME_SESSION at boot).
752
+ if (state.working) {
753
+ state.notify('Cannot resume while a turn is running', { color: 'warning' });
754
+ return false;
755
+ }
756
+ const agents = ctx.get('agents');
757
+ if (!agents) {
758
+ state.notify('Resume unavailable — agents service not loaded', { color: 'error' });
759
+ return false;
760
+ }
761
+ let handle;
762
+ // The target session's own preset (from its persisted log) — never the
763
+ // current preference: a resume re-enters the composition its history
764
+ // was produced under. Same rule for the route: only an explicit
765
+ // cordis.yml provider/model overrides the route the target's own
766
+ // request/header records (issue #30) — and only as a COMPLETE pair
767
+ // (issue #67): a provider-only pin must not merge with the recorded
768
+ // model half into a route no adapter recognizes.
769
+ const resumeComposed = await composePreset(ctx, await resolvePersistedPreset(ctx, SessionId(sessionId)));
770
+ const resumeRoute = explicitModelRoute({
771
+ provider: options.configuredProvider,
772
+ model: options.configuredModel,
773
+ });
774
+ try {
775
+ handle = await agents.resume({
776
+ resumeSessionId: SessionId(sessionId),
777
+ agentOptions: { provider: resumeRoute?.provider, model: resumeRoute?.model },
778
+ ...(resumeComposed.setup === undefined ? {} : { setup: resumeComposed.setup }),
779
+ });
780
+ }
781
+ catch (error) {
782
+ const message = error instanceof Error ? error.message : String(error);
783
+ state.notify(`Resume failed · ${message}`, { color: 'error', timeoutMs: 8000 });
784
+ return false;
785
+ }
786
+ // Replay the persisted history into a fresh transcript (same reset as
787
+ // rewindTo, plus the context window which the replay re-derives).
788
+ streaming = undefined;
789
+ reasoning = undefined;
790
+ toolCards.clear();
791
+ nextRowId = 0;
792
+ state.rows.length = 0;
793
+ // Goal/todo/title are session-scoped; the replay re-derives them for
794
+ // the session being entered (or leaves them empty).
795
+ state.todos = [];
796
+ state.goal = undefined;
797
+ state.sessionTitle = '';
798
+ state.tokens = { input: 0, output: 0 };
799
+ state.responseChars = 0;
800
+ state.activeToolCount = 0;
801
+ state.lastUserText = '';
802
+ state.working = false;
803
+ state.spinnerMode = 'requesting';
804
+ state.status = handle.agent.status;
805
+ state.agentId = handle.agent.id;
806
+ state.agentPreset = resumeComposed.agentPreset;
807
+ // Status-line route follows the resumed session (review feedback): the
808
+ // route it actually continues on — a complete cordis.yml pin, else the
809
+ // route its own request/header records carry. A bare log (no turn ever
810
+ // started) records none; keep the current display as best effort.
811
+ const resumedRoute = resumeRoute ?? recordedModelRoute(handle.agent.session.events);
812
+ if (resumedRoute !== undefined) {
813
+ state.provider = resumedRoute.provider;
814
+ state.model = resumedRoute.model;
815
+ }
816
+ state.tps = undefined;
817
+ state.tpsSamples = [];
818
+ state.lastUsage = undefined;
819
+ state.workingActivity = undefined;
820
+ state.contextWindow = undefined;
821
+ state.contextSegments = {
822
+ system: 0,
823
+ prompt: 0,
824
+ assistant: 0,
825
+ thinking: 0,
826
+ tools: 0,
827
+ };
828
+ for (const event of coalesceReplayEvents(handle.agent.session.events))
829
+ renderEvent(event);
830
+ settleStreaming();
831
+ // Rebind subscriptions to the resumed agent, then free the old one.
832
+ const oldHandle = currentHandle;
833
+ agent = handle.agent;
834
+ currentHandle = handle;
835
+ bindAgent();
836
+ refreshCommandList();
837
+ void refreshLoadedContext();
838
+ // Keep the `--resume` launcher contract pointing at the same session.
839
+ writeResumeTarget(sessionId);
840
+ // The resumed session is now the most recently used.
841
+ touchSession(sessionId);
842
+ state.emit();
843
+ void oldHandle?.dispose().catch(() => { });
844
+ return true;
845
+ },
846
+ async newSession() {
847
+ // `/new` — start a fresh conversation: brand-new agent + session, the
848
+ // transcript reset, the `--resume` marker forgotten (the old session
849
+ // stays persisted for /resume). Same reset shape as rewindTo/resumeTo.
850
+ if (state.working) {
851
+ state.notify('Cannot start a new session while a turn is running', {
852
+ color: 'warning',
853
+ });
854
+ return false;
855
+ }
856
+ const agents = ctx.get('agents');
857
+ if (!agents) {
858
+ state.notify('New session unavailable — agents service not loaded', {
859
+ color: 'error',
860
+ });
861
+ return false;
862
+ }
863
+ const sessionId = SessionId(randomUUID());
864
+ let handle;
865
+ // A fresh session composes the caller's DEFAULT preset: the cordis.yml
866
+ // `preset` key wins over the persisted `/preset` choice, which wins
867
+ // over the roster default (same precedence as activityFrames).
868
+ const newComposed = await composePreset(ctx, options.configuredPreset ?? readPresetPref());
869
+ // Same precedence for the route (issues #14/#30/#67): the pair resolves
870
+ // atomically — a complete cordis.yml route wins whole, else the
871
+ // persisted `/model` choice (a switch earlier in this run just wrote
872
+ // it, so `/new` follows the live model) wins whole, else the startup
873
+ // route. A stale persisted choice that the adapter catalog rejects
874
+ // falls back to the startup route wholesale, with a warning.
875
+ const newResolved = resolveModelRoute({ provider: options.configuredProvider, model: options.configuredModel }, readModelPref(), { provider: options.provider, model: options.model });
876
+ const newLlm = ctx.get('llm');
877
+ const { route, rejected } = await validateModelRoute(newLlm, newResolved, {
878
+ provider: options.provider,
879
+ model: options.model,
880
+ });
881
+ if (rejected !== undefined) {
882
+ state.notify(t('model-route-invalid', {
883
+ provider: rejected.provider,
884
+ model: rejected.model,
885
+ fallback: `${route.provider}/${route.model}`,
886
+ }), { color: 'warning', timeoutMs: 8000 });
887
+ }
888
+ try {
889
+ handle = await agents.create({
890
+ sessionId,
891
+ meta: {
892
+ cwd: options.cwd,
893
+ ...(newComposed.agentPreset === undefined
894
+ ? {}
895
+ : { agentPreset: newComposed.agentPreset }),
896
+ },
897
+ agentOptions: route,
898
+ ...(newComposed.setup === undefined ? {} : { setup: newComposed.setup }),
899
+ });
900
+ }
901
+ catch (error) {
902
+ const message = error instanceof Error ? error.message : String(error);
903
+ state.notify(`New session failed · ${message}`, {
904
+ color: 'error',
905
+ timeoutMs: 8000,
906
+ });
907
+ return false;
908
+ }
909
+ streaming = undefined;
910
+ reasoning = undefined;
911
+ toolCards.clear();
912
+ nextRowId = 0;
913
+ state.rows.length = 0;
914
+ // Goal/todo/title are session-scoped; the replay re-derives them for
915
+ // the session being entered (or leaves them empty).
916
+ state.todos = [];
917
+ state.goal = undefined;
918
+ state.sessionTitle = '';
919
+ state.tokens = { input: 0, output: 0 };
920
+ state.responseChars = 0;
921
+ state.activeToolCount = 0;
922
+ state.lastUserText = '';
923
+ state.working = false;
924
+ state.spinnerMode = 'requesting';
925
+ state.status = handle.agent.status;
926
+ state.agentId = handle.agent.id;
927
+ state.agentPreset = newComposed.agentPreset;
928
+ state.model = route.model;
929
+ state.provider = route.provider;
930
+ state.tps = undefined;
931
+ state.tpsSamples = [];
932
+ state.lastUsage = undefined;
933
+ state.workingActivity = undefined;
934
+ state.contextWindow = undefined;
935
+ state.contextSegments = {
936
+ system: 0,
937
+ prompt: 0,
938
+ assistant: 0,
939
+ thinking: 0,
940
+ tools: 0,
941
+ };
942
+ const oldHandle = currentHandle;
943
+ agent = handle.agent;
944
+ currentHandle = handle;
945
+ bindAgent();
946
+ refreshCommandList();
947
+ void refreshLoadedContext();
948
+ clearResumeTarget();
949
+ // The brand-new session becomes the most recently used.
950
+ touchSession(handle.agent.id);
951
+ state.emit();
952
+ void oldHandle?.dispose().catch(() => { });
953
+ return true;
954
+ },
955
+ async switchModel(provider, model) {
956
+ // `/model` picker Enter — switch the live model by forking the
957
+ // conversation at its current end and continuing with a new agent
958
+ // routed to the chosen model. Same reset shape as rewindTo/resumeTo;
959
+ // the history replays unchanged, only the request model changes.
960
+ if (state.working) {
961
+ state.notify('Cannot switch models while a turn is running', {
962
+ color: 'warning',
963
+ });
964
+ return false;
965
+ }
966
+ const sessions = ctx.get('sessions');
967
+ const agents = ctx.get('agents');
968
+ if (!sessions || !agents) {
969
+ state.notify('Model switch unavailable — session services not loaded', {
970
+ color: 'error',
971
+ });
972
+ return false;
973
+ }
974
+ let seed;
975
+ try {
976
+ // No boundary = fork the whole log (continue the conversation).
977
+ seed = sessions.fork(agent.session).events;
978
+ }
979
+ catch (error) {
980
+ const message = error instanceof Error ? error.message : String(error);
981
+ state.notify(`Cannot switch models · ${message}`, { color: 'error' });
982
+ return false;
983
+ }
984
+ const childId = SessionId(randomUUID());
985
+ let handle;
986
+ // The forked conversation keeps the session's own preset — only the
987
+ // request route changes (same rule as rewindTo).
988
+ const modelComposed = await composePreset(ctx, runningPresetOf(agent.session));
989
+ try {
990
+ handle = await agents.create({
991
+ sessionId: childId,
992
+ seed,
993
+ meta: {
994
+ cwd: options.cwd,
995
+ parentSession: agent.session.id,
996
+ seedLength: seed.length,
997
+ ...(modelComposed.agentPreset === undefined
998
+ ? {}
999
+ : { agentPreset: modelComposed.agentPreset }),
1000
+ },
1001
+ agentOptions: { provider, model },
1002
+ ...(modelComposed.setup === undefined ? {} : { setup: modelComposed.setup }),
1003
+ });
1004
+ }
1005
+ catch (error) {
1006
+ const message = error instanceof Error ? error.message : String(error);
1007
+ state.notify(`Model switch failed · ${message}`, { color: 'error', timeoutMs: 8000 });
1008
+ return false;
1009
+ }
1010
+ streaming = undefined;
1011
+ reasoning = undefined;
1012
+ toolCards.clear();
1013
+ nextRowId = 0;
1014
+ state.rows.length = 0;
1015
+ // Goal/todo/title are session-scoped; the replay re-derives them for
1016
+ // the session being entered (or leaves them empty).
1017
+ state.todos = [];
1018
+ state.goal = undefined;
1019
+ state.sessionTitle = '';
1020
+ state.tokens = { input: 0, output: 0 };
1021
+ state.responseChars = 0;
1022
+ state.activeToolCount = 0;
1023
+ state.lastUserText = '';
1024
+ state.working = false;
1025
+ state.spinnerMode = 'requesting';
1026
+ state.status = handle.agent.status;
1027
+ state.agentId = handle.agent.id;
1028
+ state.agentPreset = modelComposed.agentPreset;
1029
+ state.model = model;
1030
+ state.provider = provider;
1031
+ state.tps = undefined;
1032
+ state.tpsSamples = [];
1033
+ state.lastUsage = undefined;
1034
+ state.workingActivity = undefined;
1035
+ state.contextWindow = undefined;
1036
+ state.contextSegments = {
1037
+ system: 0,
1038
+ prompt: 0,
1039
+ assistant: 0,
1040
+ thinking: 0,
1041
+ tools: 0,
1042
+ };
1043
+ for (const event of coalesceReplayEvents(seed))
1044
+ renderEvent(event);
1045
+ settleStreaming();
1046
+ const oldHandle = currentHandle;
1047
+ agent = handle.agent;
1048
+ currentHandle = handle;
1049
+ bindAgent();
1050
+ refreshCommandList();
1051
+ void refreshLoadedContext();
1052
+ // The model-switched fork becomes the most recently used.
1053
+ touchSession(childId);
1054
+ state.emit();
1055
+ void oldHandle?.dispose().catch(() => { });
1056
+ // Persist the choice so the next boot and `/new` start on it (same
1057
+ // contract as /preset and Shift+Tab effort; issues #14/#30). A failed
1058
+ // write keeps the live switch but warns it will not survive a restart.
1059
+ if (!writeModelPref(provider, model)) {
1060
+ state.notify(t('model-pref-write-failed'), {
1061
+ color: 'warning',
1062
+ });
1063
+ }
1064
+ return true;
1065
+ },
1066
+ cycleEffort,
1067
+ clear() {
1068
+ state.rows.length = 0;
1069
+ nextRowId = 0;
1070
+ streaming = undefined;
1071
+ reasoning = undefined;
1072
+ toolCards.clear();
1073
+ state.activeToolCount = 0;
1074
+ state.responseChars = 0;
1075
+ state.rows.push({
1076
+ id: nextRowId,
1077
+ kind: 'notice',
1078
+ text: 'Session cleared',
1079
+ });
1080
+ nextRowId += 1;
1081
+ state.emit();
1082
+ },
1083
+ notify(text, options = {}) {
1084
+ const item = {
1085
+ id: nextNotificationId++,
1086
+ text,
1087
+ color: options.color,
1088
+ timeoutMs: options.timeoutMs ?? 4000,
1089
+ };
1090
+ state.notifications.push(item);
1091
+ state.emit();
1092
+ setTimeout(() => {
1093
+ const index = state.notifications.indexOf(item);
1094
+ if (index >= 0) {
1095
+ state.notifications.splice(index, 1);
1096
+ state.emit();
1097
+ }
1098
+ }, item.timeoutMs);
1099
+ },
1100
+ setActivityFrames(name) {
1101
+ if (!isPresetName(name)) {
1102
+ state.notify(t('unknown-activity-preset', { name }), { color: 'error' });
1103
+ return false;
1104
+ }
1105
+ if (name === state.activityFrames) {
1106
+ state.notify(t('activity-indicator-already', { name }), { color: 'success' });
1107
+ return true;
1108
+ }
1109
+ // Persist first (pi behavior: a failed write refuses the switch) so a
1110
+ // preference that cannot be saved never silently disappears.
1111
+ if (!writeActivityFrames(name)) {
1112
+ state.notify(t('activity-pref-write-failed'), { color: 'error' });
1113
+ return false;
1114
+ }
1115
+ state.activityFrames = name;
1116
+ state.emit();
1117
+ state.notify(t('activity-indicator-switched', { name }));
1118
+ return true;
1119
+ },
1120
+ async listPresets() {
1121
+ const presets = rosterOf(ctx);
1122
+ if (presets === undefined)
1123
+ return [];
1124
+ try {
1125
+ const list = await presets.list();
1126
+ return list.map(preset => ({
1127
+ id: preset.id,
1128
+ ...(preset.name === undefined ? {} : { name: preset.name }),
1129
+ ...(preset.description === undefined ? {} : { description: preset.description }),
1130
+ ...(preset.broken === undefined ? {} : { broken: preset.broken }),
1131
+ isDefault: preset.id === presets.defaultId,
1132
+ }));
1133
+ }
1134
+ catch {
1135
+ return [];
1136
+ }
1137
+ },
1138
+ async switchPreset(presetId) {
1139
+ const presets = rosterOf(ctx);
1140
+ if (presets === undefined) {
1141
+ state.notify(t('preset-unavailable'), { color: 'error' });
1142
+ return false;
1143
+ }
1144
+ if (state.working) {
1145
+ state.notify(t('preset-agent-running'), { color: 'warning' });
1146
+ return false;
1147
+ }
1148
+ let target;
1149
+ try {
1150
+ target = await presets.resolve(presetId);
1151
+ }
1152
+ catch (error) {
1153
+ state.notify(t('preset-not-found', { id: presetId, err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
1154
+ return false;
1155
+ }
1156
+ if (target.broken !== undefined) {
1157
+ state.notify(t('preset-load-failed', { id: presetId, broken: target.broken }), { color: 'error', timeoutMs: 8000 });
1158
+ return false;
1159
+ }
1160
+ if (target.id === state.agentPreset) {
1161
+ state.notify(t('preset-already-current', { id: target.id }), { color: 'success' });
1162
+ return true;
1163
+ }
1164
+ // Official rule (dsh-agent-presets): only a session that has produced
1165
+ // nothing may swap compositions — a started session's logged tool calls
1166
+ // would strand under a different tool set. Blank = no turn ever ran.
1167
+ const blank = !agent.session.events.some(event => event.type === 'turn/start');
1168
+ if (!blank) {
1169
+ // Persist as the default for future sessions instead of failing.
1170
+ if (!writePresetPref(target.id)) {
1171
+ state.notify(t('preset-pref-write-failed'), { color: 'error' });
1172
+ return false;
1173
+ }
1174
+ state.notify(t('preset-locked-saved-default', { current: state.agentPreset ?? 'host', id: target.id }), { color: 'warning', timeoutMs: 8000 });
1175
+ return true;
1176
+ }
1177
+ try {
1178
+ const preset = await presets.recompose(agent.ctx, target.id);
1179
+ // The switch is a logged session fact (model-visible ⟺ logged):
1180
+ // resumes/forks of this session resolve the NEW composition. The
1181
+ // type is runtime-registered in dsh-session's known-event set but
1182
+ // not yet in its typed SessionEventMap — cast the SESSION (never
1183
+ // extract the method: `append` reads the private `this.log`, so an
1184
+ // unbound call throws "Cannot read properties of undefined").
1185
+ const session = agent.session;
1186
+ session.append('agent-preset/selected', { agentPreset: preset.id });
1187
+ state.agentPreset = preset.id;
1188
+ }
1189
+ catch (error) {
1190
+ state.notify(t('preset-switch-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
1191
+ return false;
1192
+ }
1193
+ state.emit();
1194
+ if (!writePresetPref(target.id)) {
1195
+ state.notify(t('preset-switched-pref-failed', { id: target.id }), { color: 'warning' });
1196
+ return true;
1197
+ }
1198
+ state.notify(t('preset-switched-saved', { id: target.id }), { color: 'success' });
1199
+ return true;
1200
+ },
1201
+ listModels() {
1202
+ const llm = ctx.get('llm');
1203
+ if (!llm)
1204
+ return Promise.resolve([]);
1205
+ const providers = llm.listProviders();
1206
+ return Promise.all(providers.map(provider => llm.listModels(provider.id).catch(() => [])))
1207
+ .then(lists => lists.flat());
1208
+ },
1209
+ listFiles() {
1210
+ const fs = ctx.get('fs');
1211
+ return listFilesDeep(fs, state.cwd);
1212
+ },
1213
+ async listSessions() {
1214
+ // DSH's own session index: the persistence backend materializes one
1215
+ // entry per durable session log (headers carry cwd + createdAt).
1216
+ const persistence = ctx.get('sessionPersistence');
1217
+ if (!persistence)
1218
+ return [];
1219
+ try {
1220
+ const headers = await persistence.list();
1221
+ // 按工作目录隔离(Claude Code 的项目维度):/resume 只列出本会话
1222
+ // 目录启动的会话,别的项目的会话不出现在选择器里。
1223
+ const cwd = state.cwd.replace(/\/+$/, '');
1224
+ const local = headers.filter(header => (header.cwd ?? '').replace(/\/+$/, '') === cwd);
1225
+ // MRU ordering: DSH headers carry only createdAt, so dsh-tui keeps its
1226
+ // own last-used timestamps (touchSession on resume/submit/new) and
1227
+ // falls back to createdAt for sessions never touched in this install.
1228
+ const lastUsed = readLastUsed();
1229
+ const records = local
1230
+ .map(header => ({
1231
+ id: header.id,
1232
+ // Titles load lazily below (first user message); until then the
1233
+ // cwd basename stands in (matching the status line), with a
1234
+ // short id when absent.
1235
+ title: basename(header.cwd ?? '') || `session ${String(header.id).slice(0, 8)}`,
1236
+ cwd: header.cwd ?? '',
1237
+ createdAt: header.createdAt,
1238
+ updatedAt: lastUsed[header.id] ?? header.createdAt,
1239
+ }))
1240
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1241
+ // Title = the session's FIRST user message — the picker's most
1242
+ // useful label. persistence.load reads the whole log (zstd), so
1243
+ // only the newest sessions pay for it; older rows keep the
1244
+ // basename fallback. A load failure degrades silently.
1245
+ const empty = new Set();
1246
+ await Promise.all(records.slice(0, SESSION_TITLE_DEPTH).map(async (record) => {
1247
+ try {
1248
+ const { events } = await persistence.load(SessionId(record.id));
1249
+ const first = events.find(event => event.type === 'user/message');
1250
+ if (first === undefined) {
1251
+ // Launch artifact — a session with no user message holds no
1252
+ // conversation to resume, so drop it from the picker (its
1253
+ // createdAt-only updatedAt would otherwise pin it near the
1254
+ // top forever, one per dsh-tui launch).
1255
+ empty.add(record.id);
1256
+ return;
1257
+ }
1258
+ const data = first.data;
1259
+ const text = firstTextOf(data.content);
1260
+ if (text.length > 0)
1261
+ record.title = shortenTitle(text);
1262
+ }
1263
+ catch {
1264
+ // Keep the basename fallback.
1265
+ }
1266
+ }));
1267
+ return records.filter(record => !empty.has(record.id));
1268
+ }
1269
+ catch {
1270
+ return [];
1271
+ }
1272
+ },
1273
+ setResumeTarget(sessionId) {
1274
+ writeResumeTarget(sessionId);
1275
+ },
1276
+ compact() {
1277
+ // DSH compaction service key: `ctx.compaction` (dsh-compaction's
1278
+ // CompactionEngine; dsh-compaction-basic provides it in the example
1279
+ // leaf). Under agent presets the engine lives in the preset's isolate
1280
+ // realm, invisible from the root context — resolve through the agent's
1281
+ // scope chain first (minimal composes NO compaction: stays unavailable).
1282
+ const compactService = serviceForAgent(ctx, agent, 'compaction');
1283
+ if (!compactService) {
1284
+ state.notify('Compaction unavailable · no compaction service in this leaf', {
1285
+ color: 'warning',
1286
+ });
1287
+ return;
1288
+ }
1289
+ if (state.working) {
1290
+ state.notify('Cannot compact while a turn is running', { color: 'warning' });
1291
+ return;
1292
+ }
1293
+ const signal = new AbortController().signal;
1294
+ state.notify('Compacting conversation…');
1295
+ void compactService
1296
+ .compactNow(agent, signal)
1297
+ .then((result) => {
1298
+ state.notify(result ? 'Conversation compacted' : 'Nothing to compact');
1299
+ })
1300
+ .catch((error) => {
1301
+ state.notify(`Compaction failed · ${error instanceof Error ? error.message : String(error)}`, { color: 'error', timeoutMs: 8000 });
1302
+ });
1303
+ },
1304
+ async runExternalCommand(name, rawInput) {
1305
+ if (!commandService)
1306
+ return undefined;
1307
+ try {
1308
+ const execution = await commandService.execute(agent, `/${name}${rawInput}`, new AbortController().signal);
1309
+ // `undefined` = not registered; a handler error surfaces as its
1310
+ // message so the user sees why the command failed.
1311
+ return execution?.result.text ?? '';
1312
+ }
1313
+ catch (error) {
1314
+ return error instanceof Error ? error.message : String(error);
1315
+ }
1316
+ },
1317
+ pushLocal(title, lines) {
1318
+ state.rows.push({ id: nextRowId++, kind: 'local', text: title });
1319
+ for (const line of lines) {
1320
+ state.rows.push({
1321
+ id: nextRowId++,
1322
+ kind: 'local-output',
1323
+ text: preview(line, LOCAL_OUTPUT_LIMIT),
1324
+ });
1325
+ }
1326
+ state.emit();
1327
+ },
1328
+ mcpStatus() {
1329
+ // MCP tools land on the tool runtime under mcp__<server>__<tool>
1330
+ // public names (dsh-mcp-client's naming contract); group by server.
1331
+ const runtime = ctx.get('tools');
1332
+ const schemas = runtime?.schemas() ?? [];
1333
+ const byServer = new Map();
1334
+ for (const schema of schemas) {
1335
+ const match = schema.name.match(/^mcp__([a-z0-9-]+)__(.+)$/);
1336
+ if (!match)
1337
+ continue;
1338
+ const list = byServer.get(match[1]) ?? [];
1339
+ list.push(match[2]);
1340
+ byServer.set(match[1], list);
1341
+ }
1342
+ if (byServer.size === 0) {
1343
+ return [
1344
+ t('mcp-none-configured'),
1345
+ t('mcp-insert-hint'),
1346
+ ' - insert:',
1347
+ ' - id: mcp-context7',
1348
+ " name: '@deepseek-ai/dsh-mcp-client'",
1349
+ ' config: { transport: stdio, serverName: context7, command: npx, args: ["-y", "@upstash/context7-mcp"] }',
1350
+ t('mcp-readme-hint'),
1351
+ ];
1352
+ }
1353
+ const lines = [];
1354
+ for (const [server, tools] of byServer) {
1355
+ lines.push(t('mcp-server-tools', { server, count: tools.length, tools: tools.join(', ') }));
1356
+ }
1357
+ return lines;
1358
+ },
1359
+ exportSession() {
1360
+ // Export from the session log — the authoritative, complete record —
1361
+ // not the bounded transcript window (folded rows keep only previews).
1362
+ const parts = [
1363
+ t('export-title'),
1364
+ '',
1365
+ t('export-time', { time: new Date().toLocaleString() }),
1366
+ t('export-model', { model: state.model }),
1367
+ t('export-session', { id: state.agentId }),
1368
+ t('export-dir', { cwd: state.cwd }),
1369
+ '',
1370
+ ];
1371
+ for (const event of agent.session.events) {
1372
+ switch (event.type) {
1373
+ case 'user/message': {
1374
+ if (event.data.source.kind !== 'user')
1375
+ break;
1376
+ // Export what the user SAW: the typed prompt, not the expanded
1377
+ // `@`-mention attachment blocks.
1378
+ const text = firstTextOf(event.data.content);
1379
+ if (text)
1380
+ parts.push(`${t('export-user-section')}\n\n${text}\n`);
1381
+ break;
1382
+ }
1383
+ case 'assistant/message': {
1384
+ const blocks = event.data.message.content;
1385
+ for (const block of blocks) {
1386
+ if (block.type === 'reasoning' && block.text) {
1387
+ parts.push(`${t('export-thinking-section')}\n\n${block.text}\n`);
1388
+ }
1389
+ else if (block.type === 'text' && block.text) {
1390
+ parts.push(`${t('export-assistant-section')}\n\n${block.text}\n`);
1391
+ }
1392
+ }
1393
+ break;
1394
+ }
1395
+ case 'tool/call': {
1396
+ parts.push(`${t('export-tool-section', { name: event.data.name })}\n\n\`\`\`json\n${event.data.arguments}\n\`\`\`\n`);
1397
+ break;
1398
+ }
1399
+ case 'tool/result': {
1400
+ const block = event.data.message.content[0];
1401
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may not match type
1402
+ if (block.type === 'tool-result') {
1403
+ const text = textOf(block.content);
1404
+ if (text)
1405
+ parts.push(`${t('export-result-section')}\n\n\`\`\`\n${text}\n\`\`\`\n`);
1406
+ }
1407
+ break;
1408
+ }
1409
+ default:
1410
+ break;
1411
+ }
1412
+ }
1413
+ const fileName = `dsh-tui-export-${Date.now()}.md`;
1414
+ try {
1415
+ const target = join(state.cwd, fileName);
1416
+ writeFileSync(target, parts.join('\n'), 'utf8');
1417
+ return target;
1418
+ }
1419
+ catch {
1420
+ return null;
1421
+ }
1422
+ },
1423
+ initWorkspace() {
1424
+ const target = join(state.cwd, 'AGENTS.md');
1425
+ if (existsSync(target))
1426
+ return 'exists';
1427
+ const template = [
1428
+ '# AGENTS.md',
1429
+ '',
1430
+ t('agentsmd-project'),
1431
+ '',
1432
+ t('agentsmd-project-body'),
1433
+ '',
1434
+ t('agentsmd-conventions'),
1435
+ '',
1436
+ t('agentsmd-convention-read'),
1437
+ t('agentsmd-convention-style'),
1438
+ '',
1439
+ ].join('\n');
1440
+ try {
1441
+ writeFileSync(target, template, 'utf8');
1442
+ return target;
1443
+ }
1444
+ catch {
1445
+ return null;
1446
+ }
1447
+ },
1448
+ doctorInfo() {
1449
+ const lines = [];
1450
+ lines.push(`Node ${process.version} · ${process.platform} ${process.arch}`);
1451
+ lines.push(`${t('doctor-api-key', { state: process.env.DEEPSEEK_API_KEY ? t('doctor-key-configured') : t('doctor-key-missing') })}`);
1452
+ lines.push(t('doctor-model', { model: state.model, provider: options.provider }));
1453
+ lines.push(t('doctor-cwd', { cwd: state.cwd }));
1454
+ lines.push(t('doctor-context-window', { window: state.contextWindow ?? t('doctor-unknown') }));
1455
+ lines.push(`${t('doctor-session', { id: state.agentId })}${state.sessionTitle ? ' · ' + state.sessionTitle : ''}`);
1456
+ const userHome = process.env.USERPROFILE ?? homedir();
1457
+ const configCandidates = [
1458
+ join(userHome, '.dsh-cc/cordis.yml'),
1459
+ join(userHome, '.dsh/profiles/dsh-tui/cordis.patch.yml'),
1460
+ ];
1461
+ for (const candidate of configCandidates) {
1462
+ lines.push(`${t('doctor-config', { candidate, state: existsSync(candidate) ? '✓' : t('doctor-config-missing') })}`);
1463
+ }
1464
+ const sessionsDir = join(userHome, '.dsh-cc/sessions');
1465
+ lines.push(`${t('doctor-storage', { dir: sessionsDir, state: existsSync(sessionsDir) ? '✓' : t('doctor-storage-uninit') })}`);
1466
+ return lines;
1467
+ },
1468
+ async listSubagents() {
1469
+ const subagents = ctx.get('subagents');
1470
+ if (!subagents)
1471
+ return [t('subagent-not-mounted')];
1472
+ try {
1473
+ const children = await subagents.listChildren(agent.session.id);
1474
+ if (children.length === 0)
1475
+ return [t('subagent-none')];
1476
+ return children.map((child) => {
1477
+ const id = typeof child.id === 'string' ? child.id : (child.id.value ?? '');
1478
+ const label = child.label ? `「${child.label}」` : '';
1479
+ const mode = child.mode === 'continuable' ? t('subagent-resumable') : t('subagent-oneshot');
1480
+ return `${t('subagent-row', { mode, label, activity: child.activity === 'running' ? t('subagent-running') : t('subagent-archived'), id: id.slice(0, 8) })}`;
1481
+ });
1482
+ }
1483
+ catch (error) {
1484
+ return [t('subagent-query-failed', { err: error instanceof Error ? error.message : String(error) })];
1485
+ }
1486
+ },
1487
+ };
1488
+ /**
1489
+ * Assemble the context a fresh conversation for the live agent will load,
1490
+ * for the startup panel: the system prompt (sections + dynamic context +
1491
+ * tools), the workspace instruction files baseline discovery would
1492
+ * inject, and the skill catalog. Runs at boot and on every agent swap;
1493
+ * every source degrades independently, and a total failure leaves the
1494
+ * panel hidden instead of showing a broken snapshot. A snapshot computed
1495
+ * for a previous agent is discarded (swaps rebind `agent` mid-flight).
1496
+ */
1497
+ const refreshLoadedContext = async () => {
1498
+ const target = agent;
1499
+ const sections = [];
1500
+ const contexts = [];
1501
+ const files = [];
1502
+ const skills = [];
1503
+ const tools = [];
1504
+ try {
1505
+ const systemPrompt = ctx.get('systemPrompt');
1506
+ if (systemPrompt !== undefined) {
1507
+ const assembly = await systemPrompt.assemble(assembleContextFor(target));
1508
+ if (target !== agent)
1509
+ return;
1510
+ // Render each section through the shared strict interpolator with
1511
+ // this assembly's variables (renderPrompt joins; a single-section
1512
+ // assembly renders exactly one section), keeping non-empty results.
1513
+ for (const section of assembly.sections) {
1514
+ const text = renderPrompt({
1515
+ sections: [section],
1516
+ contexts: [],
1517
+ tools: [],
1518
+ variables: assembly.variables,
1519
+ });
1520
+ if (text.length > 0)
1521
+ sections.push({ name: section.name, text });
1522
+ }
1523
+ contexts.push(...renderContextSections(assembly));
1524
+ for (const tool of assembly.tools) {
1525
+ tools.push({ name: tool.name, description: tool.description ?? '' });
1526
+ }
1527
+ }
1528
+ const discovered = await discoverBaselineInstructionFiles({ cwd: options.cwd });
1529
+ if (target !== agent)
1530
+ return;
1531
+ files.push(...discovered.map(file => ({ displayPath: file.displayPath })));
1532
+ // The skills registry is host-plane but scope-layered: preset rows
1533
+ // (skill-filesystem) register into the preset's layer, so the catalog
1534
+ // must be read through the agent's scope chain (serviceForAgent falls
1535
+ // back to the host context when no roster is mounted).
1536
+ const skillsService = serviceForAgent(ctx, target, 'skills');
1537
+ if (skillsService !== undefined) {
1538
+ const catalog = await skillsService.list({});
1539
+ if (target !== agent)
1540
+ return;
1541
+ skills.push(...catalog.map(skill => ({
1542
+ name: skill.name,
1543
+ description: skill.description,
1544
+ })));
1545
+ }
1546
+ }
1547
+ catch (error) {
1548
+ ctx.logger.warn('loaded-context snapshot failed: %o', error);
1549
+ return;
1550
+ }
1551
+ state.loadedContext = { sections, contexts, files, skills, tools };
1552
+ state.emit();
1553
+ };
1554
+ /**
1555
+ * Rebuild the merged slash-command list from the registry. Registry
1556
+ * registrations are global or agent-scoped, so this runs on
1557
+ * `commands/change` and again whenever the live agent is swapped
1558
+ * (rewind/resume).
1559
+ */
1560
+ const refreshCommandList = () => {
1561
+ const merged = [...LOCAL_COMMANDS];
1562
+ if (commandService) {
1563
+ for (const descriptor of commandService.list(agent)) {
1564
+ if (merged.some(command => command.name === descriptor.name))
1565
+ continue;
1566
+ merged.push({
1567
+ name: descriptor.name,
1568
+ description: descriptor.description,
1569
+ tag: descriptor.input?.hint,
1570
+ external: true,
1571
+ });
1572
+ }
1573
+ }
1574
+ state.commandList = merged;
1575
+ state.emit();
1576
+ };
1577
+ ctx.on('commands/change', refreshCommandList);
1578
+ refreshCommandList();
1579
+ void refreshLoadedContext();
1580
+ let nextRowId = 0;
1581
+ /** The leaf's bash executor (dsh-bash-local in the example leaf) — the DSH
1582
+ * execution seam for local `!` commands and the git status breadcrumb. The
1583
+ * service registers under `ctx.shell` (ShellExecutor; dsh-bash-local and
1584
+ * dsh-pwsh-local are the providers). */
1585
+ const bash = ctx.get('shell');
1586
+ /** Claude Code's `!` mode: run a command on the user's machine and render its
1587
+ * output in the transcript as local rows (never sent to the model). */
1588
+ const runLocalCommand = async (command, includeInContext) => {
1589
+ state.rows.push({ id: nextRowId++, kind: 'local', text: command });
1590
+ state.emit();
1591
+ let output = '(no output)';
1592
+ if (bash) {
1593
+ try {
1594
+ const spec = bash.resolve({
1595
+ command,
1596
+ workdir: state.cwd,
1597
+ timeoutMs: 30000,
1598
+ });
1599
+ const result = await bash.run(spec);
1600
+ output =
1601
+ result.stdout.text.trim() ||
1602
+ result.stderr.text.trim() ||
1603
+ (result.timedOut ? '(timed out)' : '(no output)');
1604
+ }
1605
+ catch (error) {
1606
+ output = error instanceof Error ? error.message : String(error);
1607
+ }
1608
+ }
1609
+ state.rows.push({
1610
+ id: nextRowId++,
1611
+ kind: 'local-output',
1612
+ text: preview(output, LOCAL_OUTPUT_LIMIT),
1613
+ });
1614
+ state.emit();
1615
+ if (includeInContext) {
1616
+ // CC's <bash-stdout> envelope: the model treats the output as the
1617
+ // result of a local command the user just ran.
1618
+ agent.followup(createUserMessage({
1619
+ content: [{
1620
+ type: 'text',
1621
+ text: `<bash-stdout>
1622
+ ${output}
1623
+ </bash-stdout>`,
1624
+ }],
1625
+ source: { kind: 'user' },
1626
+ }));
1627
+ }
1628
+ };
1629
+ /** The in-progress assistant text row; `undefined` when no step is streaming. */
1630
+ let streaming;
1631
+ /** The in-progress reasoning row; `undefined` when no reasoning is streaming. */
1632
+ let reasoning;
1633
+ /** Reasoning rows sealed by an assistant/message this turn. They stay
1634
+ * `streaming: true` — expanded in the transcript — until turn/end folds
1635
+ * them (WebUI AssistantMarkdown keepOpen parity: thinking holds open
1636
+ * through the whole in-flight turn, tool-call steps included). */
1637
+ const sealedReasoning = [];
1638
+ /** Wall-clock start of the current reasoning row (durationMs on settle). */
1639
+ let reasoningStart = 0;
1640
+ /** Decode-throughput fold for the current turn. DSH defines one step as
1641
+ * one model call plus its tools; summing only first-token → message spans
1642
+ * excludes tool execution and per-request TTFT from generation speed. */
1643
+ let tpsTurn;
1644
+ let tpsBeforeTurn;
1645
+ let tpsTurnDecodeMs = 0;
1646
+ let tpsTurnDecodeTokens = 0;
1647
+ let tpsTurnSampled = false;
1648
+ let tpsStep;
1649
+ /** Tool cards by callId, so tool/result can settle the running card. */
1650
+ const toolCards = new Map();
1651
+ /** The host-plane tools registry (dsh-tools). Resolved once; absent in
1652
+ * bare embedders — every presenter call soft-fails to undefined and the
1653
+ * card falls back to raw text. */
1654
+ const toolsRegistry = ctx.get('tools');
1655
+ /** Ask the producing tool how its call should render (diff/terminal/…).
1656
+ * Scoped to the live agent so preset-owned tool definitions resolve —
1657
+ * the dsh-host-apiproxy presenter pattern. Unknown tool, unparseable
1658
+ * args, or a throwing presenter all degrade to the plain text card. */
1659
+ const presentCallView = (name, rawArgs) => {
1660
+ try {
1661
+ const tool = toolsRegistry?.get(name, agent);
1662
+ if (tool?.presentCall === undefined)
1663
+ return undefined;
1664
+ return tool.presentCall(JSON.parse(rawArgs));
1665
+ }
1666
+ catch {
1667
+ return undefined;
1668
+ }
1669
+ };
1670
+ /** Same for the settled result; `meta` is the tool-private presentation
1671
+ * payload the tool attached to its tool/result event (dsh-tool-fs reads
1672
+ * its result-time contextual diff back from here). */
1673
+ const presentResultView = (name, rawArgs, data) => {
1674
+ try {
1675
+ const tool = toolsRegistry?.get(name, agent);
1676
+ if (tool?.presentResult === undefined)
1677
+ return undefined;
1678
+ const block = data.message.content[0];
1679
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may not match type
1680
+ const content = block !== undefined && block.type === 'tool-result' ? block.content : [];
1681
+ return tool.presentResult(JSON.parse(rawArgs), {
1682
+ content,
1683
+ isError: block?.isError === true,
1684
+ ...(data.meta !== undefined ? { meta: data.meta } : {}),
1685
+ });
1686
+ }
1687
+ catch {
1688
+ return undefined;
1689
+ }
1690
+ };
1691
+ // ContentBlockMap is merge-extensible: plugin-added block types are
1692
+ // silently skipped (v1 renders text blocks only) — never crashes.
1693
+ const textOf = (content) => (content ?? []).map(block => (block.type === 'text' ? block.text : '')).join('').trim();
1694
+ /**
1695
+ * Transcript-facing text of a user message: the FIRST text block only.
1696
+ * `@`-mention attachments (issue #15) ride as later blocks — model-facing
1697
+ * only — so joining every block would dump file contents into the bubble,
1698
+ * the sticky header, and session titles.
1699
+ */
1700
+ const firstTextOf = (content) => (content ?? []).find(block => block.type === 'text')?.text.trim() ?? '';
1701
+ const ensureStreaming = (seq) => {
1702
+ if (streaming === undefined) {
1703
+ streaming = { id: nextRowId, kind: 'assistant', text: '', streaming: true, ...seq !== undefined ? { seq } : {} };
1704
+ nextRowId += 1;
1705
+ state.rows.push(streaming);
1706
+ }
1707
+ return streaming;
1708
+ };
1709
+ const ensureReasoning = (seq) => {
1710
+ if (reasoning === undefined) {
1711
+ reasoningStart = Date.now();
1712
+ reasoning = { id: nextRowId, kind: 'reasoning', text: '', streaming: true, ...seq !== undefined ? { seq } : {} };
1713
+ nextRowId += 1;
1714
+ state.rows.push(reasoning);
1715
+ logForDebugging('thinking: reasoning row open (expanded)');
1716
+ }
1717
+ return reasoning;
1718
+ };
1719
+ const settleStreaming = () => {
1720
+ if (streaming !== undefined)
1721
+ streaming.streaming = false;
1722
+ streaming = undefined;
1723
+ const folded = sealedReasoning.length + (reasoning !== undefined ? 1 : 0);
1724
+ for (const row of sealedReasoning)
1725
+ row.streaming = false;
1726
+ sealedReasoning.length = 0;
1727
+ if (reasoning !== undefined) {
1728
+ reasoning.streaming = false;
1729
+ reasoning.durationMs = Math.max(0, Date.now() - reasoningStart);
1730
+ }
1731
+ reasoning = undefined;
1732
+ if (folded > 0)
1733
+ logForDebugging(`thinking: folded ${folded} reasoning row(s) at turn settle`);
1734
+ };
1735
+ /** Recompute the spinner phase from live row/tool state. */
1736
+ const updateSpinnerMode = () => {
1737
+ if (state.activeToolCount > 0) {
1738
+ state.spinnerMode = 'tool-use';
1739
+ }
1740
+ else if (reasoning !== undefined) {
1741
+ // Only LIVE reasoning counts — sealed rows stay streaming=true for
1742
+ // transcript expansion until turn/end but the model is past thinking.
1743
+ state.spinnerMode = 'thinking';
1744
+ }
1745
+ else if (streaming !== undefined) {
1746
+ state.spinnerMode = 'responding';
1747
+ }
1748
+ else {
1749
+ state.spinnerMode = 'requesting';
1750
+ }
1751
+ };
1752
+ /**
1753
+ * Fold one goal-sourced message into the channel's goal projection.
1754
+ * Round-zero goal messages carry the full durable snapshot (or a clear
1755
+ * tombstone) in their source; positive-round messages are admitted
1756
+ * continuation prompts that only advance the rounds counter.
1757
+ */
1758
+ const applyGoalEvent = (event) => {
1759
+ const source = event.data.source;
1760
+ if (source.round > 0) {
1761
+ // Admitted continuation round — the snapshot itself is unchanged.
1762
+ if (state.goal !== undefined) {
1763
+ state.goal = {
1764
+ ...state.goal,
1765
+ roundsStarted: Math.max(state.goal.roundsStarted, source.round),
1766
+ };
1767
+ }
1768
+ return;
1769
+ }
1770
+ const change = source.change;
1771
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may not match the static type
1772
+ if (change === undefined || change.kind !== 'goal/change')
1773
+ return;
1774
+ if (change.operation === 'clear') {
1775
+ state.goal = undefined;
1776
+ }
1777
+ else if (change.goal !== undefined) {
1778
+ state.goal = {
1779
+ ...change.goal,
1780
+ roundsStarted: change.roundsStarted ?? state.goal?.roundsStarted ?? 0,
1781
+ };
1782
+ }
1783
+ };
1784
+ const renderEvent = (event) => {
1785
+ switch (event.type) {
1786
+ case 'user/message': {
1787
+ // Compaction checkpoint: `source = { kind: 'plugin', plugin:
1788
+ // 'compact' }` (dsh-compact's COMPACT_CHECKPOINT_SOURCE). CC shows
1789
+ // the framed summary after /compact; render it as a Divider title +
1790
+ // a summary row that defaults folded (`compact` kind) instead of
1791
+ // skipping it like other injected context.
1792
+ if (event.data.source.kind === 'plugin' &&
1793
+ event.data.source.plugin === 'compact') {
1794
+ const summary = textOf(event.data.content);
1795
+ state.rows.push({ id: nextRowId, kind: 'notice', text: 'Conversation compacted' });
1796
+ nextRowId += 1;
1797
+ if (summary) {
1798
+ state.rows.push({ id: nextRowId, kind: 'compact', text: summary });
1799
+ nextRowId += 1;
1800
+ }
1801
+ // The surface replace drops the whole pre-compact history: reset
1802
+ // the context accounting NOW so the status bar (ctx bar, tokens,
1803
+ // context-low warning) drops immediately instead of waiting for
1804
+ // the next request's usage event.
1805
+ const removed = state.contextSegments.prompt +
1806
+ state.contextSegments.assistant +
1807
+ state.contextSegments.thinking +
1808
+ state.contextSegments.tools;
1809
+ const summaryTokens = estimateTokens(summary);
1810
+ state.tokens.input = Math.max(0, state.tokens.input - removed) + summaryTokens;
1811
+ state.contextSegments = {
1812
+ system: state.contextSegments.system,
1813
+ prompt: summaryTokens,
1814
+ assistant: 0,
1815
+ thinking: 0,
1816
+ tools: 0,
1817
+ };
1818
+ state.lastUsage = {
1819
+ input: state.contextSegments.system + summaryTokens,
1820
+ output: 0,
1821
+ cacheRead: 0,
1822
+ cacheWrite: 0,
1823
+ };
1824
+ contextWarned = false;
1825
+ break;
1826
+ }
1827
+ // Same-session goal domain: round-zero goal-sourced messages carry
1828
+ // the durable goal snapshot (or clear tombstone) in their source.
1829
+ // They are not transcript bubbles — they drive the goal panel's
1830
+ // live projection (replayed on resume/rewind like every other event).
1831
+ if (event.data.source.kind === 'goal') {
1832
+ applyGoalEvent(event);
1833
+ break;
1834
+ }
1835
+ // Injected context (plugin/skill source) is not a human bubble; v1
1836
+ // renders direct human prompts only.
1837
+ if (event.data.source.kind !== 'user')
1838
+ break;
1839
+ const text = firstTextOf(event.data.content);
1840
+ if (text) {
1841
+ state.rows.push({ id: nextRowId, kind: 'user', text, seq: event.seq });
1842
+ state.lastUserText = text;
1843
+ // The context estimate counts everything sent to the model —
1844
+ // typed text AND the `@`-mention attachment blocks.
1845
+ state.contextSegments.prompt += estimateTokens(textOf(event.data.content));
1846
+ nextRowId += 1;
1847
+ }
1848
+ break;
1849
+ }
1850
+ case 'step/start': {
1851
+ if (tpsTurn === event.data.turn) {
1852
+ tpsStep = {
1853
+ turn: event.data.turn,
1854
+ step: event.data.step,
1855
+ firstTokenTime: undefined,
1856
+ outputChars: 0,
1857
+ };
1858
+ }
1859
+ break;
1860
+ }
1861
+ case 'assistant/chunk': {
1862
+ const chunk = event.data.chunk;
1863
+ if (chunk.type === 'text-delta') {
1864
+ if (chunk.text) {
1865
+ ensureStreaming(event.seq).text += chunk.text;
1866
+ state.responseChars += chunk.text.length;
1867
+ }
1868
+ }
1869
+ else if (chunk.type === 'reasoning-delta') {
1870
+ if (chunk.text)
1871
+ ensureReasoning(event.seq).text += chunk.text;
1872
+ }
1873
+ const step = tpsStep;
1874
+ if (step !== undefined &&
1875
+ step.turn === event.data.turn &&
1876
+ step.step === event.data.step &&
1877
+ isTokenDelta(chunk)) {
1878
+ step.firstTokenTime ??= event.time;
1879
+ step.outputChars += tokenDeltaChars(chunk);
1880
+ const elapsedMs = Math.max(0, event.time - step.firstTokenTime);
1881
+ if (elapsedMs > 500) {
1882
+ const decodeMs = tpsTurnDecodeMs + elapsedMs;
1883
+ const outputTokens = tpsTurnDecodeTokens + Math.ceil(step.outputChars / 4);
1884
+ state.tps = outputTokens / (decodeMs / 1000);
1885
+ }
1886
+ }
1887
+ updateSpinnerMode();
1888
+ break;
1889
+ }
1890
+ case 'assistant/message': {
1891
+ const row = ensureStreaming(event.seq);
1892
+ row.time = event.time;
1893
+ const text = textOf(event.data.message.content);
1894
+ if (text)
1895
+ row.text = text;
1896
+ row.streaming = false;
1897
+ streaming = undefined;
1898
+ if (reasoning !== undefined) {
1899
+ // Seal, don't fold: the per-step duration settles here, but the
1900
+ // row keeps streaming=true (expanded) until turn/end — WebUI
1901
+ // keepOpen parity. The next step's reasoning opens a fresh row.
1902
+ reasoning.durationMs = Math.max(0, Date.now() - reasoningStart);
1903
+ sealedReasoning.push(reasoning);
1904
+ logForDebugging(`thinking: step sealed (${reasoning.durationMs}ms), expanded until turn/end`);
1905
+ }
1906
+ reasoning = undefined;
1907
+ updateSpinnerMode();
1908
+ const usage = event.data.usage;
1909
+ if (usage !== undefined) {
1910
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
1911
+ state.tokens.input += usage.inputTokens ?? 0;
1912
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
1913
+ state.tokens.output += usage.outputTokens ?? 0;
1914
+ // The most recent request's usage describes the CURRENT context:
1915
+ // input (uncached) + cache hits all occupy the window. Cache hits
1916
+ // also drive the status-line `cache N` readout.
1917
+ state.lastUsage = {
1918
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
1919
+ input: usage.inputTokens ?? 0,
1920
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable replay data may lack tokens
1921
+ output: usage.outputTokens ?? 0,
1922
+ cacheRead: usage.cacheReadTokens ?? 0,
1923
+ cacheWrite: usage.cacheWriteTokens ?? 0,
1924
+ };
1925
+ }
1926
+ const tpsMessageStep = tpsStep;
1927
+ if (tpsTurn === event.data.turn &&
1928
+ tpsMessageStep !== undefined &&
1929
+ tpsMessageStep.turn === event.data.turn &&
1930
+ tpsMessageStep.step === event.data.step &&
1931
+ tpsMessageStep.firstTokenTime !== undefined) {
1932
+ const outputTokens = usageOutputTokens(usage)
1933
+ ?? (tpsMessageStep.outputChars > 0
1934
+ ? Math.ceil(tpsMessageStep.outputChars / 4)
1935
+ : undefined);
1936
+ if (outputTokens !== undefined) {
1937
+ tpsTurnDecodeMs += Math.max(0, event.time - tpsMessageStep.firstTokenTime);
1938
+ tpsTurnDecodeTokens += outputTokens;
1939
+ tpsTurnSampled = true;
1940
+ if (tpsTurnDecodeMs > 0) {
1941
+ state.tps = tpsTurnDecodeTokens / (tpsTurnDecodeMs / 1000);
1942
+ }
1943
+ }
1944
+ }
1945
+ if (tpsMessageStep !== undefined &&
1946
+ tpsMessageStep.turn === event.data.turn &&
1947
+ tpsMessageStep.step === event.data.step) {
1948
+ tpsStep = undefined;
1949
+ }
1950
+ // Context-bar segmentation (pi-nano-context style): assistant text
1951
+ // and tool calls in the assistant segment, thinking separately.
1952
+ for (const block of event.data.message.content) {
1953
+ if (block.type === 'text' && block.text) {
1954
+ state.contextSegments.assistant += estimateTokens(block.text);
1955
+ }
1956
+ else if (block.type === 'reasoning' && block.text) {
1957
+ state.contextSegments.thinking += estimateTokens(block.text);
1958
+ }
1959
+ }
1960
+ break;
1961
+ }
1962
+ case 'tool/call': {
1963
+ // The ask-user-question tool renders as the interactive questionnaire
1964
+ // panel (DSH user-interaction seam), not as a tool card: the model is
1965
+ // parked waiting for the human, so no running card, no active-tool
1966
+ // spinner, no args noise in the transcript. The Q&A summary is pushed
1967
+ // by the TUI once the batch is answered; tool/result for a call with
1968
+ // no card is a no-op below.
1969
+ if (event.data.name === 'ask_user_question')
1970
+ break;
1971
+ const card = {
1972
+ id: nextRowId,
1973
+ kind: 'tool',
1974
+ text: '',
1975
+ seq: event.seq,
1976
+ tool: {
1977
+ callId: event.data.callId,
1978
+ name: event.data.name,
1979
+ argsText: preview(event.data.arguments, ARGS_PREVIEW_LIMIT),
1980
+ argsFull: event.data.arguments,
1981
+ status: 'running',
1982
+ callView: presentCallView(event.data.name, event.data.arguments),
1983
+ startedAt: Date.now(),
1984
+ },
1985
+ };
1986
+ nextRowId += 1;
1987
+ toolCards.set(event.data.callId, card);
1988
+ state.rows.push(card);
1989
+ state.activeToolCount += 1;
1990
+ state.contextSegments.assistant += estimateTokens(`${event.data.name}${event.data.arguments}`);
1991
+ updateSpinnerMode();
1992
+ break;
1993
+ }
1994
+ case 'tool/result': {
1995
+ const card = toolCards.get(event.data.message.source.callId);
1996
+ if (card !== undefined && card.tool !== undefined) {
1997
+ card.tool.durationMs = Math.max(0, Date.now() - card.tool.startedAt);
1998
+ const failure = event.data.error;
1999
+ if (failure !== undefined) {
2000
+ card.tool.status = 'error';
2001
+ const errorText = `${failure.name}: ${failure.code}`;
2002
+ card.tool.errorText = errorText;
2003
+ state.contextSegments.tools += estimateTokens(errorText);
2004
+ }
2005
+ else {
2006
+ card.tool.status = 'ok';
2007
+ const block = event.data.message.content[0];
2008
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may not match type
2009
+ const result = block !== undefined && block.type === 'tool-result' ? textOf(block.content) : '';
2010
+ card.tool.resultFull = result || undefined;
2011
+ card.tool.resultText = result ? preview(result, RESULT_PREVIEW_LIMIT) : undefined;
2012
+ // The tool's own settled-state view (applied diff, terminal
2013
+ // output, read content…) wins over the raw text body. argsFull
2014
+ // pairs the args: live cards are never folded, so it is intact.
2015
+ card.tool.resultView = presentResultView(card.tool.name, card.tool.argsFull ?? '', event.data);
2016
+ state.contextSegments.tools += estimateTokens(result);
2017
+ }
2018
+ state.activeToolCount = Math.max(0, state.activeToolCount - 1);
2019
+ // The card is settled: no later event looks it up by callId, so
2020
+ // drop the index entry. The card itself stays in state.rows
2021
+ // (bounded by MAX_ROWS + foldRows, which also drops the full
2022
+ // args/result payloads of folded cards).
2023
+ toolCards.delete(event.data.message.source.callId);
2024
+ updateSpinnerMode();
2025
+ }
2026
+ break;
2027
+ }
2028
+ case 'step/end': {
2029
+ if (tpsStep !== undefined &&
2030
+ tpsStep.turn === event.data.turn &&
2031
+ tpsStep.step === event.data.step) {
2032
+ tpsStep = undefined;
2033
+ }
2034
+ break;
2035
+ }
2036
+ case 'turn/start': {
2037
+ state.working = true;
2038
+ state.turnStart = Date.now();
2039
+ state.responseChars = 0;
2040
+ state.spinnerMode = 'requesting';
2041
+ // Keep the prior turn visible until this turn produces a measurable
2042
+ // decode span, while starting a fresh weighted step fold.
2043
+ tpsBeforeTurn = state.tps;
2044
+ tpsTurn = event.data.turn;
2045
+ tpsTurnDecodeMs = 0;
2046
+ tpsTurnDecodeTokens = 0;
2047
+ tpsTurnSampled = false;
2048
+ tpsStep = undefined;
2049
+ break;
2050
+ }
2051
+ case 'turn/end': {
2052
+ settleStreaming();
2053
+ state.working = false;
2054
+ state.activeToolCount = 0;
2055
+ if (tpsTurn !== undefined && tpsTurn === event.data.turn) {
2056
+ if (tpsTurnSampled && tpsTurnDecodeMs > 0) {
2057
+ const turnTps = tpsTurnDecodeTokens / (tpsTurnDecodeMs / 1000);
2058
+ state.tps = turnTps;
2059
+ state.tpsSamples.push({ tps: turnTps, at: event.time });
2060
+ if (state.tpsSamples.length > 500)
2061
+ state.tpsSamples.shift();
2062
+ }
2063
+ else {
2064
+ // Do not leave a chars/4 live estimate behind when no completed
2065
+ // decode sample exists for this turn.
2066
+ state.tps = tpsBeforeTurn;
2067
+ }
2068
+ tpsTurn = undefined;
2069
+ tpsStep = undefined;
2070
+ tpsTurnDecodeMs = 0;
2071
+ tpsTurnDecodeTokens = 0;
2072
+ tpsTurnSampled = false;
2073
+ }
2074
+ const reason = event.data.reason;
2075
+ if (reason.kind === 'completed') {
2076
+ checkContextWarning();
2077
+ break;
2078
+ }
2079
+ if (reason.kind === 'aborted' || reason.kind === 'interrupted') {
2080
+ // `Agent.cancel()` closes the turn as `aborted`; `interrupted`
2081
+ // only appears for crash-orphaned turns. Claude Code renders both
2082
+ // user-interruption paths as a distinct dim row.
2083
+ state.rows.push({
2084
+ id: nextRowId,
2085
+ kind: 'interrupt',
2086
+ text: 'Interrupted · What should Claude do instead?',
2087
+ });
2088
+ nextRowId += 1;
2089
+ break;
2090
+ }
2091
+ const detail = reason.kind === 'error' ? reason.error.message : '';
2092
+ state.rows.push({ id: nextRowId, kind: 'notice', text: `turn ${reason.kind}${detail ? ` · ${detail}` : ''}` });
2093
+ nextRowId += 1;
2094
+ state.notify(`Turn ${reason.kind}${detail ? ` · ${detail}` : ''}`, { color: 'error', timeoutMs: 8000 });
2095
+ break;
2096
+ }
2097
+ case 'request/context':
2098
+ // Adapter-advertised context capacity; drives the context-low
2099
+ // warning (CC's TokenWarning) when the route reports one.
2100
+ if (event.data.contextWindow !== undefined) {
2101
+ state.contextWindow = event.data.contextWindow;
2102
+ }
2103
+ break;
2104
+ case 'request/header': {
2105
+ // Reasoning effort readout (status line): the header carries the
2106
+ // conversation's call config (provider/model/effort/sampling). The
2107
+ // system prompt text seeds the context bar's system segment.
2108
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable session data may lack header config
2109
+ const effort = event.data.header.config?.reasoningEffort;
2110
+ if (typeof effort === 'string') {
2111
+ state.reasoningEffort = effort;
2112
+ }
2113
+ if (typeof event.data.header.system === 'string') {
2114
+ state.contextSegments.system = estimateTokens(event.data.header.system);
2115
+ }
2116
+ break;
2117
+ }
2118
+ case 'session/title':
2119
+ state.sessionTitle = event.data.title;
2120
+ break;
2121
+ case 'todo/write':
2122
+ // Whole-list snapshot — latest write wins; log-only UI state.
2123
+ state.todos = event.data.todos;
2124
+ break;
2125
+ default:
2126
+ // Logged preset switch (blank sessions only, issue #8): a transcript
2127
+ // marker so a replayed log shows which composition produced the
2128
+ // turns after it. Not in dsh-session's typed union — matched here by
2129
+ // name, the way `activity/status` is consumed in bindAgent.
2130
+ if (event.type === 'agent-preset/selected') {
2131
+ const data = event.data;
2132
+ state.rows.push({
2133
+ id: nextRowId,
2134
+ kind: 'notice',
2135
+ text: t('agent-preset-switched', { preset: data.agentPreset ?? 'unknown' }),
2136
+ });
2137
+ nextRowId += 1;
2138
+ }
2139
+ break;
2140
+ }
2141
+ };
2142
+ // Replay the durable transcript first, then follow live events.
2143
+ for (const event of coalesceReplayEvents(agent.session.events))
2144
+ renderEvent(event);
2145
+ settleStreaming();
2146
+ // Attached to an idle agent: any replayed turn/start belongs to a previous
2147
+ // session run, so the spinner must not come up on boot.
2148
+ state.working = false;
2149
+ state.status = agent.status;
2150
+ state.emit();
2151
+ // Live subscription list, rebound to a fresh agent by rewindTo.
2152
+ let agentSubscriptions = [];
2153
+ const bindAgent = () => {
2154
+ for (const dispose of agentSubscriptions)
2155
+ dispose();
2156
+ // Re-couple the channel-owned model selection to the new agent's
2157
+ // assembly/request waterfalls, then re-apply the persisted effort when
2158
+ // this agent's route offers it (dsh-agent installModelSelection).
2159
+ selection.current = undefined;
2160
+ selection.assembled = undefined;
2161
+ void applyPreferredEffort();
2162
+ agentSubscriptions = [
2163
+ installModelSelection(agent.ctx, selection),
2164
+ ctx.on('agent/status', ({ agent: subject, status }) => {
2165
+ if (subject !== agent)
2166
+ return;
2167
+ state.status = status;
2168
+ state.emit();
2169
+ }),
2170
+ ctx.on('agent/disposed', ({ agent: subject }) => {
2171
+ if (subject !== agent)
2172
+ return;
2173
+ state.status = 'disposed';
2174
+ state.emit();
2175
+ }),
2176
+ // Pending delivery is driven by the agent inbox: a claimed message
2177
+ // has landed in a turn (steer → step boundary, followup → next turn);
2178
+ // a discarded one was dropped by a cancel or withdrawn via Alt+Up.
2179
+ // Retire it from the preview. Official dsh-agent rc.6 emits these as
2180
+ // single-payload notifications `{ agent, message }`; `inserted` is not
2181
+ // handled here because trackPending already registered the preview
2182
+ // synchronously at submit time.
2183
+ (() => {
2184
+ const retirePending = (payload) => {
2185
+ if (payload.agent !== agent)
2186
+ return;
2187
+ const messageId = payload.message?.id;
2188
+ if (typeof messageId !== 'string')
2189
+ return;
2190
+ const before = state.pending.length;
2191
+ state.pending = state.pending.filter(item => item.id !== messageId);
2192
+ if (state.pending.length !== before)
2193
+ state.emit();
2194
+ };
2195
+ const disposers = [];
2196
+ for (const event of ['agent/inbox/claimed', 'agent/inbox/discarded']) {
2197
+ disposers.push(ctx.on(event, retirePending));
2198
+ }
2199
+ return () => {
2200
+ for (const dispose of disposers)
2201
+ dispose();
2202
+ };
2203
+ })(),
2204
+ ctx.on('session/event', (session, event) => {
2205
+ if (session !== agent.session)
2206
+ return;
2207
+ // Live working-activity snapshot (log-only event, appended by
2208
+ // dsh-working-activity for UI consumers). Consumed here — NOT in
2209
+ // renderEvent — so replayed history never resurrects a stale line
2210
+ // (the renderEvent switch's default arm ignores it on replay).
2211
+ if (options.activity !== false &&
2212
+ event.type === 'activity/status') {
2213
+ const data = event.data;
2214
+ state.workingActivity = {
2215
+ phase: data.phase,
2216
+ line: data.line,
2217
+ toolCount: data.toolCount ?? 0,
2218
+ turnElapsedMs: data.turnElapsedMs ?? 0,
2219
+ ...(data.label === undefined ? {} : { label: data.label }),
2220
+ ...(data.detail === undefined ? {} : { detail: data.detail }),
2221
+ ...(data.phrase === undefined ? {} : { phrase: data.phrase }),
2222
+ };
2223
+ state.emit();
2224
+ return;
2225
+ }
2226
+ renderEvent(event);
2227
+ // Streaming deltas (one event per token) take the frame-aligned
2228
+ // path; every other event keeps synchronous notification.
2229
+ if (event.type === 'assistant/chunk')
2230
+ state.emitStream();
2231
+ else
2232
+ state.emit();
2233
+ }),
2234
+ ];
2235
+ };
2236
+ bindAgent();
2237
+ // Statusline breadcrumb: current git branch of the session cwd (best-effort).
2238
+ if (bash) {
2239
+ void bash
2240
+ .run(bash.resolve({
2241
+ command: 'git branch --show-current',
2242
+ workdir: options.cwd,
2243
+ timeoutMs: 3000,
2244
+ }))
2245
+ .then((result) => {
2246
+ const branch = result.stdout.text.trim();
2247
+ if (branch !== '') {
2248
+ state.gitBranch = branch;
2249
+ state.emit();
2250
+ }
2251
+ })
2252
+ .catch(() => {
2253
+ // Git branch detection is best-effort; on Windows the sandbox
2254
+ // backend may be unavailable (no confinement yet) or the cwd may
2255
+ // not be a git repo. Either way the statusline simply stays blank.
2256
+ });
2257
+ }
2258
+ return state;
2259
+ }
2260
+ /** Path basename for the resume-list title (`C:/a/b` → `b`). */
2261
+ function basename(path) {
2262
+ const parts = path.split(/[\\/]/);
2263
+ return parts[parts.length - 1] ?? path;
2264
+ }
2265
+ /** Context-bar token estimate (pi-nano-context: ~4 chars per token). */
2266
+ function estimateTokens(text) {
2267
+ return Math.ceil(text.length / 4);
2268
+ }
2269
+ /** Character payload of one token-bearing stream delta for the live fallback. */
2270
+ function tokenDeltaChars(chunk) {
2271
+ switch (chunk.type) {
2272
+ case 'text-delta':
2273
+ case 'reasoning-delta':
2274
+ return chunk.text.length;
2275
+ case 'tool-call-delta':
2276
+ return (chunk.name?.length ?? 0) + chunk.argumentsDelta.length;
2277
+ default:
2278
+ return 0;
2279
+ }
2280
+ }
2281
+ /** Provider output count when usable; durable imports may predate strict validation. */
2282
+ function usageOutputTokens(usage) {
2283
+ if (typeof usage !== 'object' || usage === null)
2284
+ return undefined;
2285
+ const value = usage.outputTokens;
2286
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
2287
+ ? value
2288
+ : undefined;
2289
+ }
2290
+ /**
2291
+ * Recursive `@` file listing through the leaf's fs service (dsh-fs-local):
2292
+ * walks up to MAX_DEPTH levels below `root`, skipping VCS/dependency dirs,
2293
+ * returning relative paths (directories with a trailing `/`, matching the
2294
+ * FileSuggestions tag logic) capped at MAX_FILES entries. Best-effort —
2295
+ * unreadable subtrees are skipped, not fatal.
2296
+ */
2297
+ async function listFilesDeep(fs, root) {
2298
+ if (!fs)
2299
+ return [];
2300
+ const out = [];
2301
+ const SKIP = new Set(['node_modules', '.git', '.hg', '.svn', '.DS_Store', 'dist', 'build']);
2302
+ const MAX_DEPTH = 3;
2303
+ const MAX_FILES = 100;
2304
+ const walk = async (dir, prefix, depth) => {
2305
+ if (depth > MAX_DEPTH || out.length >= MAX_FILES)
2306
+ return;
2307
+ let entries = [];
2308
+ try {
2309
+ const target = await fs.resolve(dir);
2310
+ entries = await fs.listDir(target);
2311
+ }
2312
+ catch {
2313
+ return; // unreadable subtree — skip
2314
+ }
2315
+ for (const entry of entries) {
2316
+ if (out.length >= MAX_FILES)
2317
+ return;
2318
+ if (SKIP.has(entry.name))
2319
+ continue;
2320
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
2321
+ if (entry.type === 'directory') {
2322
+ out.push(`${rel}/`);
2323
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: symlink targets optional
2324
+ await walk(entry.target?.displayPath ?? join(dir, entry.name), rel, depth + 1);
2325
+ }
2326
+ else if (entry.type === 'file') {
2327
+ out.push(rel);
2328
+ }
2329
+ }
2330
+ };
2331
+ await walk(root, '', 1);
2332
+ return out;
2333
+ }
2334
+ /** One attached file's contribution is capped so an absent-minded `@` of a
2335
+ * huge file cannot blow the context window (CC caps @-attachments too). */
2336
+ const MENTION_MAX_FILE_CHARS = 50_000;
2337
+ /** Total budget across all attachments in one message. */
2338
+ const MENTION_MAX_TOTAL_CHARS = 200_000;
2339
+ /** A directory mention contributes a shallow listing, capped at this many
2340
+ * entries. */
2341
+ const MENTION_MAX_DIR_ENTRIES = 200;
2342
+ /** The leaf's fs service in the shape mention expansion needs; undefined
2343
+ * when the plugin is not mounted (mentions then stay literal text). */
2344
+ function mentionFs(ctx) {
2345
+ return ctx.get('fs');
2346
+ }
2347
+ /**
2348
+ * Expand a submitted text's `@` mentions (issue #15) into model-facing
2349
+ * attachment blocks: each referenced file contributes its (capped) content,
2350
+ * each directory a shallow listing. The typed text stays the first block
2351
+ * verbatim — mentions that resolve keep their `@path` spelling in it, and
2352
+ * unresolved ones stay literal everywhere. Best-effort: an unreadable or
2353
+ * binary file degrades to `missing`, never a failed send.
2354
+ */
2355
+ export async function expandMentions(fs, cwd, text) {
2356
+ const blocks = [{ type: 'text', text }];
2357
+ const attached = [];
2358
+ const missing = [];
2359
+ const mentions = extractMentions(text);
2360
+ if (!fs || mentions.length === 0)
2361
+ return { blocks, attached, missing };
2362
+ let budget = MENTION_MAX_TOTAL_CHARS;
2363
+ for (const mention of mentions) {
2364
+ if (budget <= 0)
2365
+ break;
2366
+ // Mentions resolve against the session cwd, same as the model-facing fs
2367
+ // tools; absolute paths pass through untouched.
2368
+ const absolute = isAbsolute(mention.path) ? mention.path : join(cwd, mention.path);
2369
+ let target;
2370
+ let info;
2371
+ try {
2372
+ target = await fs.resolve(absolute);
2373
+ info = await fs.stat(target);
2374
+ }
2375
+ catch {
2376
+ missing.push(mention.path);
2377
+ continue;
2378
+ }
2379
+ if (info?.type === 'file') {
2380
+ try {
2381
+ const cap = Math.min(MENTION_MAX_FILE_CHARS, budget);
2382
+ let content = await fs.readText(target);
2383
+ let truncated = false;
2384
+ if (content.length > cap) {
2385
+ content = content.slice(0, cap);
2386
+ truncated = true;
2387
+ }
2388
+ budget -= content.length;
2389
+ blocks.push({
2390
+ type: 'text',
2391
+ text: `<attached-file path="${mention.path}">\n${content}${truncated ? '\n[… truncated]' : ''}\n</attached-file>`,
2392
+ });
2393
+ attached.push(mention.path);
2394
+ }
2395
+ catch {
2396
+ // Binary/undecodable or unreadable — report it like a miss.
2397
+ missing.push(mention.path);
2398
+ }
2399
+ continue;
2400
+ }
2401
+ if (info?.type === 'directory') {
2402
+ try {
2403
+ const entries = await fs.listDir(target);
2404
+ const listing = entries
2405
+ .slice(0, MENTION_MAX_DIR_ENTRIES)
2406
+ .map(entry => (entry.type === 'directory' ? `${entry.name}/` : entry.name));
2407
+ if (entries.length > MENTION_MAX_DIR_ENTRIES) {
2408
+ listing.push(`… (${entries.length - MENTION_MAX_DIR_ENTRIES} more)`);
2409
+ }
2410
+ const body = listing.join('\n');
2411
+ budget -= body.length;
2412
+ blocks.push({
2413
+ type: 'text',
2414
+ text: `<attached-directory path="${mention.path}">\n${body}\n</attached-directory>`,
2415
+ });
2416
+ attached.push(mention.path);
2417
+ }
2418
+ catch {
2419
+ missing.push(mention.path);
2420
+ }
2421
+ continue;
2422
+ }
2423
+ // Absent (stat → undefined) or a special file.
2424
+ missing.push(mention.path);
2425
+ }
2426
+ return { blocks, attached, missing };
2427
+ }