@deepseek-harness-tui/dsh-tui 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +29 -10
  2. package/bin/dsh-tui.js +1 -1
  3. package/lib/types/commands.d.ts.map +1 -1
  4. package/lib/types/commands.js +4 -2
  5. package/lib/types/components/ActivityPicker.d.ts +4 -1
  6. package/lib/types/components/ActivityPicker.d.ts.map +1 -1
  7. package/lib/types/components/ActivityPicker.js +2 -2
  8. package/lib/types/components/BtwPanel.d.ts.map +1 -1
  9. package/lib/types/components/BtwPanel.js +1 -1
  10. package/lib/types/components/Chat/SubagentMessage.d.ts +2 -1
  11. package/lib/types/components/Chat/SubagentMessage.d.ts.map +1 -1
  12. package/lib/types/components/Chat/SubagentMessage.js +11 -3
  13. package/lib/types/components/CommandSuggestions.d.ts +21 -4
  14. package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
  15. package/lib/types/components/CommandSuggestions.js +41 -14
  16. package/lib/types/components/EffortSlider.d.ts +4 -1
  17. package/lib/types/components/EffortSlider.d.ts.map +1 -1
  18. package/lib/types/components/EffortSlider.js +4 -2
  19. package/lib/types/components/ExtensionDialog.js +6 -2
  20. package/lib/types/components/FileSuggestions.d.ts +26 -5
  21. package/lib/types/components/FileSuggestions.d.ts.map +1 -1
  22. package/lib/types/components/FileSuggestions.js +53 -16
  23. package/lib/types/components/GoalTodoPanel.d.ts +26 -2
  24. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  25. package/lib/types/components/GoalTodoPanel.js +86 -17
  26. package/lib/types/components/HelpMenu.d.ts +7 -1
  27. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  28. package/lib/types/components/HelpMenu.js +12 -4
  29. package/lib/types/components/HistorySearchDialog.d.ts +4 -1
  30. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  31. package/lib/types/components/HistorySearchDialog.js +2 -2
  32. package/lib/types/components/LangPicker.d.ts +14 -0
  33. package/lib/types/components/LangPicker.d.ts.map +1 -0
  34. package/lib/types/components/LangPicker.js +17 -0
  35. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  36. package/lib/types/components/LoadedContextPanel.js +3 -1
  37. package/lib/types/components/LogoV2.d.ts +5 -1
  38. package/lib/types/components/LogoV2.d.ts.map +1 -1
  39. package/lib/types/components/LogoV2.js +12 -3
  40. package/lib/types/components/Markdown.d.ts.map +1 -1
  41. package/lib/types/components/Markdown.js +12 -3
  42. package/lib/types/components/MessageList.d.ts +43 -2
  43. package/lib/types/components/MessageList.d.ts.map +1 -1
  44. package/lib/types/components/MessageList.js +553 -41
  45. package/lib/types/components/ModelPicker.d.ts +4 -1
  46. package/lib/types/components/ModelPicker.d.ts.map +1 -1
  47. package/lib/types/components/ModelPicker.js +2 -2
  48. package/lib/types/components/PermissionsPicker.d.ts +21 -0
  49. package/lib/types/components/PermissionsPicker.d.ts.map +1 -0
  50. package/lib/types/components/PermissionsPicker.js +27 -0
  51. package/lib/types/components/PlanPicker.d.ts +15 -0
  52. package/lib/types/components/PlanPicker.d.ts.map +1 -0
  53. package/lib/types/components/PlanPicker.js +18 -0
  54. package/lib/types/components/PresetPicker.d.ts +4 -1
  55. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  56. package/lib/types/components/PresetPicker.js +2 -2
  57. package/lib/types/components/PromptInput.d.ts +4 -3
  58. package/lib/types/components/PromptInput.d.ts.map +1 -1
  59. package/lib/types/components/PromptInput.js +273 -50
  60. package/lib/types/components/RewindPicker.d.ts +12 -1
  61. package/lib/types/components/RewindPicker.d.ts.map +1 -1
  62. package/lib/types/components/RewindPicker.js +6 -4
  63. package/lib/types/components/ScrollbarGutter.d.ts +28 -0
  64. package/lib/types/components/ScrollbarGutter.d.ts.map +1 -0
  65. package/lib/types/components/ScrollbarGutter.js +68 -0
  66. package/lib/types/components/Select.d.ts +8 -1
  67. package/lib/types/components/Select.d.ts.map +1 -1
  68. package/lib/types/components/Select.js +4 -2
  69. package/lib/types/components/SkillsPicker.d.ts +4 -1
  70. package/lib/types/components/SkillsPicker.d.ts.map +1 -1
  71. package/lib/types/components/SkillsPicker.js +2 -2
  72. package/lib/types/components/SubagentCard.d.ts +2 -1
  73. package/lib/types/components/SubagentCard.d.ts.map +1 -1
  74. package/lib/types/components/SubagentCard.js +6 -1
  75. package/lib/types/components/SubagentDashboard.d.ts +4 -0
  76. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  77. package/lib/types/components/SubagentDashboard.js +9 -1
  78. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
  79. package/lib/types/components/SubagentDetailScene.js +5 -4
  80. package/lib/types/components/SuggestionCard.d.ts +63 -0
  81. package/lib/types/components/SuggestionCard.d.ts.map +1 -0
  82. package/lib/types/components/SuggestionCard.js +77 -0
  83. package/lib/types/components/ThemePicker.d.ts +4 -1
  84. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  85. package/lib/types/components/ThemePicker.js +2 -2
  86. package/lib/types/components/ThinkingToggle.d.ts +4 -1
  87. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  88. package/lib/types/components/ThinkingToggle.js +2 -2
  89. package/lib/types/components/TimelineRail.d.ts +53 -0
  90. package/lib/types/components/TimelineRail.d.ts.map +1 -0
  91. package/lib/types/components/TimelineRail.js +190 -0
  92. package/lib/types/components/WorkspaceFlowPicker.d.ts +6 -1
  93. package/lib/types/components/WorkspaceFlowPicker.d.ts.map +1 -1
  94. package/lib/types/components/WorkspaceFlowPicker.js +4 -2
  95. package/lib/types/components/WorkspaceMenuPicker.d.ts +19 -0
  96. package/lib/types/components/WorkspaceMenuPicker.d.ts.map +1 -0
  97. package/lib/types/components/WorkspaceMenuPicker.js +19 -0
  98. package/lib/types/components/WorkspacePicker.d.ts +4 -1
  99. package/lib/types/components/WorkspacePicker.d.ts.map +1 -1
  100. package/lib/types/components/WorkspacePicker.js +2 -2
  101. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  102. package/lib/types/components/approvals/ApprovalPanel.js +5 -1
  103. package/lib/types/components/design-system/ListItem.d.ts +8 -1
  104. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  105. package/lib/types/components/design-system/ListItem.js +8 -3
  106. package/lib/types/components/messages/AssistantTextMessage.d.ts +5 -2
  107. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -1
  108. package/lib/types/components/messages/AssistantTextMessage.js +6 -2
  109. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -1
  110. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  111. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -4
  112. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +8 -1
  113. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  114. package/lib/types/components/messages/AssistantToolUseMessage.js +4 -2
  115. package/lib/types/components/messages/UserPromptMessage.d.ts +2 -1
  116. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  117. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  118. package/lib/types/components/questions/AskUserQuestionPanel.js +34 -2
  119. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  120. package/lib/types/components/questions/PlanReviewPanel.js +13 -2
  121. package/lib/types/components/sessions/SessionListRow.d.ts +4 -1
  122. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  123. package/lib/types/components/sessions/SessionListRow.js +4 -2
  124. package/lib/types/components/trajectory/HotspotView.d.ts +7 -1
  125. package/lib/types/components/trajectory/HotspotView.d.ts.map +1 -1
  126. package/lib/types/components/trajectory/HotspotView.js +8 -2
  127. package/lib/types/components/trajectory/Ledger.d.ts +9 -1
  128. package/lib/types/components/trajectory/Ledger.d.ts.map +1 -1
  129. package/lib/types/components/trajectory/Ledger.js +23 -5
  130. package/lib/types/components/trajectory/WaveBand.d.ts +10 -1
  131. package/lib/types/components/trajectory/WaveBand.d.ts.map +1 -1
  132. package/lib/types/components/trajectory/WaveBand.js +7 -2
  133. package/lib/types/dsh-adapter/channel.d.ts +27 -6
  134. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  135. package/lib/types/dsh-adapter/channel.js +432 -66
  136. package/lib/types/dsh-adapter/contract.d.ts +58 -18
  137. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  138. package/lib/types/dsh-adapter/contract.js +124 -36
  139. package/lib/types/dsh-adapter/index.d.ts +10 -2
  140. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  141. package/lib/types/dsh-adapter/index.js +10 -13
  142. package/lib/types/dsh-adapter/plugin.d.ts +0 -9
  143. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  144. package/lib/types/dsh-adapter/plugin.js +136 -6
  145. package/lib/types/i18n.d.ts +194 -16
  146. package/lib/types/i18n.d.ts.map +1 -1
  147. package/lib/types/i18n.js +73 -10
  148. package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -1
  149. package/lib/types/ink/components/AlternateScreen.js +13 -1
  150. package/lib/types/ink/components/App.d.ts +12 -1
  151. package/lib/types/ink/components/App.d.ts.map +1 -1
  152. package/lib/types/ink/components/App.js +101 -4
  153. package/lib/types/ink/components/ClockContext.d.ts.map +1 -1
  154. package/lib/types/ink/components/ClockContext.js +2 -0
  155. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  156. package/lib/types/ink/components/ScrollBox.js +170 -91
  157. package/lib/types/ink/constants.d.ts +9 -0
  158. package/lib/types/ink/constants.d.ts.map +1 -1
  159. package/lib/types/ink/constants.js +9 -0
  160. package/lib/types/ink/events/click-event.d.ts +7 -16
  161. package/lib/types/ink/events/click-event.d.ts.map +1 -1
  162. package/lib/types/ink/events/click-event.js +16 -19
  163. package/lib/types/ink/events/event-handlers.d.ts +11 -1
  164. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  165. package/lib/types/ink/events/event-handlers.js +2 -0
  166. package/lib/types/ink/events/input-event.d.ts +9 -0
  167. package/lib/types/ink/events/input-event.d.ts.map +1 -1
  168. package/lib/types/ink/events/input-event.js +4 -0
  169. package/lib/types/ink/events/pointer-event.d.ts +61 -0
  170. package/lib/types/ink/events/pointer-event.d.ts.map +1 -0
  171. package/lib/types/ink/events/pointer-event.js +61 -0
  172. package/lib/types/ink/events/wheel-event.d.ts +17 -0
  173. package/lib/types/ink/events/wheel-event.d.ts.map +1 -0
  174. package/lib/types/ink/events/wheel-event.js +20 -0
  175. package/lib/types/ink/geometry-trace.d.ts +51 -0
  176. package/lib/types/ink/geometry-trace.d.ts.map +1 -0
  177. package/lib/types/ink/geometry-trace.js +83 -0
  178. package/lib/types/ink/hit-test.d.ts +56 -1
  179. package/lib/types/ink/hit-test.d.ts.map +1 -1
  180. package/lib/types/ink/hit-test.js +146 -8
  181. package/lib/types/ink/ink.d.ts +58 -1
  182. package/lib/types/ink/ink.d.ts.map +1 -1
  183. package/lib/types/ink/ink.js +268 -52
  184. package/lib/types/ink/node-cache.d.ts +5 -0
  185. package/lib/types/ink/node-cache.d.ts.map +1 -1
  186. package/lib/types/ink/parse-keypress.d.ts +8 -0
  187. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  188. package/lib/types/ink/parse-keypress.js +101 -12
  189. package/lib/types/ink/render-node-to-output.d.ts +16 -3
  190. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  191. package/lib/types/ink/render-node-to-output.js +144 -25
  192. package/lib/types/ink/selection.d.ts +29 -4
  193. package/lib/types/ink/selection.d.ts.map +1 -1
  194. package/lib/types/ink/selection.js +41 -5
  195. package/lib/types/ink/timeline-rail.d.ts +138 -0
  196. package/lib/types/ink/timeline-rail.d.ts.map +1 -0
  197. package/lib/types/ink/timeline-rail.js +179 -0
  198. package/lib/types/ink/wrap-text.d.ts.map +1 -1
  199. package/lib/types/ink/wrap-text.js +47 -5
  200. package/lib/types/screens/Chat.d.ts.map +1 -1
  201. package/lib/types/screens/Chat.js +819 -337
  202. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  203. package/lib/types/screens/SessionBrowser.js +56 -29
  204. package/lib/types/screens/Settings.d.ts.map +1 -1
  205. package/lib/types/screens/Settings.js +64 -22
  206. package/lib/types/screens/StatusLine.d.ts +2 -1
  207. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  208. package/lib/types/screens/StatusLine.js +17 -1
  209. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -1
  210. package/lib/types/screens/TrajectoryScene.js +81 -14
  211. package/lib/types/screens/chatOverlay.d.ts +221 -0
  212. package/lib/types/screens/chatOverlay.d.ts.map +1 -0
  213. package/lib/types/screens/chatOverlay.js +114 -0
  214. package/lib/types/tips.d.ts.map +1 -1
  215. package/lib/types/tips.js +64 -10
  216. package/lib/types/tuiDisplayPrefs.d.ts +15 -0
  217. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  218. package/lib/types/tuiDisplayPrefs.js +9 -0
  219. package/lib/types/utils/debug.d.ts +10 -0
  220. package/lib/types/utils/debug.d.ts.map +1 -1
  221. package/lib/types/utils/debug.js +24 -0
  222. package/lib/types/utils/fileSuggestions.d.ts +19 -0
  223. package/lib/types/utils/fileSuggestions.d.ts.map +1 -0
  224. package/lib/types/utils/fileSuggestions.js +40 -0
  225. package/lib/types/utils/mentions.d.ts.map +1 -1
  226. package/lib/types/utils/mentions.js +3 -1
  227. package/lib/types/utils/paths.d.ts.map +1 -1
  228. package/lib/types/utils/paths.js +1 -0
  229. package/package.json +69 -66
