@deepseek-harness-tui/dsh-tui 0.8.8 → 0.9.1

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 (467) hide show
  1. package/README.md +60 -238
  2. package/bin/dsh-tui.js +172 -5
  3. package/cordis.patch.yml +16 -4
  4. package/dsh-ecosystem-spec/protocols/profile-definitions.d.ts +13 -13
  5. package/dsh-ecosystem-spec/protocols/profile-definitions.js +59 -59
  6. package/dsh-ecosystem-spec/protocols/tui-channel-http.d.ts +15 -15
  7. package/dsh-ecosystem-spec/protocols/tui-channel-http.js +32 -32
  8. package/dsh-ecosystem-spec/protocols/tui-channel.d.ts +59 -59
  9. package/dsh-ecosystem-spec/protocols/tui-channel.js +166 -166
  10. package/dsh-ecosystem-spec/protocols/tui-contributions.d.ts +42 -42
  11. package/dsh-ecosystem-spec/protocols/tui-contributions.js +143 -143
  12. package/dsh-ecosystem-spec/registry/README.md +14 -14
  13. package/dsh-ecosystem-spec/registry/host-descriptor.tui.example.json +56 -56
  14. package/dsh-ecosystem-spec/registry/permissions-0.1.json +13 -13
  15. package/dsh-ecosystem-spec/registry/registry-0.15.json +149 -149
  16. package/lib/types/cc/hyperlink.d.ts +7 -1
  17. package/lib/types/cc/hyperlink.d.ts.map +1 -1
  18. package/lib/types/cc/hyperlink.js +3 -2
  19. package/lib/types/cc/markdown.d.ts.map +1 -1
  20. package/lib/types/cc/markdown.js +33 -3
  21. package/lib/types/cc/sessionColors.d.ts +27 -0
  22. package/lib/types/cc/sessionColors.d.ts.map +1 -0
  23. package/lib/types/cc/sessionColors.js +39 -0
  24. package/lib/types/commands.d.ts.map +1 -1
  25. package/lib/types/commands.js +10 -2
  26. package/lib/types/components/ActivityPicker.d.ts +4 -1
  27. package/lib/types/components/ActivityPicker.d.ts.map +1 -1
  28. package/lib/types/components/ActivityPicker.js +2 -2
  29. package/lib/types/components/AutoRecapRow.d.ts +23 -0
  30. package/lib/types/components/AutoRecapRow.d.ts.map +1 -0
  31. package/lib/types/components/AutoRecapRow.js +23 -0
  32. package/lib/types/components/BtwPanel.d.ts.map +1 -1
  33. package/lib/types/components/BtwPanel.js +1 -1
  34. package/lib/types/components/Chat/SubagentMessage.d.ts +2 -1
  35. package/lib/types/components/Chat/SubagentMessage.d.ts.map +1 -1
  36. package/lib/types/components/Chat/SubagentMessage.js +11 -3
  37. package/lib/types/components/ColorPicker.d.ts +17 -0
  38. package/lib/types/components/ColorPicker.d.ts.map +1 -0
  39. package/lib/types/components/ColorPicker.js +20 -0
  40. package/lib/types/components/CommandSuggestions.d.ts +23 -4
  41. package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
  42. package/lib/types/components/CommandSuggestions.js +41 -14
  43. package/lib/types/components/ContextBarView.d.ts +33 -0
  44. package/lib/types/components/ContextBarView.d.ts.map +1 -0
  45. package/lib/types/components/ContextBarView.js +46 -0
  46. package/lib/types/components/EffortInputBorder.d.ts +12 -1
  47. package/lib/types/components/EffortInputBorder.d.ts.map +1 -1
  48. package/lib/types/components/EffortInputBorder.js +22 -5
  49. package/lib/types/components/EffortSlider.d.ts +4 -1
  50. package/lib/types/components/EffortSlider.d.ts.map +1 -1
  51. package/lib/types/components/EffortSlider.js +4 -2
  52. package/lib/types/components/ExtensionDialog.js +6 -2
  53. package/lib/types/components/FileActionsPanel.d.ts +22 -0
  54. package/lib/types/components/FileActionsPanel.d.ts.map +1 -0
  55. package/lib/types/components/FileActionsPanel.js +28 -0
  56. package/lib/types/components/FileSuggestions.d.ts +26 -7
  57. package/lib/types/components/FileSuggestions.d.ts.map +1 -1
  58. package/lib/types/components/FileSuggestions.js +39 -12
  59. package/lib/types/components/GoalTodoPanel.d.ts +26 -2
  60. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  61. package/lib/types/components/GoalTodoPanel.js +86 -17
  62. package/lib/types/components/HelpMenu.d.ts +7 -1
  63. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  64. package/lib/types/components/HelpMenu.js +12 -4
  65. package/lib/types/components/HistorySearchDialog.d.ts +4 -1
  66. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  67. package/lib/types/components/HistorySearchDialog.js +2 -2
  68. package/lib/types/components/LangPicker.d.ts +14 -0
  69. package/lib/types/components/LangPicker.d.ts.map +1 -0
  70. package/lib/types/components/LangPicker.js +17 -0
  71. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  72. package/lib/types/components/LoadedContextPanel.js +3 -1
  73. package/lib/types/components/Markdown.d.ts.map +1 -1
  74. package/lib/types/components/Markdown.js +12 -3
  75. package/lib/types/components/MessageList.d.ts +45 -2
  76. package/lib/types/components/MessageList.d.ts.map +1 -1
  77. package/lib/types/components/MessageList.js +560 -41
  78. package/lib/types/components/ModelPicker.d.ts +27 -2
  79. package/lib/types/components/ModelPicker.d.ts.map +1 -1
  80. package/lib/types/components/ModelPicker.js +26 -6
  81. package/lib/types/components/PermissionsPicker.d.ts +21 -0
  82. package/lib/types/components/PermissionsPicker.d.ts.map +1 -0
  83. package/lib/types/components/PermissionsPicker.js +27 -0
  84. package/lib/types/components/PlanPicker.d.ts +15 -0
  85. package/lib/types/components/PlanPicker.d.ts.map +1 -0
  86. package/lib/types/components/PlanPicker.js +18 -0
  87. package/lib/types/components/PresetPicker.d.ts +4 -1
  88. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  89. package/lib/types/components/PresetPicker.js +2 -2
  90. package/lib/types/components/PromptInput.d.ts +4 -3
  91. package/lib/types/components/PromptInput.d.ts.map +1 -1
  92. package/lib/types/components/PromptInput.js +602 -75
  93. package/lib/types/components/RecapPanel.d.ts +25 -0
  94. package/lib/types/components/RecapPanel.d.ts.map +1 -0
  95. package/lib/types/components/RecapPanel.js +55 -0
  96. package/lib/types/components/RewindPicker.d.ts +12 -1
  97. package/lib/types/components/RewindPicker.d.ts.map +1 -1
  98. package/lib/types/components/RewindPicker.js +6 -4
  99. package/lib/types/components/ScrollbarGutter.d.ts +28 -0
  100. package/lib/types/components/ScrollbarGutter.d.ts.map +1 -0
  101. package/lib/types/components/ScrollbarGutter.js +119 -0
  102. package/lib/types/components/Select.d.ts +8 -1
  103. package/lib/types/components/Select.d.ts.map +1 -1
  104. package/lib/types/components/Select.js +4 -2
  105. package/lib/types/components/SkillsPicker.d.ts +4 -1
  106. package/lib/types/components/SkillsPicker.d.ts.map +1 -1
  107. package/lib/types/components/SkillsPicker.js +2 -2
  108. package/lib/types/components/SubagentCard.d.ts +2 -1
  109. package/lib/types/components/SubagentCard.d.ts.map +1 -1
  110. package/lib/types/components/SubagentCard.js +9 -2
  111. package/lib/types/components/SubagentDashboard.d.ts +4 -0
  112. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  113. package/lib/types/components/SubagentDashboard.js +9 -1
  114. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
  115. package/lib/types/components/SubagentDetailScene.js +5 -4
  116. package/lib/types/components/SuggestionCard.d.ts +65 -0
  117. package/lib/types/components/SuggestionCard.d.ts.map +1 -0
  118. package/lib/types/components/SuggestionCard.js +77 -0
  119. package/lib/types/components/ThemePicker.d.ts +4 -1
  120. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  121. package/lib/types/components/ThemePicker.js +2 -2
  122. package/lib/types/components/ThinkingToggle.d.ts +4 -1
  123. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  124. package/lib/types/components/ThinkingToggle.js +2 -2
  125. package/lib/types/components/TimelineRail.d.ts +53 -0
  126. package/lib/types/components/TimelineRail.d.ts.map +1 -0
  127. package/lib/types/components/TimelineRail.js +197 -0
  128. package/lib/types/components/WorkspaceFlowPicker.d.ts +6 -1
  129. package/lib/types/components/WorkspaceFlowPicker.d.ts.map +1 -1
  130. package/lib/types/components/WorkspaceFlowPicker.js +4 -2
  131. package/lib/types/components/WorkspaceMenuPicker.d.ts +19 -0
  132. package/lib/types/components/WorkspaceMenuPicker.d.ts.map +1 -0
  133. package/lib/types/components/WorkspaceMenuPicker.js +19 -0
  134. package/lib/types/components/WorkspacePicker.d.ts +4 -1
  135. package/lib/types/components/WorkspacePicker.d.ts.map +1 -1
  136. package/lib/types/components/WorkspacePicker.js +2 -2
  137. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  138. package/lib/types/components/approvals/ApprovalPanel.js +5 -1
  139. package/lib/types/components/design-system/ListItem.d.ts +8 -1
  140. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  141. package/lib/types/components/design-system/ListItem.js +8 -3
  142. package/lib/types/components/design-system/ThemeProvider.d.ts +7 -0
  143. package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
  144. package/lib/types/components/design-system/ThemeProvider.js +2 -1
  145. package/lib/types/components/messages/AssistantTextMessage.d.ts +5 -2
  146. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -1
  147. package/lib/types/components/messages/AssistantTextMessage.js +6 -2
  148. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -1
  149. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  150. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -4
  151. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +15 -1
  152. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  153. package/lib/types/components/messages/AssistantToolUseMessage.js +37 -7
  154. package/lib/types/components/messages/UserPromptMessage.d.ts +2 -1
  155. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  156. package/lib/types/components/questions/AskUserQuestionPanel.d.ts +7 -3
  157. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  158. package/lib/types/components/questions/AskUserQuestionPanel.js +75 -11
  159. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  160. package/lib/types/components/questions/PlanReviewPanel.js +13 -2
  161. package/lib/types/components/sessions/SessionListRow.d.ts +4 -1
  162. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  163. package/lib/types/components/sessions/SessionListRow.js +4 -2
  164. package/lib/types/components/trajectory/HotspotView.d.ts +7 -1
  165. package/lib/types/components/trajectory/HotspotView.d.ts.map +1 -1
  166. package/lib/types/components/trajectory/HotspotView.js +8 -2
  167. package/lib/types/components/trajectory/Ledger.d.ts +9 -1
  168. package/lib/types/components/trajectory/Ledger.d.ts.map +1 -1
  169. package/lib/types/components/trajectory/Ledger.js +23 -5
  170. package/lib/types/components/trajectory/WaveBand.d.ts +10 -1
  171. package/lib/types/components/trajectory/WaveBand.d.ts.map +1 -1
  172. package/lib/types/components/trajectory/WaveBand.js +7 -2
  173. package/lib/types/dsh-adapter/channel.d.ts +117 -7
  174. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  175. package/lib/types/dsh-adapter/channel.js +1301 -122
  176. package/lib/types/dsh-adapter/compat/index.d.ts +8 -6
  177. package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
  178. package/lib/types/dsh-adapter/compat/index.js +8 -6
  179. package/lib/types/dsh-adapter/compat/sessionLog.d.ts +150 -0
  180. package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
  181. package/lib/types/dsh-adapter/compat/sessionLog.js +518 -41
  182. package/lib/types/dsh-adapter/contract.d.ts +1 -1
  183. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  184. package/lib/types/dsh-adapter/contract.js +1 -0
  185. package/lib/types/dsh-adapter/extension-events.d.ts +1 -1
  186. package/lib/types/dsh-adapter/extension-events.d.ts.map +1 -1
  187. package/lib/types/dsh-adapter/index.d.ts +20 -2
  188. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  189. package/lib/types/dsh-adapter/index.js +9 -1
  190. package/lib/types/dsh-adapter/plugin.d.ts +0 -9
  191. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  192. package/lib/types/dsh-adapter/plugin.js +453 -14
  193. package/lib/types/dsh-adapter/presets.d.ts +18 -0
  194. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  195. package/lib/types/dsh-adapter/presets.js +31 -0
  196. package/lib/types/dsh-adapter/providerWizard.d.ts +29 -1
  197. package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -1
  198. package/lib/types/dsh-adapter/providerWizard.js +89 -5
  199. package/lib/types/dsh-adapter/questions.d.ts +19 -2
  200. package/lib/types/dsh-adapter/questions.d.ts.map +1 -1
  201. package/lib/types/dsh-adapter/questions.js +53 -10
  202. package/lib/types/dsh-adapter/recap.d.ts +49 -0
  203. package/lib/types/dsh-adapter/recap.d.ts.map +1 -0
  204. package/lib/types/dsh-adapter/recap.js +121 -0
  205. package/lib/types/dsh-adapter/sessionTree.d.ts +314 -0
  206. package/lib/types/dsh-adapter/sessionTree.d.ts.map +1 -0
  207. package/lib/types/dsh-adapter/sessionTree.js +954 -0
  208. package/lib/types/dsh-adapter/shortcuts.d.ts +15 -44
  209. package/lib/types/dsh-adapter/shortcuts.d.ts.map +1 -1
  210. package/lib/types/dsh-adapter/shortcuts.js +28 -165
  211. package/lib/types/dsh-adapter/types.d.ts +1 -1
  212. package/lib/types/dsh-adapter/types.d.ts.map +1 -1
  213. package/lib/types/herdr.js +1 -1
  214. package/lib/types/i18n.d.ts +770 -181
  215. package/lib/types/i18n.d.ts.map +1 -1
  216. package/lib/types/i18n.js +257 -80
  217. package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -1
  218. package/lib/types/ink/components/AlternateScreen.js +13 -1
  219. package/lib/types/ink/components/App.d.ts +12 -1
  220. package/lib/types/ink/components/App.d.ts.map +1 -1
  221. package/lib/types/ink/components/App.js +101 -4
  222. package/lib/types/ink/components/ClockContext.d.ts.map +1 -1
  223. package/lib/types/ink/components/ClockContext.js +2 -0
  224. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  225. package/lib/types/ink/components/ScrollBox.js +170 -91
  226. package/lib/types/ink/constants.d.ts +9 -0
  227. package/lib/types/ink/constants.d.ts.map +1 -1
  228. package/lib/types/ink/constants.js +9 -0
  229. package/lib/types/ink/events/click-event.d.ts +7 -16
  230. package/lib/types/ink/events/click-event.d.ts.map +1 -1
  231. package/lib/types/ink/events/click-event.js +16 -19
  232. package/lib/types/ink/events/event-handlers.d.ts +11 -1
  233. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  234. package/lib/types/ink/events/event-handlers.js +2 -0
  235. package/lib/types/ink/events/input-event.d.ts +9 -0
  236. package/lib/types/ink/events/input-event.d.ts.map +1 -1
  237. package/lib/types/ink/events/input-event.js +4 -0
  238. package/lib/types/ink/events/pointer-event.d.ts +61 -0
  239. package/lib/types/ink/events/pointer-event.d.ts.map +1 -0
  240. package/lib/types/ink/events/pointer-event.js +61 -0
  241. package/lib/types/ink/events/wheel-event.d.ts +17 -0
  242. package/lib/types/ink/events/wheel-event.d.ts.map +1 -0
  243. package/lib/types/ink/events/wheel-event.js +20 -0
  244. package/lib/types/ink/geometry-trace.d.ts +51 -0
  245. package/lib/types/ink/geometry-trace.d.ts.map +1 -0
  246. package/lib/types/ink/geometry-trace.js +83 -0
  247. package/lib/types/ink/hit-test.d.ts +56 -1
  248. package/lib/types/ink/hit-test.d.ts.map +1 -1
  249. package/lib/types/ink/hit-test.js +146 -8
  250. package/lib/types/ink/ink.d.ts +58 -1
  251. package/lib/types/ink/ink.d.ts.map +1 -1
  252. package/lib/types/ink/ink.js +230 -29
  253. package/lib/types/ink/log-update.d.ts.map +1 -1
  254. package/lib/types/ink/log-update.js +21 -0
  255. package/lib/types/ink/node-cache.d.ts +5 -0
  256. package/lib/types/ink/node-cache.d.ts.map +1 -1
  257. package/lib/types/ink/parse-keypress.d.ts +8 -0
  258. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  259. package/lib/types/ink/parse-keypress.js +101 -12
  260. package/lib/types/ink/render-node-to-output.d.ts +10 -0
  261. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  262. package/lib/types/ink/render-node-to-output.js +111 -18
  263. package/lib/types/ink/timeline-rail.d.ts +138 -0
  264. package/lib/types/ink/timeline-rail.d.ts.map +1 -0
  265. package/lib/types/ink/timeline-rail.js +179 -0
  266. package/lib/types/ink/wrap-text.d.ts.map +1 -1
  267. package/lib/types/ink/wrap-text.js +47 -5
  268. package/lib/types/migrationPrefs.d.ts +60 -0
  269. package/lib/types/migrationPrefs.d.ts.map +1 -0
  270. package/lib/types/migrationPrefs.js +97 -0
  271. package/lib/types/modelGroups.d.ts +72 -0
  272. package/lib/types/modelGroups.d.ts.map +1 -0
  273. package/lib/types/modelGroups.js +97 -0
  274. package/lib/types/modelRecents.d.ts +39 -0
  275. package/lib/types/modelRecents.d.ts.map +1 -0
  276. package/lib/types/modelRecents.js +97 -0
  277. package/lib/types/native-ts/yoga-layout/index.d.ts +11 -0
  278. package/lib/types/native-ts/yoga-layout/index.d.ts.map +1 -1
  279. package/lib/types/native-ts/yoga-layout/index.js +31 -1
  280. package/lib/types/oauth.d.ts +15 -0
  281. package/lib/types/oauth.d.ts.map +1 -0
  282. package/lib/types/oauth.js +14 -0
  283. package/lib/types/reload.d.ts +109 -0
  284. package/lib/types/reload.d.ts.map +1 -0
  285. package/lib/types/reload.js +118 -0
  286. package/lib/types/screens/Chat.d.ts +3 -1
  287. package/lib/types/screens/Chat.d.ts.map +1 -1
  288. package/lib/types/screens/Chat.js +1377 -371
  289. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  290. package/lib/types/screens/SessionBrowser.js +56 -29
  291. package/lib/types/screens/SessionTree.d.ts +27 -0
  292. package/lib/types/screens/SessionTree.d.ts.map +1 -0
  293. package/lib/types/screens/SessionTree.js +555 -0
  294. package/lib/types/screens/Settings.d.ts.map +1 -1
  295. package/lib/types/screens/Settings.js +64 -22
  296. package/lib/types/screens/StatusLine.d.ts +0 -8
  297. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  298. package/lib/types/screens/StatusLine.js +224 -45
  299. package/lib/types/screens/StatusMetrics.d.ts +35 -3
  300. package/lib/types/screens/StatusMetrics.d.ts.map +1 -1
  301. package/lib/types/screens/StatusMetrics.js +63 -8
  302. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -1
  303. package/lib/types/screens/TrajectoryScene.js +81 -14
  304. package/lib/types/screens/chatOverlay.d.ts +237 -0
  305. package/lib/types/screens/chatOverlay.d.ts.map +1 -0
  306. package/lib/types/screens/chatOverlay.js +116 -0
  307. package/lib/types/tips.d.ts.map +1 -1
  308. package/lib/types/tips.js +64 -10
  309. package/lib/types/tuiDisplayPrefs.d.ts +15 -0
  310. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  311. package/lib/types/tuiDisplayPrefs.js +9 -0
  312. package/lib/types/update.d.ts +139 -0
  313. package/lib/types/update.d.ts.map +1 -1
  314. package/lib/types/update.js +382 -30
  315. package/lib/types/utils/debug.d.ts +10 -0
  316. package/lib/types/utils/debug.d.ts.map +1 -1
  317. package/lib/types/utils/debug.js +24 -0
  318. package/lib/types/utils/fileTarget.d.ts +46 -0
  319. package/lib/types/utils/fileTarget.d.ts.map +1 -0
  320. package/lib/types/utils/fileTarget.js +178 -0
  321. package/lib/types/utils/keymap.d.ts +134 -0
  322. package/lib/types/utils/keymap.d.ts.map +1 -0
  323. package/lib/types/utils/keymap.js +311 -0
  324. package/lib/types/utils/mentions.d.ts +25 -2
  325. package/lib/types/utils/mentions.d.ts.map +1 -1
  326. package/lib/types/utils/mentions.js +69 -10
  327. package/lib/types/utils/openExternal.d.ts +50 -0
  328. package/lib/types/utils/openExternal.d.ts.map +1 -0
  329. package/lib/types/utils/openExternal.js +170 -0
  330. package/lib/types/utils/paths.d.ts.map +1 -1
  331. package/lib/types/utils/paths.js +1 -0
  332. package/node_modules/@deepseek-harness-tui/dsh-auth/LICENSE +21 -0
  333. package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +140 -0
  334. package/node_modules/@deepseek-harness-tui/dsh-auth/cordis.patch.yml +11 -0
  335. package/node_modules/@deepseek-harness-tui/dsh-auth/dsh-plugin.json +44 -0
  336. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/command.d.ts +14 -0
  337. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/command.js +67 -0
  338. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.d.ts +64 -0
  339. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.js +170 -0
  340. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.d.ts +79 -0
  341. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.js +196 -0
  342. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.d.ts +68 -0
  343. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.js +193 -0
  344. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/opener.d.ts +32 -0
  345. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/opener.js +101 -0
  346. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.d.ts +31 -0
  347. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.js +68 -0
  348. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/service.d.ts +69 -0
  349. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/service.js +129 -0
  350. package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +70 -0
  351. package/node_modules/@dsh-std/command/CHANGELOG.md +8 -8
  352. package/node_modules/@dsh-std/command/LICENSE +21 -21
  353. package/node_modules/@dsh-std/command/README.md +26 -26
  354. package/node_modules/@dsh-std/command/README.zh.md +28 -28
  355. package/node_modules/@dsh-std/command/lib/index.js.map +1 -1
  356. package/node_modules/@dsh-std/command/package.json +22 -22
  357. package/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  358. package/node_modules/@dsh-std/connection/LICENSE +21 -21
  359. package/node_modules/@dsh-std/connection/README.md +42 -42
  360. package/node_modules/@dsh-std/connection/README.zh.md +42 -42
  361. package/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  362. package/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  363. package/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  364. package/node_modules/@dsh-std/connection/package.json +20 -20
  365. package/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  366. package/node_modules/@dsh-std/core/LICENSE +21 -21
  367. package/node_modules/@dsh-std/core/README.md +13 -13
  368. package/node_modules/@dsh-std/core/README.zh.md +13 -13
  369. package/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  370. package/node_modules/@dsh-std/core/package.json +21 -21
  371. package/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
  372. package/node_modules/@dsh-std/manifest/LICENSE +21 -21
  373. package/node_modules/@dsh-std/manifest/README.md +5 -5
  374. package/node_modules/@dsh-std/manifest/README.zh.md +5 -5
  375. package/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
  376. package/node_modules/@dsh-std/manifest/package.json +20 -20
  377. package/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
  378. package/node_modules/@dsh-std/messages/CHANGELOG.md +8 -8
  379. package/node_modules/@dsh-std/messages/LICENSE +21 -21
  380. package/node_modules/@dsh-std/messages/README.md +5 -5
  381. package/node_modules/@dsh-std/messages/README.zh.md +7 -7
  382. package/node_modules/@dsh-std/messages/lib/index.js.map +1 -1
  383. package/node_modules/@dsh-std/messages/package.json +20 -20
  384. package/node_modules/@dsh-std/messages/schema/message-observer-envelope-0.15.schema.json +54 -54
  385. package/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
  386. package/node_modules/@dsh-std/presentation/LICENSE +21 -21
  387. package/node_modules/@dsh-std/presentation/README.md +27 -27
  388. package/node_modules/@dsh-std/presentation/README.zh.md +29 -29
  389. package/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
  390. package/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
  391. package/node_modules/@dsh-std/presentation/package.json +25 -25
  392. package/node_modules/@dsh-std/storage/CHANGELOG.md +8 -8
  393. package/node_modules/@dsh-std/storage/LICENSE +21 -21
  394. package/node_modules/@dsh-std/storage/README.md +5 -5
  395. package/node_modules/@dsh-std/storage/README.zh.md +7 -7
  396. package/node_modules/@dsh-std/storage/lib/index.js.map +1 -1
  397. package/node_modules/@dsh-std/storage/package.json +20 -20
  398. package/node_modules/@dsh-std/storage/schema/local-storage-0.15.schema.json +56 -56
  399. package/package.json +19 -5
  400. package/presets/liangshen/.dsh-tui-managed.json +1 -1
  401. package/presets/liangshen/instruction-hint.mjs +8 -0
  402. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  403. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/LICENSE +21 -21
  404. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.md +42 -42
  405. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.zh.md +42 -42
  406. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  407. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  408. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  409. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/package.json +20 -20
  410. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  411. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/LICENSE +21 -21
  412. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.md +13 -13
  413. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.zh.md +13 -13
  414. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  415. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/package.json +21 -21
  416. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
  417. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/LICENSE +21 -21
  418. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.md +5 -5
  419. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.zh.md +5 -5
  420. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
  421. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/package.json +20 -20
  422. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
  423. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
  424. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/LICENSE +21 -21
  425. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.md +27 -27
  426. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.zh.md +29 -29
  427. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
  428. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
  429. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/package.json +25 -25
  430. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  431. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/LICENSE +21 -21
  432. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.md +13 -13
  433. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.zh.md +13 -13
  434. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  435. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/package.json +21 -21
  436. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  437. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/LICENSE +21 -21
  438. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.md +13 -13
  439. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.zh.md +13 -13
  440. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  441. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/package.json +21 -21
  442. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  443. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/LICENSE +21 -21
  444. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.md +13 -13
  445. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.zh.md +13 -13
  446. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  447. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/package.json +21 -21
  448. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  449. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/LICENSE +21 -21
  450. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.md +42 -42
  451. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.zh.md +42 -42
  452. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  453. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  454. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  455. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/package.json +20 -20
  456. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  457. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/LICENSE +21 -21
  458. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.md +13 -13
  459. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.zh.md +13 -13
  460. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  461. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/package.json +21 -21
  462. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  463. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/LICENSE +21 -21
  464. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.md +13 -13
  465. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.zh.md +13 -13
  466. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  467. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/package.json +21 -21