@@ -1,6 +1,6 @@
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, subscribeLang, LANGS } from '../i18n.js';
4
4
  import { AlternateScreen, Box, Text, useInput, ScrollBox, useTheme, useTerminalSize } from '../ui.js';
5
5
  import * as tuiKit from '../ui.js';
6
6
  import { POINTER } from '../cc/figures.js';
@@ -22,6 +22,9 @@ import { useCopyOnSelect } from '../ink/hooks/use-copy-on-select.js';
22
22
  import { useSelection } from '../ink/hooks/use-selection.js';
23
23
  import { NoSelect } from '../ink/components/NoSelect.js';
24
24
  import { LogoHeader, MessageList } from '../components/MessageList.js';
25
+ import { TimelineRail } from '../components/TimelineRail.js';
26
+ import { ScrollbarGutter } from '../components/ScrollbarGutter.js';
27
+ import { normalizeScrollGutter } from '../tuiDisplayPrefs.js';
25
28
  import { OverlayAbove } from '../components/OverlayAbove.js';
26
29
  import { PromptInput } from '../components/PromptInput.js';
27
30
  import { GoalTodoPanel } from '../components/GoalTodoPanel.js';
@@ -35,10 +38,14 @@ import { SkillsPicker, SkillsPickerLoading } from '../components/SkillsPicker.js
35
38
  import { SessionBrowser } from './SessionBrowser.js';
36
39
  import { Settings } from './Settings.js';
37
40
  import { WorkspacePicker } from '../components/WorkspacePicker.js';
41
+ import { WorkspaceMenuPicker } from '../components/WorkspaceMenuPicker.js';
38
42
  import { WorkspaceFlowPicker } from '../components/WorkspaceFlowPicker.js';
39
43
  import { ActivityPicker } from '../components/ActivityPicker.js';
40
44
  import { EffortSlider } from '../components/EffortSlider.js';
41
45
  import { PresetPicker } from '../components/PresetPicker.js';
46
+ import { PermissionsPicker, PERMISSION_PRESET_IDS } from '../components/PermissionsPicker.js';
47
+ import { PlanPicker } from '../components/PlanPicker.js';
48
+ import { LangPicker } from '../components/LangPicker.js';
42
49
  import { ThemePicker, getThemeOptions } from '../components/ThemePicker.js';
43
50
  import { AUTO_THEME_NAME, getAutoThemeBase } from '../theme.js';
44
51
  import { FRAME_PRESETS, PRESET_NAMES } from '../components/activityFrames.js';
@@ -61,6 +68,7 @@ import { LoadingState } from '../components/design-system/LoadingState.js';
61
68
  import { Pane } from '../components/design-system/Pane.js';
62
69
  import { loadHistory } from '../history.js';
63
70
  import { formatLoadedContextReport } from '../utils/loaded-context.js';
71
+ import { NO_OVERLAY, chatOverlayReducer, dialogOverlayVisible, wrapIndex, } from './chatOverlay.js';
64
72
  /** Shared empty snapshot for hosts whose channel has no event log. */
65
73
  const NO_EVENTS = [];
66
74
  /** Row kinds the message-selection cursor can land on. */
@@ -113,8 +121,11 @@ function searchableText(row) {
113
121
  }
114
122
  /**
115
123
  * 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
124
+ * (with the user message the viewport is showing pinned above the transcript
125
+ * while scrolled up, and a 1-column minimap scrollbar with one node per
126
+ * user message — the current message's node is highlighted, clicking a node
127
+ * jumps to it), transient notifications, the working spinner, the bordered
128
+ * prompt
118
129
  * input (with slash-command overlay) and the status line pinned at the
119
130
  * bottom.
120
131
  *
@@ -188,16 +199,42 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
188
199
  const [expanded, setExpanded] = React.useState(false);
189
200
  const [helpOpen, setHelpOpen] = React.useState(false);
190
201
  const [handle, setHandle] = React.useState(null);
202
+ /**
203
+ * Conversation timeline snapshot (reported by MessageList): one entry
204
+ * per user turn plus the viewport-derived navigation targets. The
205
+ * ACTIVE turn — the one whose content owns the viewport top row — pins
206
+ * the sticky prompt header AND highlights the transcript rail's tick,
207
+ * from one report so the two can never disagree; upId/downId drive the
208
+ * rail's ▲/▼. Null activeId while pinned to the bottom only when there
209
+ * are no turns (header hidden there anyway).
210
+ */
211
+ const [timeline, setTimeline] = React.useState({
212
+ turns: [],
213
+ activeId: null,
214
+ upId: null,
215
+ downId: null,
216
+ });
191
217
  const [selectionActive, setSelectionActive] = React.useState(false);
192
218
  const [selectedId, setSelectedId] = React.useState(null);
193
219
  const [expandedRows, setExpandedRows] = React.useState(() => new Set());
194
- const [modelPickerOpen, setModelPickerOpen] = React.useState(false);
220
+ /** 流式 reasoning 行的用户折叠(点击/进入折叠态)。与 expandedRows 分开:
221
+ * 流式默认展开,用户点一下 = 折叠(preview ticker 或单行头);落定后
222
+ * 默认折叠,此集合不再参与——两种默认互不翻转。 */
223
+ const [streamFoldedRows, setStreamFoldedRows] = React.useState(() => new Set());
224
+ /**
225
+ * The transient-dialog layer (every picker/dialog `<OverlayAbove>` hosts,
226
+ * plus /tips) as ONE value: mutual exclusion between the panels is
227
+ * structural instead of emerging from "an open picker makes the prompt
228
+ * inert". Transitions live in the pure reducer (chatOverlay.ts), which
229
+ * scripts/verify-chat-overlay.ts pins without a renderer. Async data the
230
+ * pickers show (model list, preset roster, …) stays in the caches below —
231
+ * it persists across open/close so a reopened picker paints the previous
232
+ * list while the fresh one loads, exactly as the boolean era did.
233
+ */
234
+ const [overlay, dispatchOverlay] = React.useReducer(chatOverlayReducer, NO_OVERLAY);
195
235
  const [models, setModels] = React.useState([]);
196
- const [modelIndex, setModelIndex] = React.useState(0);
197
236
  /** `/skills` 技能目录(issue #204):null = 注册表快照在途。 */
198
- const [skillsPickerOpen, setSkillsPickerOpen] = React.useState(false);
199
237
  const [skillsList, setSkillsList] = React.useState(null);
200
- const [skillsIndex, setSkillsIndex] = React.useState(0);
201
238
  /** `/resume` opens the session browser, a screen rather than a panel. It
202
239
  * owns its own selection, filters and keyboard — Chat only opens it. */
203
240
  const [browserOpen, setBrowserOpen] = React.useState(false);
@@ -205,53 +242,22 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
205
242
  * browser, a screen rather than a panel: it owns its own focus, staged
206
243
  * drafts and keyboard; Chat only opens it. */
207
244
  const [settingsOpen, setSettingsOpen] = React.useState(false);
208
- const [workspacePickerOpen, setWorkspacePickerOpen] = React.useState(false);
209
245
  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
246
  const workspaceFlowRequestRef = React.useRef(0);
216
247
  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);
248
+ /** `/preset` agent-preset roster (issue #8): loads async, persists. */
222
249
  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);
250
+ /** `/effort` adapter levels: load async before the slider opens. */
226
251
  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
252
  const [themeName, setTheme] = useTheme();
232
253
  const { rows: terminalRows } = useTerminalSize();
233
254
  const [showAllMessages, setShowAllMessages] = React.useState(false);
255
+ /** Fold state for the GoalTodoPanel todo section (ctrl/cmd+q or click). */
256
+ const [todoCollapsed, setTodoCollapsed] = React.useState(false);
234
257
  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);
258
+ /** ctrl+r history-search entries (loaded on open, persists). */
242
259
  const [historyEntries, setHistoryEntries] = React.useState([]);
243
260
  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
261
  /** Monotonic token: only the latest rewind decision may land (a slow
256
262
  * plugin answering after the user moved on must not open a confirm for
257
263
  * a row they are no longer looking at). */
@@ -265,8 +271,6 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
265
271
  btwAbortRef.current = null;
266
272
  setBtw(null);
267
273
  };
268
- /** /tips usage-tips overlay: pure UI state, no session side effects. */
269
- const [tipsOpen, setTipsOpen] = React.useState(false);
270
274
  /** Subagent dashboard (Ctrl+A): displays active/completed subagents. */
271
275
  const [subagentDashboardOpen, setSubagentDashboardOpen] = React.useState(false);
272
276
  /** Detail view for a specific subagent (opened from dashboard). */
@@ -321,8 +325,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
321
325
  ink?.invalidatePrevFrame();
322
326
  ink?.reanchorViewport();
323
327
  }, []);
324
- /** `/` transcript search (less-style incsearch, ported from CC's REPL). */
325
- const [searchOpen, setSearchOpen] = React.useState(false);
328
+ /** `/` transcript search (less-style incsearch, ported from CC's REPL).
329
+ * Only the bar's open/closed mode lives in `overlay`; the query and match
330
+ * counters persist past the bar closing so n/N keep walking the matches. */
331
+ const searchActive = overlay.kind === 'search';
326
332
  const [searchQuery, setSearchQuery] = React.useState('');
327
333
  const [searchCursor, setSearchCursor] = React.useState(0);
328
334
  const [searchCount, setSearchCount] = React.useState(0);
@@ -353,7 +359,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
353
359
  : -1;
354
360
  }
355
361
  }, [isSticky, channel.rows]);
356
- const showPill = !isSticky && unseenCount > 0;
362
+ // The pill shows whenever the view is off the bottom (one-click return
363
+ // home): with unseen rows it counts them, otherwise it is the plain
364
+ // "return to bottom" affordance (Enter/End/click all land it).
365
+ const showPill = !isSticky;
357
366
  // Idle Ctrl+C: first press arms an exit, second press exits (CC's
358
367
  // double-press semantics, simplified). Under Windows ConPTY the key
359
368
  // arrives as stdin data (key.ctrl && input === 'c') — the useInput
@@ -423,26 +432,30 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
423
432
  useTerminalTitle(`${titlePrefix} 🐋 ${channel.sessionTitle}`);
424
433
  const handleWorkspaceResult = (result) => {
425
434
  workspaceFlowAbortRef.current = null;
426
- setWorkspaceFlowBusy(false);
427
- setWorkspaceFlowInput(null);
428
435
  if (result.kind === 'target') {
429
- setWorkspaceFlow(null);
436
+ dispatchOverlay({ type: 'close-if', kind: 'workspace-flow' });
430
437
  void channel.switchWorkspace(result.target);
431
438
  return;
432
439
  }
433
440
  if (result.choices.length === 0) {
434
- setWorkspaceFlow(null);
441
+ dispatchOverlay({ type: 'close-if', kind: 'workspace-flow' });
435
442
  channel.notify(t('workspace-command-empty'));
436
443
  return;
437
444
  }
438
- setWorkspaceFlow(result);
439
- setWorkspaceFlowIndex(0);
445
+ // open-if: 'workspace-flow' stays allowed so an in-flow action can
446
+ // transition to its next stage; a picker the user opened after leaving
447
+ // the menu wins over a late command result.
448
+ dispatchOverlay({
449
+ type: 'open-if',
450
+ overlay: { kind: 'workspace-flow', flow: result, index: 0, busy: false, input: null },
451
+ when: ['none', 'workspace-flow'],
452
+ });
440
453
  };