@@ -1,12 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
- import { t, getLang, setLang, isLang, writeLangPref, subscribeLang } from '../i18n.js';
3
+ import { t, getLang, setLang, isLang, writeLangPref, readLangPref, subscribeLang, LANGS } from '../i18n.js';
4
+ import { readThemePref } from '../themePrefs.js';
5
+ import { readPresetPref } from '../presetPrefs.js';
6
+ import { readModelPref } from '../modelPrefs.js';
7
+ import { readActivityFrames } from '../activityPrefs.js';
8
+ import { envThemeOverride } from '../components/design-system/ThemeProvider.js';
9
+ import { hasPath } from '../dsh-adapter/settingsEditor.js';
10
+ import { planReload } from '../reload.js';
4
11
  import { AlternateScreen, Box, Text, useInput, ScrollBox, useTheme, useTerminalSize } from '../ui.js';
5
12
  import * as tuiKit from '../ui.js';
6
13
  import { POINTER } from '../cc/figures.js';
7
- import { isMod, isPlainReturnInput, modLabel } from '../utils/modifiers.js';
14
+ import { isPlainReturnInput, modLabel } from '../utils/modifiers.js';
15
+ import { actionMatches } from '../utils/keymap.js';
8
16
  import { formatTokens } from '../cc/format.js';
9
17
  import { homeDir } from '../utils/paths.js';
18
+ import { deriveModelGroups, modelPickerLanding, recentCatalogModels, RECENTS_GROUP_PROVIDER, } from '../modelGroups.js';
19
+ import { readModelRecents, recordModelUse } from '../modelRecents.js';
10
20
  import { sessionCwdMatches } from '../dsh-adapter/channel.js';
11
21
  import { TuiDialogStore } from '../dsh-adapter/dialogs.js';
12
22
  import { TuiStatusStore } from '../dsh-adapter/status.js';
@@ -22,9 +32,13 @@ import { useCopyOnSelect } from '../ink/hooks/use-copy-on-select.js';
22
32
  import { useSelection } from '../ink/hooks/use-selection.js';
23
33
  import { NoSelect } from '../ink/components/NoSelect.js';
24
34
  import { LogoHeader, MessageList } from '../components/MessageList.js';
35
+ import { TimelineRail } from '../components/TimelineRail.js';
36
+ import { ScrollbarGutter } from '../components/ScrollbarGutter.js';
37
+ import { normalizeScrollGutter } from '../tuiDisplayPrefs.js';
25
38
  import { OverlayAbove } from '../components/OverlayAbove.js';
26
39
  import { PromptInput } from '../components/PromptInput.js';
27
40
  import { GoalTodoPanel } from '../components/GoalTodoPanel.js';
41
+ import { AutoRecapRow } from '../components/AutoRecapRow.js';
28
42
  import { LoadedContextPanel } from '../components/LoadedContextPanel.js';
29
43
  import { StatusLine } from './StatusLine.js';
30
44
  import { WorkingSpinner, useThinkingStatus } from '../components/WorkingSpinner.js';
@@ -33,12 +47,18 @@ import { ModelPicker } from '../components/ModelPicker.js';
33
47
  import { PluginSceneBoundary } from '../components/PluginSceneBoundary.js';
34
48
  import { SkillsPicker, SkillsPickerLoading } from '../components/SkillsPicker.js';
35
49
  import { SessionBrowser } from './SessionBrowser.js';
50
+ import { SessionTree } from './SessionTree.js';
36
51
  import { Settings } from './Settings.js';
37
52
  import { WorkspacePicker } from '../components/WorkspacePicker.js';
53
+ import { WorkspaceMenuPicker } from '../components/WorkspaceMenuPicker.js';
38
54
  import { WorkspaceFlowPicker } from '../components/WorkspaceFlowPicker.js';
39
55
  import { ActivityPicker } from '../components/ActivityPicker.js';
56
+ import { ColorPicker } from '../components/ColorPicker.js';
40
57
  import { EffortSlider } from '../components/EffortSlider.js';
41
58
  import { PresetPicker } from '../components/PresetPicker.js';
59
+ import { PermissionsPicker, PERMISSION_PRESET_IDS } from '../components/PermissionsPicker.js';
60
+ import { PlanPicker } from '../components/PlanPicker.js';
61
+ import { LangPicker } from '../components/LangPicker.js';
42
62
  import { ThemePicker, getThemeOptions } from '../components/ThemePicker.js';
43
63
  import { AUTO_THEME_NAME, getAutoThemeBase } from '../theme.js';
44
64
  import { FRAME_PRESETS, PRESET_NAMES } from '../components/activityFrames.js';
@@ -46,9 +66,15 @@ import { ThinkingToggle } from '../components/ThinkingToggle.js';
46
66
  import { HistorySearchDialog } from '../components/HistorySearchDialog.js';
47
67
  import { RewindPicker } from '../components/RewindPicker.js';
48
68
  import { BtwPanel } from '../components/BtwPanel.js';
69
+ import { RecapPanel } from '../components/RecapPanel.js';
70
+ import { isValidSessionColor, SESSION_COLOR_NAMES } from '../cc/sessionColors.js';
49
71
  import { TipsPanel } from '../components/TipsPanel.js';
50
72
  import { SubagentDashboard } from '../components/SubagentDashboard.js';
51
73
  import { SubagentDetailScene } from '../components/SubagentDetailScene.js';
74
+ import { FileActionsPanel, FILE_ACTION_COUNT } from '../components/FileActionsPanel.js';
75
+ import { openExternal, openFile, revealInFileManager } from '../utils/openExternal.js';
76
+ import { fileUrlToPath, parseFileLinkUrl, resolveTargetPath } from '../utils/fileTarget.js';
77
+ import { statSync } from 'node:fs';
52
78
  import { setClipboard } from '../ink/termio/osc.js';
53
79
  import { TerminalWriteContext } from '../ink/useTerminalNotification.js';
54
80
  import instances from '../ink/instances.js';
@@ -61,6 +87,7 @@ import { LoadingState } from '../components/design-system/LoadingState.js';
61
87
  import { Pane } from '../components/design-system/Pane.js';
62
88
  import { loadHistory } from '../history.js';
63
89
  import { formatLoadedContextReport } from '../utils/loaded-context.js';
90
+ import { NO_OVERLAY, chatOverlayReducer, dialogOverlayVisible, wrapIndex, } from './chatOverlay.js';
64
91
  /** Shared empty snapshot for hosts whose channel has no event log. */
65
92
  const NO_EVENTS = [];
66
93
  /** Row kinds the message-selection cursor can land on. */
@@ -113,8 +140,11 @@ function searchableText(row) {
113
140
  }
114
141
  /**
115
142
  * Main chat screen in the Claude Code layout: a scrollable transcript
116
- * (with the current turn's prompt pinned above the viewport while scrolled
117
- * up), transient notifications, the working spinner, the bordered prompt
143
+ * (with the user message the viewport is showing pinned above the transcript
144
+ * while scrolled up, and a 1-column minimap scrollbar with one node per
145
+ * user message — the current message's node is highlighted, clicking a node
146
+ * jumps to it), transient notifications, the working spinner, the bordered
147
+ * prompt
118
148
  * input (with slash-command overlay) and the status line pinned at the
119
149
  * bottom.
120
150
  *
@@ -137,7 +167,7 @@ let fallbackApprovalStore;
137
167
  */
138
168
  let fallbackDialogStore;
139
169
  let fallbackStatusStore;
140
- export function Chat({ channel, questionStore, approvalStore, extensionDialogs, extensionStatus, extensionShortcuts, onExit, onUpdate, fullscreen = false, trajectorySeen: trajectorySeenProp, }) {
170
+ export function Chat({ channel, questionStore, approvalStore, extensionDialogs, extensionStatus, extensionShortcuts, onExit, onUpdate, onRestart, fullscreen = false, trajectorySeen: trajectorySeenProp, }) {
141
171
  const writeRaw = React.useContext(TerminalWriteContext);
142
172
  // Re-render whenever the channel mutates; rows/status are read fresh below.
143
173
  React.useSyncExternalStore(channel.subscribe, () => channel.version);
@@ -188,70 +218,110 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
188
218
  const [expanded, setExpanded] = React.useState(false);
189
219
  const [helpOpen, setHelpOpen] = React.useState(false);
190
220
  const [handle, setHandle] = React.useState(null);
221
+ /**
222
+ * Conversation timeline snapshot (reported by MessageList): one entry
223
+ * per user turn plus the viewport-derived navigation targets. The
224
+ * ACTIVE turn — the one whose content owns the viewport top row — pins
225
+ * the sticky prompt header AND highlights the transcript rail's tick,
226
+ * from one report so the two can never disagree; upId/downId drive the
227
+ * rail's ▲/▼. Null activeId while pinned to the bottom only when there
228
+ * are no turns (header hidden there anyway).
229
+ */
230
+ const [timeline, setTimeline] = React.useState({
231
+ turns: [],
232
+ activeId: null,
233
+ upId: null,
234
+ downId: null,
235
+ });
191
236
  const [selectionActive, setSelectionActive] = React.useState(false);
192
237
  const [selectedId, setSelectedId] = React.useState(null);
193
238
  const [expandedRows, setExpandedRows] = React.useState(() => new Set());
194
- const [modelPickerOpen, setModelPickerOpen] = React.useState(false);
239
+ /** 流式 reasoning 行的用户折叠(点击/进入折叠态)。与 expandedRows 分开:
240
+ * 流式默认展开,用户点一下 = 折叠(preview ticker 或单行头);落定后
241
+ * 默认折叠,此集合不再参与——两种默认互不翻转。 */
242
+ const [streamFoldedRows, setStreamFoldedRows] = React.useState(() => new Set());
243
+ /**
244
+ * The transient-dialog layer (every picker/dialog `<OverlayAbove>` hosts,
245
+ * plus /tips) as ONE value: mutual exclusion between the panels is
246
+ * structural instead of emerging from "an open picker makes the prompt
247
+ * inert". Transitions live in the pure reducer (chatOverlay.ts), which
248
+ * scripts/verify-chat-overlay.ts pins without a renderer. Async data the
249
+ * pickers show (model list, preset roster, …) stays in the caches below —
250
+ * it persists across open/close so a reopened picker paints the previous
251
+ * list while the fresh one loads, exactly as the boolean era did.
252
+ */
253
+ const [overlay, dispatchOverlay] = React.useReducer(chatOverlayReducer, NO_OVERLAY);
195
254
  const [models, setModels] = React.useState([]);
196
- const [modelIndex, setModelIndex] = React.useState(0);
255
+ /** Provider display identities for the /model group level; refreshed alongside `models`. */
256
+ const [providerInfos, setProviderInfos] = React.useState([]);
257
+ /** /model 最近使用分组:成功切换即记录(去重置顶,上限 10),重启保留。 */
258
+ const [modelRecents, setModelRecents] = React.useState(() => readModelRecents());
259
+ /** Two-level /model: the drilled-in provider route; undefined = group level.
260
+ * Reset on open; stale ids resolve back to the group level via `activeModelGroup`. */
261
+ const [modelGroup, setModelGroup] = React.useState(undefined);
262
+ /** True while the picker sits in the single-provider fast path (drilled in
263
+ * at open, the group level never shown): Esc closes directly and no back
264
+ * hint renders — a pinned recents pseudo-group must not fake a two-level
265
+ * walk the user never saw (issue #527 regression: repro-picker-windowing). */
266
+ const [modelPickerDirect, setModelPickerDirect] = React.useState(false);
267
+ /** Group rows over the current catalog, first-appearance (registry) order,
268
+ * with the pinned recents pseudo-group first when any entry is catalogued. */
269
+ const modelGroups = React.useMemo(() => deriveModelGroups(models, providerInfos, modelRecents), [models, providerInfos, modelRecents]);
270
+ /** The drilled-in group, but only while it still exists in the catalog. */
271
+ const activeModelGroup = modelGroup !== undefined && modelGroups.some(group => group.provider === modelGroup)
272
+ ? modelGroup
273
+ : undefined;
274
+ const groupModels = React.useMemo(() => {
275
+ if (activeModelGroup === undefined)
276
+ return [];
277
+ if (activeModelGroup === RECENTS_GROUP_PROVIDER)
278
+ return recentCatalogModels(modelRecents, models);
279
+ return models.filter(model => model.provider === activeModelGroup);
280
+ }, [models, modelRecents, activeModelGroup]);
281
+ /** Switch + record: every successful switch feeds the /model recents group
282
+ * (picker Enter/click, `/model provider/id`, the wizard's live switch,
283
+ * and /reload's applied model all ride this one path). */
284
+ const switchModelRecorded = (provider, id, name) => {
285
+ if (name !== undefined)
286
+ channel.notify(t('model-switching', { name }));
287
+ return channel.switchModel(provider, id).then((ok) => {
288
+ if (!ok)
289
+ return ok;
290
+ if (name !== undefined)
291
+ channel.notify(t('model-switched', { name }));
292
+ setModelRecents(recordModelUse({ provider, id }));
293
+ return ok;
294
+ });
295
+ };
197
296
  /** `/skills` 技能目录(issue #204):null = 注册表快照在途。 */
198
- const [skillsPickerOpen, setSkillsPickerOpen] = React.useState(false);
199
297
  const [skillsList, setSkillsList] = React.useState(null);
200
- const [skillsIndex, setSkillsIndex] = React.useState(0);
201
298
  /** `/resume` opens the session browser, a screen rather than a panel. It
202
299
  * owns its own selection, filters and keyboard — Chat only opens it. */
203
300
  const [browserOpen, setBrowserOpen] = React.useState(false);
301
+ /** `/tree` opens the session family tree (pi's Session Tree): every rewind
302
+ * fork stitched back onto the message it diverged from, hover previews,
303
+ * and per-node rewind/fork/adopt actions. Like the browser, a screen. */
304
+ const [treeOpen, setTreeOpen] = React.useState(false);
204
305
  /** `/settings` opens the plugin settings screen (issue #165) — like the
205
306
  * browser, a screen rather than a panel: it owns its own focus, staged
206
307
  * drafts and keyboard; Chat only opens it. */
207
308
  const [settingsOpen, setSettingsOpen] = React.useState(false);
208
- const [workspacePickerOpen, setWorkspacePickerOpen] = React.useState(false);
209
309
  const [workspaceTargets, setWorkspaceTargets] = React.useState([]);
210
- const [workspaceIndex, setWorkspaceIndex] = React.useState(0);
211
- const [workspaceFlow, setWorkspaceFlow] = React.useState(null);
212
- const [workspaceFlowIndex, setWorkspaceFlowIndex] = React.useState(0);
213
- const [workspaceFlowBusy, setWorkspaceFlowBusy] = React.useState(false);
214
- const [workspaceFlowInput, setWorkspaceFlowInput] = React.useState(null);
215
310
  const workspaceFlowRequestRef = React.useRef(0);
216
311
  const workspaceFlowAbortRef = React.useRef(null);
217
- /** `/activity` indicator picker (pi extension's interactive select). */
218
- const [activityPickerOpen, setActivityPickerOpen] = React.useState(false);
219
- const [activityIndex, setActivityIndex] = React.useState(0);
220
- /** `/preset` agent-preset picker (issue #8): roster list loads async. */
221
- const [presetPickerOpen, setPresetPickerOpen] = React.useState(false);
312
+ /** `/preset` agent-preset roster (issue #8): loads async, persists. */
222
313
  const [presetOptions, setPresetOptions] = React.useState([]);
223
- const [presetIndex, setPresetIndex] = React.useState(0);
224
- /** `/effort` rheostat slider: adapter levels load async, focus moves ←/→. */
225
- const [effortSliderOpen, setEffortSliderOpen] = React.useState(false);
314
+ /** `/effort` adapter levels: load async before the slider opens. */
226
315
  const [effortOptions, setEffortOptions] = React.useState([]);
227
- const [effortIndex, setEffortIndex] = React.useState(0);
228
- /** `/theme` color-theme picker (built-ins + ~/.dsh-tui/themes user themes). */
229
- const [themePickerOpen, setThemePickerOpen] = React.useState(false);
230
- const [themeIndex, setThemeIndex] = React.useState(0);
231
316
  const [themeName, setTheme] = useTheme();
232
317
  const { rows: terminalRows } = useTerminalSize();
233
318
  const [showAllMessages, setShowAllMessages] = React.useState(false);
319
+ /** Fold state for the GoalTodoPanel todo section (ctrl/cmd+q or click). */
320
+ const [todoCollapsed, setTodoCollapsed] = React.useState(false);
234
321
  const [thinkingVisible, setThinkingVisible] = React.useState(true);
235
- const [thinkingOpen, setThinkingOpen] = React.useState(false);
236
- const [thinkingFocus, setThinkingFocus] = React.useState(0);
237
- /** ctrl+r history search dialog (ported from CC's HistorySearchDialog). */
238
- const [historyOpen, setHistoryOpen] = React.useState(false);
239
- const [historyQuery, setHistoryQuery] = React.useState('');
240
- const [historyCursor, setHistoryCursor] = React.useState(0);
241
- const [historyFocus, setHistoryFocus] = React.useState(0);
322
+ /** ctrl+r history-search entries (loaded on open, persists). */
242
323
  const [historyEntries, setHistoryEntries] = React.useState([]);
243
324
  const [historyFill, setHistoryFill] = React.useState(null);
244
- /** Double-Esc rewind picker (CC rewind): open state + focused row + confirm. */
245
- const [rewindOpen, setRewindOpen] = React.useState(false);
246
- const [rewindIndex, setRewindIndex] = React.useState(0);
247
- const [rewindConfirm, setRewindConfirm] = React.useState(null);
248
- /** Plugin rewind modes (tui/rewind-prompt seam): extra choices offered in
249
- * the confirm pane; null = the plain conversation-only confirm. */
250
- const [rewindModes, setRewindModes] = React.useState(null);
251
- const [rewindModeIndex, setRewindModeIndex] = React.useState(0);
252
- /** True while the tui/rewind-prompt decision is in flight (a plugin may be
253
- * showing its own dialog); keys except Esc are swallowed meanwhile. */
254
- const [rewindBusy, setRewindBusy] = React.useState(false);
255
325
  /** Monotonic token: only the latest rewind decision may land (a slow
256
326
  * plugin answering after the user moved on must not open a confirm for
257
327
  * a row they are no longer looking at). */
@@ -265,8 +335,68 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
265
335
  btwAbortRef.current = null;
266
336
  setBtw(null);
267
337
  };
268
- /** /tips usage-tips overlay: pure UI state, no session side effects. */
269
- const [tipsOpen, setTipsOpen] = React.useState(false);
338
+ /** /recap overlay (pi-recap semantics): pure UI state like /btw — the
339
+ * summary never enters the transcript or session log; applying the
340
+ * proposed title goes through the normal /rename path. `auto` marks the
341
+ * recapOnOpen-triggered run (rendered as the dim AutoRecapRow until
342
+ * expanded); `expanded` lifts an auto recap into the full RecapPanel;
343
+ * `rowsAtTrigger` is the last user-row id when the auto run started —
344
+ * a newer user row (the user starts a new message) retires the recap. */
345
+ const [recap, setRecap] = React.useState(null);
346
+ const recapAbortRef = React.useRef(null);
347
+ const closeRecap = () => {
348
+ recapAbortRef.current?.abort();
349
+ recapAbortRef.current = null;
350
+ setRecap(null);
351
+ };
352
+ // Auto-recap (`dsh-tui.recapOnOpen`): every time the session switches
353
+ // (mount = open/resume, rewind/fork included), summarize its tail into
354
+ // the dim AutoRecapRow. Failures stay silent in auto mode — `/recap`
355
+ // surfaces them; the summary never enters the transcript or session log.
356
+ const autoRecapSessionId = channel.agentId;
357
+ React.useEffect(() => {
358
+ // A session switch retires the previous recap outright — an old
359
+ // session's 回顾 has no place above a new conversation.
360
+ setRecap(null);
361
+ if (!channel.autoRecapOnOpen)
362
+ return;
363
+ // No conversation yet (/new): nothing to recap, don't even fire.
364
+ if (!channel.rows.some(row => row.kind === 'user' || row.kind === 'assistant'))
365
+ return;
366
+ recapAbortRef.current?.abort();
367
+ const controller = new AbortController();
368
+ recapAbortRef.current = controller;
369
+ const lastUserId = channel.rows.filter(row => row.kind === 'user').at(-1)?.id ?? -1;
370
+ setRecap({ raw: '', summary: '', error: undefined, done: false, titleApplied: false, auto: true, expanded: false, rowsAtTrigger: lastUserId });
371
+ void channel.recapRecent({
372
+ signal: controller.signal,
373
+ onText: delta => setRecap(prev => (prev ? { ...prev, raw: prev.raw + delta } : prev)),
374
+ }).then(result => {
375
+ if (controller.signal.aborted)
376
+ return;
377
+ setRecap(prev => {
378
+ if (prev === null || !prev.auto)
379
+ return prev;
380
+ // Auto mode stays quiet on failure (no activity / llm missing / error).
381
+ if (result.summary === null)
382
+ return null;
383
+ return { ...prev, summary: result.summary, title: result.title, error: result.error, done: true };
384
+ });
385
+ });
386
+ return () => controller.abort();
387
+ }, [autoRecapSessionId]);
388
+ // The user starts a new message → the auto recap has served its purpose
389
+ // (catching them up) and bows out. A newer user row is the signal; the
390
+ // assistant's own streamed rows don't count.
391
+ const lastUserRowId = channel.rows.filter(row => row.kind === 'user').at(-1)?.id ?? -1;
392
+ React.useEffect(() => {
393
+ if (recap !== null &&
394
+ recap.auto &&
395
+ recap.rowsAtTrigger !== undefined &&
396
+ lastUserRowId > recap.rowsAtTrigger) {
397
+ closeRecap();
398
+ }
399
+ }, [lastUserRowId, recap]);
270
400
  /** Subagent dashboard (Ctrl+A): displays active/completed subagents. */
271
401
  const [subagentDashboardOpen, setSubagentDashboardOpen] = React.useState(false);
272
402
  /** Detail view for a specific subagent (opened from dashboard). */
@@ -277,6 +407,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
277
407
  */
278
408
  const [logoNonce, setLogoNonce] = React.useState(0);
279
409
  React.useEffect(() => () => btwAbortRef.current?.abort(), []);
410
+ React.useEffect(() => () => recapAbortRef.current?.abort(), []);
280
411
  /**
281
412
  * The trajectory scene (issue #80 evolution). Unlike every other overlay
282
413
  * here it is not a panel but a whole screen: while open, Chat renders the
@@ -321,8 +452,72 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
321
452
  ink?.invalidatePrevFrame();
322
453
  ink?.reanchorViewport();
323
454
  }, []);
324
- /** `/` transcript search (less-style incsearch, ported from CC's REPL). */
325
- const [searchOpen, setSearchOpen] = React.useState(false);
455
+ /**
456
+ * Click-to-act targets: the Ink instance's hyperlink-open callback (wired
457
+ * in the effect below) resolves every clickable target the transcript
458
+ * renders — http(s) links open the browser, `dsh-file:`/`file://` paths
459
+ * open the file-action menu. `dsh-file:` payloads are RAW display paths
460
+ * (possibly relative), so they resolve against the CURRENT channel cwd
461
+ * at click time (read through a ref so this callback keeps a stable
462
+ * identity — it is threaded into memoized row components).
463
+ */
464
+ const cwdRef = React.useRef(channel.cwd);
465
+ React.useEffect(() => {
466
+ cwdRef.current = channel.cwd;
467
+ }, [channel.cwd]);
468
+ const openFileActions = React.useCallback((rawPath) => {
469
+ const resolved = resolveTargetPath(rawPath, cwdRef.current);
470
+ // Whether the target is a directory decides the first menu row's label
471
+ // ("open file" vs "open folder"). Missing paths count as files.
472
+ let isDir = false;
473
+ try {
474
+ isDir = statSync(resolved).isDirectory();
475
+ }
476
+ catch {
477
+ isDir = false;
478
+ }
479
+ dispatchOverlay({ type: 'open', overlay: { kind: 'file-actions', path: resolved, index: 0, isDir } });
480
+ }, []);
481
+ /** Run one file-action menu row: 0 = open file, 1 = reveal in file
482
+ * manager, 2 = copy absolute path. */
483
+ const runFileAction = React.useCallback((index, path) => {
484
+ if (index === 0)
485
+ openFile(path);
486
+ else if (index === 1)
487
+ revealInFileManager(path);
488
+ else
489
+ void setClipboard(path);
490
+ }, []);
491
+ const handleOpenTarget = React.useCallback((url) => {
492
+ const rawPath = parseFileLinkUrl(url);
493
+ if (rawPath !== undefined) {
494
+ openFileActions(rawPath);
495
+ return;
496
+ }
497
+ const filePath = fileUrlToPath(url);
498
+ if (filePath !== undefined) {
499
+ openFileActions(filePath);
500
+ return;
501
+ }
502
+ openExternal(url);
503
+ }, [openFileActions]);
504
+ // Wire the click-to-open callback into the Ink instance (the field is
505
+ // otherwise never set — clicking links was a no-op). Re-wired whenever
506
+ // the handler changes (cwd moves), cleared on unmount.
507
+ React.useEffect(() => {
508
+ const ink = instances.get(process.stdout) ?? instances.values().next().value;
509
+ if (ink)
510
+ ink.onHyperlinkClick = handleOpenTarget;
511
+ return () => {
512
+ const current = instances.get(process.stdout) ?? instances.values().next().value;
513
+ if (current)
514
+ current.onHyperlinkClick = undefined;
515
+ };
516
+ }, [handleOpenTarget]);
517
+ /** `/` transcript search (less-style incsearch, ported from CC's REPL).
518
+ * Only the bar's open/closed mode lives in `overlay`; the query and match
519
+ * counters persist past the bar closing so n/N keep walking the matches. */
520
+ const searchActive = overlay.kind === 'search';
326
521
  const [searchQuery, setSearchQuery] = React.useState('');
327
522
  const [searchCursor, setSearchCursor] = React.useState(0);
328
523
  const [searchCount, setSearchCount] = React.useState(0);
@@ -353,7 +548,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
353
548
  : -1;
354
549
  }
355
550
  }, [isSticky, channel.rows]);
356
- const showPill = !isSticky && unseenCount > 0;
551
+ // The pill shows whenever the view is off the bottom (one-click return
552
+ // home): with unseen rows it counts them, otherwise it is the plain
553
+ // "return to bottom" affordance (Enter/End/click all land it).
554
+ const showPill = !isSticky;
357
555
  // Idle Ctrl+C: first press arms an exit, second press exits (CC's
358
556
  // double-press semantics, simplified). Under Windows ConPTY the key
359
557
  // arrives as stdin data (key.ctrl && input === 'c') — the useInput
@@ -423,26 +621,30 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
423
621
  useTerminalTitle(`${titlePrefix} 🐋 ${channel.sessionTitle}`);
424
622
  const handleWorkspaceResult = (result) => {
425
623
  workspaceFlowAbortRef.current = null;
426
- setWorkspaceFlowBusy(false);
427
- setWorkspaceFlowInput(null);
428
624
  if (result.kind === 'target') {
429
- setWorkspaceFlow(null);
625
+ dispatchOverlay({ type: 'close-if', kind: 'workspace-flow' });
430
626
  void channel.switchWorkspace(result.target);
431
627
  return;
432
628
  }
433
629
  if (result.choices.length === 0) {
434
- setWorkspaceFlow(null);
630
+ dispatchOverlay({ type: 'close-if', kind: 'workspace-flow' });
435
631
  channel.notify(t('workspace-command-empty'));
436
632
  return;
437
633
  }
438
- setWorkspaceFlow(result);
439
- setWorkspaceFlowIndex(0);
634
+ // open-if: 'workspace-flow' stays allowed so an in-flow action can
635
+ // transition to its next stage; a picker the user opened after leaving
636
+ // the menu wins over a late command result.
637
+ dispatchOverlay({
638
+ type: 'open-if',
639
+ overlay: { kind: 'workspace-flow', flow: result, index: 0, busy: false, input: null },
640
+ when: ['none', 'workspace-flow'],
641
+ });
440
642
  };