441
454
  const runWorkspaceFlowAction = (action) => {
442
455
  const request = ++workspaceFlowRequestRef.current;
443
456
  const controller = new AbortController();
444
457
  workspaceFlowAbortRef.current = controller;
445
- setWorkspaceFlowBusy(true);
458
+ dispatchOverlay({ type: 'flow-busy', busy: true });
446
459
  void Promise.resolve()
447
460
  .then(() => action(controller.signal))
448
461
  .then((result) => {
@@ -453,10 +466,25 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
453
466
  if (request !== workspaceFlowRequestRef.current)
454
467
  return;
455
468
  workspaceFlowAbortRef.current = null;
456
- setWorkspaceFlowBusy(false);
469
+ dispatchOverlay({ type: 'flow-busy', busy: false });
457
470
  channel.notify(t('workspace-command-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
458
471
  });
459
472
  };
473
+ /** Bare `/workspace` menu rows: built-in subcommands first, then the
474
+ * dynamically registered extensions (same reserved-name filter the Tab
475
+ * completion applies). Recomputed per render — the extension list is
476
+ * live. */
477
+ const workspaceMenuOptions = [
478
+ { id: 'resume', label: 'resume', description: t('workspace-menu-resume-desc') },
479
+ { id: 'rename', label: 'rename', description: t('workspace-menu-rename-desc') },
480
+ { id: 'open', label: 'open', description: t('workspace-menu-open-desc') },
481
+ // Optional call: verify/repro scripts and embedders stub the channel
482
+ // without the workspace-commands API — render must not throw for them
483
+ // (a thrown render unmounts the whole Ink root).
484
+ ...(channel.workspaceCommands?.() ?? [])
485
+ .filter(command => !['resume', 'rename', 'open'].includes(command.name.toLowerCase()))
486
+ .map(command => ({ id: command.name, label: command.name, description: command.description })),
487
+ ];
460
488
  const openWorkspaceTarget = (reference) => {
461
489
  void channel.resolveWorkspace(reference).then((target) => {
462
490
  if (target === undefined) {
@@ -475,12 +503,45 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
475
503
  return;
476
504
  }
477
505
  setWorkspaceTargets(targets);
478
- setWorkspaceIndex(Math.max(0, targets.findIndex(target => target.cwd === channel.cwd)));
479
- setWorkspacePickerOpen(true);
506
+ // open-if: the listing is async — whatever the user opened meanwhile wins.
507
+ dispatchOverlay({
508
+ type: 'open-if',
509
+ overlay: {
510
+ kind: 'workspace-picker',
511
+ index: Math.max(0, targets.findIndex(target => target.cwd === channel.cwd)),
512
+ },
513
+ when: ['none'],
514
+ });
480
515
  }).catch((error) => {
481
516
  channel.notify(t('workspace-list-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' });
482
517
  });
483
518
  };
519
+ /**
520
+ * Run one /workspace menu row (Enter path, shared with the mouse click):
521
+ * built-ins dispatch locally, extension commands go through the channel.
522
+ */
523
+ const runWorkspaceMenuOption = (option) => {
524
+ dispatchOverlay({ type: 'close-if', kind: 'workspace-menu' });
525
+ if (option === undefined)
526
+ return;
527
+ if (option.id === 'resume') {
528
+ openWorkspaceResume();
529
+ }
530
+ else if (option.id === 'rename') {
531
+ channel.notify(t('workspace-rename-usage'));
532
+ }
533
+ else if (option.id === 'open') {
534
+ channel.notify(t('workspace-open-usage'));
535
+ }
536
+ else {
537
+ void channel.runWorkspaceCommand(option.id, '').then((result) => {
538
+ if (result !== undefined)
539
+ handleWorkspaceResult(result);
540
+ }).catch((error) => {
541
+ channel.notify(t('workspace-command-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
542
+ });
543
+ }
544
+ };
484
545
  /**
485
546
  * Dispatch a slash command; false lets the input flow to the model.
486
547
  * Built-in names run the local switch; anything registered by a DSH
@@ -488,6 +549,31 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
488
549
  * result text lands as a notification. `rawInput` carries the text after
489
550
  * the command name (`/plan off` → ` off`).
490
551
  */
552
+ /** Localized display name of a sandbox mode id (`read-only` /
553
+ * `workspace-write` / `danger-full-access`), for `/permission status`. */
554
+ const permissionPresetName = (id) => {
555
+ switch (id) {
556
+ case 'read-only': return t('permission-preset-readonly');
557
+ case 'workspace-write': return t('permission-preset-workspace-write');
558
+ case 'danger-full-access': return t('permission-preset-full-access');
559
+ default: return id ?? '—';
560
+ }
561
+ };
562
+ /** Hot-swap the UI language (`/lang <id>` and the LangPicker both land
563
+ * here): persist to ~/.dsh-tui/lang.json and mirror into the dsh-tui
564
+ * settings namespace when it is served (best effort). */
565
+ const applyLang = (lang) => {
566
+ const ok = writeLangPref(lang);
567
+ setLang(lang);
568
+ const settingsHost = channel.settingsHost();
569
+ const tuiView = settingsHost?.listNamespaces().find(entry => entry.ns === 'dsh-tui');
570
+ if (settingsHost !== undefined && tuiView !== undefined) {
571
+ void settingsHost
572
+ .write('dsh-tui', [{ op: 'set', path: ['lang'], value: lang }], tuiView.revision)
573
+ .catch(() => { });
574
+ }
575
+ channel.notify(ok ? t('lang-switched', { lang }) : t('lang-switch-failed', { lang }), { color: ok ? 'success' : 'error' });
576
+ };
491
577
  const runCommand = (name, rawInput = '') => {
492
578
  switch (name) {
493
579
  case 'activity': {
@@ -524,8 +610,13 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
524
610
  return true;
525
611
  }
526
612
  setHelpOpen(false);
527
- setActivityIndex(Math.max(0, PRESET_NAMES.indexOf(channel.activityFrames ?? 'random')));
528
- setActivityPickerOpen(true);
613
+ dispatchOverlay({
614
+ type: 'open',
615
+ overlay: {
616
+ kind: 'activity',
617
+ index: Math.max(0, PRESET_NAMES.indexOf(channel.activityFrames ?? 'random')),
618
+ },
619
+ });
529
620
  return true;
530
621
  }
531
622
  case 'preset': {
@@ -552,16 +643,26 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
552
643
  return true;
553
644
  }
554
645
  setHelpOpen(false);
555
- setPresetPickerOpen(true);
646
+ // The picker opens immediately over the cached roster (no loading
647
+ // pane — deliberate contrast with /model); the fresh list lands with
648
+ // the authoritative focus. Both loader writes are kind-guarded, so a
649
+ // picker the user already left is not resurrected or re-focused.
650
+ dispatchOverlay({
651
+ type: 'open',
652
+ overlay: {
653
+ kind: 'preset',
654
+ index: Math.max(0, presetOptions.findIndex(preset => preset.id === channel.agentPreset)),
655
+ },
656
+ });
556
657
  void channel.listPresets().then((list) => {
557
658
  if (list.length === 0) {
558
- setPresetPickerOpen(false);
659
+ dispatchOverlay({ type: 'close-if', kind: 'preset' });
559
660
  channel.notify(t('preset-roster-unmounted'), { color: 'warning' });
560
661
  return;
561
662
  }
562
663
  setPresetOptions(list);
563
664
  const index = list.findIndex(preset => preset.id === channel.agentPreset);
564
- setPresetIndex(index >= 0 ? index : 0);
665
+ dispatchOverlay({ type: 'set-index', kind: 'preset', index: index >= 0 ? index : 0 });
565
666
  });
566
667
  return true;
567
668
  }
@@ -592,16 +693,22 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
592
693
  setEffortOptions(efforts);
593
694
  const current = channel.reasoningEffort ?? defaultEffort;
594
695
  const index = efforts.findIndex(effort => effort.id === current);
595
- setEffortIndex(index >= 0 ? index : 0);
596
- setEffortSliderOpen(true);
696
+ // open-if: a picker the user opened during the round trip wins
697
+ // over this late-arriving slider.
698
+ dispatchOverlay({
699
+ type: 'open-if',
700
+ overlay: { kind: 'effort', index: index >= 0 ? index : 0 },
701
+ when: ['none'],
702
+ });
597
703
  });
598
704
  return true;
599
705
  }
600
706
  case 'lang': {
601
707
  // `/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.
708
+ // (hot-swap, persisted to ~/.dsh-tui/lang.json), bare `/lang` opens
709
+ // the en/zh picker. Precedence on next launch: DSH_TUI_LANG >
710
+ // settings.yaml `dsh-tui.lang` > cordis.yml `lang` > the persisted
711
+ // choice.
605
712
  const parts = rawInput.trim().split(/\s+/).filter(Boolean);
606
713
  if (parts[0] === 'status') {
607
714
  setHelpOpen(false);
@@ -615,19 +722,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
615
722
  if (parts.length > 0) {
616
723
  setHelpOpen(false);
617
724
  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' });
725
+ applyLang(parts[0]);
631
726
  }
632
727
  else {
633
728
  channel.notify(t('lang-unknown', { lang: parts[0] }), { color: 'error' });
@@ -635,11 +730,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
635
730
  return true;
636
731
  }
637
732
  setHelpOpen(false);
638
- channel.pushLocal('/lang', [
639
- t('lang-current', { lang: getLang() }),
640
- t('lang-switch-hint'),
641
- t('lang-persist-hint'),
642
- ]);
733
+ dispatchOverlay({
734
+ type: 'open',
735
+ overlay: { kind: 'lang', index: getLang() === 'zh' ? 0 : 1 },
736
+ });
643
737
  return true;
644
738
  }
645
739
  case 'theme': {
@@ -672,8 +766,13 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
672
766
  return true;
673
767
  }
674
768
  setHelpOpen(false);
675
- setThemeIndex(Math.max(0, getThemeOptions().findIndex(option => option.value === themeName)));
676
- setThemePickerOpen(true);
769
+ dispatchOverlay({
770
+ type: 'open',
771
+ overlay: {
772
+ kind: 'theme',
773
+ index: Math.max(0, getThemeOptions().findIndex(option => option.value === themeName)),
774
+ },
775
+ });
677
776
  return true;
678
777
  }
679
778
  case 'new': {
@@ -735,32 +834,90 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
735
834
  case 'help':
736
835
  setHelpOpen(true);
737
836
  return true;
738
- case 'model':
837
+ case 'model': {
838
+ // `/model <provider/model>` switches directly (same live-fork path
839
+ // as the picker's Enter), bare `/model` opens the picker.
840
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
841
+ if (parts.length > 0) {
842
+ setHelpOpen(false);
843
+ const spec = parts[0];
844
+ const slash = spec.indexOf('/');
845
+ const provider = slash >= 0 ? spec.slice(0, slash) : undefined;
846
+ const id = slash >= 0 ? spec.slice(slash + 1) : spec;
847
+ if (provider === undefined || id.length === 0 || provider.length === 0) {
848
+ channel.notify(t('model-usage'), { color: 'warning' });
849
+ return true;
850
+ }
851
+ void channel.listModels().then((list) => {
852
+ const model = list.find(m => m.provider === provider && m.id === id);
853
+ if (model === undefined) {
854
+ channel.notify(t('model-unknown', { spec }), { color: 'error', timeoutMs: 8000 });
855
+ return;
856
+ }
857
+ channel.notify(t('model-switching', { name: model.name }));
858
+ void channel.switchModel(provider, id).then((ok) => {
859
+ if (ok)
860
+ channel.notify(t('model-switched', { name: model.name }));
861
+ });
862
+ });
863
+ return true;
864
+ }
739
865
  setHelpOpen(false);
740
- setModelPickerOpen(true);
866
+ // Opens over the cached catalog (empty cache shows the loading
867
+ // pane); the fresh list lands with the authoritative focus, and the
868
+ // kind-guarded set-index cannot re-focus a picker the user left.
869
+ dispatchOverlay({
870
+ type: 'open',
871
+ overlay: {
872
+ kind: 'model',
873
+ index: Math.max(0, models.findIndex(model => model.provider === channel.provider && model.id === channel.model)),
874
+ },
875
+ });
741
876
  void channel.listModels().then((list) => {
742
877
  setModels(list);
743
878
  const index = list.findIndex(model => model.provider === channel.provider && model.id === channel.model);
744
- setModelIndex(index >= 0 ? index : 0);
879
+ dispatchOverlay({ type: 'set-index', kind: 'model', index: index >= 0 ? index : 0 });
745
880
  });
746
881
  return true;
747
- case 'skills':
882
+ }
883
+ case 'skills': {
748
884
  // issue #204: 列出当前 agent 的完整技能目录(名称 + 来源 + 简述),
749
885
  // Enter 把可直调技能以 `/name ` 填回输入行(completion-only 分发的
750
886
  // 同一路径)。注册表读取走 channel(快照 scoped 到 live agent)。
887
+ // `/skills <name>` 直达同一个填回动作,跳过选择器。
888
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
889
+ if (parts.length > 0) {
890
+ setHelpOpen(false);
891
+ void channel.listSkills().then((list) => {
892
+ if (list === undefined) {
893
+ channel.notify(t('skills-load-failed'), { color: 'error' });
894
+ return;
895
+ }
896
+ const skill = list.find(s => s.name === parts[0]);
897
+ if (skill === undefined) {
898
+ channel.notify(t('skills-unknown', { name: parts[0] }), { color: 'error' });
899
+ return;
900
+ }
901
+ if (skill.userInvocable)
902
+ setHistoryFill(`/${skill.name} `);
903
+ else
904
+ channel.notify(t('skills-not-invocable', { name: parts[0] }), { color: 'warning' });
905
+ });
906
+ return true;
907
+ }
751
908
  setHelpOpen(false);
752
909
  setSkillsList(null);
753
- setSkillsIndex(0);
754
- setSkillsPickerOpen(true);
910
+ dispatchOverlay({ type: 'open', overlay: { kind: 'skills', index: 0 } });
755
911
  void channel.listSkills().then((list) => {
756
912
  if (list === undefined) {
757
- setSkillsPickerOpen(false);
913
+ dispatchOverlay({ type: 'close-if', kind: 'skills' });
758
914
  channel.notify(t('skills-load-failed'), { color: 'error' });
759
915
  return;
760
916
  }
761
917
  setSkillsList(list);
762
918
  });
763
919
  return true;
920
+ }
764
921
  case 'provider': {
765
922
  // Interactive add-provider wizard (/provider): drives the shared
766
923
  // question panel, persists profile + key via the channel's settings/
@@ -786,8 +943,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
786
943
  }
787
944
  case 'thinking':
788
945
  setHelpOpen(false);
789
- setThinkingOpen(true);
790
- setThinkingFocus(thinkingVisible ? 0 : 1);
946
+ dispatchOverlay({
947
+ type: 'open',
948
+ overlay: { kind: 'thinking', focus: thinkingVisible ? 0 : 1 },
949
+ });
791
950
  return true;
792
951
  case 'tokens': {
793
952
  const usage = t('tokens-usage', { in: formatTokens(channel.tokens.input), out: formatTokens(channel.tokens.output) });
@@ -815,10 +974,9 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
815
974
  const subcommand = (separator < 0 ? trimmed : trimmed.slice(0, separator)).toLowerCase();
816
975
  const input = separator < 0 ? '' : trimmed.slice(separator).trim();
817
976
  if (subcommand === '') {
818
- const extensions = channel.workspaceCommands()
819
- .map(command => ` | ${command.name}`)
820
- .join('');
821
- channel.pushLocal('/workspace', [t('workspace-command-usage', { commands: extensions })]);
977
+ // Bare `/workspace` opens the action menu (resume / rename / open
978
+ // plus any registered extensions) instead of a text usage line.
979
+ dispatchOverlay({ type: 'open', overlay: { kind: 'workspace-menu', index: 0 } });
822
980
  }
823
981
  else if (subcommand === 'resume') {
824
982
  openWorkspaceResume();
@@ -996,25 +1154,87 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
996
1154
  case 'logout':
997
1155
  channel.notify(t('login-logout-hint'));
998
1156
  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;
1157
+ case 'permission': {
1158
+ // The command itself is registered by dsh-sandbox-policy (dsh-base
1159
+ // permission-presets row): bare `/permission` opens the preset
1160
+ // picker (read-only / workspace-write / danger-full-access) and
1161
+ // Enter dispatches `/permission <preset>` through the same
1162
+ // external-command path a hand-typed argument takes. `/permission
1163
+ // status` prints the policy explainer (absorbed from the removed
1164
+ // `/permissions` command); other arguments pass through verbatim.
1165
+ // When the row is not mounted the default external path (or the
1166
+ // model, when nothing is registered) wins.
1167
+ const mounted = channel.commandList.some(command => command.external && command.name === 'permission');
1168
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1169
+ if (mounted && parts[0] === 'status') {
1170
+ setHelpOpen(false);
1171
+ channel.pushLocal('/permission', [
1172
+ t('permission-current', { name: permissionPresetName(channel.mode.sandbox) }),
1173
+ t('permission-policy-hint'),
1174
+ t('permission-approval-hint'),
1175
+ t('permission-root-hint', { cwd: channel.cwd }),
1176
+ t('permission-path-hint'),
1177
+ ]);
1178
+ return true;
1179
+ }
1180
+ if (mounted && parts.length === 0) {
1181
+ setHelpOpen(false);
1182
+ const index = PERMISSION_PRESET_IDS.indexOf(channel.mode.sandbox ?? '');
1183
+ dispatchOverlay({
1184
+ type: 'open',
1185
+ overlay: { kind: 'permission', index: index >= 0 ? index : 1 },
1186
+ });
1187
+ return true;
1188
+ }
1189
+ if (mounted) {
1190
+ setHelpOpen(false);
1191
+ void channel.runExternalCommand('permission', rawInput).then((text) => {
1192
+ if (text !== undefined && text !== '') {
1193
+ channel.notify(text);
1194
+ }
1195
+ else if (text === undefined) {
1196
+ channel.notify(t('command-not-found', { name: 'permission' }), { color: 'error' });
1197
+ }
1198
+ });
1199
+ return true;
1200
+ }
1201
+ return false;
1202
+ }
1203
+ case 'plan': {
1204
+ // Registered by dsh-plan-mode: bare `/plan` opens an on/off picker
1205
+ // marked with the current state instead of toggling blindly; Enter
1206
+ // dispatches `/plan` or `/plan off`. Arguments pass through verbatim
1207
+ // (`/plan off`), and an unmounted row falls back to the default
1208
+ // external path.
1209
+ const mounted = channel.commandList.some(command => command.external && command.name === 'plan');
1210
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1211
+ if (mounted && parts.length === 0) {
1212
+ setHelpOpen(false);
1213
+ dispatchOverlay({
1214
+ type: 'open',
1215
+ overlay: { kind: 'plan', index: channel.mode.plan === true ? 0 : 1 },
1216
+ });
1217
+ return true;
1218
+ }
1219
+ if (mounted) {
1220
+ setHelpOpen(false);
1221
+ void channel.runExternalCommand('plan', rawInput).then((text) => {
1222
+ if (text !== undefined && text !== '') {
1223
+ channel.notify(text);
1224
+ }
1225
+ else if (text === undefined) {
1226
+ channel.notify(t('command-not-found', { name: 'plan' }), { color: 'error' });
1227
+ }
1228
+ });
1229
+ return true;
1230
+ }
1231
+ return false;
1232
+ }
1013
1233
  case 'add-dir':
1014
1234
  setHelpOpen(false);
1015
1235
  channel.pushLocal('/add-dir', [
1016
- t('permissions-root-hint', { cwd: channel.cwd }),
1017
- t('permissions-path-hint'),
1236
+ t('permission-root-hint', { cwd: channel.cwd }),
1237
+ t('permission-path-hint'),
1018
1238
  ]);
1019
1239
  return true;
1020
1240
  case 'hooks':
@@ -1088,7 +1308,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1088
1308
  }
1089
1309
  case 'tips':
1090
1310
  setHelpOpen(false);
1091
- setTipsOpen(true);
1311
+ dispatchOverlay({ type: 'open', overlay: { kind: 'tips' } });
1092
1312
  return true;
1093
1313
  case 'connect':
1094
1314
  setHelpOpen(false);
@@ -1142,6 +1362,9 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1142
1362
  ? channel.rows.filter(row => SELECTABLE_KINDS.has(row.kind))
1143
1363
  : NO_ROWS;
1144
1364
  // ctrl+r history search: substring match on the query, newest first.
1365
+ // The draft lives in the overlay variant; the derived '' while closed
1366
+ // keeps the memo inputs stable (nothing renders the matches then).
1367
+ const historyQuery = overlay.kind === 'history' ? overlay.query : '';
1145
1368
  const historyMatches = React.useMemo(() => {
1146
1369
  const q = historyQuery.trim().toLowerCase();
1147
1370
  return q ? historyEntries.filter(e => e.text.toLowerCase().includes(q)) : historyEntries;
@@ -1150,15 +1373,15 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1150
1373
  // selectable user turns; steering side-questions are excluded). Computed
1151
1374
  // per render while the picker is open — `channel.rows` is a live in-place
1152
1375
  // array (see selectableRows).
1153
- const rewindRows = rewindOpen
1376
+ const rewindRows = overlay.kind === 'rewind'
1154
1377
  ? channel.rows
1155
1378
  .filter(row => row.kind === 'user' && row.label === undefined)
1156
1379
  .reverse()
1157
1380
  : NO_ROWS;
1158
1381
  /** Open the rewind picker (from PromptInput's double-Esc on an empty input). */
1159
1382
  const openRewind = () => {
1160
- // rewindOpen is still false this render, so rewindRows is empty — scan
1161
- // directly instead of reading the gated list.
1383
+ // The overlay is not 'rewind' yet this render, so rewindRows is empty —
1384
+ // scan directly instead of reading the gated list.
1162
1385
  const candidates = channel.rows
1163
1386
  .filter(row => row.kind === 'user' && row.label === undefined)
1164
1387
  .reverse();
@@ -1166,12 +1389,11 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1166
1389
  channel.notify(t('rewind-none'));
1167
1390
  return;
1168
1391
  }
1169
- setRewindIndex(0);
1170
- setRewindConfirm(null);
1171
- setRewindModes(null);
1172
- setRewindBusy(false);
1173
1392
  rewindRequestRef.current += 1;
1174
- setRewindOpen(true);
1393
+ dispatchOverlay({
1394
+ type: 'open',
1395
+ overlay: { kind: 'rewind', index: 0, confirm: null, modes: null, modeIndex: 0, busy: false },
1396
+ });
1175
1397
  };
1176
1398
  /**
1177
1399
  * Enter on a rewind candidate: ask the plugins first (tui/rewind-prompt).
@@ -1180,16 +1402,15 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1180
1402
  */
1181
1403
  const requestRewindConfirm = async (row) => {
1182
1404
  const token = ++rewindRequestRef.current;
1183
- setRewindBusy(true);
1405
+ dispatchOverlay({ type: 'rewind-busy', busy: true });
1184
1406
  const decision = await channel.promptRewind(row);
1185
1407
  if (token !== rewindRequestRef.current)
1186
1408
  return;
1187
- setRewindBusy(false);
1188
- if (decision === 'cancel')
1409
+ if (decision === 'cancel') {
1410
+ dispatchOverlay({ type: 'rewind-busy', busy: false });
1189
1411
  return;
1190
- setRewindConfirm(row);
1191
- setRewindModes(decision?.modes ?? null);
1192
- setRewindModeIndex(0);
1412
+ }
1413
+ dispatchOverlay({ type: 'rewind-decision', confirm: row, modes: decision?.modes ?? null });
1193
1414
  };
1194
1415
  /** Execute the confirmed rewind; the message comes back into the input. */
1195
1416
  const performRewind = async (row, mode = null) => {
@@ -1277,13 +1498,31 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1277
1498
  }
1278
1499
  setForceMountRowId(rowId);
1279
1500
  };
1501
+ /**
1502
+ * Reveal-and-seek for a row folded behind the recent-rows window (the
1503
+ * rail's tick for an old turn, the doc's revealAndSeekRow): expand the
1504
+ * fold first, then the ordinary seek takes over — the completion effect
1505
+ * below force-mounts the row and scrollToElement lands it once its ref
1506
+ * (and Yoga top) exist. The fold toggle is idempotent, so calling this
1507
+ * for an already-revealed row is harmless.
1508
+ */
1509
+ const revealAndSeekRow = (rowId) => {
1510
+ if (!showAllMessages)
1511
+ setShowAllMessages(true);
1512
+ seekRow(rowId);
1513
+ };
1280
1514
  React.useLayoutEffect(() => {
1281
1515
  if (forceMountRowId === null)
1282
1516
  return;
1283
1517
  const el = rowRefsRef.current.get(forceMountRowId);
1284
1518
  if (el) {
1285
1519
  handle?.scrollToElement(el);
1286
- setForceMountRowId(null);
1520
+ // Clear deferred to a macrotask: clearing here would let React's
1521
+ // synchronous re-render narrow the virtualization window and unmount
1522
+ // the row BEFORE the renderer's deferred pass reads its Yoga top
1523
+ // (scrollAnchor processing runs in a microtask) — the seek would
1524
+ // silently no-op (detached anchor element).
1525
+ setTimeout(() => setForceMountRowId(null), 0);
1287
1526
  }
1288
1527
  });
1289
1528
  // `/` transcript search: rows whose searchable text contains the query.
@@ -1300,7 +1539,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1300
1539
  // Incsearch: highlight all matches (screen-space overlay) and keep the
1301
1540
  // current match row in view as the query changes (CC semantics).
1302
1541
  React.useEffect(() => {
1303
- if (!searchOpen)
1542
+ if (!searchActive)
1304
1543
  return;
1305
1544
  setHighlight(searchQuery);
1306
1545
  const count = searchMatches.length;
@@ -1312,10 +1551,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1312
1551
  if (target) {
1313
1552
  seekRow(target.row.id);
1314
1553
  }
1315
- }, [searchQuery, searchOpen]);
1554
+ }, [searchQuery, searchActive]);
1316
1555
  // n/N navigation: move the current match into view.
1317
1556
  React.useEffect(() => {
1318
- if (!searchOpen)
1557
+ if (!searchActive)
1319
1558
  return;
1320
1559
  const target = searchMatches[searchCurrent];
1321
1560
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty/filtered list
@@ -1352,6 +1591,16 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1352
1591
  return next;
1353
1592
  });
1354
1593
  }, []);