441
643
  const runWorkspaceFlowAction = (action) => {
442
644
  const request = ++workspaceFlowRequestRef.current;
443
645
  const controller = new AbortController();
444
646
  workspaceFlowAbortRef.current = controller;
445
- setWorkspaceFlowBusy(true);
647
+ dispatchOverlay({ type: 'flow-busy', busy: true });
446
648
  void Promise.resolve()
447
649
  .then(() => action(controller.signal))
448
650
  .then((result) => {
@@ -453,10 +655,25 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
453
655
  if (request !== workspaceFlowRequestRef.current)
454
656
  return;
455
657
  workspaceFlowAbortRef.current = null;
456
- setWorkspaceFlowBusy(false);
658
+ dispatchOverlay({ type: 'flow-busy', busy: false });
457
659
  channel.notify(t('workspace-command-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
458
660
  });
459
661
  };
662
+ /** Bare `/workspace` menu rows: built-in subcommands first, then the
663
+ * dynamically registered extensions (same reserved-name filter the Tab
664
+ * completion applies). Recomputed per render — the extension list is
665
+ * live. */
666
+ const workspaceMenuOptions = [
667
+ { id: 'resume', label: 'resume', description: t('workspace-menu-resume-desc') },
668
+ { id: 'rename', label: 'rename', description: t('workspace-menu-rename-desc') },
669
+ { id: 'open', label: 'open', description: t('workspace-menu-open-desc') },
670
+ // Optional call: verify/repro scripts and embedders stub the channel
671
+ // without the workspace-commands API — render must not throw for them
672
+ // (a thrown render unmounts the whole Ink root).
673
+ ...(channel.workspaceCommands?.() ?? [])
674
+ .filter(command => !['resume', 'rename', 'open'].includes(command.name.toLowerCase()))
675
+ .map(command => ({ id: command.name, label: command.name, description: command.description })),
676
+ ];
460
677
  const openWorkspaceTarget = (reference) => {
461
678
  void channel.resolveWorkspace(reference).then((target) => {
462
679
  if (target === undefined) {
@@ -475,12 +692,45 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
475
692
  return;
476
693
  }
477
694
  setWorkspaceTargets(targets);
478
- setWorkspaceIndex(Math.max(0, targets.findIndex(target => target.cwd === channel.cwd)));
479
- setWorkspacePickerOpen(true);
695
+ // open-if: the listing is async — whatever the user opened meanwhile wins.
696
+ dispatchOverlay({
697
+ type: 'open-if',
698
+ overlay: {
699
+ kind: 'workspace-picker',
700
+ index: Math.max(0, targets.findIndex(target => target.cwd === channel.cwd)),
701
+ },
702
+ when: ['none'],
703
+ });
480
704
  }).catch((error) => {
481
705
  channel.notify(t('workspace-list-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' });
482
706
  });
483
707
  };
708
+ /**
709
+ * Run one /workspace menu row (Enter path, shared with the mouse click):
710
+ * built-ins dispatch locally, extension commands go through the channel.
711
+ */
712
+ const runWorkspaceMenuOption = (option) => {
713
+ dispatchOverlay({ type: 'close-if', kind: 'workspace-menu' });
714
+ if (option === undefined)
715
+ return;
716
+ if (option.id === 'resume') {
717
+ openWorkspaceResume();
718
+ }
719
+ else if (option.id === 'rename') {
720
+ channel.notify(t('workspace-rename-usage'));
721
+ }
722
+ else if (option.id === 'open') {
723
+ channel.notify(t('workspace-open-usage'));
724
+ }
725
+ else {
726
+ void channel.runWorkspaceCommand(option.id, '').then((result) => {
727
+ if (result !== undefined)
728
+ handleWorkspaceResult(result);
729
+ }).catch((error) => {
730
+ channel.notify(t('workspace-command-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
731
+ });
732
+ }
733
+ };
484
734
  /**
485
735
  * Dispatch a slash command; false lets the input flow to the model.
486
736
  * Built-in names run the local switch; anything registered by a DSH
@@ -488,6 +738,41 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
488
738
  * result text lands as a notification. `rawInput` carries the text after
489
739
  * the command name (`/plan off` → ` off`).
490
740
  */
741
+ /** Localized display name of a sandbox mode id (`read-only` /
742
+ * `workspace-write` / `danger-full-access`), for `/permission status`. */
743
+ const permissionPresetName = (id) => {
744
+ switch (id) {
745
+ case 'read-only': return t('permission-preset-readonly');
746
+ case 'workspace-write': return t('permission-preset-workspace-write');
747
+ case 'danger-full-access': return t('permission-preset-full-access');
748
+ default: return id ?? '—';
749
+ }
750
+ };
751
+ /** Hot-swap the UI language (`/lang <id>` and the LangPicker both land
752
+ * here): persist to ~/.dsh-tui/lang.json and mirror into the dsh-tui
753
+ * settings namespace when it is served (best effort). */
754
+ const applyLang = (lang) => {
755
+ const ok = writeLangPref(lang);
756
+ setLang(lang);
757
+ const settingsHost = channel.settingsHost();
758
+ const tuiView = settingsHost?.listNamespaces().find(entry => entry.ns === 'dsh-tui');
759
+ if (settingsHost !== undefined && tuiView !== undefined) {
760
+ void settingsHost
761
+ .write('dsh-tui', [{ op: 'set', path: ['lang'], value: lang }], tuiView.revision)
762
+ .catch(() => { });
763
+ }
764
+ channel.notify(ok ? t('lang-switched', { lang }) : t('lang-switch-failed', { lang }), { color: ok ? 'success' : 'error' });
765
+ };
766
+ /** Localized label of one /reload surface, for the change report. */
767
+ const reloadKindLabel = (kind) => {
768
+ switch (kind) {
769
+ case 'theme': return t('reload-kind-theme');
770
+ case 'lang': return t('reload-kind-lang');
771
+ case 'preset': return t('reload-kind-preset');
772
+ case 'model': return t('reload-kind-model');
773
+ case 'activity': return t('reload-kind-activity');
774
+ }
775
+ };
491
776
  const runCommand = (name, rawInput = '') => {
492
777
  switch (name) {
493
778
  case 'activity': {
@@ -524,8 +809,13 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
524
809
  return true;
525
810
  }
526
811
  setHelpOpen(false);
527
- setActivityIndex(Math.max(0, PRESET_NAMES.indexOf(channel.activityFrames ?? 'random')));
528
- setActivityPickerOpen(true);
812
+ dispatchOverlay({
813
+ type: 'open',
814
+ overlay: {
815
+ kind: 'activity',
816
+ index: Math.max(0, PRESET_NAMES.indexOf(channel.activityFrames ?? 'random')),
817
+ },
818
+ });
529
819
  return true;
530
820
  }
531
821
  case 'preset': {
@@ -552,16 +842,26 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
552
842
  return true;
553
843
  }
554
844
  setHelpOpen(false);
555
- setPresetPickerOpen(true);
845
+ // The picker opens immediately over the cached roster (no loading
846
+ // pane — deliberate contrast with /model); the fresh list lands with
847
+ // the authoritative focus. Both loader writes are kind-guarded, so a
848
+ // picker the user already left is not resurrected or re-focused.
849
+ dispatchOverlay({
850
+ type: 'open',
851
+ overlay: {
852
+ kind: 'preset',
853
+ index: Math.max(0, presetOptions.findIndex(preset => preset.id === channel.agentPreset)),
854
+ },
855
+ });
556
856
  void channel.listPresets().then((list) => {
557
857
  if (list.length === 0) {
558
- setPresetPickerOpen(false);
858
+ dispatchOverlay({ type: 'close-if', kind: 'preset' });
559
859
  channel.notify(t('preset-roster-unmounted'), { color: 'warning' });
560
860
  return;
561
861
  }
562
862
  setPresetOptions(list);
563
863
  const index = list.findIndex(preset => preset.id === channel.agentPreset);
564
- setPresetIndex(index >= 0 ? index : 0);
864
+ dispatchOverlay({ type: 'set-index', kind: 'preset', index: index >= 0 ? index : 0 });
565
865
  });
566
866
  return true;
567
867
  }
@@ -592,16 +892,22 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
592
892
  setEffortOptions(efforts);
593
893
  const current = channel.reasoningEffort ?? defaultEffort;
594
894
  const index = efforts.findIndex(effort => effort.id === current);
595
- setEffortIndex(index >= 0 ? index : 0);
596
- setEffortSliderOpen(true);
895
+ // open-if: a picker the user opened during the round trip wins
896
+ // over this late-arriving slider.
897
+ dispatchOverlay({
898
+ type: 'open-if',
899
+ overlay: { kind: 'effort', index: index >= 0 ? index : 0 },
900
+ when: ['none'],
901
+ });
597
902
  });
598
903
  return true;
599
904
  }
600
905
  case 'lang': {
601
906
  // `/lang` shows the current UI language, `/lang en|zh` switches
602
- // (hot-swap, persisted to ~/.dsh-tui/lang.json). Precedence on next
603
- // launch: DSH_TUI_LANG > settings.yaml `dsh-tui.lang` > cordis.yml
604
- // `lang` > the persisted choice.
907
+ // (hot-swap, persisted to ~/.dsh-tui/lang.json), bare `/lang` opens
908
+ // the en/zh picker. Precedence on next launch: DSH_TUI_LANG >
909
+ // settings.yaml `dsh-tui.lang` > cordis.yml `lang` > the persisted
910
+ // choice.
605
911
  const parts = rawInput.trim().split(/\s+/).filter(Boolean);
606
912
  if (parts[0] === 'status') {
607
913
  setHelpOpen(false);
@@ -615,19 +921,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
615
921
  if (parts.length > 0) {
616
922
  setHelpOpen(false);
617
923
  if (isLang(parts[0])) {
618
- const ok = writeLangPref(parts[0]);
619
- setLang(parts[0]);
620
- // Mirror into the dsh-tui settings namespace when it is served,
621
- // so /settings and the next boot see the same last-write-wins
622
- // choice (best effort; lang.json stays the fallback).
623
- const settingsHost = channel.settingsHost();
624
- const tuiView = settingsHost?.listNamespaces().find(entry => entry.ns === 'dsh-tui');
625
- if (settingsHost !== undefined && tuiView !== undefined) {
626
- void settingsHost
627
- .write('dsh-tui', [{ op: 'set', path: ['lang'], value: parts[0] }], tuiView.revision)
628
- .catch(() => { });
629
- }
630
- channel.notify(ok ? t('lang-switched', { lang: parts[0] }) : t('lang-switch-failed', { lang: parts[0] }), { color: ok ? 'success' : 'error' });
924
+ applyLang(parts[0]);
631
925
  }
632
926
  else {
633
927
  channel.notify(t('lang-unknown', { lang: parts[0] }), { color: 'error' });
@@ -635,11 +929,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
635
929
  return true;
636
930
  }
637
931
  setHelpOpen(false);
638
- channel.pushLocal('/lang', [
639
- t('lang-current', { lang: getLang() }),
640
- t('lang-switch-hint'),
641
- t('lang-persist-hint'),
642
- ]);
932
+ dispatchOverlay({
933
+ type: 'open',
934
+ overlay: { kind: 'lang', index: getLang() === 'zh' ? 0 : 1 },
935
+ });
643
936
  return true;
644
937
  }
645
938
  case 'theme': {
@@ -672,15 +965,59 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
672
965
  return true;
673
966
  }
674
967
  setHelpOpen(false);
675
- setThemeIndex(Math.max(0, getThemeOptions().findIndex(option => option.value === themeName)));
676
- setThemePickerOpen(true);
968
+ dispatchOverlay({
969
+ type: 'open',
970
+ overlay: {
971
+ kind: 'theme',
972
+ index: Math.max(0, getThemeOptions().findIndex(option => option.value === themeName)),
973
+ },
974
+ });
975
+ return true;
976
+ }
977
+ case 'color': {
978
+ // `/color`(CC accent,按会话持久化):无参打开调色板选择器,
979
+ // `/color <name>` 直接设置,`/color status` 显示当前,`/color
980
+ // reset` 清除回主题默认。颜色经 `session/color` 事件按会话保存
981
+ // ——resume/rewind 后仍是这个会话自己的颜色(见 channel.ts)。
982
+ setHelpOpen(false);
983
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
984
+ if (parts.length === 0) {
985
+ dispatchOverlay({
986
+ type: 'open',
987
+ overlay: {
988
+ kind: 'color',
989
+ index: Math.max(0, SESSION_COLOR_NAMES.indexOf(channel.sessionColor)),
990
+ },
991
+ });
992
+ return true;
993
+ }
994
+ if (parts[0] === 'status') {
995
+ channel.pushLocal('/color', [
996
+ channel.sessionColor === ''
997
+ ? t('color-current-none')
998
+ : t('color-current', { name: channel.sessionColor }),
999
+ t('color-usage', { list: SESSION_COLOR_NAMES.join('/') }),
1000
+ ]);
1001
+ return true;
1002
+ }
1003
+ if (parts[0] === 'reset') {
1004
+ channel.setSessionColor('');
1005
+ channel.notify(t('color-reset'));
1006
+ return true;
1007
+ }
1008
+ const colorName = parts[0].toLowerCase();
1009
+ if (!isValidSessionColor(colorName)) {
1010
+ channel.notify(t('color-unknown', { name: colorName, list: SESSION_COLOR_NAMES.join(' · ') }), { color: 'error' });
1011
+ return true;
1012
+ }
1013
+ channel.setSessionColor(colorName);
1014
+ channel.notify(t('color-set', { name: colorName }), { color: 'success' });
677
1015
  return true;
678
1016
  }
679
1017
  case 'new': {
680
1018
  // One-shot `/new` (issue #25): the old session stays persisted and
681
1019
  // is recoverable via /resume, so discarding the live view is
682
- // non-destructive — no CC-style "press /new again" confirmation.
683
- setHelpOpen(false);
1020
+ // non-destructive — no CC-style "press /new again" confirmation. setHelpOpen(false)
684
1021
  void channel.newSession().then((ok) => {
685
1022
  if (!ok)
686
1023
  return;
@@ -735,32 +1072,102 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
735
1072
  case 'help':
736
1073
  setHelpOpen(true);
737
1074
  return true;
738
- case 'model':
1075
+ case 'model': {
1076
+ // `/model <provider/model>` switches directly (same live-fork path
1077
+ // as the picker's Enter), bare `/model` opens the picker.
1078
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1079
+ if (parts.length > 0) {
1080
+ setHelpOpen(false);
1081
+ const spec = parts[0];
1082
+ const slash = spec.indexOf('/');
1083
+ const provider = slash >= 0 ? spec.slice(0, slash) : undefined;
1084
+ const id = slash >= 0 ? spec.slice(slash + 1) : spec;
1085
+ if (provider === undefined || id.length === 0 || provider.length === 0) {
1086
+ channel.notify(t('model-usage'), { color: 'warning' });
1087
+ return true;
1088
+ }
1089
+ void channel.listModels().then((list) => {
1090
+ const model = list.find(m => m.provider === provider && m.id === id);
1091
+ if (model === undefined) {
1092
+ channel.notify(t('model-unknown', { spec }), { color: 'error', timeoutMs: 8000 });
1093
+ return;
1094
+ }
1095
+ void switchModelRecorded(provider, id, model.name);
1096
+ });
1097
+ return true;
1098
+ }
739
1099
  setHelpOpen(false);
740
- setModelPickerOpen(true);
1100
+ // Opens over the cached catalog (empty cache shows the loading
1101
+ // pane); the fresh list lands with the authoritative focus, and the
1102
+ // kind-guarded set-index cannot re-focus a picker the user left.
1103
+ // Seed-on-open: the model in use IS a use — recording it here means
1104
+ // the recents group exists before the first post-update switch, and
1105
+ // switching A→B keeps A in the list (the file records what was
1106
+ // used, not only switches made after the file appeared).
1107
+ let recentsNow = modelRecents;
1108
+ if (channel.provider !== '' && channel.model !== ''
1109
+ && !recentsNow.some(ref => ref.provider === channel.provider && ref.id === channel.model)) {
1110
+ recentsNow = recordModelUse({ provider: channel.provider, id: channel.model });
1111
+ setModelRecents(recentsNow);
1112
+ }
1113
+ // Two-level landing: recents (when catalogued) focus their pinned
1114
+ // row; else multi-provider catalogs focus the current provider's
1115
+ // group row; a single-provider catalog without a meaningful recents
1116
+ // list drills straight into its model list (pre-grouping UX).
1117
+ {
1118
+ const landing = modelPickerLanding(models, channel.provider, channel.model, recentsNow);
1119
+ setModelGroup(landing.group);
1120
+ setModelPickerDirect(landing.group !== undefined);
1121
+ dispatchOverlay({ type: 'open', overlay: { kind: 'model', index: landing.index } });
1122
+ }
741
1123
  void channel.listModels().then((list) => {
742
1124
  setModels(list);
743
- const index = list.findIndex(model => model.provider === channel.provider && model.id === channel.model);
744
- setModelIndex(index >= 0 ? index : 0);
1125
+ const landing = modelPickerLanding(list, channel.provider, channel.model, recentsNow);
1126
+ setModelGroup(landing.group);
1127
+ setModelPickerDirect(landing.group !== undefined);
1128
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: landing.index });
745
1129
  });
1130
+ void channel.listProviders().then(setProviderInfos).catch(() => setProviderInfos([]));
746
1131
  return true;
747
- case 'skills':
1132
+ }
1133
+ case 'skills': {
748
1134
  // issue #204: 列出当前 agent 的完整技能目录(名称 + 来源 + 简述),
749
1135
  // Enter 把可直调技能以 `/name ` 填回输入行(completion-only 分发的
750
1136
  // 同一路径)。注册表读取走 channel(快照 scoped 到 live agent)。
1137
+ // `/skills <name>` 直达同一个填回动作,跳过选择器。
1138
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1139
+ if (parts.length > 0) {
1140
+ setHelpOpen(false);
1141
+ void channel.listSkills().then((list) => {
1142
+ if (list === undefined) {
1143
+ channel.notify(t('skills-load-failed'), { color: 'error' });
1144
+ return;
1145
+ }
1146
+ const skill = list.find(s => s.name === parts[0]);
1147
+ if (skill === undefined) {
1148
+ channel.notify(t('skills-unknown', { name: parts[0] }), { color: 'error' });
1149
+ return;
1150
+ }
1151
+ if (skill.userInvocable)
1152
+ setHistoryFill(`/${skill.name} `);
1153
+ else
1154
+ channel.notify(t('skills-not-invocable', { name: parts[0] }), { color: 'warning' });
1155
+ });
1156
+ return true;
1157
+ }
751
1158
  setHelpOpen(false);
752
1159
  setSkillsList(null);
753
- setSkillsIndex(0);
754
- setSkillsPickerOpen(true);
1160
+ dispatchOverlay({ type: 'open', overlay: { kind: 'skills', index: 0 } });
755
1161
  void channel.listSkills().then((list) => {
756
1162
  if (list === undefined) {
757
- setSkillsPickerOpen(false);
1163
+ dispatchOverlay({ type: 'close-if', kind: 'skills' });
758
1164
  channel.notify(t('skills-load-failed'), { color: 'error' });
759
1165
  return;
760
1166
  }
761
1167
  setSkillsList(list);
762
1168
  });
763
1169
  return true;
1170
+ }
764
1171
  case 'provider': {
765
1172
  // Interactive add-provider wizard (/provider): drives the shared
766
1173
  // question panel, persists profile + key via the channel's settings/
@@ -777,7 +1184,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
777
1184
  notify: (text, options) => channel.notify(text, options),
778
1185
  pushLocal: (title, lines) => channel.pushLocal(title, lines),
779
1186
  working: () => channel.working,
780
- switchModel: (provider, model) => channel.switchModel(provider, model),
1187
+ switchModel: (provider, model) => switchModelRecorded(provider, model),
781
1188
  }).catch(() => {
782
1189
  // The wizard notifies on every handled failure; this only swallows
783
1190
  // an unexpected reject so it never surfaces as an unhandled promise.
@@ -786,8 +1193,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
786
1193
  }
787
1194
  case 'thinking':
788
1195
  setHelpOpen(false);
789
- setThinkingOpen(true);
790
- setThinkingFocus(thinkingVisible ? 0 : 1);
1196
+ dispatchOverlay({
1197
+ type: 'open',
1198
+ overlay: { kind: 'thinking', focus: thinkingVisible ? 0 : 1 },
1199
+ });
791
1200
  return true;
792
1201
  case 'tokens': {
793
1202
  const usage = t('tokens-usage', { in: formatTokens(channel.tokens.input), out: formatTokens(channel.tokens.output) });
@@ -815,10 +1224,9 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
815
1224
  const subcommand = (separator < 0 ? trimmed : trimmed.slice(0, separator)).toLowerCase();
816
1225
  const input = separator < 0 ? '' : trimmed.slice(separator).trim();
817
1226
  if (subcommand === '') {
818
- const extensions = channel.workspaceCommands()
819
- .map(command => ` | ${command.name}`)
820
- .join('');
821
- channel.pushLocal('/workspace', [t('workspace-command-usage', { commands: extensions })]);
1227
+ // Bare `/workspace` opens the action menu (resume / rename / open
1228
+ // plus any registered extensions) instead of a text usage line.
1229
+ dispatchOverlay({ type: 'open', overlay: { kind: 'workspace-menu', index: 0 } });
822
1230
  }
823
1231
  else if (subcommand === 'resume') {
824
1232
  openWorkspaceResume();
@@ -869,6 +1277,21 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
869
1277
  setHelpOpen(false);
870
1278
  openRewind();
871
1279
  return true;
1280
+ case 'tree': {
1281
+ // The session family tree (pi's Session Tree): every fork branch
1282
+ // stitched back, hover previews, per-node rewind/fork/adopt.
1283
+ setHelpOpen(false);
1284
+ setTreeOpen(true);
1285
+ return true;
1286
+ }
1287
+ case 'fork': {
1288
+ // Tip fork (kimi-code semantics): a persisted copy of the whole
1289
+ // conversation the user enters via /resume — the live session and
1290
+ // its running turn stay untouched.
1291
+ setHelpOpen(false);
1292
+ void channel.forkSession();
1293
+ return true;
1294
+ }
872
1295
  case 'exit':
873
1296
  case 'quit':
874
1297
  case 'q':
@@ -972,12 +1395,16 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
972
1395
  setHelpOpen(false);
973
1396
  void channel.describeCredential('DEEPSEEK_API_KEY')
974
1397
  .catch(() => undefined)
975
- .then(status => {
1398
+ .then(async (status) => {
976
1399
  const keyStatus = status === undefined
977
1400
  ? t('login-credentials-unavailable')
978
1401
  : status.configured
979
1402
  ? t('login-key-configured', { ref: 'DEEPSEEK_API_KEY' })
980
1403
  : t('login-key-missing');
1404
+ // OAuth account states ride along only while a dsh-auth-style
1405
+ // plugin is mounted; absent it the lines below are exactly the
1406
+ // pre-plugin set.
1407
+ const oauth = await channel.oauthProviderStatuses().catch(() => undefined);
981
1408
  channel.pushLocal('/login', [
982
1409
  t('login-api-key', { status: keyStatus }),
983
1410
  ...(status === undefined
@@ -989,6 +1416,20 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
989
1416
  }),
990
1417
  ]),
991
1418
  t('login-base-url', { url: process.env.DEEPSEEK_BASE_URL ?? t('login-official-endpoint') }),
1419
+ ...(oauth === undefined
1420
+ ? []
1421
+ : [
1422
+ t('login-oauth-heading'),
1423
+ ...oauth.map(row => t('login-oauth-row', {
1424
+ provider: row.provider,
1425
+ state: row.signedIn
1426
+ ? t('login-oauth-in', { time: new Date(row.expiresAt ?? 0).toISOString() })
1427
+ : row.expired
1428
+ ? t('login-oauth-expired')
1429
+ : t('login-oauth-signed-out'),
1430
+ })),
1431
+ t('login-oauth-hint'),
1432
+ ]),
992
1433
  ]);
993
1434
  });
994
1435
  return true;
@@ -996,25 +1437,87 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
996
1437
  case 'logout':
997
1438
  channel.notify(t('login-logout-hint'));
998
1439
  return true;
999
- case 'permissions':
1000
- setHelpOpen(false);
1001
- channel.pushLocal('/permissions', [
1002
- t('permissions-policy-hint'),
1003
- t('permissions-approval-hint'),
1004
- // /permission comes from the dsh-base permission-presets row via the
1005
- // commands registry; only advertise it when this composition
1006
- // actually mounted it (the bare cordis.yml leaf has no
1007
- // permission-presets, so the command does not exist there).
1008
- ...(channel.commandList.some(command => command.name === 'permission')
1009
- ? [t('permissions-preset-hint')]
1010
- : []),
1011
- ]);
1012
- return true;
1440
+ case 'permission': {
1441
+ // The command itself is registered by dsh-sandbox-policy (dsh-base
1442
+ // permission-presets row): bare `/permission` opens the preset
1443
+ // picker (read-only / workspace-write / danger-full-access) and
1444
+ // Enter dispatches `/permission <preset>` through the same
1445
+ // external-command path a hand-typed argument takes. `/permission
1446
+ // status` prints the policy explainer (absorbed from the removed
1447
+ // `/permissions` command); other arguments pass through verbatim.
1448
+ // When the row is not mounted the default external path (or the
1449
+ // model, when nothing is registered) wins.
1450
+ const mounted = channel.commandList.some(command => command.external && command.name === 'permission');
1451
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1452
+ if (mounted && parts[0] === 'status') {
1453
+ setHelpOpen(false);
1454
+ channel.pushLocal('/permission', [
1455
+ t('permission-current', { name: permissionPresetName(channel.mode.sandbox) }),
1456
+ t('permission-policy-hint'),
1457
+ t('permission-approval-hint'),
1458
+ t('permission-root-hint', { cwd: channel.cwd }),
1459
+ t('permission-path-hint'),
1460
+ ]);
1461
+ return true;
1462
+ }
1463
+ if (mounted && parts.length === 0) {
1464
+ setHelpOpen(false);
1465
+ const index = PERMISSION_PRESET_IDS.indexOf(channel.mode.sandbox ?? '');
1466
+ dispatchOverlay({
1467
+ type: 'open',
1468
+ overlay: { kind: 'permission', index: index >= 0 ? index : 1 },
1469
+ });
1470
+ return true;
1471
+ }
1472
+ if (mounted) {
1473
+ setHelpOpen(false);
1474
+ void channel.runExternalCommand('permission', rawInput).then((text) => {
1475
+ if (text !== undefined && text !== '') {
1476
+ channel.notify(text);
1477
+ }
1478
+ else if (text === undefined) {
1479
+ channel.notify(t('command-not-found', { name: 'permission' }), { color: 'error' });
1480
+ }
1481
+ });
1482
+ return true;
1483
+ }
1484
+ return false;
1485
+ }
1486
+ case 'plan': {
1487
+ // Registered by dsh-plan-mode: bare `/plan` opens an on/off picker
1488
+ // marked with the current state instead of toggling blindly; Enter
1489
+ // dispatches `/plan` or `/plan off`. Arguments pass through verbatim
1490
+ // (`/plan off`), and an unmounted row falls back to the default
1491
+ // external path.
1492
+ const mounted = channel.commandList.some(command => command.external && command.name === 'plan');
1493
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1494
+ if (mounted && parts.length === 0) {
1495
+ setHelpOpen(false);
1496
+ dispatchOverlay({
1497
+ type: 'open',
1498
+ overlay: { kind: 'plan', index: channel.mode.plan === true ? 0 : 1 },
1499
+ });
1500
+ return true;
1501
+ }
1502
+ if (mounted) {
1503
+ setHelpOpen(false);
1504
+ void channel.runExternalCommand('plan', rawInput).then((text) => {
1505
+ if (text !== undefined && text !== '') {
1506
+ channel.notify(text);
1507
+ }
1508
+ else if (text === undefined) {
1509
+ channel.notify(t('command-not-found', { name: 'plan' }), { color: 'error' });
1510
+ }
1511
+ });
1512
+ return true;
1513
+ }
1514
+ return false;
1515
+ }
1013
1516
  case 'add-dir':
1014
1517
  setHelpOpen(false);
1015
1518
  channel.pushLocal('/add-dir', [
1016
- t('permissions-root-hint', { cwd: channel.cwd }),
1017
- t('permissions-path-hint'),
1519
+ t('permission-root-hint', { cwd: channel.cwd }),
1520
+ t('permission-path-hint'),
1018
1521
  ]);
1019
1522
  return true;
1020
1523
  case 'hooks':
@@ -1041,6 +1544,100 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1041
1544
  onUpdate();
1042
1545
  }
1043
1546
  return true;
1547
+ case 'reload': {
1548
+ // pi-style soft reload: re-read the persisted preference files
1549
+ // (~/.dsh-tui/{theme,lang,agent-preset,model,working-activity}.json)
1550
+ // and re-apply live, honoring the boot-time precedence (env >
1551
+ // cordis.yml > settings user layer > pref). The dsh-tui settings
1552
+ // namespace is NOT re-read here — its watch applies edits live and
1553
+ // the platform watcher hot-reloads settings.yaml itself. What no
1554
+ // reload can re-read (cordis.yml root config, frozen fullscreen,
1555
+ // newly built code) is listed in the footer and served by /restart.
1556
+ setHelpOpen(false);
1557
+ const tuiNamespace = channel.settingsHost()
1558
+ ?.listNamespaces()
1559
+ .find(entry => entry.ns === 'dsh-tui');
1560
+ const plan = planReload({
1561
+ envTheme: envThemeOverride(),
1562
+ envLang: isLang(process.env.DSH_TUI_LANG) ? process.env.DSH_TUI_LANG : undefined,
1563
+ themePref: readThemePref(),
1564
+ currentTheme: themeName,
1565
+ langPref: readLangPref(),
1566
+ currentLang: getLang(),
1567
+ langOverriddenBySettings: tuiNamespace !== undefined && hasPath(tuiNamespace.user, ['lang']),
1568
+ configuredLang: channel.configuredLang,
1569
+ configuredPreset: channel.configuredPreset,
1570
+ presetPref: readPresetPref(),
1571
+ currentPreset: channel.agentPreset,
1572
+ configuredModel: {
1573
+ provider: channel.configuredProvider,
1574
+ model: channel.configuredModel,
1575
+ },
1576
+ modelPref: readModelPref(),
1577
+ currentModel: { provider: channel.provider, model: channel.model },
1578
+ configuredActivity: channel.configuredActivityFrames,
1579
+ activityPref: readActivityFrames(),
1580
+ currentActivity: channel.activityFrames,
1581
+ });
1582
+ for (const item of plan.apply) {
1583
+ switch (item.kind) {
1584
+ case 'theme':
1585
+ setTheme(item.to);
1586
+ break;
1587
+ case 'lang':
1588
+ applyLang(item.to);
1589
+ break;
1590
+ case 'preset':
1591
+ void channel.switchPreset(item.to);
1592
+ break;
1593
+ case 'model':
1594
+ if (item.route !== undefined) {
1595
+ void switchModelRecorded(item.route.provider, item.route.model);
1596
+ }
1597
+ break;
1598
+ case 'activity':
1599
+ channel.setActivityFrames(item.to);
1600
+ break;
1601
+ }
1602
+ }
1603
+ const lines = [t('reload-header')];
1604
+ for (const item of plan.apply) {
1605
+ lines.push(t('reload-applied', {
1606
+ kind: reloadKindLabel(item.kind),
1607
+ from: item.from,
1608
+ to: item.to,
1609
+ }));
1610
+ }
1611
+ for (const kind of plan.unchanged) {
1612
+ lines.push(t('reload-unchanged', { kind: reloadKindLabel(kind) }));
1613
+ }
1614
+ for (const skip of plan.skipped) {
1615
+ const key = skip.reason === 'env-wins'
1616
+ ? 'reload-skipped-env'
1617
+ : skip.reason === 'config-wins' ? 'reload-skipped-config' : 'reload-skipped-invalid';
1618
+ lines.push(t(key, { kind: reloadKindLabel(skip.kind) }));
1619
+ }
1620
+ lines.push(t('reload-footer'));
1621
+ channel.pushLocal('/reload', lines);
1622
+ return true;
1623
+ }
1624
+ case 'restart':
1625
+ // pi-style reload tail: /reload cannot re-read boot-time-only state
1626
+ // (cordis.yml root config, frozen fullscreen layout, newly built
1627
+ // code), so /restart respawns the process with the original argv and
1628
+ // resumes this session — the /update handoff minus the pnpm step.
1629
+ setHelpOpen(false);
1630
+ if (onRestart === undefined) {
1631
+ channel.notify(t('restart-unavailable'), { color: 'warning' });
1632
+ }
1633
+ else if (channel.working) {
1634
+ channel.notify(t('update-working'), { color: 'warning' });
1635
+ }
1636
+ else {
1637
+ channel.notify(t('restart-starting'));
1638
+ onRestart();
1639
+ }
1640
+ return true;
1044
1641
  case 'vim':
1045
1642
  channel.notify(t('vim-not-implemented'));
1046
1643
  return true;
@@ -1051,6 +1648,33 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1051
1648
  t('terminal-paste-hint', { mod: modLabel }),
1052
1649
  ]);
1053
1650
  return true;
1651
+ case 'recap': {
1652
+ // `/recap`(pi-recap 语义):对会话最近活动做一次无工具单轮
1653
+ // 调用,生成一行摘要 + 建议标题。摘要是纯 UI 状态(不进 transcript
1654
+ // 也不进 session log);建议标题经「应用」按钮走 /rename 路径。
1655
+ setHelpOpen(false);
1656
+ recapAbortRef.current?.abort();
1657
+ const controller = new AbortController();
1658
+ recapAbortRef.current = controller;
1659
+ setRecap({ raw: '', summary: '', error: undefined, done: false, titleApplied: false, auto: false, expanded: true });
1660
+ void channel.recapRecent({
1661
+ signal: controller.signal,
1662
+ onText: delta => setRecap(prev => (prev ? { ...prev, raw: prev.raw + delta } : prev)),
1663
+ }).then(result => {
1664
+ if (controller.signal.aborted)
1665
+ return;
1666
+ setRecap(prev => (prev
1667
+ ? {
1668
+ ...prev,
1669
+ summary: result.summary ?? prev.raw,
1670
+ title: result.title,
1671
+ error: result.error,
1672
+ done: true,
1673
+ }
1674
+ : prev));
1675
+ });
1676
+ return true;
1677
+ }
1054
1678
  case 'btw': {
1055
1679
  // CC /btw:单轮无工具侧问,overlay 态纯 UI,不打断主回合、不写
1056
1680
  // 会话历史。空参数只提示用法。
@@ -1088,7 +1712,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1088
1712
  }
1089
1713
  case 'tips':
1090
1714
  setHelpOpen(false);
1091
- setTipsOpen(true);
1715
+ dispatchOverlay({ type: 'open', overlay: { kind: 'tips' } });
1092
1716
  return true;
1093
1717
  case 'connect':
1094
1718
  setHelpOpen(false);
@@ -1142,6 +1766,9 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1142
1766
  ? channel.rows.filter(row => SELECTABLE_KINDS.has(row.kind))
1143
1767
  : NO_ROWS;
1144
1768
  // ctrl+r history search: substring match on the query, newest first.
1769
+ // The draft lives in the overlay variant; the derived '' while closed
1770
+ // keeps the memo inputs stable (nothing renders the matches then).
1771
+ const historyQuery = overlay.kind === 'history' ? overlay.query : '';
1145
1772
  const historyMatches = React.useMemo(() => {
1146
1773
  const q = historyQuery.trim().toLowerCase();
1147
1774
  return q ? historyEntries.filter(e => e.text.toLowerCase().includes(q)) : historyEntries;
@@ -1150,15 +1777,15 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1150
1777
  // selectable user turns; steering side-questions are excluded). Computed
1151
1778
  // per render while the picker is open — `channel.rows` is a live in-place
1152
1779
  // array (see selectableRows).
1153
- const rewindRows = rewindOpen
1780
+ const rewindRows = overlay.kind === 'rewind'
1154
1781
  ? channel.rows
1155
1782
  .filter(row => row.kind === 'user' && row.label === undefined)
1156
1783
  .reverse()
1157
1784
  : NO_ROWS;
1158
1785
  /** Open the rewind picker (from PromptInput's double-Esc on an empty input). */
1159
1786
  const openRewind = () => {
1160
- // rewindOpen is still false this render, so rewindRows is empty — scan
1161
- // directly instead of reading the gated list.
1787
+ // The overlay is not 'rewind' yet this render, so rewindRows is empty —
1788
+ // scan directly instead of reading the gated list.
1162
1789
  const candidates = channel.rows
1163
1790
  .filter(row => row.kind === 'user' && row.label === undefined)
1164
1791
  .reverse();
@@ -1166,12 +1793,11 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1166
1793
  channel.notify(t('rewind-none'));
1167
1794
  return;
1168
1795
  }
1169
- setRewindIndex(0);
1170
- setRewindConfirm(null);
1171
- setRewindModes(null);
1172
- setRewindBusy(false);
1173
1796
  rewindRequestRef.current += 1;
1174
- setRewindOpen(true);
1797
+ dispatchOverlay({
1798
+ type: 'open',
1799
+ overlay: { kind: 'rewind', index: 0, confirm: null, modes: null, modeIndex: 0, busy: false },
1800
+ });
1175
1801
  };
1176
1802
  /**
1177
1803
  * Enter on a rewind candidate: ask the plugins first (tui/rewind-prompt).
@@ -1180,16 +1806,15 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1180
1806
  */
1181
1807
  const requestRewindConfirm = async (row) => {
1182
1808
  const token = ++rewindRequestRef.current;
1183
- setRewindBusy(true);
1809
+ dispatchOverlay({ type: 'rewind-busy', busy: true });
1184
1810
  const decision = await channel.promptRewind(row);
1185
1811
  if (token !== rewindRequestRef.current)
1186
1812
  return;
1187
- setRewindBusy(false);
1188
- if (decision === 'cancel')
1813
+ if (decision === 'cancel') {
1814
+ dispatchOverlay({ type: 'rewind-busy', busy: false });
1189
1815
  return;
1190
- setRewindConfirm(row);
1191
- setRewindModes(decision?.modes ?? null);
1192
- setRewindModeIndex(0);
1816
+ }
1817
+ dispatchOverlay({ type: 'rewind-decision', confirm: row, modes: decision?.modes ?? null });
1193
1818
  };
1194
1819
  /** Execute the confirmed rewind; the message comes back into the input. */
1195
1820
  const performRewind = async (row, mode = null) => {
@@ -1277,13 +1902,31 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1277
1902
  }
1278
1903
  setForceMountRowId(rowId);
1279
1904
  };
1905
+ /**
1906
+ * Reveal-and-seek for a row folded behind the recent-rows window (the
1907
+ * rail's tick for an old turn, the doc's revealAndSeekRow): expand the
1908
+ * fold first, then the ordinary seek takes over — the completion effect
1909
+ * below force-mounts the row and scrollToElement lands it once its ref
1910
+ * (and Yoga top) exist. The fold toggle is idempotent, so calling this
1911
+ * for an already-revealed row is harmless.
1912
+ */
1913
+ const revealAndSeekRow = (rowId) => {
1914
+ if (!showAllMessages)
1915
+ setShowAllMessages(true);
1916
+ seekRow(rowId);
1917
+ };
1280
1918
  React.useLayoutEffect(() => {
1281
1919
  if (forceMountRowId === null)
1282
1920
  return;
1283
1921
  const el = rowRefsRef.current.get(forceMountRowId);
1284
1922
  if (el) {
1285
1923
  handle?.scrollToElement(el);
1286
- setForceMountRowId(null);
1924
+ // Clear deferred to a macrotask: clearing here would let React's
1925
+ // synchronous re-render narrow the virtualization window and unmount
1926
+ // the row BEFORE the renderer's deferred pass reads its Yoga top
1927
+ // (scrollAnchor processing runs in a microtask) — the seek would
1928
+ // silently no-op (detached anchor element).
1929
+ setTimeout(() => setForceMountRowId(null), 0);
1287
1930
  }
1288
1931
  });
1289
1932
  // `/` transcript search: rows whose searchable text contains the query.
@@ -1300,7 +1943,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1300
1943
  // Incsearch: highlight all matches (screen-space overlay) and keep the
1301
1944
  // current match row in view as the query changes (CC semantics).
1302
1945
  React.useEffect(() => {
1303
- if (!searchOpen)
1946
+ if (!searchActive)
1304
1947
  return;
1305
1948
  setHighlight(searchQuery);
1306
1949
  const count = searchMatches.length;
@@ -1312,10 +1955,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1312
1955
  if (target) {
1313
1956
  seekRow(target.row.id);
1314
1957
  }
1315
- }, [searchQuery, searchOpen]);
1958
+ }, [searchQuery, searchActive]);
1316
1959
  // n/N navigation: move the current match into view.