1594
+ const toggleStreamFolded = React.useCallback((rowId) => {
1595
+ setStreamFoldedRows((previous) => {
1596
+ const next = new Set(previous);
1597
+ if (next.has(rowId))
1598
+ next.delete(rowId);
1599
+ else
1600
+ next.add(rowId);
1601
+ return next;
1602
+ });
1603
+ }, []);
1355
1604
  const registerRowRef = React.useCallback((rowId, el) => {
1356
1605
  if (el)
1357
1606
  rowRefsRef.current.set(rowId, el);
@@ -1388,13 +1637,30 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1388
1637
  // Mouse wheel scrolls the transcript even while a question/approval/
1389
1638
  // dialog panel is open — those panels own arrow/Enter/Esc keys, but the
1390
1639
  // 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.
1640
+ //
1641
+ // Wheel routing is position-first: events landing over a ScrollBox
1642
+ // (transcript, help, subagent panels…) are consumed by that box in
1643
+ // App's input batch (onWheelAt) and never reach this branch. What
1644
+ // arrives here is the fallback: wheel over non-scroll areas (prompt,
1645
+ // status bar) or over floating overlays.
1646
+ // - Help stays yielded: PromptInput's help ScrollBox handles the
1647
+ // remaining global wheel while help is open (both covered layers
1648
+ // must not move).
1649
+ // - Pickers/dialogs are modal: wheel that fell through over them
1650
+ // must NOT scroll the transcript behind (the audit's
1651
+ // pass-through gap), so yield like the keyboard guards above.
1393
1652
  // Events only arrive with mouse tracking on; inline mode never sees
1394
1653
  // them, so this is a no-op there.
1395
1654
  if (key.wheelUp || key.wheelDown) {
1396
1655
  if (helpOpen)
1397
1656
  return;
1657
+ // Any open transient dialog is modal to the wheel; the one exception
1658
+ // mirrors the render gate — a workspace picker whose target list has
1659
+ // not landed paints nothing, so wheel-through keeps scrolling.
1660
+ const overlayModal = overlay.kind !== 'none' &&
1661
+ (overlay.kind !== 'workspace-picker' || workspaceTargets.length > 0);
1662
+ if (overlayModal)
1663
+ return;
1398
1664
  handle?.scrollBy(key.wheelUp ? -3 : 3);
1399
1665
  event.stopImmediatePropagation();
1400
1666
  return;
@@ -1424,11 +1690,11 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1424
1690
  event.stopImmediatePropagation();
1425
1691
  return;
1426
1692
  }
1427
- if (searchOpen) {
1693
+ if (overlay.kind === 'search') {
1428
1694
  // Transcript search bar (less-style): edit the query, Enter commits
1429
1695
  // (query persists for n/N), Esc/ctrl+c cancels back to the anchor.
1430
1696
  if (key.escape || (key.ctrl && input === 'c')) {
1431
- setSearchOpen(false);
1697
+ dispatchOverlay({ type: 'close' });
1432
1698
  setHighlight('');
1433
1699
  handle?.scrollTo(searchAnchorRef.current);
1434
1700
  }
@@ -1436,7 +1702,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1436
1702
  // Enter commits; 0-match junk queries don't persist (CC behavior).
1437
1703
  if (searchCount === 0)
1438
1704
  setSearchQuery('');
1439
- setSearchOpen(false);
1705
+ dispatchOverlay({ type: 'close' });
1440
1706
  }
1441
1707
  else if (key.backspace) {
1442
1708
  if (searchCursor > 0) {
@@ -1482,41 +1748,41 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1482
1748
  event.stopImmediatePropagation();
1483
1749
  return;
1484
1750
  }
1485
- if (thinkingOpen) {
1751
+ if (overlay.kind === 'thinking') {
1486
1752
  if (key.upArrow || key.downArrow) {
1487
- setThinkingFocus(index => (index === 0 ? 1 : 0));
1753
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: 2 });
1488
1754
  }
1489
1755
  else if (plainReturn) {
1490
- const visible = thinkingFocus === 0;
1756
+ const visible = overlay.focus === 0;
1491
1757
  setThinkingVisible(visible);
1492
- setThinkingOpen(false);
1758
+ dispatchOverlay({ type: 'close' });
1493
1759
  channel.notify(t('thinking-toggled', { state: visible ? t('thinking-on') : t('thinking-off') }));
1494
1760
  }
1495
1761
  else if (key.escape) {
1496
- setThinkingOpen(false);
1762
+ dispatchOverlay({ type: 'close' });
1497
1763
  }
1498
1764
  return;
1499
1765
  }
1500
- if (workspaceFlow !== null) {
1766
+ if (overlay.kind === 'workspace-flow') {
1767
+ const { flow, busy, input: flowInput } = overlay;
1501
1768
  if (key.escape) {
1502
- if (workspaceFlowInput !== null && !workspaceFlowBusy) {
1503
- setWorkspaceFlowInput(null);
1769
+ if (flowInput !== null && !busy) {
1770
+ dispatchOverlay({ type: 'flow-input', input: null });
1504
1771
  return;
1505
1772
  }
1506
1773
  workspaceFlowAbortRef.current?.abort();
1507
1774
  workspaceFlowAbortRef.current = null;
1508
1775
  workspaceFlowRequestRef.current += 1;
1509
- setWorkspaceFlowBusy(false);
1510
- setWorkspaceFlow(null);
1776
+ dispatchOverlay({ type: 'close' });
1511
1777
  return;
1512
1778
  }
1513
- if (workspaceFlowBusy)
1779
+ if (busy)
1514
1780
  return;
1515
- if (workspaceFlowInput !== null) {
1516
- const choice = workspaceFlow.choices.find(candidate => candidate.id === workspaceFlowInput.choiceId);
1781
+ if (flowInput !== null) {
1782
+ const choice = flow.choices.find(candidate => candidate.id === flowInput.choiceId);
1517
1783
  const editor = choice?.input;
1518
1784
  if (plainReturn) {
1519
- const value = workspaceFlowInput.value.trim();
1785
+ const value = flowInput.value.trim();
1520
1786
  if (value.length === 0) {
1521
1787
  channel.notify(t('workspace-flow-input-empty'), { color: 'warning' });
1522
1788
  }
@@ -1524,99 +1790,108 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1524
1790
  runWorkspaceFlowAction(signal => editor.submit(value, signal));
1525
1791
  }
1526
1792
  }
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,
1793
+ else if (key.backspace && flowInput.cursor > 0) {
1794
+ dispatchOverlay({
1795
+ type: 'flow-input-edit',
1796
+ value: flowInput.value.slice(0, flowInput.cursor - 1) + flowInput.value.slice(flowInput.cursor),
1797
+ cursor: flowInput.cursor - 1,
1532
1798
  });
1533
1799
  }
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),
1800
+ else if (key.delete && flowInput.cursor < flowInput.value.length) {
1801
+ dispatchOverlay({
1802
+ type: 'flow-input-edit',
1803
+ value: flowInput.value.slice(0, flowInput.cursor) + flowInput.value.slice(flowInput.cursor + 1),
1804
+ cursor: flowInput.cursor,
1538
1805
  });
1539
1806
  }
1540
1807
  else if (key.leftArrow) {
1541
- setWorkspaceFlowInput(current => current === null ? null : {
1542
- ...current,
1543
- cursor: Math.max(0, current.cursor - 1),
1808
+ dispatchOverlay({
1809
+ type: 'flow-input-edit',
1810
+ value: flowInput.value,
1811
+ cursor: Math.max(0, flowInput.cursor - 1),
1544
1812
  });
1545
1813
  }
1546
1814
  else if (key.rightArrow) {
1547
- setWorkspaceFlowInput(current => current === null ? null : {
1548
- ...current,
1549
- cursor: Math.min(current.value.length, current.cursor + 1),
1815
+ dispatchOverlay({
1816
+ type: 'flow-input-edit',
1817
+ value: flowInput.value,
1818
+ cursor: Math.min(flowInput.value.length, flowInput.cursor + 1),
1550
1819
  });
1551
1820
  }
1552
1821
  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,
1822
+ dispatchOverlay({
1823
+ type: 'flow-input-edit',
1824
+ value: flowInput.value.slice(0, flowInput.cursor) + input + flowInput.value.slice(flowInput.cursor),
1825
+ cursor: flowInput.cursor + input.length,
1557
1826
  });
1558
1827
  }
1559
1828
  return;
1560
1829
  }
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));
1830
+ if (key.upArrow || key.downArrow) {
1831
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: flow.choices.length });
1566
1832
  }
1567
1833
  else if (key.tab && !key.shift) {
1568
- const choice = workspaceFlow.choices[workspaceFlowIndex];
1834
+ const choice = flow.choices[overlay.index];
1569
1835
  if (choice?.input !== undefined) {
1570
1836
  const value = choice.input.initialValue ?? '';
1571
- setWorkspaceFlowInput({
1837
+ const flowInputNext = {
1572
1838
  choiceId: choice.id,
1573
1839
  value,
1574
1840
  cursor: value.length,
1575
1841
  ...(choice.input.placeholder === undefined ? {} : { placeholder: choice.input.placeholder }),
1576
- });
1842
+ };
1843
+ dispatchOverlay({ type: 'flow-input', input: flowInputNext });
1577
1844
  }
1578
1845
  }
1579
1846
  else if (plainReturn) {
1580
- const choice = workspaceFlow.choices[workspaceFlowIndex];
1847
+ const choice = flow.choices[overlay.index];
1581
1848
  if (choice !== undefined) {
1582
1849
  runWorkspaceFlowAction(signal => choice.choose(signal));
1583
1850
  }
1584
1851
  }
1585
1852
  return;
1586
1853
  }
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));
1854
+ if (overlay.kind === 'workspace-picker') {
1855
+ if (key.upArrow || key.downArrow) {
1856
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: workspaceTargets.length });
1593
1857
  }
1594
1858
  else if (plainReturn) {
1595
- const target = workspaceTargets[workspaceIndex];
1596
- setWorkspacePickerOpen(false);
1859
+ const target = workspaceTargets[overlay.index];
1860
+ dispatchOverlay({ type: 'close' });
1597
1861
  if (target !== undefined)
1598
1862
  void channel.switchWorkspace(target);
1599
1863
  }
1600
1864
  else if (key.escape) {
1601
- setWorkspacePickerOpen(false);
1865
+ dispatchOverlay({ type: 'close' });
1602
1866
  }
1603
1867
  return;
1604
1868
  }
1605
- if (modelPickerOpen) {
1606
- if (key.upArrow) {
1607
- setModelIndex(index => (index <= 0 ? models.length - 1 : index - 1));
1869
+ if (overlay.kind === 'workspace-menu') {
1870
+ const menu = workspaceMenuOptions;
1871
+ if (key.upArrow || key.downArrow) {
1872
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: menu.length });
1608
1873
  }
1609
- else if (key.downArrow) {
1610
- setModelIndex(index => (index >= models.length - 1 ? 0 : index + 1));
1874
+ else if (plainReturn) {
1875
+ const option = menu[overlay.index];
1876
+ runWorkspaceMenuOption(option);
1877
+ }
1878
+ else if (key.escape) {
1879
+ dispatchOverlay({ type: 'close' });
1880
+ }
1881
+ return;
1882
+ }
1883
+ if (overlay.kind === 'model') {
1884
+ if (key.upArrow || key.downArrow) {
1885
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: models.length });
1611
1886
  }
1612
1887
  else if (plainReturn) {
1613
- const model = models[modelIndex];
1888
+ const model = models[overlay.index];
1614
1889
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1615
1890
  if (model) {
1616
1891
  // Enter switches the live model right away: the conversation is
1617
1892
  // forked at its end and continued with an agent routed to the new
1618
1893
  // model (history replays unchanged).
1619
- setModelPickerOpen(false);
1894
+ dispatchOverlay({ type: 'close' });
1620
1895
  channel.notify(t('model-switching', { name: model.name }));
1621
1896
  void channel.switchModel(model.provider, model.id).then((ok) => {
1622
1897
  if (ok)
@@ -1624,27 +1899,23 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1624
1899
  });
1625
1900
  }
1626
1901
  else {
1627
- setModelPickerOpen(false);
1902
+ dispatchOverlay({ type: 'close' });
1628
1903
  }
1629
1904
  }
1630
1905
  else if (key.escape) {
1631
- setModelPickerOpen(false);
1906
+ dispatchOverlay({ type: 'close' });
1632
1907
  }
1633
1908
  return;
1634
1909
  }
1635
- if (skillsPickerOpen) {
1910
+ if (overlay.kind === 'skills') {
1636
1911
  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));
1912
+ if (key.upArrow || key.downArrow) {
1913
+ // count 0 (snapshot still loading) is a no-op inside the reducer.
1914
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: list.length });
1644
1915
  }
1645
1916
  else if (plainReturn) {
1646
- const skill = list[skillsIndex];
1647
- setSkillsPickerOpen(false);
1917
+ const skill = list[overlay.index];
1918
+ dispatchOverlay({ type: 'close' });
1648
1919
  // 可直调技能 Enter 填入 `/name `——与 / 菜单选中技能同一条
1649
1920
  // completion-only 分发路径;模型专用技能(userInvocable=false)只关闭。
1650
1921
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
@@ -1652,213 +1923,252 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1652
1923
  setHistoryFill(`/${skill.name} `);
1653
1924
  }
1654
1925
  else if (key.escape) {
1655
- setSkillsPickerOpen(false);
1926
+ dispatchOverlay({ type: 'close' });
1656
1927
  }
1657
1928
  return;
1658
1929
  }
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));
1930
+ if (overlay.kind === 'activity') {
1931
+ if (key.upArrow || key.downArrow) {
1932
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: PRESET_NAMES.length });
1665
1933
  }
1666
1934
  else if (plainReturn) {
1667
- const name = PRESET_NAMES[activityIndex];
1668
- setActivityPickerOpen(false);
1935
+ const name = PRESET_NAMES[overlay.index];
1936
+ dispatchOverlay({ type: 'close' });
1669
1937
  if (name)
1670
1938
  channel.setActivityFrames(name);
1671
1939
  }
1672
1940
  else if (key.escape) {
1673
- setActivityPickerOpen(false);
1941
+ dispatchOverlay({ type: 'close' });
1674
1942
  }
1675
1943
  return;
1676
1944
  }
1677
- if (effortSliderOpen) {
1945
+ if (overlay.kind === 'effort') {
1678
1946
  if (key.leftArrow || key.rightArrow) {
1679
1947
  const delta = key.leftArrow ? -1 : 1;
1680
- const next = (effortIndex + delta + effortOptions.length) % effortOptions.length;
1681
- setEffortIndex(next);
1948
+ // The same wrap rule the reducer applies — computed here too so the
1949
+ // newly focused level is applied in this very keystroke.
1950
+ const next = wrapIndex(overlay.index, delta, effortOptions.length);
1951
+ dispatchOverlay({ type: 'move', delta, count: effortOptions.length });
1682
1952
  const option = effortOptions[next];
1683
1953
  // Live-apply: the slider IS the control; Esc does not revert.
1684
1954
  if (option)
1685
1955
  void channel.setEffort(option.id);
1686
1956
  }
1687
1957
  else if (plainReturn || key.escape) {
1688
- setEffortSliderOpen(false);
1958
+ dispatchOverlay({ type: 'close' });
1689
1959
  }
1690
1960
  return;
1691
1961
  }
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));
1962
+ if (overlay.kind === 'preset') {
1963
+ if (key.upArrow || key.downArrow) {
1964
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: presetOptions.length });
1698
1965
  }
1699
1966
  else if (plainReturn) {
1700
- const option = presetOptions[presetIndex];
1701
- setPresetPickerOpen(false);
1967
+ const option = presetOptions[overlay.index];
1968
+ dispatchOverlay({ type: 'close' });
1702
1969
  if (option)
1703
1970
  void channel.switchPreset(option.id);
1704
1971
  }
1705
1972
  else if (key.escape) {
1706
- setPresetPickerOpen(false);
1973
+ dispatchOverlay({ type: 'close' });
1707
1974
  }
1708
1975
  return;
1709
1976
  }
1710
- if (themePickerOpen) {
1711
- const options = getThemeOptions();
1712
- if (key.upArrow) {
1713
- setThemeIndex(index => (index <= 0 ? options.length - 1 : index - 1));
1977
+ if (overlay.kind === 'permission') {
1978
+ if (key.upArrow || key.downArrow) {
1979
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: PERMISSION_PRESET_IDS.length });
1714
1980
  }
1715
- else if (key.downArrow) {
1716
- setThemeIndex(index => (index >= options.length - 1 ? 0 : index + 1));
1981
+ else if (plainReturn) {
1982
+ const id = PERMISSION_PRESET_IDS[overlay.index];
1983
+ dispatchOverlay({ type: 'close' });
1984
+ if (id !== undefined) {
1985
+ void channel.runExternalCommand('permission', ` ${id}`).then((text) => {
1986
+ if (text !== undefined && text !== '')
1987
+ channel.notify(text);
1988
+ });
1989
+ }
1990
+ }
1991
+ else if (key.escape) {
1992
+ dispatchOverlay({ type: 'close' });
1993
+ }
1994
+ return;
1995
+ }
1996
+ if (overlay.kind === 'plan') {
1997
+ if (key.upArrow || key.downArrow) {
1998
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: 2 });
1717
1999
  }
1718
2000
  else if (plainReturn) {
1719
- setThemePickerOpen(false);
1720
- const name = options[themeIndex]?.value;
2001
+ const on = overlay.index === 0;
2002
+ dispatchOverlay({ type: 'close' });
2003
+ void channel.runExternalCommand('plan', on ? '' : ' off').then((text) => {
2004
+ if (text !== undefined && text !== '')
2005
+ channel.notify(text);
2006
+ });
2007
+ }
2008
+ else if (key.escape) {
2009
+ dispatchOverlay({ type: 'close' });
2010
+ }
2011
+ return;
2012
+ }
2013
+ if (overlay.kind === 'lang') {
2014
+ if (key.upArrow || key.downArrow) {
2015
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: 2 });
2016
+ }
2017
+ else if (plainReturn) {
2018
+ const lang = LANGS[overlay.index];
2019
+ dispatchOverlay({ type: 'close' });
2020
+ if (lang !== undefined)
2021
+ applyLang(lang);
2022
+ }
2023
+ else if (key.escape) {
2024
+ dispatchOverlay({ type: 'close' });
2025
+ }
2026
+ return;
2027
+ }
2028
+ if (overlay.kind === 'theme') {
2029
+ const options = getThemeOptions();
2030
+ if (key.upArrow || key.downArrow) {
2031
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: options.length });
2032
+ }
2033
+ else if (plainReturn) {
2034
+ dispatchOverlay({ type: 'close' });
2035
+ const name = options[overlay.index]?.value;
1721
2036
  if (name !== undefined) {
1722
2037
  const ok = setTheme(name);
1723
2038
  channel.notify(ok ? t('theme-switched-saved', { name }) : t('theme-switch-failed', { name }), { color: ok ? 'success' : 'error' });
1724
2039
  }
1725
2040
  }
1726
2041
  else if (key.escape) {
1727
- setThemePickerOpen(false);
2042
+ dispatchOverlay({ type: 'close' });
1728
2043
  }
1729
2044
  return;
1730
2045
  }