1317
1960
  React.useEffect(() => {
1318
- if (!searchOpen)
1961
+ if (!searchActive)
1319
1962
  return;
1320
1963
  const target = searchMatches[searchCurrent];
1321
1964
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty/filtered list
@@ -1352,6 +1995,16 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1352
1995
  return next;
1353
1996
  });
1354
1997
  }, []);
1998
+ const toggleStreamFolded = React.useCallback((rowId) => {
1999
+ setStreamFoldedRows((previous) => {
2000
+ const next = new Set(previous);
2001
+ if (next.has(rowId))
2002
+ next.delete(rowId);
2003
+ else
2004
+ next.add(rowId);
2005
+ return next;
2006
+ });
2007
+ }, []);
1355
2008
  const registerRowRef = React.useCallback((rowId, el) => {
1356
2009
  if (el)
1357
2010
  rowRefsRef.current.set(rowId, el);
@@ -1372,6 +2025,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1372
2025
  // search box, which Chat would otherwise route into the prompt.
1373
2026
  if (browserOpen)
1374
2027
  return;
2028
+ // Same for the session tree: plain letters drive its search, clicks and
2029
+ // Enter drive its action menu.
2030
+ if (treeOpen)
2031
+ return;
1375
2032
  // Same for the settings screen: plain letters (s save / d discard) and
1376
2033
  // the field draft editor belong to it alone.
1377
2034
  if (settingsOpen)
@@ -1388,13 +2045,30 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1388
2045
  // Mouse wheel scrolls the transcript even while a question/approval/
1389
2046
  // dialog panel is open — those panels own arrow/Enter/Esc keys, but the
1390
2047
  // transcript above them should still be scrollable in fullscreen mode.
1391
- // Help is different: its own ScrollBox owns wheel input, so Chat must
1392
- // yield before stopping propagation or both covered layers would move.
2048
+ //
2049
+ // Wheel routing is position-first: events landing over a ScrollBox
2050
+ // (transcript, help, subagent panels…) are consumed by that box in
2051
+ // App's input batch (onWheelAt) and never reach this branch. What
2052
+ // arrives here is the fallback: wheel over non-scroll areas (prompt,
2053
+ // status bar) or over floating overlays.
2054
+ // - Help stays yielded: PromptInput's help ScrollBox handles the
2055
+ // remaining global wheel while help is open (both covered layers
2056
+ // must not move).
2057
+ // - Pickers/dialogs are modal: wheel that fell through over them
2058
+ // must NOT scroll the transcript behind (the audit's
2059
+ // pass-through gap), so yield like the keyboard guards above.
1393
2060
  // Events only arrive with mouse tracking on; inline mode never sees
1394
2061
  // them, so this is a no-op there.
1395
2062
  if (key.wheelUp || key.wheelDown) {
1396
2063
  if (helpOpen)
1397
2064
  return;
2065
+ // Any open transient dialog is modal to the wheel; the one exception
2066
+ // mirrors the render gate — a workspace picker whose target list has
2067
+ // not landed paints nothing, so wheel-through keeps scrolling.
2068
+ const overlayModal = overlay.kind !== 'none' &&
2069
+ (overlay.kind !== 'workspace-picker' || workspaceTargets.length > 0);
2070
+ if (overlayModal)
2071
+ return;
1398
2072
  handle?.scrollBy(key.wheelUp ? -3 : 3);
1399
2073
  event.stopImmediatePropagation();
1400
2074
  return;
@@ -1424,11 +2098,11 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1424
2098
  event.stopImmediatePropagation();
1425
2099
  return;
1426
2100
  }
1427
- if (searchOpen) {
2101
+ if (overlay.kind === 'search') {
1428
2102
  // Transcript search bar (less-style): edit the query, Enter commits
1429
2103
  // (query persists for n/N), Esc/ctrl+c cancels back to the anchor.
1430
2104
  if (key.escape || (key.ctrl && input === 'c')) {
1431
- setSearchOpen(false);
2105
+ dispatchOverlay({ type: 'close' });
1432
2106
  setHighlight('');
1433
2107
  handle?.scrollTo(searchAnchorRef.current);
1434
2108
  }
@@ -1436,7 +2110,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1436
2110
  // Enter commits; 0-match junk queries don't persist (CC behavior).
1437
2111
  if (searchCount === 0)
1438
2112
  setSearchQuery('');
1439
- setSearchOpen(false);
2113
+ dispatchOverlay({ type: 'close' });
1440
2114
  }
1441
2115
  else if (key.backspace) {
1442
2116
  if (searchCursor > 0) {
@@ -1482,41 +2156,41 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1482
2156
  event.stopImmediatePropagation();
1483
2157
  return;
1484
2158
  }
1485
- if (thinkingOpen) {
2159
+ if (overlay.kind === 'thinking') {
1486
2160
  if (key.upArrow || key.downArrow) {
1487
- setThinkingFocus(index => (index === 0 ? 1 : 0));
2161
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: 2 });
1488
2162
  }
1489
2163
  else if (plainReturn) {
1490
- const visible = thinkingFocus === 0;
2164
+ const visible = overlay.focus === 0;
1491
2165
  setThinkingVisible(visible);
1492
- setThinkingOpen(false);
2166
+ dispatchOverlay({ type: 'close' });
1493
2167
  channel.notify(t('thinking-toggled', { state: visible ? t('thinking-on') : t('thinking-off') }));
1494
2168
  }
1495
2169
  else if (key.escape) {
1496
- setThinkingOpen(false);
2170
+ dispatchOverlay({ type: 'close' });
1497
2171
  }
1498
2172
  return;
1499
2173
  }
1500
- if (workspaceFlow !== null) {
2174
+ if (overlay.kind === 'workspace-flow') {
2175
+ const { flow, busy, input: flowInput } = overlay;
1501
2176
  if (key.escape) {
1502
- if (workspaceFlowInput !== null && !workspaceFlowBusy) {
1503
- setWorkspaceFlowInput(null);
2177
+ if (flowInput !== null && !busy) {
2178
+ dispatchOverlay({ type: 'flow-input', input: null });
1504
2179
  return;
1505
2180
  }
1506
2181
  workspaceFlowAbortRef.current?.abort();
1507
2182
  workspaceFlowAbortRef.current = null;
1508
2183
  workspaceFlowRequestRef.current += 1;
1509
- setWorkspaceFlowBusy(false);
1510
- setWorkspaceFlow(null);
2184
+ dispatchOverlay({ type: 'close' });
1511
2185
  return;
1512
2186
  }
1513
- if (workspaceFlowBusy)
2187
+ if (busy)
1514
2188
  return;
1515
- if (workspaceFlowInput !== null) {
1516
- const choice = workspaceFlow.choices.find(candidate => candidate.id === workspaceFlowInput.choiceId);
2189
+ if (flowInput !== null) {
2190
+ const choice = flow.choices.find(candidate => candidate.id === flowInput.choiceId);
1517
2191
  const editor = choice?.input;
1518
2192
  if (plainReturn) {
1519
- const value = workspaceFlowInput.value.trim();
2193
+ const value = flowInput.value.trim();
1520
2194
  if (value.length === 0) {
1521
2195
  channel.notify(t('workspace-flow-input-empty'), { color: 'warning' });
1522
2196
  }
@@ -1524,127 +2198,158 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1524
2198
  runWorkspaceFlowAction(signal => editor.submit(value, signal));
1525
2199
  }
1526
2200
  }
1527
- else if (key.backspace && workspaceFlowInput.cursor > 0) {
1528
- setWorkspaceFlowInput(current => current === null ? null : {
1529
- ...current,
1530
- value: current.value.slice(0, current.cursor - 1) + current.value.slice(current.cursor),
1531
- cursor: current.cursor - 1,
2201
+ else if (key.backspace && flowInput.cursor > 0) {
2202
+ dispatchOverlay({
2203
+ type: 'flow-input-edit',
2204
+ value: flowInput.value.slice(0, flowInput.cursor - 1) + flowInput.value.slice(flowInput.cursor),
2205
+ cursor: flowInput.cursor - 1,
1532
2206
  });
1533
2207
  }
1534
- else if (key.delete && workspaceFlowInput.cursor < workspaceFlowInput.value.length) {
1535
- setWorkspaceFlowInput(current => current === null ? null : {
1536
- ...current,
1537
- value: current.value.slice(0, current.cursor) + current.value.slice(current.cursor + 1),
2208
+ else if (key.delete && flowInput.cursor < flowInput.value.length) {
2209
+ dispatchOverlay({
2210
+ type: 'flow-input-edit',
2211
+ value: flowInput.value.slice(0, flowInput.cursor) + flowInput.value.slice(flowInput.cursor + 1),
2212
+ cursor: flowInput.cursor,
1538
2213
  });
1539
2214
  }
1540
2215
  else if (key.leftArrow) {
1541
- setWorkspaceFlowInput(current => current === null ? null : {
1542
- ...current,
1543
- cursor: Math.max(0, current.cursor - 1),
2216
+ dispatchOverlay({
2217
+ type: 'flow-input-edit',
2218
+ value: flowInput.value,
2219
+ cursor: Math.max(0, flowInput.cursor - 1),
1544
2220
  });
1545
2221
  }
1546
2222
  else if (key.rightArrow) {
1547
- setWorkspaceFlowInput(current => current === null ? null : {
1548
- ...current,
1549
- cursor: Math.min(current.value.length, current.cursor + 1),
2223
+ dispatchOverlay({
2224
+ type: 'flow-input-edit',
2225
+ value: flowInput.value,
2226
+ cursor: Math.min(flowInput.value.length, flowInput.cursor + 1),
1550
2227
  });
1551
2228
  }
1552
2229
  else if (input.length > 0 && !key.ctrl && !key.meta && !key.super && !key.tab) {
1553
- setWorkspaceFlowInput(current => current === null ? null : {
1554
- ...current,
1555
- value: current.value.slice(0, current.cursor) + input + current.value.slice(current.cursor),
1556
- cursor: current.cursor + input.length,
2230
+ dispatchOverlay({
2231
+ type: 'flow-input-edit',
2232
+ value: flowInput.value.slice(0, flowInput.cursor) + input + flowInput.value.slice(flowInput.cursor),
2233
+ cursor: flowInput.cursor + input.length,
1557
2234
  });
1558
2235
  }
1559
2236
  return;
1560
2237
  }
1561
- if (key.upArrow) {
1562
- setWorkspaceFlowIndex(index => (index <= 0 ? workspaceFlow.choices.length - 1 : index - 1));
1563
- }
1564
- else if (key.downArrow) {
1565
- setWorkspaceFlowIndex(index => (index >= workspaceFlow.choices.length - 1 ? 0 : index + 1));
2238
+ if (key.upArrow || key.downArrow) {
2239
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: flow.choices.length });
1566
2240
  }
1567
2241
  else if (key.tab && !key.shift) {
1568
- const choice = workspaceFlow.choices[workspaceFlowIndex];
2242
+ const choice = flow.choices[overlay.index];
1569
2243
  if (choice?.input !== undefined) {
1570
2244
  const value = choice.input.initialValue ?? '';
1571
- setWorkspaceFlowInput({
2245
+ const flowInputNext = {
1572
2246
  choiceId: choice.id,
1573
2247
  value,
1574
2248
  cursor: value.length,
1575
2249
  ...(choice.input.placeholder === undefined ? {} : { placeholder: choice.input.placeholder }),
1576
- });
2250
+ };
2251
+ dispatchOverlay({ type: 'flow-input', input: flowInputNext });
1577
2252
  }
1578
2253
  }
1579
2254
  else if (plainReturn) {
1580
- const choice = workspaceFlow.choices[workspaceFlowIndex];
2255
+ const choice = flow.choices[overlay.index];
1581
2256
  if (choice !== undefined) {
1582
2257
  runWorkspaceFlowAction(signal => choice.choose(signal));
1583
2258
  }
1584
2259
  }
1585
2260
  return;
1586
2261
  }
1587
- if (workspacePickerOpen) {
1588
- if (key.upArrow) {
1589
- setWorkspaceIndex(index => (index <= 0 ? workspaceTargets.length - 1 : index - 1));
1590
- }
1591
- else if (key.downArrow) {
1592
- setWorkspaceIndex(index => (index >= workspaceTargets.length - 1 ? 0 : index + 1));
2262
+ if (overlay.kind === 'workspace-picker') {
2263
+ if (key.upArrow || key.downArrow) {
2264
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: workspaceTargets.length });
1593
2265
  }
1594
2266
  else if (plainReturn) {
1595
- const target = workspaceTargets[workspaceIndex];
1596
- setWorkspacePickerOpen(false);
2267
+ const target = workspaceTargets[overlay.index];
2268
+ dispatchOverlay({ type: 'close' });
1597
2269
  if (target !== undefined)
1598
2270
  void channel.switchWorkspace(target);
1599
2271
  }
1600
2272
  else if (key.escape) {
1601
- setWorkspacePickerOpen(false);
2273
+ dispatchOverlay({ type: 'close' });
1602
2274
  }
1603
2275
  return;
1604
2276
  }
1605
- if (modelPickerOpen) {
1606
- if (key.upArrow) {
1607
- setModelIndex(index => (index <= 0 ? models.length - 1 : index - 1));
2277
+ if (overlay.kind === 'workspace-menu') {
2278
+ const menu = workspaceMenuOptions;
2279
+ if (key.upArrow || key.downArrow) {
2280
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: menu.length });
1608
2281
  }
1609
- else if (key.downArrow) {
1610
- setModelIndex(index => (index >= models.length - 1 ? 0 : index + 1));
2282
+ else if (plainReturn) {
2283
+ const option = menu[overlay.index];
2284
+ runWorkspaceMenuOption(option);
2285
+ }
2286
+ else if (key.escape) {
2287
+ dispatchOverlay({ type: 'close' });
2288
+ }
2289
+ return;
2290
+ }
2291
+ if (overlay.kind === 'model') {
2292
+ // Two-level picker: group rows at the top (Enter drills in), one
2293
+ // provider's models below (Enter switches, the same live-fork path as
2294
+ // the flat picker always had). Esc/⌫ climbs one level and only closes
2295
+ // at the top; a single-group catalog never shows the group level, so
2296
+ // Esc there closes directly.
2297
+ const rowCount = activeModelGroup === undefined ? modelGroups.length : groupModels.length;
2298
+ if (key.upArrow || key.downArrow) {
2299
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: rowCount });
1611
2300
  }
1612
2301
  else if (plainReturn) {
1613
- const model = models[modelIndex];
2302
+ if (activeModelGroup === undefined) {
2303
+ const group = modelGroups[overlay.index];
2304
+ if (!group) {
2305
+ dispatchOverlay({ type: 'close' });
2306
+ return;
2307
+ }
2308
+ setModelGroup(group.provider);
2309
+ // The recents group opens on its most-recent entry; a provider
2310
+ // group on its current model when it owns one, else its first row.
2311
+ if (group.provider === RECENTS_GROUP_PROVIDER) {
2312
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: 0 });
2313
+ return;
2314
+ }
2315
+ const landing = modelPickerLanding(models.filter(model => model.provider === group.provider), channel.provider, channel.model);
2316
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: landing.index });
2317
+ return;
2318
+ }
2319
+ const model = groupModels[overlay.index];
1614
2320
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1615
2321
  if (model) {
1616
2322
  // Enter switches the live model right away: the conversation is
1617
2323
  // forked at its end and continued with an agent routed to the new
1618
- // model (history replays unchanged).
1619
- setModelPickerOpen(false);
1620
- channel.notify(t('model-switching', { name: model.name }));
1621
- void channel.switchModel(model.provider, model.id).then((ok) => {
1622
- if (ok)
1623
- channel.notify(t('model-switched', { name: model.name }));
1624
- });
2324
+ // model (history replays unchanged) — and feeds the recents group.
2325
+ dispatchOverlay({ type: 'close' });
2326
+ void switchModelRecorded(model.provider, model.id, model.name);
1625
2327
  }
1626
2328
  else {
1627
- setModelPickerOpen(false);
2329
+ dispatchOverlay({ type: 'close' });
1628
2330
  }
1629
2331
  }
1630
- else if (key.escape) {
1631
- setModelPickerOpen(false);
2332
+ else if (key.escape || key.backspace) {
2333
+ if (activeModelGroup !== undefined && modelGroups.length > 1 && !modelPickerDirect) {
2334
+ setModelGroup(undefined);
2335
+ const groupIndex = Math.max(0, modelGroups.findIndex(group => group.provider === activeModelGroup));
2336
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: groupIndex });
2337
+ }
2338
+ else {
2339
+ dispatchOverlay({ type: 'close' });
2340
+ }
1632
2341
  }
1633
2342
  return;
1634
2343
  }
1635
- if (skillsPickerOpen) {
2344
+ if (overlay.kind === 'skills') {
1636
2345
  const list = skillsList ?? [];
1637
- if (key.upArrow) {
1638
- if (list.length > 0)
1639
- setSkillsIndex(index => (index <= 0 ? list.length - 1 : index - 1));
1640
- }
1641
- else if (key.downArrow) {
1642
- if (list.length > 0)
1643
- setSkillsIndex(index => (index >= list.length - 1 ? 0 : index + 1));
2346
+ if (key.upArrow || key.downArrow) {
2347
+ // count 0 (snapshot still loading) is a no-op inside the reducer.
2348
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: list.length });
1644
2349
  }
1645
2350
  else if (plainReturn) {
1646
- const skill = list[skillsIndex];
1647
- setSkillsPickerOpen(false);
2351
+ const skill = list[overlay.index];
2352
+ dispatchOverlay({ type: 'close' });
1648
2353
  // 可直调技能 Enter 填入 `/name `——与 / 菜单选中技能同一条
1649
2354
  // completion-only 分发路径;模型专用技能(userInvocable=false)只关闭。
1650
2355
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
@@ -1652,239 +2357,313 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1652
2357
  setHistoryFill(`/${skill.name} `);
1653
2358
  }
1654
2359
  else if (key.escape) {
1655
- setSkillsPickerOpen(false);
2360
+ dispatchOverlay({ type: 'close' });
1656
2361
  }
1657
2362
  return;
1658
2363
  }
1659
- if (activityPickerOpen) {
1660
- if (key.upArrow) {
1661
- setActivityIndex(index => (index <= 0 ? PRESET_NAMES.length - 1 : index - 1));
1662
- }
1663
- else if (key.downArrow) {
1664
- setActivityIndex(index => (index >= PRESET_NAMES.length - 1 ? 0 : index + 1));
2364
+ if (overlay.kind === 'activity') {
2365
+ if (key.upArrow || key.downArrow) {
2366
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: PRESET_NAMES.length });
1665
2367
  }
1666
2368
  else if (plainReturn) {
1667
- const name = PRESET_NAMES[activityIndex];
1668
- setActivityPickerOpen(false);
2369
+ const name = PRESET_NAMES[overlay.index];
2370
+ dispatchOverlay({ type: 'close' });
1669
2371
  if (name)
1670
2372
  channel.setActivityFrames(name);
1671
2373
  }
1672
2374
  else if (key.escape) {
1673
- setActivityPickerOpen(false);
2375
+ dispatchOverlay({ type: 'close' });
1674
2376
  }
1675
2377
  return;
1676
2378
  }
1677
- if (effortSliderOpen) {
2379
+ if (overlay.kind === 'color') {
2380
+ if (key.upArrow || key.downArrow) {
2381
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: SESSION_COLOR_NAMES.length });
2382
+ }
2383
+ else if (plainReturn) {
2384
+ const name = SESSION_COLOR_NAMES[overlay.index];
2385
+ dispatchOverlay({ type: 'close' });
2386
+ if (name) {
2387
+ channel.setSessionColor(name);
2388
+ channel.notify(t('color-set', { name }), { color: 'success' });
2389
+ }
2390
+ }
2391
+ else if (key.escape) {
2392
+ dispatchOverlay({ type: 'close' });
2393
+ }
2394
+ return;
2395
+ }
2396
+ if (overlay.kind === 'effort') {
1678
2397
  if (key.leftArrow || key.rightArrow) {
1679
2398
  const delta = key.leftArrow ? -1 : 1;
1680
- const next = (effortIndex + delta + effortOptions.length) % effortOptions.length;
1681
- setEffortIndex(next);
2399
+ // The same wrap rule the reducer applies — computed here too so the
2400
+ // newly focused level is applied in this very keystroke.
2401
+ const next = wrapIndex(overlay.index, delta, effortOptions.length);
2402
+ dispatchOverlay({ type: 'move', delta, count: effortOptions.length });
1682
2403
  const option = effortOptions[next];
1683
2404
  // Live-apply: the slider IS the control; Esc does not revert.
1684
2405
  if (option)
1685
2406
  void channel.setEffort(option.id);
1686
2407
  }
1687
2408
  else if (plainReturn || key.escape) {
1688
- setEffortSliderOpen(false);
2409
+ dispatchOverlay({ type: 'close' });
1689
2410
  }
1690
2411
  return;
1691
2412
  }
1692
- if (presetPickerOpen) {
1693
- if (key.upArrow) {
1694
- setPresetIndex(index => (index <= 0 ? presetOptions.length - 1 : index - 1));
1695
- }
1696
- else if (key.downArrow) {
1697
- setPresetIndex(index => (index >= presetOptions.length - 1 ? 0 : index + 1));
2413
+ if (overlay.kind === 'preset') {
2414
+ if (key.upArrow || key.downArrow) {
2415
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: presetOptions.length });
1698
2416
  }
1699
2417
  else if (plainReturn) {
1700
- const option = presetOptions[presetIndex];
1701
- setPresetPickerOpen(false);
2418
+ const option = presetOptions[overlay.index];
2419
+ dispatchOverlay({ type: 'close' });
1702
2420
  if (option)
1703
2421
  void channel.switchPreset(option.id);
1704
2422
  }
1705
2423
  else if (key.escape) {
1706
- setPresetPickerOpen(false);
2424
+ dispatchOverlay({ type: 'close' });
1707
2425
  }
1708
2426
  return;
1709
2427
  }
1710
- if (themePickerOpen) {
1711
- const options = getThemeOptions();
1712
- if (key.upArrow) {
1713
- setThemeIndex(index => (index <= 0 ? options.length - 1 : index - 1));
2428
+ if (overlay.kind === 'permission') {
2429
+ if (key.upArrow || key.downArrow) {
2430
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: PERMISSION_PRESET_IDS.length });
1714
2431
  }
1715
- else if (key.downArrow) {
1716
- setThemeIndex(index => (index >= options.length - 1 ? 0 : index + 1));
2432
+ else if (plainReturn) {
2433
+ const id = PERMISSION_PRESET_IDS[overlay.index];
2434
+ dispatchOverlay({ type: 'close' });
2435
+ if (id !== undefined) {
2436
+ void channel.runExternalCommand('permission', ` ${id}`).then((text) => {
2437
+ if (text !== undefined && text !== '')
2438
+ channel.notify(text);
2439
+ });
2440
+ }
2441
+ }
2442
+ else if (key.escape) {
2443
+ dispatchOverlay({ type: 'close' });
2444
+ }
2445
+ return;
2446
+ }
2447
+ if (overlay.kind === 'plan') {
2448
+ if (key.upArrow || key.downArrow) {
2449
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: 2 });
2450
+ }
2451
+ else if (plainReturn) {
2452
+ const on = overlay.index === 0;
2453
+ dispatchOverlay({ type: 'close' });
2454
+ void channel.runExternalCommand('plan', on ? '' : ' off').then((text) => {
2455
+ if (text !== undefined && text !== '')
2456
+ channel.notify(text);
2457
+ });
2458
+ }
2459
+ else if (key.escape) {
2460
+ dispatchOverlay({ type: 'close' });
2461
+ }
2462
+ return;
2463
+ }
2464
+ if (overlay.kind === 'lang') {
2465
+ if (key.upArrow || key.downArrow) {
2466
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: 2 });
2467
+ }
2468
+ else if (plainReturn) {
2469
+ const lang = LANGS[overlay.index];
2470
+ dispatchOverlay({ type: 'close' });
2471
+ if (lang !== undefined)
2472
+ applyLang(lang);
2473
+ }
2474
+ else if (key.escape) {
2475
+ dispatchOverlay({ type: 'close' });
2476
+ }
2477
+ return;
2478
+ }
2479
+ if (overlay.kind === 'theme') {
2480
+ const options = getThemeOptions();
2481
+ if (key.upArrow || key.downArrow) {
2482
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: options.length });
1717
2483
  }
1718
2484
  else if (plainReturn) {
1719
- setThemePickerOpen(false);
1720
- const name = options[themeIndex]?.value;
2485
+ dispatchOverlay({ type: 'close' });
2486
+ const name = options[overlay.index]?.value;
1721
2487
  if (name !== undefined) {
1722
2488
  const ok = setTheme(name);
1723
2489
  channel.notify(ok ? t('theme-switched-saved', { name }) : t('theme-switch-failed', { name }), { color: ok ? 'success' : 'error' });
1724
2490
  }
1725
2491
  }
1726
2492
  else if (key.escape) {
1727
- setThemePickerOpen(false);
2493
+ dispatchOverlay({ type: 'close' });
1728
2494
  }
1729
2495
  return;
1730
2496
  }
1731
- if (historyOpen) {
2497
+ if (overlay.kind === 'history') {
2498
+ const { query, cursor, focus } = overlay;
1732
2499
  if (key.escape) {
1733
- setHistoryOpen(false);
2500
+ dispatchOverlay({ type: 'close' });
1734
2501
  }
1735
2502
  else if (key.ctrl && (input === 'c' || input === 'd')) {
1736
2503
  // CC's history search cancels on ctrl+c/ctrl+d too.
1737
- setHistoryOpen(false);
2504
+ dispatchOverlay({ type: 'close' });
1738
2505
  }
1739
2506
  else if (plainReturn) {
1740
- const entry = historyMatches[historyFocus];
2507
+ const entry = historyMatches[focus];
1741
2508
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty match list
1742
2509
  if (entry) {
1743
2510
  setHistoryFill(entry.text);
1744
- setHistoryOpen(false);
2511
+ dispatchOverlay({ type: 'close' });
1745
2512
  }
1746
2513
  }
1747
2514
  else if (key.upArrow) {
1748
- setHistoryFocus(index => historyMatches.length === 0 ? 0 : (index <= 0 ? historyMatches.length - 1 : index - 1));
2515
+ if (historyMatches.length > 0) {
2516
+ dispatchOverlay({ type: 'move', delta: -1, count: historyMatches.length });
2517
+ }
1749
2518
  }
1750
- else if (key.downArrow || (isMod(key) && input === 'r')) {
1751
- // CC's historySearch:next — ↓ and repeat ctrl+r walk to the next match.
1752
- setHistoryFocus(index => historyMatches.length === 0 ? 0 : (index >= historyMatches.length - 1 ? 0 : index + 1));
2519
+ else if (key.downArrow || actionMatches('history', input, key)) {
2520
+ // CC's historySearch:next — ↓ and the history key (default Ctrl+R)
2521
+ // walk to the next match.
2522
+ if (historyMatches.length > 0) {
2523
+ dispatchOverlay({ type: 'move', delta: 1, count: historyMatches.length });
2524
+ }
1753
2525
  }
1754
2526
  else if (key.backspace) {
1755
- if (historyCursor > 0) {
1756
- const next = historyQuery.slice(0, historyCursor - 1) + historyQuery.slice(historyCursor);
1757
- setHistoryQuery(next);
1758
- setHistoryCursor(historyCursor - 1);
1759
- setHistoryFocus(0);
2527
+ if (cursor > 0) {
2528
+ dispatchOverlay({
2529
+ type: 'history-edit',
2530
+ query: query.slice(0, cursor - 1) + query.slice(cursor),
2531
+ cursor: cursor - 1,
2532
+ focus: 0,
2533
+ });
1760
2534
  }
1761
2535
  }
1762
2536
  else if (key.delete) {
1763
- if (historyCursor < historyQuery.length) {
1764
- setHistoryQuery(historyQuery.slice(0, historyCursor) + historyQuery.slice(historyCursor + 1));
1765
- setHistoryFocus(0);
2537
+ if (cursor < query.length) {
2538
+ dispatchOverlay({
2539
+ type: 'history-edit',
2540
+ query: query.slice(0, cursor) + query.slice(cursor + 1),
2541
+ focus: 0,
2542
+ });
1766
2543
  }
1767
2544
  }
1768
2545
  else if (key.leftArrow) {
1769
2546
  // Step by code point, not UTF-16 unit: an emoji is two units, and
1770
2547
  // a mid-pair caret offset would split it in the SearchBox render.
1771
- setHistoryCursor(cursor => {
1772
- if (cursor <= 0)
1773
- return 0;
1774
- const ch = [...historyQuery.slice(0, cursor)].pop();
1775
- return cursor - ch.length;
1776
- });
2548
+ if (cursor > 0) {
2549
+ const ch = [...query.slice(0, cursor)].pop();
2550
+ dispatchOverlay({ type: 'history-edit', cursor: cursor - ch.length });
2551
+ }
1777
2552
  }
1778
2553
  else if (key.rightArrow) {
1779
- setHistoryCursor(cursor => {
1780
- if (cursor >= historyQuery.length)
1781
- return historyQuery.length;
1782
- const ch = [...historyQuery.slice(cursor)][0];
1783
- return cursor + ch.length;
1784
- });
2554
+ if (cursor < query.length) {
2555
+ const ch = [...query.slice(cursor)][0];
2556
+ dispatchOverlay({ type: 'history-edit', cursor: cursor + ch.length });
2557
+ }
1785
2558
  }
1786
2559
  else if (key.home) {
1787
- setHistoryCursor(0);
2560
+ dispatchOverlay({ type: 'history-edit', cursor: 0 });
1788
2561
  }
1789
2562
  else if (key.end) {
1790
- setHistoryCursor(historyQuery.length);
2563
+ dispatchOverlay({ type: 'history-edit', cursor: query.length });
1791
2564
  }
1792
2565
  else if (!key.ctrl && !key.meta && !key.super && input) {
1793
- const next = historyQuery.slice(0, historyCursor) + input + historyQuery.slice(historyCursor);
1794
- setHistoryQuery(next);
1795
- setHistoryCursor(historyCursor + input.length);
1796
- setHistoryFocus(0);
2566
+ dispatchOverlay({
2567
+ type: 'history-edit',
2568
+ query: query.slice(0, cursor) + input + query.slice(cursor),
2569
+ cursor: cursor + input.length,
2570
+ focus: 0,
2571
+ });
1797
2572
  }
1798
2573
  return;
1799
2574
  }
1800
- if (rewindOpen) {
2575
+ if (overlay.kind === 'rewind') {
1801
2576
  // While the plugin decision is in flight the picker is read-only;
1802
2577
  // Esc abandons the wait (the stale answer is dropped by the token).
1803
- if (rewindBusy) {
2578
+ if (overlay.busy) {
1804
2579
  if (key.escape) {
1805
2580
  rewindRequestRef.current += 1;
1806
- setRewindBusy(false);
2581
+ dispatchOverlay({ type: 'rewind-busy', busy: false });
1807
2582
  }
1808
2583
  return;
1809
2584
  }
1810
- if (rewindConfirm !== null) {
1811
- if (rewindModes !== null) {
2585
+ if (overlay.confirm !== null) {
2586
+ const row = overlay.confirm;
2587
+ if (overlay.modes !== null) {
1812
2588
  // Plugin offered modes: the confirm pane is a choice list —
1813
2589
  // option 0 is always the built-in conversation-only rewind.
1814
- const optionCount = rewindModes.length + 1;
1815
- if (key.upArrow) {
1816
- setRewindModeIndex(index => (index <= 0 ? optionCount - 1 : index - 1));
1817
- }
1818
- else if (key.downArrow) {
1819
- setRewindModeIndex(index => (index >= optionCount - 1 ? 0 : index + 1));
2590
+ const optionCount = overlay.modes.length + 1;
2591
+ if (key.upArrow || key.downArrow) {
2592
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: optionCount });
1820
2593
  }
1821
2594
  else if (plainReturn) {
1822
- const row = rewindConfirm;
1823
- const mode = rewindModeIndex === 0 ? null : (rewindModes[rewindModeIndex - 1]?.id ?? null);
1824
- setRewindOpen(false);
1825
- setRewindConfirm(null);
1826
- setRewindModes(null);
2595
+ const mode = overlay.modeIndex === 0 ? null : (overlay.modes[overlay.modeIndex - 1]?.id ?? null);
2596
+ dispatchOverlay({ type: 'close' });
1827
2597
  void performRewind(row, mode);
1828
2598
  }
1829
2599
  else if (key.escape) {
1830
- setRewindConfirm(null);
1831
- setRewindModes(null);
2600
+ dispatchOverlay({ type: 'rewind-back' });
1832
2601
  }
1833
2602
  return;
1834
2603
  }
1835
2604
  // Confirmation state: Enter rewinds, Esc backs out to the list.
1836
2605
  if (plainReturn) {
1837
- const row = rewindConfirm;
1838
- setRewindOpen(false);
1839
- setRewindConfirm(null);
2606
+ dispatchOverlay({ type: 'close' });
1840
2607
  void performRewind(row);
1841
2608
  }
1842
2609
  else if (key.escape) {
1843
- setRewindConfirm(null);
2610
+ dispatchOverlay({ type: 'rewind-back' });
1844
2611
  }
1845
2612
  }
1846
- else if (key.upArrow) {
1847
- setRewindIndex(index => (index <= 0 ? rewindRows.length - 1 : index - 1));
1848
- }
1849
- else if (key.downArrow) {
1850
- setRewindIndex(index => (index >= rewindRows.length - 1 ? 0 : index + 1));
2613
+ else if (key.upArrow || key.downArrow) {
2614
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: rewindRows.length });
1851
2615
  }
1852
2616
  else if (plainReturn) {
1853
- const row = rewindRows[rewindIndex];
2617
+ const row = rewindRows[overlay.index];
1854
2618
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1855
2619
  if (row)
1856
2620
  void requestRewindConfirm(row);
1857
2621
  }
1858
2622
  else if (key.escape) {
1859
- setRewindOpen(false);
1860
- setRewindConfirm(null);
1861
- setRewindModes(null);
2623
+ dispatchOverlay({ type: 'close' });
1862
2624
  }
1863
2625
  return;
1864
2626
  }
1865
- if (isMod(key) && input === 't') {
1866
- // Ctrl+T opens the trajectory scene at any point in the session.
2627
+ if (overlay.kind === 'file-actions') {
2628
+ // Click-to-act file menu: ↑/↓ move, Enter runs the focused action,
2629
+ // Esc closes.
2630
+ if (key.upArrow || key.downArrow) {
2631
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: FILE_ACTION_COUNT });
2632
+ }
2633
+ else if (plainReturn) {
2634
+ const path = overlay.path;
2635
+ dispatchOverlay({ type: 'close' });
2636
+ runFileAction(overlay.index, path);
2637
+ }
2638
+ else if (key.escape) {
2639
+ dispatchOverlay({ type: 'close' });
2640
+ }
2641
+ return;
2642
+ }
2643
+ if (actionMatches('trajectory', input, key)) {
2644
+ // The trajectory scene key (default Ctrl+T) opens it at any point in
2645
+ // the session.
1867
2646
  openScene();
1868
2647
  return;
1869
2648
  }
1870
- if (isMod(key) && input === 'a') {
1871
- // Ctrl+A opens the subagent dashboard.
2649
+ if (actionMatches('dashboard', input, key)) {
2650
+ // The subagent dashboard key (default Ctrl+A) opens the dashboard.
1872
2651
  setSubagentDashboardOpen(true);
1873
2652
  return;
1874
2653
  }
1875
- if (isMod(key) && input === 'p' && loadedContextVisible) {
1876
- // Ctrl+P toggles the startup loaded-context panel while it is on
1877
- // screen (transcript still empty); once rows take over and the
1878
- // panel disappears the key has nothing left to do.
2654
+ if (actionMatches('contextPanel', input, key) && loadedContextVisible) {
2655
+ // The loaded-context panel key (default Ctrl+P) toggles the startup
2656
+ // panel while it is on screen (transcript still empty); once rows take
2657
+ // over and the panel disappears the key has nothing left to do.
1879
2658
  toggleLoadedContext();
1880
2659
  return;
1881
2660
  }
1882
- if (isMod(key) && input === 'r' && !helpOpen) {
1883
- setHistoryQuery('');
1884
- setHistoryCursor(0);
1885
- setHistoryFocus(0);
2661
+ if (actionMatches('history', input, key) && !helpOpen) {
1886
2662
  setHistoryEntries(loadHistory());
1887
- setHistoryOpen(true);
2663
+ dispatchOverlay({
2664
+ type: 'open',
2665
+ overlay: { kind: 'history', query: '', cursor: 0, focus: 0 },
2666
+ });
1888
2667
  return;
1889
2668
  }
1890
2669
  if (key.shift && key.upArrow && !selectionActive && !helpOpen) {
@@ -1921,11 +2700,12 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1921
2700
  }
1922
2701
  event.stopImmediatePropagation();
1923
2702
  }
1924
- else if (isMod(key) && input === 'o' && !helpOpen) {
1925
- // Leaving transcript mode (Ctrl+O) — search was already handled above.
1926
- // Help is modal: toggling this state behind the overlay is invisible,
1927
- // then the next `/` unexpectedly opens transcript search instead of
1928
- // slash-command completion after Help closes.
2703
+ else if (actionMatches('transcript', input, key) && !helpOpen) {
2704
+ // Leaving transcript mode (default Ctrl+O) — search was already
2705
+ // handled above. Help is modal: toggling this state behind the
2706
+ // overlay is invisible, then the next `/` unexpectedly opens
2707
+ // transcript search instead of slash-command completion after Help
2708
+ // closes.
1929
2709
  setExpanded(previous => !previous);
1930
2710
  // The toggle rewrites every thinking row's layout at once. The
1931
2711
  // ordinary scroll-based diff pushes rows into terminal scrollback on
@@ -1947,7 +2727,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1947
2727
  setSearchCursor(0);
1948
2728
  setSearchCurrent(0);
1949
2729
  setSearchCount(0);
1950
- setSearchOpen(true);
2730
+ dispatchOverlay({ type: 'open', overlay: { kind: 'search' } });
1951
2731
  event.stopImmediatePropagation();
1952
2732
  }
1953
2733
  }
@@ -1970,16 +2750,34 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1970
2750
  requestExit();
1971
2751
  }
1972
2752
  }
1973
- else if (isMod(key) && input === 'l') {
1974
- // CC's app:redraw — clear the physical terminal and repaint.
2753
+ else if (actionMatches('redraw', input, key)) {
2754
+ // CC's app:redraw (default Ctrl+L) — clear the physical terminal and
2755
+ // repaint.
1975
2756
  instances.get(process.stdout)?.forceRedraw();
1976
2757
  }
1977
- else if (isMod(key) && input === 'e') {
2758
+ else if (actionMatches('showAll', input, key)) {
1978
2759
  setShowAllMessages(previous => !previous);
1979
2760
  }
1980
- else if (plainReturn && showPill) {
2761
+ else if (actionMatches('todoFold', input, key)) {
2762
+ // Fold/unfold the GoalTodoPanel todo section (default Ctrl+Q) — works
2763
+ // mid-turn too: the collapsed line keeps the done/total count and the
2764
+ // live task preview, so long todo lists stop crowding the prompt.
2765
+ setTodoCollapsed(previous => !previous);
2766
+ }
2767
+ else if (plainReturn && !isSticky) {
2768
+ // Enter while scrolled up returns to the bottom (CC's pill: the
2769
+ // affordance now exists whenever the view is off the bottom, not
2770
+ // only with unseen rows).
1981
2771
  handle?.scrollToBottom();
1982
2772
  }
2773
+ else if (key.end && !isSticky) {
2774
+ // End = jump to bottom, less/vim semantics. Global on the chat
2775
+ // screen (search/history overlays consume their own End first —
2776
+ // cursor-to-line-end there). At the bottom already: no-op, so the
2777
+ // key stays harmless in muscle memory.
2778
+ handle?.scrollToBottom();
2779
+ event.stopImmediatePropagation();
2780
+ }
1983
2781
  else if (extensionShortcuts !== undefined && extensionShortcuts.dispatch(input, key)) {
1984
2782
  // Plugin shortcut (tuiShortcuts seam): matched only after every
1985
2783
  // built-in global binding above declined — locals always win, and the
@@ -2029,6 +2827,16 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2029
2827
  // hosts are already in it and must not nest a second one.
2030
2828
  return fullscreen ? browser : _jsx(AlternateScreen, { children: browser });
2031
2829
  }
2830
+ // The session tree follows the browser's rule exactly: it REPLACES the
2831
+ // conversation (an early return after every hook above has run), so there
2832
+ // is no transcript underneath to be repainted or bled through. The dropped
2833
+ // turn's prompt returns through the same fill path a rewind picker uses.
2834
+ if (treeOpen) {
2835
+ const tree = (_jsx(SessionTree, { channel: channel, currentSessionId: channel.agentId, onClose: () => setTreeOpen(false), onRestoreText: (text) => {
2836
+ setHistoryFill(text);
2837
+ } }));
2838
+ return fullscreen ? tree : _jsx(AlternateScreen, { children: tree });
2839
+ }
2032
2840
  // The settings screen follows the browser's rule exactly: it REPLACES the
2033
2841
  // conversation (an early return after every hook above has run), so there
2034
2842
  // is no transcript underneath to be repainted or bled through.
@@ -2061,10 +2869,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2061
2869
  }, onClose: () => setSubagentDashboardOpen(false) }));
2062
2870
  return fullscreen ? dashboard : _jsx(AlternateScreen, { children: dashboard });
2063
2871
  }
2064
- /** Prompt input is inert while a modal dialog owns the keyboard. */
2065
- const promptSelectionActive = selectionActive || modelPickerOpen || skillsPickerOpen || workspacePickerOpen || workspaceFlow !== null || activityPickerOpen ||
2066
- effortSliderOpen || presetPickerOpen || themePickerOpen || thinkingOpen || historyOpen || rewindOpen || searchOpen ||
2067
- btw !== null || tipsOpen;
2872
+ /** Prompt input is inert while a modal dialog owns the keyboard. The
2873
+ * overlay union covers every picker/dialog and /tips in one check;
2874
+ * message-selection mode and the /btw panel live outside it. */
2875
+ const promptSelectionActive = selectionActive || overlay.kind !== 'none' || btw !== null;
2068
2876
  // The trajectory scene replaces the conversation for as long as it is open.
2069
2877
  // Rendering it INSTEAD of (not above) the transcript is what makes it a
2070
2878
  // screen rather than an overlay: it owns the full viewport, and the
@@ -2077,24 +2885,60 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2077
2885
  const scene = _jsx(TrajectoryScene, { channel: channel, build: trajectory, onClose: closeScene });
2078
2886
  return fullscreen ? scene : _jsx(AlternateScreen, { children: scene });
2079
2887
  }
2080
- // 浮层整体挂载条件:必须与内部各面板的可见条件精确同值。关闭时把
2081
- // 整个 absolute 浮层从树里移除——渲染器的"移除 absolute 节点"检测只看
2082
- // 被移除子树自身的 style.position(dom.ts collectRemovedRects),若浮层
2083
- // 常驻、只移除其普通子节点,blit 解毒不触发,被覆盖的转录行会在
2084
- // blit-skip 后留空(Esc 关 picker 一片空白的根因)。
2085
- const dialogOverlayOpen = thinkingOpen || (workspacePickerOpen && workspaceTargets.length > 0) || workspaceFlow !== null ||
2086
- modelPickerOpen || skillsPickerOpen ||
2087
- activityPickerOpen || (effortSliderOpen && effortOptions.length > 1) ||
2088
- (presetPickerOpen && presetOptions.length > 0) || themePickerOpen || historyOpen ||
2089
- rewindOpen || searchOpen || tipsOpen;
2090
- return (_jsxs(Box, { ref: wakeTickRef, flexDirection: "column", flexGrow: 1, width: "100%", children: [!isSticky && channel.lastUserText && (_jsx(StickyPromptHeader, { text: channel.lastUserText, onClick: () => {
2091
- // Click jumps back to the pinned prompt (CC's StickyPromptHeader).
2092
- const lastUser = [...channel.rows].reverse().find(row => row.kind === 'user');
2093
- if (lastUser)
2094
- seekRow(lastUser.id);
2888
+ // 浮层整体挂载条件:与内部各面板的可见条件同值(数据门在
2889
+ // dialogOverlayVisible 里逐面板镜像)。关闭时把整个 absolute 浮层从树里
2890
+ // 移除——渲染器的"移除 absolute 节点"检测只看被移除子树自身的
2891
+ // style.position(dom.ts collectRemovedRects),若浮层常驻、只移除其
2892
+ // 普通子节点,blit 解毒不触发,被覆盖的转录行会在 blit-skip 后留空
2893
+ // (Esc 关 picker 一片空白的根因)。
2894
+ const dialogOverlayOpen = dialogOverlayVisible(overlay, {
2895
+ workspaceTargetCount: workspaceTargets.length,
2896
+ effortOptionCount: effortOptions.length,
2897
+ presetOptionCount: presetOptions.length,
2898
+ });
2899
+ // The sticky header pins the turn owning the viewport top row
2900
+ // (timeline.activeId, reported by MessageList) — scrolled up to an old
2901
+ // turn, it carries THAT turn's prompt, not the latest one.
2902
+ // channel.rows is a live in-place array, so the lookup is per-render.
2903
+ const anchorUserRowId = timeline.activeId;
2904
+ const anchorUserText = anchorUserRowId === null
2905
+ ? null
2906
+ : channel.rows.find(row => row.id === anchorUserRowId)?.text ?? null;
2907
+ return (_jsxs(Box, { ref: wakeTickRef, flexDirection: "column", flexGrow: 1, width: "100%", children: [!isSticky && anchorUserText && (_jsx(StickyPromptHeader, { text: anchorUserText, onClick: () => {
2908
+ // Click snaps the pinned prompt to the viewport top (CC's
2909
+ // StickyPromptHeader). Jump by the SAME content coordinate the
2910
+ // rail's tick uses (timeline turn top = the prompt TEXT top):
2911
+ // the element-based seek lands the row wrapper's margin at the
2912
+ // top instead — one row shy of the text top the anchor rule
2913
+ // compares against — and the header would flip to the previous
2914
+ // turn immediately after the click.
2915
+ const turn = timeline.turns.find(t => t.id === anchorUserRowId);
2916
+ if (turn)
2917
+ handle?.scrollTo(turn.top);
2918
+ else if (anchorUserRowId !== null)
2919
+ seekRow(anchorUserRowId);
2095
2920
  else
2096
2921
  handle?.scrollToBottom();
2097
- } })), _jsxs(ScrollBox, { ref: setHandle, flexDirection: "column", flexGrow: 1, flexShrink: 1, stickyScroll: true, children: [_jsx(LogoHeader, { model: channel.model, effort: channel.reasoningEffort, cwd: channel.displayCwd, whale: channel.whale }, logoNonce), loadedContextVisible && (_jsx(LoadedContextPanel, { context: channel.loadedContext, open: loadedContextOpen, onToggle: toggleLoadedContext })), _jsx(MessageList, { rows: channel.rows, failureHintRowId: failureHintRowId, failureHint: t('traj-hint-failure', { key: `${modLabel}t` }), expanded: expanded, expandedRows: expandedRows, selectedId: selectionActive ? selectedId : null, onToggleRow: toggleRowExpanded, model: channel.model, diffLayout: channel.diffLayout, thinkingFold: channel.thinkingFold, toolBackground: channel.toolBackground, activityFrames: channel.activityFrames, showAll: showAllMessages, thinkingVisible: thinkingVisible, onToggleAll: () => { setShowAllMessages(previous => !previous); }, onLoadOlder: () => channel.loadOlder(), registerRowRef: registerRowRef, scrollHandle: handle, forceMountRowId: forceMountRowId, newSinceRowId: isSticky ? null : lastSeenRowIdRef.current, onUnseenCount: setUnseenCount })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [showPill && (_jsx(NewMessagesPill, { count: unseenCount, onClick: () => handle?.scrollToBottom() })), channel.working &&
2922
+ } })), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, width: "100%", children: [_jsxs(ScrollBox, { ref: setHandle, flexDirection: "column", flexGrow: 1, flexShrink: 1, stickyScroll: true, children: [_jsx(LogoHeader, { model: channel.model, effort: channel.reasoningEffort, cwd: channel.displayCwd, whale: channel.whale,
2923
+ // Resuming a long session skips the ~3.4s opening animation: it
2924
+ // keeps firing low-frequency React commits that compete with the
2925
+ // transcript mount batches (and the first wheel events) for the
2926
+ // frame budget right when the user wants to read history. Fresh
2927
+ // sessions keep the full intro; restored ones settle instantly.
2928
+ skipIntro: channel.rows.length > 30 }, logoNonce), loadedContextVisible && (_jsx(LoadedContextPanel, { context: channel.loadedContext, open: loadedContextOpen, onToggle: toggleLoadedContext })), _jsx(MessageList, { rows: channel.rows, failureHintRowId: failureHintRowId, failureHint: t('traj-hint-failure', { key: `${modLabel}t` }), expanded: expanded, expandedRows: expandedRows, selectedId: selectionActive ? selectedId : null, onToggleRow: toggleRowExpanded, streamFoldedRows: streamFoldedRows, onToggleStreamFold: toggleStreamFolded, model: channel.model, diffLayout: channel.diffLayout, thinkingFold: channel.thinkingFold, toolBackground: channel.toolBackground, activityFrames: channel.activityFrames, showAll: showAllMessages, thinkingVisible: thinkingVisible, historyPaintEnabled: !fullscreen, onToggleAll: () => { setShowAllMessages(previous => !previous); }, onLoadOlder: () => channel.loadOlder(), registerRowRef: registerRowRef, scrollHandle: handle, forceMountRowId: forceMountRowId, newSinceRowId: isSticky ? null : lastSeenRowIdRef.current, onUnseenCount: setUnseenCount, onTimeline: setTimeline, onOpenSubagent: (agentId) => setSubagentDetailId(agentId), onOpenFile: openFileActions })] }), (() => {
2929
+ // Gutter mode (settings `dsh-tui.scrollGutter`): the timeline
2930
+ // rail (default), the proportional scrollbar, or nothing. The
2931
+ // slot keeps its 2 columns in both rendered modes (Qwen's
2932
+ // permanent-gutter rule — an appearing/disappearing gutter
2933
+ // changes the transcript width and rewraps everything).
2934
+ const gutter = normalizeScrollGutter(channel.scrollGutter);
2935
+ if (gutter === 'hidden')
2936
+ return null;
2937
+ if (gutter === 'scrollbar') {
2938
+ return _jsx(ScrollbarGutter, { handle: handle, terminalWidth: terminalColumns });
2939
+ }
2940
+ return (_jsx(TimelineRail, { handle: handle, turns: timeline.turns, activeId: timeline.activeId, upId: timeline.upId, downId: timeline.downId, terminalWidth: terminalColumns, hoverEnabled: !promptSelectionActive, onRevealTurn: revealAndSeekRow }));
2941
+ })()] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [showPill && (_jsx(NewMessagesPill, { count: unseenCount, onClick: () => handle?.scrollToBottom() })), channel.working &&
2098
2942
  (channel.activityEnabled &&
2099
2943
  !channel.minimal &&
2100
2944
  channel.workingActivity !== undefined &&
@@ -2104,22 +2948,182 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2104
2948
  // the animated chars/4 estimate, matching the classic
2105
2949
  // spinner's counter (the suffix used raw chars before,
2106
2950
  // inflating the reading next to a real upload number).
2107
- suffix: `${lastUploadTokens > 0 ? ` · ↑ ${formatTokens(lastUploadTokens)}` : ''} · ↓ ${formatTokens(Math.round(channel.responseChars / 4))} tokens` }) })) : (_jsx(WorkingSpinner, { mode: channel.spinnerMode, hasActiveTools: channel.activeToolCount > 0, responseLengthRef: responseLengthRef, uploadTokensRef: uploadTokensRef, loadingStartTimeRef: loadingStartTimeRef, totalPausedMsRef: totalPausedMsRef, pauseStartTimeRef: pauseStartTimeRef, thinkingStatus: thinkingStatus }))), _jsx(GoalTodoPanel, { channel: channel }), statusEntries.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate", children: statusEntries.map(entry => entry.text).join(' · ') })), approvalSnapshot !== null ? (_jsx(ApprovalPanel, { approval: approvalSnapshot, onDecide: outcome => approvals.decide(outcome) }, approvalSnapshot.key)) : dialogSnapshot !== null ? (_jsx(ExtensionDialog, { dialog: dialogSnapshot, onDecide: value => dialogs.decide(dialogSnapshot.key, value), onCancel: () => dialogs.cancel(dialogSnapshot.key) }, dialogSnapshot.key)) : tipsOpen ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(TipsPanel, { onClose: () => setTipsOpen(false) }) })) : btw !== null ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(BtwPanel, { question: btw.question, answer: btw.answer, error: btw.error, streaming: !btw.done, onClose: closeBtw, onCopy: () => {
2951
+ suffix: `${lastUploadTokens > 0 ? ` · ↑ ${formatTokens(lastUploadTokens)}` : ''} · ↓ ${formatTokens(Math.round(channel.responseChars / 4))} tokens` }) })) : (_jsx(WorkingSpinner, { mode: channel.spinnerMode, hasActiveTools: channel.activeToolCount > 0, responseLengthRef: responseLengthRef, uploadTokensRef: uploadTokensRef, loadingStartTimeRef: loadingStartTimeRef, totalPausedMsRef: totalPausedMsRef, pauseStartTimeRef: pauseStartTimeRef, thinkingStatus: thinkingStatus }))), _jsx(GoalTodoPanel, { channel: channel, collapsed: todoCollapsed, onToggle: () => setTodoCollapsed(previous => !previous) }), recap !== null && recap.auto && !recap.expanded && (_jsx(AutoRecapRow, { summary: recap.summary, streaming: !recap.done, onExpand: () => setRecap(prev => (prev ? { ...prev, expanded: true } : prev)), onDismiss: () => closeRecap() })), statusEntries.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate", children: statusEntries.map(entry => entry.text).join(' · ') })), approvalSnapshot !== null ? (_jsx(ApprovalPanel, { approval: approvalSnapshot, onDecide: outcome => approvals.decide(outcome) }, approvalSnapshot.key)) : dialogSnapshot !== null ? (_jsx(ExtensionDialog, { dialog: dialogSnapshot, onDecide: value => dialogs.decide(dialogSnapshot.key, value), onCancel: () => dialogs.cancel(dialogSnapshot.key) }, dialogSnapshot.key)) : overlay.kind === 'tips' ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(TipsPanel, { onClose: () => dispatchOverlay({ type: 'close-if', kind: 'tips' }) }) })) : recap !== null && (!recap.auto || recap.expanded) ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RecapPanel, { summary: recap.summary, title: recap.title, error: recap.error, streaming: !recap.done, titleApplied: recap.titleApplied, onClose: () => {
2952
+ // An expanded auto recap collapses back to its dim row;
2953
+ // a manual /recap closes outright.
2954
+ if (recap.auto) {
2955
+ setRecap(prev => (prev ? { ...prev, expanded: false } : prev));
2956
+ }
2957
+ else {
2958
+ closeRecap();
2959
+ }
2960
+ }, onCopy: () => {
2961
+ void setClipboard(recap.summary ?? '').then(raw => { if (raw)
2962
+ writeRaw?.(raw); });
2963
+ channel.notify(t('copied-chars', { n: (recap.summary ?? '').length }), { timeoutMs: 1500 });
2964
+ }, onApplyTitle: () => {
2965
+ if (recap.title === undefined || recap.titleApplied)
2966
+ return;
2967
+ channel.renameSession(recap.title);
2968
+ setRecap(prev => (prev ? { ...prev, titleApplied: true } : prev));
2969
+ channel.notify(t('recap-title-applied-notify', { title: recap.title }), { color: 'success' });
2970
+ } }) })) : btw !== null ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(BtwPanel, { question: btw.question, answer: btw.answer, error: btw.error, streaming: !btw.done, onClose: closeBtw, onCopy: () => {
2108
2971
  void setClipboard(btw.answer ?? '').then(raw => { if (raw)
2109
2972
  writeRaw?.(raw); });
2110
2973
  channel.notify(t('copied-chars', { n: (btw.answer ?? '').length }), { timeoutMs: 1500 });
2111
- } }) })) : questionSnapshot !== null ? (_jsx(AskUserQuestionPanel, { question: questionSnapshot.question, position: questionSnapshot.position, total: questionSnapshot.total, answered: questionSnapshot.answered, onAnswer: selection => questionStore.answerCurrent(selection), onCancel: () => questionStore.cancelCurrent() }, questionSnapshot.key)) : (_jsx(PromptInput, { channel: channel, helpOpen: helpOpen, onToggleHelp: () => { setHelpOpen(previous => !previous); }, onRunCommand: runCommand, selectionActive: promptSelectionActive, fillText: historyFill, onFillConsumed: () => { setHistoryFill(null); }, onRewindRequest: openRewind, controllerRef: promptControllerRef })), _jsx(StatusLine, { channel: channel, selectionActive: selectionActive, helpOpen: helpOpen, wake: wakeBand === undefined
2974
+ } }) })) : questionSnapshot !== null ? (_jsx(AskUserQuestionPanel, { question: questionSnapshot.question, position: questionSnapshot.position, total: questionSnapshot.total, answered: questionSnapshot.answered, initialDraft: questionSnapshot.draft, onAnswer: selection => questionStore.answerCurrent(selection), onCancel: () => questionStore.cancelCurrent(), onBack: questionSnapshot.canGoBack
2975
+ ? draft => questionStore.backCurrent(draft)
2976
+ : undefined }, questionSnapshot.key)) : (_jsx(PromptInput, { channel: channel, helpOpen: helpOpen, onToggleHelp: () => { setHelpOpen(previous => !previous); }, onRunCommand: runCommand, selectionActive: promptSelectionActive, fillText: historyFill, onFillConsumed: () => { setHistoryFill(null); }, onRewindRequest: openRewind, controllerRef: promptControllerRef })), _jsx(StatusLine, { channel: channel, selectionActive: selectionActive, helpOpen: helpOpen, wake: wakeBand === undefined
2112
2977
  ? undefined
2113
2978
  : {
2114
2979
  band: wakeBand,
2115
2980
  hint: trajectorySeen ? undefined : `${modLabel}t`,
2116
2981
  tick: Math.floor(wakeTime / 120),
2117
- } }), dialogOverlayOpen && (_jsxs(OverlayAbove, { maxHeight: Math.max(terminalRows - 8, 8), children: [thinkingOpen && (_jsx(ThinkingToggle, { currentValue: thinkingVisible, focusIndex: thinkingFocus })), workspacePickerOpen && workspaceTargets.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspacePicker, { targets: workspaceTargets, focusIndex: workspaceIndex, currentCwd: channel.cwd }) })), workspaceFlow !== null && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspaceFlowPicker, { title: workspaceFlow.title, choices: workspaceFlow.choices, focusIndex: workspaceFlowIndex, busy: workspaceFlowBusy, input: workspaceFlowInput }) })), modelPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: models.length === 0 ? (_jsx(ModelPickerLoading, {})) : (_jsx(ModelPicker, { models: models, focusIndex: modelIndex, currentModel: `${channel.provider}/${channel.model}` })) })), skillsPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: skillsList === null ? (_jsx(SkillsPickerLoading, {})) : (_jsx(SkillsPicker, { skills: skillsList, focusIndex: skillsIndex })) })), activityPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ActivityPicker, { focusIndex: activityIndex, currentPreset: channel.activityFrames }) })), effortSliderOpen && effortOptions.length > 1 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(EffortSlider, { options: effortOptions, focusIndex: effortIndex, currentId: channel.reasoningEffort }) })), presetPickerOpen && presetOptions.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PresetPicker, { presets: presetOptions, focusIndex: presetIndex, currentPreset: channel.agentPreset }) })), themePickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ThemePicker, { focusIndex: themeIndex, currentTheme: themeName }) })), historyOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(HistorySearchDialog, { query: historyQuery, cursorOffset: historyCursor, matches: historyMatches, focusIndex: historyFocus }) })), rewindOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RewindPicker, { rows: rewindRows, focusIndex: rewindIndex, confirmRow: rewindConfirm, modes: rewindModes, modeIndex: rewindModeIndex, busy: rewindBusy }) })), searchOpen && _jsx(TranscriptSearchBar, { query: searchQuery, cursorOffset: searchCursor, count: searchCount, current: searchCurrent })] }))] })] }));
2982
+ } }), dialogOverlayOpen && (_jsxs(OverlayAbove, { maxHeight: Math.max(terminalRows - 8, 1), children: [overlay.kind === 'thinking' && (_jsx(ThinkingToggle, { currentValue: thinkingVisible, focusIndex: overlay.focus, onPick: (index) => {
2983
+ // 点击行 = 设焦点 + 应用(与 Enter 同一条路径)
2984
+ const visible = index === 0;
2985
+ setThinkingVisible(visible);
2986
+ dispatchOverlay({ type: 'close' });
2987
+ channel.notify(t('thinking-toggled', { state: visible ? t('thinking-on') : t('thinking-off') }));
2988
+ } })), overlay.kind === 'workspace-picker' && workspaceTargets.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspacePicker, { targets: workspaceTargets, focusIndex: overlay.index, currentCwd: channel.cwd, onPick: (index) => {
2989
+ // 点击行 = 切换该行目标(与 Enter 同一条路径)
2990
+ const target = workspaceTargets[index];
2991
+ dispatchOverlay({ type: 'close' });
2992
+ if (target !== undefined)
2993
+ void channel.switchWorkspace(target);
2994
+ } }) })), overlay.kind === 'workspace-menu' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspaceMenuPicker, { options: workspaceMenuOptions, focusIndex: overlay.index, onPick: (index) => {
2995
+ // 点击行 = 执行该行(与 Enter 同一条路径)
2996
+ runWorkspaceMenuOption(workspaceMenuOptions[index]);
2997
+ } }) })), overlay.kind === 'workspace-flow' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspaceFlowPicker, { title: overlay.flow.title, choices: overlay.flow.choices, focusIndex: overlay.index, busy: overlay.busy, input: overlay.input, onPick: (index) => {
2998
+ // 点击行 = 设焦点 + 执行分支(与 Enter 同一条路径);
2999
+ // busy/输入态在组件侧禁点
3000
+ const choice = overlay.flow.choices[index];
3001
+ if (choice === undefined)
3002
+ return;
3003
+ dispatchOverlay({ type: 'set-index', kind: 'workspace-flow', index });
3004
+ runWorkspaceFlowAction(signal => choice.choose(signal));
3005
+ } }) })), overlay.kind === 'model' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: models.length === 0 ? (_jsx(ModelPickerLoading, {})) : activeModelGroup === undefined ? (_jsx(ModelPicker, { groups: modelGroups, focusIndex: overlay.index, currentProvider: channel.provider, onPick: (index) => {
3006
+ // 点击分组行 = 进入该组(与 Enter 同一条路径)
3007
+ const group = modelGroups[index];
3008
+ if (!group)
3009
+ return;
3010
+ setModelGroup(group.provider);
3011
+ if (group.provider === RECENTS_GROUP_PROVIDER) {
3012
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: 0 });
3013
+ return;
3014
+ }
3015
+ const landing = modelPickerLanding(models.filter(model => model.provider === group.provider), channel.provider, channel.model);
3016
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: landing.index });
3017
+ } })) : (_jsx(ModelPicker, { models: groupModels, groupLabel: activeModelGroup === RECENTS_GROUP_PROVIDER
3018
+ ? t('picker-group-recent')
3019
+ : modelGroups.find(group => group.provider === activeModelGroup)?.label, showBack: modelGroups.length > 1 && !modelPickerDirect, showProviderPrefix: activeModelGroup === RECENTS_GROUP_PROVIDER, focusIndex: overlay.index, currentModel: `${channel.provider}/${channel.model}`, onPick: (index) => {
3020
+ // 点击行 = 应用该行模型(与 Enter 同一条路径)
3021
+ const model = groupModels[index];
3022
+ if (!model)
3023
+ return;
3024
+ dispatchOverlay({ type: 'close' });
3025
+ void switchModelRecorded(model.provider, model.id, model.name);
3026
+ } })) })), overlay.kind === 'skills' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: skillsList === null ? (_jsx(SkillsPickerLoading, {})) : (_jsx(SkillsPicker, { skills: skillsList, focusIndex: overlay.index, onPick: (index) => {
3027
+ const skill = skillsList[index];
3028
+ if (!skill)
3029
+ return;
3030
+ dispatchOverlay({ type: 'close' });
3031
+ if (skill.userInvocable)
3032
+ setHistoryFill(`/${skill.name} `);
3033
+ } })) })), overlay.kind === 'activity' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ActivityPicker, { focusIndex: overlay.index, currentPreset: channel.activityFrames, onPick: (index) => {
3034
+ dispatchOverlay({ type: 'close' });
3035
+ const name = PRESET_NAMES[index];
3036
+ if (name)
3037
+ channel.setActivityFrames(name);
3038
+ } }) })), overlay.kind === 'color' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ColorPicker, { focusIndex: overlay.index, currentColor: channel.sessionColor, onPick: (index) => {
3039
+ dispatchOverlay({ type: 'close' });
3040
+ const name = SESSION_COLOR_NAMES[index];
3041
+ if (name) {
3042
+ channel.setSessionColor(name);
3043
+ channel.notify(t('color-set', { name }), { color: 'success' });
3044
+ }
3045
+ } }) })), overlay.kind === 'effort' && effortOptions.length > 1 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(EffortSlider, { options: effortOptions, focusIndex: overlay.index, currentId: channel.reasoningEffort,
3046
+ // 点击档位 = 移到该档并即时应用(与 ←/→ 同语义)
3047
+ onPick: (index) => {
3048
+ dispatchOverlay({ type: 'set-index', kind: 'effort', index });
3049
+ const option = effortOptions[index];
3050
+ if (option)
3051
+ void channel.setEffort(option.id);
3052
+ } }) })), overlay.kind === 'preset' && presetOptions.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PresetPicker, { presets: presetOptions, focusIndex: overlay.index, currentPreset: channel.agentPreset, onPick: (index) => {
3053
+ dispatchOverlay({ type: 'close' });
3054
+ const option = presetOptions[index];
3055
+ if (option)
3056
+ void channel.switchPreset(option.id);
3057
+ } }) })), overlay.kind === 'permission' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PermissionsPicker, { focusIndex: overlay.index, currentMode: channel.mode.sandbox, cwd: channel.cwd, onPick: (index) => {
3058
+ dispatchOverlay({ type: 'close' });
3059
+ const id = PERMISSION_PRESET_IDS[index];
3060
+ if (id !== undefined) {
3061
+ void channel.runExternalCommand('permission', ` ${id}`).then((text) => {
3062
+ if (text !== undefined && text !== '')
3063
+ channel.notify(text);
3064
+ });
3065
+ }
3066
+ } }) })), overlay.kind === 'plan' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PlanPicker, { focusIndex: overlay.index, currentOn: channel.mode.plan === true, onPick: (index) => {
3067
+ dispatchOverlay({ type: 'close' });
3068
+ const on = index === 0;
3069
+ void channel.runExternalCommand('plan', on ? '' : ' off').then((text) => {
3070
+ if (text !== undefined && text !== '')
3071
+ channel.notify(text);
3072
+ });
3073
+ } }) })), overlay.kind === 'lang' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(LangPicker, { focusIndex: overlay.index, currentLang: getLang(), onPick: (index) => {
3074
+ const lang = LANGS[index];
3075
+ if (lang === undefined)
3076
+ return;
3077
+ dispatchOverlay({ type: 'close' });
3078
+ applyLang(lang);
3079
+ } }) })), overlay.kind === 'theme' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ThemePicker, { focusIndex: overlay.index, currentTheme: themeName, onPick: (index) => {
3080
+ dispatchOverlay({ type: 'close' });
3081
+ const name = getThemeOptions()[index]?.value;
3082
+ if (name !== undefined) {
3083
+ const ok = setTheme(name);
3084
+ channel.notify(ok ? t('theme-switched-saved', { name }) : t('theme-switch-failed', { name }), { color: ok ? 'success' : 'error' });
3085
+ }
3086
+ } }) })), overlay.kind === 'history' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(HistorySearchDialog, { query: overlay.query, cursorOffset: overlay.cursor, matches: historyMatches, focusIndex: overlay.focus, onPick: (index) => {
3087
+ // 点击行 = 填入该历史命令(与 Enter 同路径)
3088
+ const entry = historyMatches[index];
3089
+ if (entry) {
3090
+ setHistoryFill(entry.text);
3091
+ dispatchOverlay({ type: 'close' });
3092
+ }
3093
+ } }) })), overlay.kind === 'rewind' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RewindPicker, { rows: rewindRows, focusIndex: overlay.index, confirmRow: overlay.confirm, modes: overlay.modes, modeIndex: overlay.modeIndex, busy: overlay.busy, onPickRow: (index) => {
3094
+ // 列表页点击只选中:进入确认态保留键盘 Enter 显式触发
3095
+ dispatchOverlay({ type: 'set-index', kind: 'rewind', index });
3096
+ }, onConfirm: () => {
3097
+ // 确认页即显式确认层,点击直接执行(与 Enter 同路径)
3098
+ const row = overlay.confirm;
3099
+ if (row === null)
3100
+ return;
3101
+ dispatchOverlay({ type: 'close' });
3102
+ void performRewind(row);
3103
+ }, onPickMode: (index) => {
3104
+ // 模式列表点击直接执行该模式(与 Enter 同路径)
3105
+ const row = overlay.confirm;
3106
+ if (row === null)
3107
+ return;
3108
+ // 模式页仅当 modes 非空才渲染,这里空安全取值
3109
+ const mode = index === 0 ? null : (overlay.modes?.[index - 1]?.id ?? null);
3110
+ dispatchOverlay({ type: 'close' });
3111
+ void performRewind(row, mode);
3112
+ } }) })), overlay.kind === 'file-actions' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(FileActionsPanel, { path: overlay.path, isDir: overlay.isDir, focusIndex: overlay.index, onPick: (index) => {
3113
+ // 点击行直接执行该动作(与 Enter 同路径)
3114
+ const path = overlay.path;
3115
+ dispatchOverlay({ type: 'close' });
3116
+ runFileAction(index, path);
3117
+ } }) })), overlay.kind === 'search' && _jsx(TranscriptSearchBar, { query: searchQuery, cursorOffset: searchCursor, count: searchCount, current: searchCurrent })] }))] })] }));
2118
3118
  }