1731
- if (historyOpen) {
2046
+ if (overlay.kind === 'history') {
2047
+ const { query, cursor, focus } = overlay;
1732
2048
  if (key.escape) {
1733
- setHistoryOpen(false);
2049
+ dispatchOverlay({ type: 'close' });
1734
2050
  }
1735
2051
  else if (key.ctrl && (input === 'c' || input === 'd')) {
1736
2052
  // CC's history search cancels on ctrl+c/ctrl+d too.
1737
- setHistoryOpen(false);
2053
+ dispatchOverlay({ type: 'close' });
1738
2054
  }
1739
2055
  else if (plainReturn) {
1740
- const entry = historyMatches[historyFocus];
2056
+ const entry = historyMatches[focus];
1741
2057
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty match list
1742
2058
  if (entry) {
1743
2059
  setHistoryFill(entry.text);
1744
- setHistoryOpen(false);
2060
+ dispatchOverlay({ type: 'close' });
1745
2061
  }
1746
2062
  }
1747
2063
  else if (key.upArrow) {
1748
- setHistoryFocus(index => historyMatches.length === 0 ? 0 : (index <= 0 ? historyMatches.length - 1 : index - 1));
2064
+ if (historyMatches.length > 0) {
2065
+ dispatchOverlay({ type: 'move', delta: -1, count: historyMatches.length });
2066
+ }
1749
2067
  }
1750
2068
  else if (key.downArrow || (isMod(key) && input === 'r')) {
1751
2069
  // 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));
2070
+ if (historyMatches.length > 0) {
2071
+ dispatchOverlay({ type: 'move', delta: 1, count: historyMatches.length });
2072
+ }
1753
2073
  }
1754
2074
  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);
2075
+ if (cursor > 0) {
2076
+ dispatchOverlay({
2077
+ type: 'history-edit',
2078
+ query: query.slice(0, cursor - 1) + query.slice(cursor),
2079
+ cursor: cursor - 1,
2080
+ focus: 0,
2081
+ });
1760
2082
  }
1761
2083
  }
1762
2084
  else if (key.delete) {
1763
- if (historyCursor < historyQuery.length) {
1764
- setHistoryQuery(historyQuery.slice(0, historyCursor) + historyQuery.slice(historyCursor + 1));
1765
- setHistoryFocus(0);
2085
+ if (cursor < query.length) {
2086
+ dispatchOverlay({
2087
+ type: 'history-edit',
2088
+ query: query.slice(0, cursor) + query.slice(cursor + 1),
2089
+ focus: 0,
2090
+ });
1766
2091
  }
1767
2092
  }
1768
2093
  else if (key.leftArrow) {
1769
2094
  // Step by code point, not UTF-16 unit: an emoji is two units, and
1770
2095
  // 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
- });
2096
+ if (cursor > 0) {
2097
+ const ch = [...query.slice(0, cursor)].pop();
2098
+ dispatchOverlay({ type: 'history-edit', cursor: cursor - ch.length });
2099
+ }
1777
2100
  }
1778
2101
  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
- });
2102
+ if (cursor < query.length) {
2103
+ const ch = [...query.slice(cursor)][0];
2104
+ dispatchOverlay({ type: 'history-edit', cursor: cursor + ch.length });
2105
+ }
1785
2106
  }
1786
2107
  else if (key.home) {
1787
- setHistoryCursor(0);
2108
+ dispatchOverlay({ type: 'history-edit', cursor: 0 });
1788
2109
  }
1789
2110
  else if (key.end) {
1790
- setHistoryCursor(historyQuery.length);
2111
+ dispatchOverlay({ type: 'history-edit', cursor: query.length });
1791
2112
  }
1792
2113
  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);
2114
+ dispatchOverlay({
2115
+ type: 'history-edit',
2116
+ query: query.slice(0, cursor) + input + query.slice(cursor),
2117
+ cursor: cursor + input.length,
2118
+ focus: 0,
2119
+ });
1797
2120
  }
1798
2121
  return;
1799
2122
  }
1800
- if (rewindOpen) {
2123
+ if (overlay.kind === 'rewind') {
1801
2124
  // While the plugin decision is in flight the picker is read-only;
1802
2125
  // Esc abandons the wait (the stale answer is dropped by the token).
1803
- if (rewindBusy) {
2126
+ if (overlay.busy) {
1804
2127
  if (key.escape) {
1805
2128
  rewindRequestRef.current += 1;
1806
- setRewindBusy(false);
2129
+ dispatchOverlay({ type: 'rewind-busy', busy: false });
1807
2130
  }
1808
2131
  return;
1809
2132
  }
1810
- if (rewindConfirm !== null) {
1811
- if (rewindModes !== null) {
2133
+ if (overlay.confirm !== null) {
2134
+ const row = overlay.confirm;
2135
+ if (overlay.modes !== null) {
1812
2136
  // Plugin offered modes: the confirm pane is a choice list —
1813
2137
  // 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));
2138
+ const optionCount = overlay.modes.length + 1;
2139
+ if (key.upArrow || key.downArrow) {
2140
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: optionCount });
1820
2141
  }
1821
2142
  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);
2143
+ const mode = overlay.modeIndex === 0 ? null : (overlay.modes[overlay.modeIndex - 1]?.id ?? null);
2144
+ dispatchOverlay({ type: 'close' });
1827
2145
  void performRewind(row, mode);
1828
2146
  }
1829
2147
  else if (key.escape) {
1830
- setRewindConfirm(null);
1831
- setRewindModes(null);
2148
+ dispatchOverlay({ type: 'rewind-back' });
1832
2149
  }
1833
2150
  return;
1834
2151
  }
1835
2152
  // Confirmation state: Enter rewinds, Esc backs out to the list.
1836
2153
  if (plainReturn) {
1837
- const row = rewindConfirm;
1838
- setRewindOpen(false);
1839
- setRewindConfirm(null);
2154
+ dispatchOverlay({ type: 'close' });
1840
2155
  void performRewind(row);
1841
2156
  }
1842
2157
  else if (key.escape) {
1843
- setRewindConfirm(null);
2158
+ dispatchOverlay({ type: 'rewind-back' });
1844
2159
  }
1845
2160
  }
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));
2161
+ else if (key.upArrow || key.downArrow) {
2162
+ dispatchOverlay({ type: 'move', delta: key.upArrow ? -1 : 1, count: rewindRows.length });
1851
2163
  }
1852
2164
  else if (plainReturn) {
1853
- const row = rewindRows[rewindIndex];
2165
+ const row = rewindRows[overlay.index];
1854
2166
  // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1855
2167
  if (row)
1856
2168
  void requestRewindConfirm(row);
1857
2169
  }
1858
2170
  else if (key.escape) {
1859
- setRewindOpen(false);
1860
- setRewindConfirm(null);
1861
- setRewindModes(null);
2171
+ dispatchOverlay({ type: 'close' });
1862
2172
  }
1863
2173
  return;
1864
2174
  }
@@ -1880,11 +2190,11 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1880
2190
  return;
1881
2191
  }
1882
2192
  if (isMod(key) && input === 'r' && !helpOpen) {
1883
- setHistoryQuery('');
1884
- setHistoryCursor(0);
1885
- setHistoryFocus(0);
1886
2193
  setHistoryEntries(loadHistory());
1887
- setHistoryOpen(true);
2194
+ dispatchOverlay({
2195
+ type: 'open',
2196
+ overlay: { kind: 'history', query: '', cursor: 0, focus: 0 },
2197
+ });
1888
2198
  return;
1889
2199
  }
1890
2200
  if (key.shift && key.upArrow && !selectionActive && !helpOpen) {
@@ -1947,7 +2257,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1947
2257
  setSearchCursor(0);
1948
2258
  setSearchCurrent(0);
1949
2259
  setSearchCount(0);
1950
- setSearchOpen(true);
2260
+ dispatchOverlay({ type: 'open', overlay: { kind: 'search' } });
1951
2261
  event.stopImmediatePropagation();
1952
2262
  }
1953
2263
  }
@@ -1977,9 +2287,26 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
1977
2287
  else if (isMod(key) && input === 'e') {
1978
2288
  setShowAllMessages(previous => !previous);
1979
2289
  }
1980
- else if (plainReturn && showPill) {
2290
+ else if (isMod(key) && input === 'q') {
2291
+ // Fold/unfold the GoalTodoPanel todo section — works mid-turn too:
2292
+ // the collapsed line keeps the done/total count and the live task
2293
+ // preview, so long todo lists stop crowding the prompt.
2294
+ setTodoCollapsed(previous => !previous);
2295
+ }
2296
+ else if (plainReturn && !isSticky) {
2297
+ // Enter while scrolled up returns to the bottom (CC's pill: the
2298
+ // affordance now exists whenever the view is off the bottom, not
2299
+ // only with unseen rows).
1981
2300
  handle?.scrollToBottom();
1982
2301
  }
2302
+ else if (key.end && !isSticky) {
2303
+ // End = jump to bottom, less/vim semantics. Global on the chat
2304
+ // screen (search/history overlays consume their own End first —
2305
+ // cursor-to-line-end there). At the bottom already: no-op, so the
2306
+ // key stays harmless in muscle memory.
2307
+ handle?.scrollToBottom();
2308
+ event.stopImmediatePropagation();
2309
+ }
1983
2310
  else if (extensionShortcuts !== undefined && extensionShortcuts.dispatch(input, key)) {
1984
2311
  // Plugin shortcut (tuiShortcuts seam): matched only after every
1985
2312
  // built-in global binding above declined — locals always win, and the
@@ -2061,10 +2388,10 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2061
2388
  }, onClose: () => setSubagentDashboardOpen(false) }));
2062
2389
  return fullscreen ? dashboard : _jsx(AlternateScreen, { children: dashboard });
2063
2390
  }
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;
2391
+ /** Prompt input is inert while a modal dialog owns the keyboard. The
2392
+ * overlay union covers every picker/dialog and /tips in one check;
2393
+ * message-selection mode and the /btw panel live outside it. */
2394
+ const promptSelectionActive = selectionActive || overlay.kind !== 'none' || btw !== null;
2068
2395
  // The trajectory scene replaces the conversation for as long as it is open.
2069
2396
  // Rendering it INSTEAD of (not above) the transcript is what makes it a
2070
2397
  // screen rather than an overlay: it owns the full viewport, and the
@@ -2077,24 +2404,60 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2077
2404
  const scene = _jsx(TrajectoryScene, { channel: channel, build: trajectory, onClose: closeScene });
2078
2405
  return fullscreen ? scene : _jsx(AlternateScreen, { children: scene });
2079
2406
  }
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);
2407
+ // 浮层整体挂载条件:与内部各面板的可见条件同值(数据门在
2408
+ // dialogOverlayVisible 里逐面板镜像)。关闭时把整个 absolute 浮层从树里
2409
+ // 移除——渲染器的"移除 absolute 节点"检测只看被移除子树自身的
2410
+ // style.position(dom.ts collectRemovedRects),若浮层常驻、只移除其
2411
+ // 普通子节点,blit 解毒不触发,被覆盖的转录行会在 blit-skip 后留空
2412
+ // (Esc 关 picker 一片空白的根因)。
2413
+ const dialogOverlayOpen = dialogOverlayVisible(overlay, {
2414
+ workspaceTargetCount: workspaceTargets.length,
2415
+ effortOptionCount: effortOptions.length,
2416
+ presetOptionCount: presetOptions.length,
2417
+ });
2418
+ // The sticky header pins the turn owning the viewport top row
2419
+ // (timeline.activeId, reported by MessageList) — scrolled up to an old
2420
+ // turn, it carries THAT turn's prompt, not the latest one.
2421
+ // channel.rows is a live in-place array, so the lookup is per-render.
2422
+ const anchorUserRowId = timeline.activeId;
2423
+ const anchorUserText = anchorUserRowId === null
2424
+ ? null
2425
+ : channel.rows.find(row => row.id === anchorUserRowId)?.text ?? null;
2426
+ return (_jsxs(Box, { ref: wakeTickRef, flexDirection: "column", flexGrow: 1, width: "100%", children: [!isSticky && anchorUserText && (_jsx(StickyPromptHeader, { text: anchorUserText, onClick: () => {
2427
+ // Click snaps the pinned prompt to the viewport top (CC's
2428
+ // StickyPromptHeader). Jump by the SAME content coordinate the
2429
+ // rail's tick uses (timeline turn top = the prompt TEXT top):
2430
+ // the element-based seek lands the row wrapper's margin at the
2431
+ // top instead — one row shy of the text top the anchor rule
2432
+ // compares against — and the header would flip to the previous
2433
+ // turn immediately after the click.
2434
+ const turn = timeline.turns.find(t => t.id === anchorUserRowId);
2435
+ if (turn)
2436
+ handle?.scrollTo(turn.top);
2437
+ else if (anchorUserRowId !== null)
2438
+ seekRow(anchorUserRowId);
2095
2439
  else
2096
2440
  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 &&
2441
+ } })), _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,
2442
+ // Resuming a long session skips the ~3.4s opening animation: it
2443
+ // keeps firing low-frequency React commits that compete with the
2444
+ // transcript mount batches (and the first wheel events) for the
2445
+ // frame budget right when the user wants to read history. Fresh
2446
+ // sessions keep the full intro; restored ones settle instantly.
2447
+ 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) })] }), (() => {
2448
+ // Gutter mode (settings `dsh-tui.scrollGutter`): the timeline
2449
+ // rail (default), the proportional scrollbar, or nothing. The
2450
+ // slot keeps its 2 columns in both rendered modes (Qwen's
2451
+ // permanent-gutter rule — an appearing/disappearing gutter
2452
+ // changes the transcript width and rewraps everything).
2453
+ const gutter = normalizeScrollGutter(channel.scrollGutter);
2454
+ if (gutter === 'hidden')
2455
+ return null;
2456
+ if (gutter === 'scrollbar') {
2457
+ return _jsx(ScrollbarGutter, { handle: handle, terminalWidth: terminalColumns });
2458
+ }
2459
+ return (_jsx(TimelineRail, { handle: handle, turns: timeline.turns, activeId: timeline.activeId, upId: timeline.upId, downId: timeline.downId, terminalWidth: terminalColumns, hoverEnabled: !promptSelectionActive, onRevealTurn: revealAndSeekRow }));
2460
+ })()] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [showPill && (_jsx(NewMessagesPill, { count: unseenCount, onClick: () => handle?.scrollToBottom() })), channel.working &&
2098
2461
  (channel.activityEnabled &&
2099
2462
  !channel.minimal &&
2100
2463
  channel.workingActivity !== undefined &&
@@ -2104,7 +2467,7 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2104
2467
  // the animated chars/4 estimate, matching the classic
2105
2468
  // spinner's counter (the suffix used raw chars before,
2106
2469
  // 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: () => {
2470
+ 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) }), 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' }) }) })) : 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
2471
  void setClipboard(btw.answer ?? '').then(raw => { if (raw)
2109
2472
  writeRaw?.(raw); });
2110
2473
  channel.notify(t('copied-chars', { n: (btw.answer ?? '').length }), { timeoutMs: 1500 });
@@ -2114,12 +2477,129 @@ export function Chat({ channel, questionStore, approvalStore, extensionDialogs,
2114
2477
  band: wakeBand,
2115
2478
  hint: trajectorySeen ? undefined : `${modLabel}t`,
2116
2479
  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 })] }))] })] }));
2480
+ } }), dialogOverlayOpen && (_jsxs(OverlayAbove, { maxHeight: Math.max(terminalRows - 8, 1), children: [overlay.kind === 'thinking' && (_jsx(ThinkingToggle, { currentValue: thinkingVisible, focusIndex: overlay.focus, onPick: (index) => {
2481
+ // 点击行 = 设焦点 + 应用(与 Enter 同一条路径)
2482
+ const visible = index === 0;
2483
+ setThinkingVisible(visible);
2484
+ dispatchOverlay({ type: 'close' });
2485
+ channel.notify(t('thinking-toggled', { state: visible ? t('thinking-on') : t('thinking-off') }));
2486
+ } })), 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) => {
2487
+ // 点击行 = 切换该行目标(与 Enter 同一条路径)
2488
+ const target = workspaceTargets[index];
2489
+ dispatchOverlay({ type: 'close' });
2490
+ if (target !== undefined)
2491
+ void channel.switchWorkspace(target);
2492
+ } }) })), overlay.kind === 'workspace-menu' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspaceMenuPicker, { options: workspaceMenuOptions, focusIndex: overlay.index, onPick: (index) => {
2493
+ // 点击行 = 执行该行(与 Enter 同一条路径)
2494
+ runWorkspaceMenuOption(workspaceMenuOptions[index]);
2495
+ } }) })), 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) => {
2496
+ // 点击行 = 设焦点 + 执行分支(与 Enter 同一条路径);
2497
+ // busy/输入态在组件侧禁点
2498
+ const choice = overlay.flow.choices[index];
2499
+ if (choice === undefined)
2500
+ return;
2501
+ dispatchOverlay({ type: 'set-index', kind: 'workspace-flow', index });
2502
+ runWorkspaceFlowAction(signal => choice.choose(signal));
2503
+ } }) })), overlay.kind === 'model' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: models.length === 0 ? (_jsx(ModelPickerLoading, {})) : (_jsx(ModelPicker, { models: models, focusIndex: overlay.index, currentModel: `${channel.provider}/${channel.model}`, onPick: (index) => {
2504
+ // 点击行 = 应用该行模型(与 Enter 同一条路径)
2505
+ const model = models[index];
2506
+ if (!model)
2507
+ return;
2508
+ dispatchOverlay({ type: 'close' });
2509
+ channel.notify(t('model-switching', { name: model.name }));
2510
+ void channel.switchModel(model.provider, model.id).then((ok) => {
2511
+ if (ok)
2512
+ channel.notify(t('model-switched', { name: model.name }));
2513
+ });
2514
+ } })) })), overlay.kind === 'skills' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: skillsList === null ? (_jsx(SkillsPickerLoading, {})) : (_jsx(SkillsPicker, { skills: skillsList, focusIndex: overlay.index, onPick: (index) => {
2515
+ const skill = skillsList[index];
2516
+ if (!skill)
2517
+ return;
2518
+ dispatchOverlay({ type: 'close' });
2519
+ if (skill.userInvocable)
2520
+ setHistoryFill(`/${skill.name} `);
2521
+ } })) })), overlay.kind === 'activity' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ActivityPicker, { focusIndex: overlay.index, currentPreset: channel.activityFrames, onPick: (index) => {
2522
+ dispatchOverlay({ type: 'close' });
2523
+ const name = PRESET_NAMES[index];
2524
+ if (name)
2525
+ channel.setActivityFrames(name);
2526
+ } }) })), overlay.kind === 'effort' && effortOptions.length > 1 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(EffortSlider, { options: effortOptions, focusIndex: overlay.index, currentId: channel.reasoningEffort,
2527
+ // 点击档位 = 移到该档并即时应用(与 ←/→ 同语义)
2528
+ onPick: (index) => {
2529
+ dispatchOverlay({ type: 'set-index', kind: 'effort', index });
2530
+ const option = effortOptions[index];
2531
+ if (option)
2532
+ void channel.setEffort(option.id);
2533
+ } }) })), 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) => {
2534
+ dispatchOverlay({ type: 'close' });
2535
+ const option = presetOptions[index];
2536
+ if (option)
2537
+ void channel.switchPreset(option.id);
2538
+ } }) })), overlay.kind === 'permission' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PermissionsPicker, { focusIndex: overlay.index, currentMode: channel.mode.sandbox, cwd: channel.cwd, onPick: (index) => {
2539
+ dispatchOverlay({ type: 'close' });
2540
+ const id = PERMISSION_PRESET_IDS[index];
2541
+ if (id !== undefined) {
2542
+ void channel.runExternalCommand('permission', ` ${id}`).then((text) => {
2543
+ if (text !== undefined && text !== '')
2544
+ channel.notify(text);
2545
+ });
2546
+ }
2547
+ } }) })), overlay.kind === 'plan' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PlanPicker, { focusIndex: overlay.index, currentOn: channel.mode.plan === true, onPick: (index) => {
2548
+ dispatchOverlay({ type: 'close' });
2549
+ const on = index === 0;
2550
+ void channel.runExternalCommand('plan', on ? '' : ' off').then((text) => {
2551
+ if (text !== undefined && text !== '')
2552
+ channel.notify(text);
2553
+ });
2554
+ } }) })), overlay.kind === 'lang' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(LangPicker, { focusIndex: overlay.index, currentLang: getLang(), onPick: (index) => {
2555
+ const lang = LANGS[index];
2556
+ if (lang === undefined)
2557
+ return;
2558
+ dispatchOverlay({ type: 'close' });
2559
+ applyLang(lang);
2560
+ } }) })), overlay.kind === 'theme' && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ThemePicker, { focusIndex: overlay.index, currentTheme: themeName, onPick: (index) => {
2561
+ dispatchOverlay({ type: 'close' });
2562
+ const name = getThemeOptions()[index]?.value;
2563
+ if (name !== undefined) {
2564
+ const ok = setTheme(name);
2565
+ channel.notify(ok ? t('theme-switched-saved', { name }) : t('theme-switch-failed', { name }), { color: ok ? 'success' : 'error' });
2566
+ }
2567
+ } }) })), 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) => {
2568
+ // 点击行 = 填入该历史命令(与 Enter 同路径)
2569
+ const entry = historyMatches[index];
2570
+ if (entry) {
2571
+ setHistoryFill(entry.text);
2572
+ dispatchOverlay({ type: 'close' });
2573
+ }
2574
+ } }) })), 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) => {
2575
+ // 列表页点击只选中:进入确认态保留键盘 Enter 显式触发
2576
+ dispatchOverlay({ type: 'set-index', kind: 'rewind', index });
2577
+ }, onConfirm: () => {
2578
+ // 确认页即显式确认层,点击直接执行(与 Enter 同路径)
2579
+ const row = overlay.confirm;
2580
+ if (row === null)
2581
+ return;
2582
+ dispatchOverlay({ type: 'close' });
2583
+ void performRewind(row);
2584
+ }, onPickMode: (index) => {
2585
+ // 模式列表点击直接执行该模式(与 Enter 同路径)
2586
+ const row = overlay.confirm;
2587
+ if (row === null)
2588
+ return;
2589
+ // 模式页仅当 modes 非空才渲染,这里空安全取值
2590
+ const mode = index === 0 ? null : (overlay.modes?.[index - 1]?.id ?? null);
2591
+ dispatchOverlay({ type: 'close' });
2592
+ void performRewind(row, mode);
2593
+ } }) })), overlay.kind === 'search' && _jsx(TranscriptSearchBar, { query: searchQuery, cursorOffset: searchCursor, count: searchCount, current: searchCurrent })] }))] })] }));
2118
2594
  }
2119
2595
  /**
2120
2596
  * The pinned prompt header shown above the ScrollBox while the user has
2121
2597
  * scrolled up (mirroring Claude Code's FullscreenLayout.StickyPromptHeader).
2122
- * Fixed at 1 row so the ScrollBox never shifts when the text changes.
2598
+ * Pins the user message the transcript viewport is currently showing — the
2599
+ * topmost visible user message, or the nearest one above when only assistant
2600
+ * content fills the view — so it tracks which turn the user is reading
2601
+ * instead of always carrying the latest prompt. Fixed at 1 row so the
2602
+ * ScrollBox never shifts when the text changes.
2123
2603
  */
2124
2604
  function StickyPromptHeader({ text, onClick, }) {
2125
2605
  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 +2607,9 @@ function StickyPromptHeader({ text, onClick, }) {
2127
2607
  /** The `↓ N new messages` pill shown while scrolled up with new content. */
2128
2608
  function NewMessagesPill({ count, onClick, }) {
2129
2609
  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 }), ' '] }) }) }));
2610
+ 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
2611
+ ? t(count === 1 ? 'new-message' : 'new-messages', { n: count })
2612
+ : t('back-to-bottom'), ' '] }) }) }));
2131
2613
  }
2132
2614
  /** /model while the provider catalog is still loading (CC's LoadingState). */
2133
2615
  function ModelPickerLoading() {