2119
3119
  /**
2120
3120
  * The pinned prompt header shown above the ScrollBox while the user has
2121
3121
  * scrolled up (mirroring Claude Code's FullscreenLayout.StickyPromptHeader).
2122
- * Fixed at 1 row so the ScrollBox never shifts when the text changes.
3122
+ * Pins the user message the transcript viewport is currently showing — the
3123
+ * topmost visible user message, or the nearest one above when only assistant
3124
+ * content fills the view — so it tracks which turn the user is reading
3125
+ * instead of always carrying the latest prompt. Fixed at 1 row so the
3126
+ * ScrollBox never shifts when the text changes.
2123
3127
  */
2124
3128
  function StickyPromptHeader({ text, onClick, }) {
2125
3129
  return (_jsx(Box, { flexShrink: 0, width: "100%", height: 1, paddingRight: 1, onClick: onClick, children: _jsxs(Text, { color: "briefLabelYou", bold: true, wrap: "truncate-end", children: [POINTER, " ", text] }) }));
@@ -2127,7 +3131,9 @@ function StickyPromptHeader({ text, onClick, }) {
2127
3131
  /** The `↓ N new messages` pill shown while scrolled up with new content. */
2128
3132
  function NewMessagesPill({ count, onClick, }) {
2129
3133
  const [hover, setHover] = React.useState(false);
2130
- return (_jsx(Box, { paddingX: 2, paddingTop: 1, children: _jsx(Box, { backgroundColor: hover ? 'userMessageBackgroundHover' : 'background', onClick: onClick, onMouseEnter: () => { setHover(true); }, onMouseLeave: () => { setHover(false); }, children: _jsxs(Text, { color: "inverseText", bold: true, children: [' ', "\u2193 ", t(count === 1 ? 'new-message' : 'new-messages', { n: count }), ' '] }) }) }));
3134
+ return (_jsx(Box, { paddingX: 2, paddingTop: 1, children: _jsx(Box, { backgroundColor: hover ? 'userMessageBackgroundHover' : 'background', onClick: onClick, onMouseEnter: () => { setHover(true); }, onMouseLeave: () => { setHover(false); }, children: _jsxs(Text, { color: "inverseText", bold: true, children: [' ', count > 0
3135
+ ? t(count === 1 ? 'new-message' : 'new-messages', { n: count })
3136
+ : t('back-to-bottom'), ' '] }) }) }));
2131
3137
  }
2132
3138
  /** /model while the provider catalog is still loading (CC's LoadingState). */
2133
3139
  function ModelPickerLoading() {