@deepseek-harness-tui/dsh-tui 0.9.3 → 0.10.0-beta.2

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 (267) hide show
  1. package/README.md +24 -6
  2. package/cordis.patch.yml +117 -39
  3. package/cordis.yml +13 -10
  4. package/lib/types/api.d.ts +7 -0
  5. package/lib/types/api.d.ts.map +1 -0
  6. package/lib/types/api.js +1 -0
  7. package/lib/types/cc/hyperlink.d.ts.map +1 -1
  8. package/lib/types/cc/hyperlink.js +42 -4
  9. package/lib/types/cc/markdown.d.ts.map +1 -1
  10. package/lib/types/cc/markdown.js +15 -5
  11. package/lib/types/commands.d.ts +6 -0
  12. package/lib/types/commands.d.ts.map +1 -1
  13. package/lib/types/commands.js +12 -8
  14. package/lib/types/components/MessageList.d.ts +8 -4
  15. package/lib/types/components/MessageList.d.ts.map +1 -1
  16. package/lib/types/components/MessageList.js +94 -32
  17. package/lib/types/components/PermissionsPicker.d.ts +6 -7
  18. package/lib/types/components/PermissionsPicker.d.ts.map +1 -1
  19. package/lib/types/components/PermissionsPicker.js +20 -14
  20. package/lib/types/components/PromptEditor.d.ts +46 -0
  21. package/lib/types/components/PromptEditor.d.ts.map +1 -0
  22. package/lib/types/components/PromptEditor.js +60 -0
  23. package/lib/types/components/PromptInput.d.ts +12 -0
  24. package/lib/types/components/PromptInput.d.ts.map +1 -1
  25. package/lib/types/components/PromptInput.js +1240 -205
  26. package/lib/types/components/RewindPicker.js +1 -1
  27. package/lib/types/components/SplitDiffView.d.ts +11 -1
  28. package/lib/types/components/SplitDiffView.d.ts.map +1 -1
  29. package/lib/types/components/SplitDiffView.js +9 -2
  30. package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
  31. package/lib/types/components/StreamingMarkdown.js +140 -3
  32. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  33. package/lib/types/components/SubagentDashboard.js +2 -2
  34. package/lib/types/components/SubagentDetailScene.js +1 -1
  35. package/lib/types/components/ThemePicker.d.ts +13 -15
  36. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  37. package/lib/types/components/ThemePicker.js +24 -26
  38. package/lib/types/components/Tooltip.d.ts +67 -0
  39. package/lib/types/components/Tooltip.d.ts.map +1 -0
  40. package/lib/types/components/Tooltip.js +220 -0
  41. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  42. package/lib/types/components/approvals/ApprovalPanel.js +1 -1
  43. package/lib/types/components/design-system/Divider.d.ts +14 -1
  44. package/lib/types/components/design-system/Divider.d.ts.map +1 -1
  45. package/lib/types/components/design-system/Divider.js +86 -8
  46. package/lib/types/components/design-system/ThemeProvider.d.ts +5 -2
  47. package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
  48. package/lib/types/components/design-system/ThemeProvider.js +52 -16
  49. package/lib/types/components/design-system/ThemedBox.d.ts +23 -2
  50. package/lib/types/components/design-system/ThemedBox.d.ts.map +1 -1
  51. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +11 -6
  52. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  53. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -6
  54. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +13 -1
  55. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  56. package/lib/types/components/messages/AssistantToolUseMessage.js +37 -6
  57. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  58. package/lib/types/components/messages/UserPromptMessage.js +7 -1
  59. package/lib/types/components/questions/AskUserQuestionPanel.js +1 -1
  60. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  61. package/lib/types/components/questions/PlanReviewPanel.js +29 -3
  62. package/lib/types/components/sessions/SessionListRow.d.ts +8 -1
  63. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  64. package/lib/types/components/sessions/SessionListRow.js +9 -2
  65. package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -1
  66. package/lib/types/components/sessions/SessionPreview.js +5 -3
  67. package/lib/types/components/sessions/WorkspaceListRow.d.ts +17 -0
  68. package/lib/types/components/sessions/WorkspaceListRow.d.ts.map +1 -0
  69. package/lib/types/components/sessions/WorkspaceListRow.js +27 -0
  70. package/lib/types/components/smoothReveal.d.ts +97 -0
  71. package/lib/types/components/smoothReveal.d.ts.map +1 -0
  72. package/lib/types/components/smoothReveal.js +279 -0
  73. package/lib/types/customTheme.d.ts +4 -0
  74. package/lib/types/customTheme.d.ts.map +1 -1
  75. package/lib/types/customTheme.js +9 -5
  76. package/lib/types/dsh-adapter/approvals.d.ts +114 -2
  77. package/lib/types/dsh-adapter/approvals.d.ts.map +1 -1
  78. package/lib/types/dsh-adapter/approvals.js +241 -3
  79. package/lib/types/dsh-adapter/channel.d.ts +59 -2
  80. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  81. package/lib/types/dsh-adapter/channel.js +499 -80
  82. package/lib/types/dsh-adapter/childStderr.d.ts.map +1 -1
  83. package/lib/types/dsh-adapter/childStderr.js +9 -0
  84. package/lib/types/dsh-adapter/contract.d.ts +17 -13
  85. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  86. package/lib/types/dsh-adapter/contract.js +36 -27
  87. package/lib/types/dsh-adapter/credentialRefGuard.d.ts +40 -0
  88. package/lib/types/dsh-adapter/credentialRefGuard.d.ts.map +1 -0
  89. package/lib/types/dsh-adapter/credentialRefGuard.js +43 -0
  90. package/lib/types/dsh-adapter/effect-ledger.d.ts +1 -1
  91. package/lib/types/dsh-adapter/effect-ledger.d.ts.map +1 -1
  92. package/lib/types/dsh-adapter/effect-ledger.js +1 -0
  93. package/lib/types/dsh-adapter/extensions.d.ts +4 -1
  94. package/lib/types/dsh-adapter/extensions.d.ts.map +1 -1
  95. package/lib/types/dsh-adapter/extensions.js +8 -1
  96. package/lib/types/dsh-adapter/index.d.ts +11 -1
  97. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  98. package/lib/types/dsh-adapter/index.js +2 -0
  99. package/lib/types/dsh-adapter/plugin-test-utils.d.ts +62 -0
  100. package/lib/types/dsh-adapter/plugin-test-utils.d.ts.map +1 -0
  101. package/lib/types/dsh-adapter/plugin-test-utils.js +61 -0
  102. package/lib/types/dsh-adapter/plugin.d.ts +20 -0
  103. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  104. package/lib/types/dsh-adapter/plugin.js +174 -39
  105. package/lib/types/dsh-adapter/plugins-info.d.ts.map +1 -1
  106. package/lib/types/dsh-adapter/plugins-info.js +12 -0
  107. package/lib/types/dsh-adapter/preset-resolution.d.ts +49 -0
  108. package/lib/types/dsh-adapter/preset-resolution.d.ts.map +1 -0
  109. package/lib/types/dsh-adapter/preset-resolution.js +42 -0
  110. package/lib/types/dsh-adapter/presets.d.ts +0 -26
  111. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  112. package/lib/types/dsh-adapter/presets.js +4 -14
  113. package/lib/types/dsh-adapter/promptDebug.d.ts.map +1 -1
  114. package/lib/types/dsh-adapter/promptDebug.js +4 -1
  115. package/lib/types/dsh-adapter/providerGuard.d.ts +75 -0
  116. package/lib/types/dsh-adapter/providerGuard.d.ts.map +1 -0
  117. package/lib/types/dsh-adapter/providerGuard.js +104 -0
  118. package/lib/types/dsh-adapter/questions-answerer.d.ts +49 -0
  119. package/lib/types/dsh-adapter/questions-answerer.d.ts.map +1 -0
  120. package/lib/types/dsh-adapter/questions-answerer.js +61 -0
  121. package/lib/types/dsh-adapter/recap.d.ts.map +1 -1
  122. package/lib/types/dsh-adapter/recap.js +12 -6
  123. package/lib/types/dsh-adapter/sanitize.d.ts.map +1 -1
  124. package/lib/types/dsh-adapter/sanitize.js +6 -1
  125. package/lib/types/dsh-adapter/sessions/digest.d.ts +21 -1
  126. package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -1
  127. package/lib/types/dsh-adapter/sessions/digest.js +229 -3
  128. package/lib/types/dsh-adapter/sessions/frames.d.ts +3 -0
  129. package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -1
  130. package/lib/types/dsh-adapter/sessions/frames.js +34 -19
  131. package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -1
  132. package/lib/types/dsh-adapter/sessions/list.js +148 -31
  133. package/lib/types/dsh-adapter/sessions/store.d.ts +8 -0
  134. package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -1
  135. package/lib/types/dsh-adapter/sessions/store.js +24 -4
  136. package/lib/types/dsh-adapter/sessions/types.d.ts +3 -0
  137. package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -1
  138. package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -1
  139. package/lib/types/dsh-adapter/settings-sections.js +13 -1
  140. package/lib/types/dsh-adapter/settingsEditor.d.ts +6 -0
  141. package/lib/types/dsh-adapter/settingsEditor.d.ts.map +1 -1
  142. package/lib/types/dsh-adapter/settingsEditor.js +15 -3
  143. package/lib/types/dsh-adapter/subagents.d.ts +4 -0
  144. package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
  145. package/lib/types/dsh-adapter/subagents.js +8 -0
  146. package/lib/types/dsh-adapter/themes.d.ts +51 -0
  147. package/lib/types/dsh-adapter/themes.d.ts.map +1 -0
  148. package/lib/types/dsh-adapter/themes.js +272 -0
  149. package/lib/types/dsh-adapter/toast.d.ts +67 -0
  150. package/lib/types/dsh-adapter/toast.d.ts.map +1 -0
  151. package/lib/types/dsh-adapter/toast.js +141 -0
  152. package/lib/types/dsh-adapter/types.d.ts +1 -1
  153. package/lib/types/extensions.d.ts +4 -0
  154. package/lib/types/extensions.d.ts.map +1 -1
  155. package/lib/types/extensions.js +5 -1
  156. package/lib/types/history.d.ts.map +1 -1
  157. package/lib/types/history.js +7 -2
  158. package/lib/types/hooks/useRuntimeThemeSnapshot.d.ts +10 -0
  159. package/lib/types/hooks/useRuntimeThemeSnapshot.d.ts.map +1 -0
  160. package/lib/types/hooks/useRuntimeThemeSnapshot.js +32 -0
  161. package/lib/types/i18n.d.ts +199 -41
  162. package/lib/types/i18n.d.ts.map +1 -1
  163. package/lib/types/i18n.js +82 -20
  164. package/lib/types/ink/components/App.d.ts +20 -0
  165. package/lib/types/ink/components/App.d.ts.map +1 -1
  166. package/lib/types/ink/components/App.js +142 -13
  167. package/lib/types/ink/components/Box.d.ts +39 -2
  168. package/lib/types/ink/components/Box.d.ts.map +1 -1
  169. package/lib/types/ink/components/Box.js +61 -37
  170. package/lib/types/ink/events/context-menu-event.d.ts +19 -0
  171. package/lib/types/ink/events/context-menu-event.d.ts.map +1 -0
  172. package/lib/types/ink/events/context-menu-event.js +20 -0
  173. package/lib/types/ink/events/drag-event.d.ts +44 -0
  174. package/lib/types/ink/events/drag-event.d.ts.map +1 -0
  175. package/lib/types/ink/events/drag-event.js +39 -0
  176. package/lib/types/ink/events/event-handlers.d.ts +8 -0
  177. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  178. package/lib/types/ink/events/event-handlers.js +8 -0
  179. package/lib/types/ink/hit-test.d.ts +69 -0
  180. package/lib/types/ink/hit-test.d.ts.map +1 -1
  181. package/lib/types/ink/hit-test.js +260 -6
  182. package/lib/types/ink/ink.d.ts +25 -0
  183. package/lib/types/ink/ink.d.ts.map +1 -1
  184. package/lib/types/ink/ink.js +74 -10
  185. package/lib/types/ink/parse-keypress.d.ts +20 -0
  186. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  187. package/lib/types/ink/parse-keypress.js +190 -22
  188. package/lib/types/ink/renderer.d.ts.map +1 -1
  189. package/lib/types/ink/renderer.js +15 -1
  190. package/lib/types/ink/screen.d.ts +7 -0
  191. package/lib/types/ink/screen.d.ts.map +1 -1
  192. package/lib/types/ink/screen.js +8 -1
  193. package/lib/types/ink/termio/osc.d.ts +2 -1
  194. package/lib/types/ink/termio/osc.d.ts.map +1 -1
  195. package/lib/types/ink/termio/osc.js +26 -5
  196. package/lib/types/plugin-spec/registry.d.ts +1 -1
  197. package/lib/types/plugin-spec/registry.js +1 -1
  198. package/lib/types/presetPrefs.d.ts +6 -5
  199. package/lib/types/presetPrefs.d.ts.map +1 -1
  200. package/lib/types/presetPrefs.js +10 -5
  201. package/lib/types/screens/Chat.d.ts +4 -1
  202. package/lib/types/screens/Chat.d.ts.map +1 -1
  203. package/lib/types/screens/Chat.js +169 -68
  204. package/lib/types/screens/SessionBrowser.d.ts +9 -4
  205. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  206. package/lib/types/screens/SessionBrowser.js +483 -75
  207. package/lib/types/screens/SessionTree.d.ts.map +1 -1
  208. package/lib/types/screens/SessionTree.js +5 -1
  209. package/lib/types/screens/Settings.d.ts.map +1 -1
  210. package/lib/types/screens/Settings.js +5 -1
  211. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  212. package/lib/types/screens/StatusLine.js +5 -1
  213. package/lib/types/screens/chatOverlay.d.ts +3 -2
  214. package/lib/types/screens/chatOverlay.d.ts.map +1 -1
  215. package/lib/types/screens/chatOverlay.js +2 -0
  216. package/lib/types/sessionPins.d.ts +33 -0
  217. package/lib/types/sessionPins.d.ts.map +1 -0
  218. package/lib/types/sessionPins.js +181 -0
  219. package/lib/types/sessions/format.d.ts +2 -0
  220. package/lib/types/sessions/format.d.ts.map +1 -1
  221. package/lib/types/sessions/format.js +9 -0
  222. package/lib/types/sessions/view.d.ts +42 -6
  223. package/lib/types/sessions/view.d.ts.map +1 -1
  224. package/lib/types/sessions/view.js +133 -35
  225. package/lib/types/test-utils.d.ts +2 -0
  226. package/lib/types/test-utils.d.ts.map +1 -0
  227. package/lib/types/test-utils.js +8 -0
  228. package/lib/types/theme.d.ts +22 -5
  229. package/lib/types/theme.d.ts.map +1 -1
  230. package/lib/types/theme.js +57 -7
  231. package/lib/types/themeCatalog.d.ts +36 -0
  232. package/lib/types/themeCatalog.d.ts.map +1 -0
  233. package/lib/types/themeCatalog.js +157 -0
  234. package/lib/types/tips.d.ts.map +1 -1
  235. package/lib/types/tips.js +114 -18
  236. package/lib/types/update.d.ts +159 -3
  237. package/lib/types/update.d.ts.map +1 -1
  238. package/lib/types/update.js +497 -16
  239. package/lib/types/utils/debug.d.ts.map +1 -1
  240. package/lib/types/utils/debug.js +2 -1
  241. package/lib/types/utils/keymap.d.ts +1 -1
  242. package/lib/types/utils/keymap.d.ts.map +1 -1
  243. package/lib/types/utils/keymap.js +19 -2
  244. package/lib/types/utils/urlGuard.d.ts +28 -0
  245. package/lib/types/utils/urlGuard.d.ts.map +1 -0
  246. package/lib/types/utils/urlGuard.js +21 -0
  247. package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +3 -0
  248. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.d.ts +7 -11
  249. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.d.ts +16 -1
  250. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.js +23 -2
  251. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.d.ts +5 -5
  252. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/pi-ai.d.ts +23 -0
  253. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/pi-ai.js +27 -0
  254. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.d.ts +17 -3
  255. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.js +36 -4
  256. package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +11 -12
  257. package/package.json +114 -74
  258. package/lib/types/dsh-adapter/packaged-skills.d.ts +0 -19
  259. package/lib/types/dsh-adapter/packaged-skills.d.ts.map +0 -1
  260. package/lib/types/dsh-adapter/packaged-skills.js +0 -78
  261. package/skills/audit/SKILL.md +0 -24
  262. package/skills/bug/SKILL.md +0 -27
  263. package/skills/pr-comments/SKILL.md +0 -20
  264. package/skills/practice/SKILL.md +0 -22
  265. package/skills/release-notes/SKILL.md +0 -25
  266. package/skills/review/SKILL.md +0 -25
  267. package/skills/vuln-check/SKILL.md +0 -25
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from 'react';
3
+ import stripAnsi from 'strip-ansi';
3
4
  import { readFile, unlink } from 'node:fs/promises';
4
5
  import { basename } from 'node:path';
5
6
  import { t } from '../i18n.js';
@@ -10,6 +11,8 @@ import { EffortTierBadge } from './EffortTierBadge.js';
10
11
  import { isLightThemeActive } from '../theme.js';
11
12
  import { sessionColorHex } from '../cc/sessionColors.js';
12
13
  import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js';
14
+ import { TerminalWriteContext } from '../ink/useTerminalNotification.js';
15
+ import { setClipboard } from '../ink/termio/osc.js';
13
16
  import { noteAuxNumber } from '../ink/geometry-trace.js';
14
17
  import instances from '../ink/instances.js';
15
18
  import { stringWidth } from '../ink/stringWidth.js';
@@ -17,6 +20,7 @@ import { truncateToWidth } from '../ink/truncateToWidth.js';
17
20
  import { getGraphemeSegmenter } from '../utils/intl.js';
18
21
  import { formatClipboardInsert, readClipboard } from '../utils/clipboard.js';
19
22
  import { editInExternalEditor } from '../utils/externalEditor.js';
23
+ import { setPromptEditorNode, EditorButton } from './PromptEditor.js';
20
24
  import { isHiddenCommandName, parseCommandName } from '../commands.js';
21
25
  import { appendHistory } from '../history.js';
22
26
  import { mentionAtCaret } from '../utils/mentions.js';
@@ -41,6 +45,25 @@ const HISTORY_LIMIT = 50;
41
45
  const FOLD_MIN_LINES = 6;
42
46
  const FOLD_MIN_CHARS = 600;
43
47
  const isBigInput = (text) => text.split('\n').length >= FOLD_MIN_LINES || text.length >= FOLD_MIN_CHARS;
48
+ /**
49
+ * Editable prompt text must have one stable source-to-screen geometry. The
50
+ * renderer interprets ANSI as zero-width styling and expands tabs relative to
51
+ * global tab stops; keeping either in `value` would let wrapping/click mapping
52
+ * count different cells and could split an escape sequence during selection.
53
+ * Strip terminal controls and expand tabs at ingress while preserving newlines.
54
+ */
55
+ const EDITABLE_CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f]/u;
56
+ /** Normalize editable text so no terminal control characters remain in state. */
57
+ function sanitizeEditableText(text) {
58
+ // Fast path for ordinary and multi-line drafts: newline is intentionally
59
+ // absent from the probe, so a large clean paste returns without regex work.
60
+ if (!EDITABLE_CONTROL.test(text))
61
+ return text;
62
+ return stripAnsi(text)
63
+ .replace(/\r\n?/gu, '\n')
64
+ .replace(/\t/gu, ' ')
65
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '');
66
+ }
44
67
  function clipboardImageMediaType(path) {
45
68
  if (/\.png$/iu.test(path))
46
69
  return 'image/png';
@@ -71,6 +94,58 @@ function wordBoundaryRight(text, cursor) {
71
94
  index++;
72
95
  return index;
73
96
  }
97
+ // --- vim normal-mode helpers -----------------------------------------------
98
+ // `/vim` 编辑模式的 normal 键位几何:行/词移动与删除目标。空白分词
99
+ // (不区分 vim 的 word/WORD),对输入框场景足够且行为直观。
100
+ /** Offset of the current line's first character. */
101
+ function vimLineStart(text, cursor) {
102
+ return text.lastIndexOf('\n', cursor - 1) + 1;
103
+ }
104
+ /** Offset of the current line's last character (exclusive, no '\n'). */
105
+ function vimLineEnd(text, cursor) {
106
+ const next = text.indexOf('\n', cursor);
107
+ return next === -1 ? text.length : next;
108
+ }
109
+ /** Offset of the line's first non-whitespace character (`^`). */
110
+ function vimLineFirstNonBlank(text, cursor) {
111
+ const start = vimLineStart(text, cursor);
112
+ const end = vimLineEnd(text, cursor);
113
+ let i = start;
114
+ while (i < end && /\s/.test(text[i]))
115
+ i++;
116
+ return i;
117
+ }
118
+ /** Next word start (`w`): skip the rest of the current word, then leading
119
+ * whitespace. Whitespace-delimited, vim-style. */
120
+ function vimWordForward(text, cursor) {
121
+ const len = text.length;
122
+ let i = cursor;
123
+ if (i < len && !/\s/.test(text[i])) {
124
+ while (i < len && !/\s/.test(text[i]))
125
+ i++;
126
+ }
127
+ while (i < len && /\s/.test(text[i]))
128
+ i++;
129
+ return i;
130
+ }
131
+ /** Previous word start (`b`): from inside a word, its own start; from
132
+ * whitespace, the preceding word's start. */
133
+ function vimWordBackward(text, cursor) {
134
+ let i = cursor;
135
+ while (i > 0 && /\s/.test(text[i - 1]))
136
+ i--;
137
+ while (i > 0 && !/\s/.test(text[i - 1]))
138
+ i--;
139
+ return i;
140
+ }
141
+ /** End of the current word (`dw`): the whitespace boundary after `cursor`. */
142
+ function vimWordEnd(text, cursor) {
143
+ const len = text.length;
144
+ let i = cursor;
145
+ while (i < len && !/\s/.test(text[i]))
146
+ i++;
147
+ return i;
148
+ }
74
149
  // --- grapheme-cluster geometry ---------------------------------------------
75
150
  // The caret, editing keys, and wrapping MUST agree on one text unit. Mixing
76
151
  // UTF-16 code units (arrows/backspace), code points (wrap), and display
@@ -160,6 +235,20 @@ function normalizeCursorOffset(text, offset) {
160
235
  /** Max input rows before the visible viewport starts scrolling (CC's
161
236
  * maxVisibleLines behavior — the box keeps a stable height). */
162
237
  const MAX_VISIBLE_LINES = 5;
238
+ /**
239
+ * Fixed chrome rows around the expanded editor's text area: round border
240
+ * (top+bottom), the title row, the status row, and the button row. The
241
+ * editor viewport gets `terminalRows - this` rows.
242
+ */
243
+ const EDITOR_CHROME_ROWS = 5;
244
+ /**
245
+ * Double-click self-detection window: two clicks within this many ms and
246
+ * one cell (in either axis) of each other count as a double-click and
247
+ * select the word under the pointer. The drag protocol resets the ink
248
+ * multi-click chain on every press inside a drag target (the value box
249
+ * carries onDragStart), so the prompt detects the double-click itself.
250
+ */
251
+ const DOUBLE_CLICK_MS = 500;
163
252
  /**
164
253
  * Claude Code style prompt input: rounded border box (top+bottom borders
165
254
  * only), `❯ ` prompt char (dimmed while a turn is working), the text with a
@@ -193,8 +282,44 @@ const MAX_VISIBLE_LINES = 5;
193
282
  */
194
283
  export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, selectionActive, fillText, onFillConsumed, onRewindRequest, controllerRef, }) {
195
284
  const [themeName] = useTheme();
285
+ // Raw stdout writer for OSC 52 clipboard writes (selection copy) — must
286
+ // bypass the frame pipeline; null outside a mounted Ink App.
287
+ const writeRaw = React.useContext(TerminalWriteContext);
196
288
  const [value, setValue] = React.useState('');
197
289
  const [cursor, setCursor] = React.useState(0);
290
+ /**
291
+ * Mouse text selection: UTF-16 offsets [start, end) in `value`, snapped
292
+ * to grapheme boundaries, start ≤ end. Null = no selection. Created by
293
+ * drag, Shift+click extension and double-click word select; consumed by
294
+ * Backspace/Delete/typing; Esc clears it without touching the text.
295
+ * With a fold block the range always stays inside the head or the tail
296
+ * (it never crosses the chip row — clamped on every write).
297
+ */
298
+ const [selection, setSelection] = React.useState(null);
299
+ const selectionRef = React.useRef(null);
300
+ /** Anchor offset of the in-flight drag gesture (set by dragstart). */
301
+ const dragAnchorRef = React.useRef(null);
302
+ /** Double-click self-detection: last click's timestamp/screen cell. */
303
+ const lastClickAtRef = React.useRef(0);
304
+ const lastClickColRef = React.useRef(-1);
305
+ const lastClickRowRef = React.useRef(-1);
306
+ /**
307
+ * vim editing mode (`/vim` toggle): when on, Esc switches the prompt to
308
+ * its NORMAL submode where bare keys are vim keys instead of text, and
309
+ * i/a/o (…) return to INSERT. Enabled in insert mode so the transition
310
+ * is seamless; the mode is session-scoped (not persisted).
311
+ */
312
+ const [vimEnabled, setVimEnabled] = React.useState(false);
313
+ /** Insert submode (false = vim NORMAL). */
314
+ const [vimInsert, setVimInsert] = React.useState(true);
315
+ const vimEnabledRef = React.useRef(false);
316
+ const vimInsertRef = React.useRef(true);
317
+ vimEnabledRef.current = vimEnabled;
318
+ vimInsertRef.current = vimInsert;
319
+ /** Undo stack: vim editing ops push {value, cursor}; `u` pops. */
320
+ const vimUndoRef = React.useRef([]);
321
+ /** Pending vim operator: `d` pressed, awaiting its second key. */
322
+ const vimPendingRef = React.useRef('');
198
323
  /**
199
324
  * CC-style fold block: the [start, end) span of `value` that renders as
200
325
  * a one-line chip while the text around it stays fully editable. Created
@@ -202,6 +327,39 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
202
327
  * delete removes it — typing NEVER unfolds the block.
203
328
  */
204
329
  const [foldBlock, setFoldBlock] = React.useState(null);
330
+ /** Synchronous mirror used by batched keys, controller clear, and mouse drag. */
331
+ const foldBlockRef = React.useRef(null);
332
+ /**
333
+ * Fullscreen draft editor (`expandEditor`, default Ctrl+Shift+E, or the
334
+ * ⛶ affordance at the end of the input row). While expanded the SAME
335
+ * editing state renders into the PromptEditorLayer cover (published via
336
+ * setPromptEditorNode each render): Enter inserts a newline, Ctrl+Enter
337
+ * submits, Esc collapses. The fold chip is bypassed (full text shown).
338
+ */
339
+ const [expanded, setExpanded] = React.useState(false);
340
+ const expandedRef = React.useRef(false);
341
+ /** First visible row of the expanded viewport (merged with caret-follow
342
+ * during render; wheel events advance it and tick a re-render). */
343
+ const expandedScrollRef = React.useRef(0);
344
+ /**
345
+ * Free-scroll latch: wheel browsing parks the viewport anywhere; the
346
+ * caret-follow merge skips while it is set, and the next real caret move
347
+ * (typing, arrows, click) re-engages following (GUI-editor semantics).
348
+ */
349
+ const editorFreeScrollRef = React.useRef(false);
350
+ const prevCaretLineRef = React.useRef(-1);
351
+ const [, setExpandedTick] = React.useState(0);
352
+ const prevExpandedRef = React.useRef(false);
353
+ /**
354
+ * Feature gate (settings `dsh-tui.expandEditor`, on by default; a mock
355
+ * channel without the field also reads as on). Off hides the ⛶
356
+ * affordance and refuses the shortcut — the editor cannot open.
357
+ */
358
+ const expandEnabled = channel.expandEditor !== false;
359
+ /** Latest expanded viewport metrics for the useInput wheel branch. */
360
+ const editorViewportRef = React.useRef(null);
361
+ /** Hover state of the ⤢/⛶ expand affordance in the input row. */
362
+ const [expandHovered, setExpandHovered] = React.useState(false);
205
363
  /** Pointer over the input box (drives the hover peek card). */
206
364
  const [hovered, setHovered] = React.useState(false);
207
365
  /** 120ms grace so the pointer crossing the input border row from the
@@ -233,9 +391,48 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
233
391
  clear: () => {
234
392
  valueRef.current = '';
235
393
  cursorRef.current = 0;
394
+ selectionRef.current = null;
395
+ foldBlockRef.current = null;
396
+ dragAnchorRef.current = null;
397
+ lastClickAtRef.current = 0;
398
+ lastClickColRef.current = -1;
399
+ lastClickRowRef.current = -1;
400
+ // Chat's idle Ctrl+C clear also withdraws the fullscreen editor —
401
+ // the draft it was editing is gone, the cover must not linger.
402
+ expandedRef.current = false;
403
+ setSelection(null);
404
+ setFoldBlock(null);
405
+ setExpanded(false);
236
406
  setValue('');
237
407
  setCursor(0);
238
408
  },
409
+ consumeSelectionCopy: () => {
410
+ const sel = selectionRef.current;
411
+ if (!sel)
412
+ return false;
413
+ const text = valueRef.current.slice(sel.start, sel.end);
414
+ void setClipboard(text).then(raw => {
415
+ if (raw)
416
+ writeRaw?.(raw);
417
+ });
418
+ // The selection stays: copy never clears it (Esc/typing/delete do).
419
+ return true;
420
+ },
421
+ toggleVim: () => {
422
+ const next = !vimEnabledRef.current;
423
+ vimEnabledRef.current = next;
424
+ setVimEnabled(next);
425
+ // Every toggle lands in INSERT: a fresh vim user keeps typing
426
+ // normally until they press Esc for the first time. Turning the
427
+ // mode off also clears the undo stack — a later re-enable must
428
+ // never `u` its way back past edits made while vim was off.
429
+ vimInsertRef.current = true;
430
+ setVimInsert(true);
431
+ vimPendingRef.current = '';
432
+ vimUndoRef.current = [];
433
+ return next;
434
+ },
435
+ vimActive: () => vimEnabledRef.current,
239
436
  };
240
437
  return () => {
241
438
  controllerRef.current = null;
@@ -274,6 +471,14 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
274
471
  };
275
472
  }, []);
276
473
  const { columns, rows: terminalRows } = useTerminalSize();
474
+ React.useEffect(() => {
475
+ // PromptInput self-detects double-clicks because its drag target resets
476
+ // App's global chain. Geometry changed across resize, so the same screen
477
+ // cell no longer identifies the same grapheme.
478
+ lastClickAtRef.current = 0;
479
+ lastClickColRef.current = -1;
480
+ lastClickRowRef.current = -1;
481
+ }, [columns, terminalRows]);
277
482
  const helpScrollRef = React.useRef(null);
278
483
  // Help viewport budget: the overlay anchors at the composer's top edge
279
484
  // (OverlayAbove bottom:'100%') and grows UP, so its budget is the space
@@ -291,6 +496,7 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
291
496
  const helpViewportHeight = Math.max(3, Math.min(terminalRows - 7, 15));
292
497
  const suggestions = value.startsWith('/') ? channel.commandCompletions(value) : [];
293
498
  const overlayOpen = suggestions.length > 0 &&
499
+ !expanded &&
294
500
  !helpOpen &&
295
501
  !selectionActive &&
296
502
  !value.includes('\n');
@@ -330,13 +536,13 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
330
536
  fileEscRef.current = -1;
331
537
  }, [value]);
332
538
  const fileOverlayOpen = fileMatches.length > 0 &&
539
+ !expanded &&
333
540
  !helpOpen &&
334
541
  !selectionActive &&
335
542
  fileEscRef.current !== mention?.start;
336
543
  /** Fold-block state + a synchronous mirror (setInput reads the ref).
337
544
  * Creating a block also drags a caret that sits inside it out to the
338
545
  * block's end (the block is atomic; typing continues after it). */
339
- const foldBlockRef = React.useRef(null);
340
546
  const updateFoldBlock = (block) => {
341
547
  foldBlockRef.current = block;
342
548
  setFoldBlock(block);
@@ -349,6 +555,12 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
349
555
  }
350
556
  };
351
557
  const setInput = (next, cursorOffset = next.length) => {
558
+ // Apply the same ingress normalization to fills/history/editor results as
559
+ // to paste. Map the requested caret through the sanitized prefix so an
560
+ // ANSI sequence removed before it cannot leave the caret past the text.
561
+ const sanitizedCursor = sanitizeEditableText(next.slice(0, cursorOffset)).length;
562
+ next = sanitizeEditableText(next);
563
+ cursorOffset = sanitizedCursor;
352
564
  const prev = valueRef.current;
353
565
  const prevCursor = cursorRef.current;
354
566
  const block = foldBlockRef.current;
@@ -384,9 +596,51 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
384
596
  // read → several keys, no render in between) read on their next turn.
385
597
  valueRef.current = next;
386
598
  cursorRef.current = offset;
599
+ // Every real edit drops the selection: its offsets describe the OLD
600
+ // text. Selection-consuming callers (delete/replace) read it first.
601
+ selectionRef.current = null;
602
+ setSelection(null);
387
603
  setValue(next);
388
604
  setCursor(offset);
389
605
  };
606
+ /**
607
+ * Write the selection [start, end) (snapped to grapheme boundaries,
608
+ * start ≤ end); a degenerate range clears it. With a fold block the
609
+ * range is clamped into the side that holds `start` — the block is
610
+ * atomic and a selection may never cross the chip row.
611
+ */
612
+ const updateSelection = (start, end) => {
613
+ const text = valueRef.current;
614
+ const anchor = normalizeCursorOffset(text, start);
615
+ let lo = normalizeCursorOffset(text, Math.min(start, end));
616
+ let hi = normalizeCursorOffset(text, Math.max(start, end));
617
+ const block = foldBlockRef.current;
618
+ if (block) {
619
+ // Clamp by the ORIGINAL anchor side, not sorted `lo`: for a reverse
620
+ // tail→head drag, `lo` is in the head even though the gesture belongs
621
+ // to the tail. Both selection and caret must stay on the anchor side.
622
+ if (anchor <= block.start) {
623
+ hi = Math.min(hi, block.start);
624
+ }
625
+ else {
626
+ lo = Math.max(lo, block.end);
627
+ hi = Math.max(hi, block.end);
628
+ }
629
+ }
630
+ if (lo >= hi) {
631
+ selectionRef.current = null;
632
+ setSelection(null);
633
+ }
634
+ else {
635
+ selectionRef.current = { start: lo, end: hi };
636
+ setSelection({ start: lo, end: hi });
637
+ }
638
+ };
639
+ /** Drop the selection without touching text or caret. */
640
+ const clearSelection = () => {
641
+ selectionRef.current = null;
642
+ setSelection(null);
643
+ };
390
644
  /**
391
645
  * Accept the selected file suggestion: replace ONLY the mention token at
392
646
  * the caret (prefix/suffix text survives), quoting whitespace paths. A
@@ -541,14 +795,106 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
541
795
  }
542
796
  return handled;
543
797
  };
798
+ /**
799
+ * The Enter main path, shared by the inline prompt, the expanded
800
+ * editor's Ctrl+Enter, and its Send button:
801
+ * - command menu open → run the SELECTED command (never send `/mo`);
802
+ * - model working → STEER into the running turn (next step boundary,
803
+ * agent continues — the "immediate" send; Codex/pi semantics);
804
+ * - otherwise → submit directly (or run a unique command).
805
+ * Reads valueRef so a key batch (typing + Enter in one stdin read)
806
+ * operates on the text the preceding keys produced.
807
+ */
808
+ const handleEnter = () => {
809
+ // A single Enter can arrive as two events in cmd pipelines (`\r`
810
+ // parsed as return + a raw `\n` line): collapse them so one keypress
811
+ // never sends the message twice.
812
+ const now = Date.now();
813
+ if (now - lastEnterAtRef.current < 80)
814
+ return;
815
+ lastEnterAtRef.current = now;
816
+ const value = valueRef.current;
817
+ if (overlayOpen) {
818
+ const command = suggestions[selectedCommand];
819
+ if (command) {
820
+ tryRunCommand(command.commandLine);
821
+ return;
822
+ }
823
+ }
824
+ // File-completion overlay open → Enter accepts the selection (same
825
+ // contract as the command menu: the overlay owns Enter while open).
826
+ if (fileOverlayOpen) {
827
+ const file = fileMatches[fileSelected];
828
+ if (file) {
829
+ acceptFile(file);
830
+ return;
831
+ }
832
+ }
833
+ if (channel.working && value.trim() !== '') {
834
+ // CC's immediate-command semantics: /btw and /skills are exempt from
835
+ // steering — neither command interrupts the running turn. Hidden
836
+ // UI-only easter eggs (e.g. /deepseek) are also safe to run while
837
+ // streaming. Every other input keeps the steer behavior so /new
838
+ // /model etc. stay idle-only.
839
+ const parsed = value.startsWith('/') ? parseCommandName(value) : undefined;
840
+ if (parsed !== undefined && (((parsed.name === 'btw' || parsed.name === 'skills')
841
+ && channel.commandList.some(c => c.name === parsed.name))
842
+ || isHiddenCommandName(parsed.name))) {
843
+ if (tryRunCommand(value))
844
+ return;
845
+ }
846
+ steerSend(value);
847
+ return;
848
+ }
849
+ if (!tryRunCommand(value))
850
+ submitText(value);
851
+ };
852
+ /** Expand/collapse the fullscreen editor (shortcut + ⛶ affordance).
853
+ * Expanding also DROPS any fold block: the fullscreen view shows and
854
+ * edits the full text, and the block's atomic-clamp semantics (caret
855
+ * pushed to its edges, selection clamped to one side) would contradict
856
+ * that — the chip's own click-to-expand already means "unfold". */
857
+ const toggleExpand = () => {
858
+ const next = !expandedRef.current;
859
+ expandedRef.current = next;
860
+ setExpanded(next);
861
+ if (next) {
862
+ expandedScrollRef.current = 0;
863
+ if (foldBlockRef.current)
864
+ updateFoldBlock(null);
865
+ }
866
+ // Geometry changed wholesale (a fullscreen cover): the double-click
867
+ // self-detection's screen-cell memory is void, same as on resize.
868
+ lastClickAtRef.current = 0;
869
+ lastClickColRef.current = -1;
870
+ lastClickRowRef.current = -1;
871
+ };
872
+ /** Withdraw the fullscreen editor, keeping text/caret/selection intact. */
873
+ const collapseEditor = () => {
874
+ expandedRef.current = false;
875
+ setExpanded(false);
876
+ lastClickAtRef.current = 0;
877
+ lastClickColRef.current = -1;
878
+ lastClickRowRef.current = -1;
879
+ };
880
+ /** The editor's explicit send (Ctrl+Enter / Send button): the Enter main
881
+ * path, then collapse — an empty draft just collapses. */
882
+ const submitFromEditor = () => {
883
+ handleEnter();
884
+ collapseEditor();
885
+ };
544
886
  /** Clipboard reads are asynchronous; insert against the latest render so
545
887
  * typing while PowerShell owns the clipboard never gets overwritten.
546
- * Returns the resulting value and the insertion offset so callers can
547
- * apply the paste fold. */
888
+ * An active selection is replaced by the insert. Returns the resulting
889
+ * value and the insertion offset so callers can apply the paste fold. */
548
890
  const insertClipboardAtCaret = (text) => {
891
+ text = sanitizeEditableText(text);
549
892
  const current = valueRef.current;
550
- const position = cursorRef.current;
551
- const next = current.slice(0, position) + text + current.slice(position);
893
+ const sel = selectionRef.current;
894
+ const position = sel ? sel.start : cursorRef.current;
895
+ const next = sel
896
+ ? current.slice(0, sel.start) + text + current.slice(sel.end)
897
+ : current.slice(0, position) + text + current.slice(position);
552
898
  setInput(next, position + text.length);
553
899
  setSelectedCommand(0);
554
900
  setFileSelected(0);
@@ -573,11 +919,48 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
573
919
  // setValue can never overwrite fresh typing (and vice versa).
574
920
  if (editorBusyRef.current)
575
921
  return;
922
+ // Ctrl+Shift+E (remappable via /settings): toggle the fullscreen draft
923
+ // editor. Matched before anything else so it works both while editing
924
+ // and from an idle prompt; selectionActive has already returned above.
925
+ // Refused while the feature is turned off in /settings.
926
+ if (expandEnabled && actionMatches('expandEditor', input, key)) {
927
+ event?.stopImmediatePropagation();
928
+ toggleExpand();
929
+ return;
930
+ }
576
931
  // App deliberately dispatches every parsed key from one stdin read in a
577
932
  // single React update. Read the synchronous mirrors so each event sees
578
933
  // the text/caret produced by the preceding event in that batch.
579
934
  const value = valueRef.current;
580
935
  const cursor = cursorRef.current;
936
+ const selection = selectionRef.current;
937
+ // ── mouse selection (drag / Shift+click / double-click) ─────────────
938
+ // Layered ahead of the fold-block rules: with an active selection, Esc
939
+ // ONLY drops the highlight (text untouched), and Backspace/Delete
940
+ // delete the selected span. Arrows/typing handle the selection at their
941
+ // own arms below.
942
+ if (key.escape && selection && !helpOpen && !overlayOpen && !fileOverlayOpen) {
943
+ event.stopImmediatePropagation();
944
+ clearSelection();
945
+ return;
946
+ }
947
+ if ((key.backspace || key.delete) && selection) {
948
+ const next = value.slice(0, selection.start) + value.slice(selection.end);
949
+ setInput(next, selection.start);
950
+ setSelectedCommand(0);
951
+ setFileSelected(0);
952
+ return;
953
+ }
954
+ // ── 全屏草稿编辑(expandEditor,默认 Ctrl+Shift+E / 输入行 ⛶)─────
955
+ // 展开态拥有屏幕;Esc 收起(有选区时上面的 selection 分支已先行只清
956
+ // 选区)。滚轮不经此——编辑区的 onWheel 位置路由直接驱动滚动窗口。
957
+ if (key.escape && expandedRef.current) {
958
+ // Collapse runs AHEAD of the fold-block/vim Esc meanings: the
959
+ // fullscreen cover is the outermost modal layer.
960
+ event?.stopImmediatePropagation();
961
+ collapseEditor();
962
+ return;
963
+ }
581
964
  // Fold block (CC-style): Esc expands it — it must NEVER clear text
582
965
  // that LOOKS like one line; Backspace at the block's tail / Delete at
583
966
  // its head deletes the WHOLE block in one key; ←/→ jump over the
@@ -614,25 +997,36 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
614
997
  // Grapheme boundaries of the current text: every caret move / delete
615
998
  // below snaps onto one of these offsets (never mid-cluster).
616
999
  const bounds = graphemeBoundaries(value);
617
- /** Insert text at the caret (typing, paste) and dismiss overlays. */
1000
+ /** Insert text at the caret (typing, paste) and dismiss overlays. An
1001
+ * active selection is REPLACED by the insert (standard editor
1002
+ * semantics). Returns the insertion offset so callers can derive the
1003
+ * inserted span (paste fold). */
618
1004
  const insertAtCaret = (text) => {
619
1005
  if (helpOpen)
620
1006
  onToggleHelp();
621
- const next = value.slice(0, cursor) + text + value.slice(cursor);
622
- setInput(next, cursor + text.length);
1007
+ const sel = selectionRef.current;
1008
+ const at = sel ? sel.start : cursor;
1009
+ const next = sel
1010
+ ? value.slice(0, sel.start) + text + value.slice(sel.end)
1011
+ : value.slice(0, cursor) + text + value.slice(cursor);
1012
+ setInput(next, at + text.length);
623
1013
  setSelectedCommand(0);
624
1014
  setFileSelected(0);
1015
+ return at;
625
1016
  };
626
1017
  // Bracketed paste (terminal paste — Ctrl+Shift+V / right-click): insert
627
- // verbatim at the caret. Paste content may contain newlines — that is
628
- // NOT Enter — so this branch runs before the whole-line submit rule.
1018
+ // at the caret after removing terminal controls and expanding tabs.
1019
+ // Newlines remain data — they are NOT Enter — so this branch runs before
1020
+ // the whole-line submit rule.
629
1021
  if (event?.isPasted && input.length > 0) {
630
- const text = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
631
- insertAtCaret(text);
1022
+ const text = sanitizeEditableText(input.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
1023
+ const at = insertAtCaret(text);
632
1024
  // A big paste becomes a CC-style fold block right away (hover peeks
633
1025
  // at it); an existing block is replaced by the new paste's span.
634
- if (isBigInput(text))
635
- updateFoldBlock({ start: cursor, end: cursor + text.length });
1026
+ // The EXPANDED editor never folds — pasting there is plain text
1027
+ // (fold semantics would clamp the caret out of the pasted span).
1028
+ if (!expandedRef.current && isBigInput(text))
1029
+ updateFoldBlock({ start: at, end: at + text.length });
636
1030
  return;
637
1031
  }
638
1032
  // Clipboard paste (default Ctrl+V / Cmd+V, plus the Alt+V alias for
@@ -683,11 +1077,11 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
683
1077
  }
684
1078
  // Insert against the LIVE input state: the read above resolved
685
1079
  // asynchronously and the user may have typed while waiting.
686
- const text = formatClipboardInsert(content);
1080
+ const text = sanitizeEditableText(formatClipboardInsert(content));
687
1081
  const { at } = insertClipboardAtCaret(text);
688
- // Same fold as bracketed paste: big clipboard text becomes a
689
- // fold block covering exactly the pasted span.
690
- if (isBigInput(text))
1082
+ // Same fold as bracketed paste — but never inside the expanded
1083
+ // editor (plain text there, see the isPasted branch).
1084
+ if (!expandedRef.current && isBigInput(text))
691
1085
  updateFoldBlock({ start: at, end: at + text.length });
692
1086
  })
693
1087
  .catch(() => {
@@ -748,67 +1142,25 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
748
1142
  })();
749
1143
  return;
750
1144
  }
751
- /**
752
- * CR/LF line from Windows cmd pipelines):
753
- * - command menu open → run the SELECTED command (never send `/mo`);
754
- * - model working → STEER into the running turn (next step boundary,
755
- * agent continues — the "immediate" send; Codex/pi semantics);
756
- * - otherwise → submit directly (or run a unique command).
757
- */
758
- const handleEnter = () => {
759
- // A single Enter can arrive as two events in cmd pipelines (`\r`
760
- // parsed as return + a raw `\n` line): collapse them so one keypress
761
- // never sends the message twice.
762
- const now = Date.now();
763
- if (now - lastEnterAtRef.current < 80)
764
- return;
765
- lastEnterAtRef.current = now;
766
- if (overlayOpen) {
767
- const command = suggestions[selectedCommand];
768
- if (command) {
769
- tryRunCommand(command.commandLine);
770
- return;
771
- }
772
- }
773
- // File-completion overlay open → Enter accepts the selection (same
774
- // contract as the command menu: the overlay owns Enter while open).
775
- if (fileOverlayOpen) {
776
- const file = fileMatches[fileSelected];
777
- if (file) {
778
- acceptFile(file);
779
- return;
780
- }
781
- }
782
- if (channel.working && value.trim() !== '') {
783
- // CC's immediate-command semantics: /btw is exempt from steering —
784
- // the side question never interrupts the running turn. Hidden
785
- // UI-only easter eggs (e.g. /deepseek) are also safe to run while
786
- // streaming. Every other input keeps the steer behavior so /new
787
- // /model etc. stay idle-only.
788
- const parsed = value.startsWith('/') ? parseCommandName(value) : undefined;
789
- if (parsed !== undefined && ((parsed.name === 'btw' && channel.commandList.some(c => c.name === 'btw'))
790
- || isHiddenCommandName(parsed.name))) {
791
- tryRunCommand(value);
792
- return;
793
- }
794
- steerSend(value);
795
- return;
796
- }
797
- if (!tryRunCommand(value))
798
- submitText(value);
799
- };
800
- // Ctrl+J is the only portable multiline fallback when a terminal cannot
801
- // report modifiers on Enter. The parser names its bare LF `enter`, while
802
- // the physical Enter key arrives as CR (`return`).
803
- if (input === '\n' && event?.keypress.name === 'enter') {
1145
+ // Ctrl+J is the portable multiline fallback when a terminal cannot
1146
+ // report modifiers on Enter. Legacy terminals deliver bare LF (`enter`),
1147
+ // while kitty/modifyOtherKeys encode it as an exact Ctrl+J key.
1148
+ const isCtrlJ = input === 'j' && key.ctrl && !key.shift && !key.meta && !key.super;
1149
+ if ((input === '\n' && event?.keypress.name === 'enter') || isCtrlJ) {
804
1150
  insertAtCaret('\n');
805
1151
  return;
806
1152
  }
807
1153
  // Whole-line input from Windows ConPTY pipelines (cmd batch -> node):
808
1154
  // the trailing CR/LF marks a complete line to submit. A bare CR/CRLF is
809
1155
  // Enter, while real multi-char piped lines keep the legacy direct-submit
810
- // path.
1156
+ // path. The expanded editor treats piped lines as DATA — CR/LF
1157
+ // normalizes to '\n' and inserts (sending needs Ctrl+Enter there), so a
1158
+ // fast batch never dumps raw '\r' into the draft.
811
1159
  if (input.includes('\n') || input.includes('\r')) {
1160
+ if (expandedRef.current) {
1161
+ insertAtCaret(sanitizeEditableText(input.replace(/\r\n/g, '\n').replace(/\r/g, '\n')));
1162
+ return;
1163
+ }
812
1164
  if (/^[\r\n]+$/.test(input)) {
813
1165
  handleEnter();
814
1166
  return;
@@ -826,9 +1178,16 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
826
1178
  return;
827
1179
  }
828
1180
  if (key.return && isMod(key)) {
829
- // Ctrl+Enter / Cmd+Enter: interrupt the running turn and process this
830
- // message immediately (Windows Terminal sends CSI 13;5u / 13;1;5u).
831
- interruptSend();
1181
+ // Ctrl+Enter / Cmd+Enter: in the EXPANDED editor this is the explicit
1182
+ // send (Enter inserts newlines there, so sending needs its own key);
1183
+ // inline it interrupts the running turn and processes this message
1184
+ // immediately (Windows Terminal sends CSI 13;5u / 13;1;5u).
1185
+ if (expandedRef.current) {
1186
+ submitFromEditor();
1187
+ }
1188
+ else {
1189
+ interruptSend();
1190
+ }
832
1191
  return;
833
1192
  }
834
1193
  if (key.return && (key.shift || key.meta)) {
@@ -837,9 +1196,14 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
837
1196
  // supports extended key reporting (kitty/modifyOtherKeys allowlist in
838
1197
  // ink/terminal.ts); Option+Enter (ESC CR) is the fallback on terminals
839
1198
  // that can't report shift — e.g. macOS Terminal.app (issue #110).
840
- const next = value.slice(0, cursor) + '\n' + value.slice(cursor);
841
- setInput(next, cursor + 1);
842
- setSelectedCommand(0);
1199
+ insertAtCaret('\n');
1200
+ return;
1201
+ }
1202
+ if (key.return && expandedRef.current) {
1203
+ // Expanded editor: plain Enter inserts a newline (text-editor
1204
+ // semantics — long-draft editing must never misfire a send). The
1205
+ // shift/meta variants above already newline; Ctrl+Enter sends.
1206
+ insertAtCaret('\n');
843
1207
  return;
844
1208
  }
845
1209
  if (key.return) {
@@ -852,6 +1216,14 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
852
1216
  event.stopImmediatePropagation();
853
1217
  return;
854
1218
  }
1219
+ // Expanded editor: Tab is indentation, Shift+Tab a silent swallow —
1220
+ // neither command completion nor session-mode cycling makes sense on
1221
+ // the fullscreen editor, and both would fire invisibly behind it.
1222
+ if (expandedRef.current && key.tab) {
1223
+ if (!key.shift)
1224
+ insertAtCaret(' ');
1225
+ return;
1226
+ }
855
1227
  // Shift+Tab cycles the configured session modes (default: 默认 →
856
1228
  // 计划模式 → 完全访问; each mode bundles plan/sandbox/approval atoms —
857
1229
  // see the `modes` config). Must precede the plain-Tab arms — the parser
@@ -1031,23 +1403,29 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1031
1403
  }
1032
1404
  if (isMod(key) && key.leftArrow) {
1033
1405
  // Jump to the previous word boundary (readline alt+b). Must precede the
1034
- // bare-arrow arms: Ctrl+Left arrives as leftArrow + ctrl.
1035
- setInput(value, wordBoundaryLeft(value, cursor));
1406
+ // bare-arrow arms: Ctrl+Left arrives as leftArrow + ctrl. An active
1407
+ // selection collapses to its START edge first (editor semantics).
1408
+ const sel = selectionRef.current;
1409
+ setInput(value, sel ? sel.start : wordBoundaryLeft(value, cursor));
1036
1410
  return;
1037
1411
  }
1038
1412
  if (isMod(key) && key.rightArrow) {
1039
1413
  // Jump to the next word boundary (readline alt+f).
1040
- setInput(value, wordBoundaryRight(value, cursor));
1414
+ const sel = selectionRef.current;
1415
+ setInput(value, sel ? sel.end : wordBoundaryRight(value, cursor));
1041
1416
  return;
1042
1417
  }
1043
1418
  if (key.leftArrow) {
1044
1419
  // Grapheme-step: skip the whole cluster (surrogate pair, ZWJ emoji,
1045
- // combining mark) so the caret never sits inside one.
1046
- setInput(value, previousGraphemeBoundary(bounds, cursor));
1420
+ // combining mark) so the caret never sits inside one. With a
1421
+ // selection, collapse to its start edge instead.
1422
+ const sel = selectionRef.current;
1423
+ setInput(value, sel ? sel.start : previousGraphemeBoundary(bounds, cursor));
1047
1424
  return;
1048
1425
  }
1049
1426
  if (key.rightArrow) {
1050
- setInput(value, nextGraphemeBoundary(bounds, cursor));
1427
+ const sel = selectionRef.current;
1428
+ setInput(value, sel ? sel.end : nextGraphemeBoundary(bounds, cursor));
1051
1429
  return;
1052
1430
  }
1053
1431
  if (key.backspace) {
@@ -1114,6 +1492,256 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1114
1492
  setInput(value.slice(0, clipped) + value.slice(cursor), clipped);
1115
1493
  return;
1116
1494
  }
1495
+ // ── vim mode (`/vim`) ──────────────────────────────────────────────
1496
+ // INSERT submode: only Esc is intercepted (back to NORMAL — the usual
1497
+ // clear/rewind Esc semantics stay disabled while vim is on).
1498
+ // NORMAL submode: bare characters and Esc are vim keys; help/command/
1499
+ // file overlays, modified combos, Tab, Enter, arrows and other
1500
+ // structured keys keep their existing handlers.
1501
+ const vimNormalEdit = (next, cursorOffset) => {
1502
+ updateFoldBlock(null);
1503
+ setInput(next, cursorOffset);
1504
+ setSelectedCommand(0);
1505
+ setFileSelected(0);
1506
+ };
1507
+ const vimPushUndo = () => {
1508
+ if (vimUndoRef.current.length >= 100)
1509
+ vimUndoRef.current.shift();
1510
+ vimUndoRef.current.push({ value: valueRef.current, cursor: cursorRef.current });
1511
+ };
1512
+ const handleVimNormal = (input) => {
1513
+ // One stdin batch can merge several bare keys into a single event
1514
+ // (fast typing), so re-read the synchronous mirrors and recompute
1515
+ // the grapheme boundaries: every key in the batch operates on the
1516
+ // text/caret its predecessor produced (the outer `value`/`cursor`/
1517
+ // `bounds` reflect only the batch's first key).
1518
+ const value = valueRef.current;
1519
+ const cursor = cursorRef.current;
1520
+ const bounds = graphemeBoundaries(value);
1521
+ const pending = vimPendingRef.current;
1522
+ vimPendingRef.current = '';
1523
+ // Operator pending (`d`): the second key picks the target.
1524
+ if (pending === 'd') {
1525
+ switch (input) {
1526
+ case 'd': { // delete the whole line, newline included (vim `dd`);
1527
+ // the last line has no newline — its content is cleared
1528
+ vimPushUndo();
1529
+ const lineStart = vimLineStart(value, cursor);
1530
+ const lineEnd = vimLineEnd(value, cursor);
1531
+ const end = lineEnd < value.length ? lineEnd + 1 : lineEnd;
1532
+ vimNormalEdit(value.slice(0, lineStart) + value.slice(end), lineStart);
1533
+ return;
1534
+ }
1535
+ case '$': { // delete to end of line
1536
+ vimPushUndo();
1537
+ const end = vimLineEnd(value, cursor);
1538
+ vimNormalEdit(value.slice(0, cursor) + value.slice(end), cursor);
1539
+ return;
1540
+ }
1541
+ case '0':
1542
+ case '^': { // delete to start of line
1543
+ vimPushUndo();
1544
+ const start = input === '^' ? vimLineFirstNonBlank(value, cursor) : vimLineStart(value, cursor);
1545
+ vimNormalEdit(value.slice(0, start) + value.slice(cursor), start);
1546
+ return;
1547
+ }
1548
+ case 'w': { // delete to end of word
1549
+ vimPushUndo();
1550
+ const end = vimWordEnd(value, cursor);
1551
+ vimNormalEdit(value.slice(0, cursor) + value.slice(end), cursor);
1552
+ return;
1553
+ }
1554
+ default:
1555
+ return; // unrecognized second key: cancel the operator, drop it
1556
+ }
1557
+ }
1558
+ switch (input) {
1559
+ case 'h':
1560
+ setInput(value, previousGraphemeBoundary(bounds, cursor));
1561
+ return;
1562
+ case 'l':
1563
+ setInput(value, nextGraphemeBoundary(bounds, cursor));
1564
+ return;
1565
+ case 'j': { // down one line (single-line input: no-op)
1566
+ const line = cursorLine(value, cursor);
1567
+ const lines = value.split('\n');
1568
+ if (line >= lines.length - 1)
1569
+ return;
1570
+ const col = Math.min(cursorColumn(value, cursor), (lines[line + 1] ?? '').length);
1571
+ setInput(value, vimLineEnd(value, cursor) + 1 + col);
1572
+ return;
1573
+ }
1574
+ case 'k': { // up one line
1575
+ const line = cursorLine(value, cursor);
1576
+ if (line <= 0)
1577
+ return;
1578
+ const lines = value.split('\n');
1579
+ const col = Math.min(cursorColumn(value, cursor), (lines[line - 1] ?? '').length);
1580
+ const prevStart = vimLineStart(value, vimLineStart(value, cursor) - 1);
1581
+ setInput(value, prevStart + col);
1582
+ return;
1583
+ }
1584
+ case '0': {
1585
+ setInput(value, vimLineStart(value, cursor));
1586
+ return;
1587
+ }
1588
+ case '^': {
1589
+ setInput(value, vimLineFirstNonBlank(value, cursor));
1590
+ return;
1591
+ }
1592
+ case '$': {
1593
+ setInput(value, vimLineEnd(value, cursor));
1594
+ return;
1595
+ }
1596
+ case 'w':
1597
+ setInput(value, vimWordForward(value, cursor));
1598
+ return;
1599
+ case 'b':
1600
+ setInput(value, vimWordBackward(value, cursor));
1601
+ return;
1602
+ case 'x': { // delete the character at the caret; at the very end of
1603
+ // the text delete the last character (vim: the caret sits ON the
1604
+ // last char after `$`, so `x` must still delete it). Same rule
1605
+ // when the caret sits right before a '\n' mid-draft — after `$`
1606
+ // the caret is on the line's last char, `x` must delete THAT
1607
+ // char, not the newline (which would join the lines).
1608
+ if (cursor > 0 && (cursor === value.length || value[cursor] === '\n')) {
1609
+ const start = previousGraphemeBoundary(bounds, cursor);
1610
+ vimPushUndo();
1611
+ vimNormalEdit(value.slice(0, start) + value.slice(cursor), start);
1612
+ return;
1613
+ }
1614
+ const end = nextGraphemeBoundary(bounds, cursor);
1615
+ if (end === cursor)
1616
+ return;
1617
+ vimPushUndo();
1618
+ vimNormalEdit(value.slice(0, cursor) + value.slice(end), cursor);
1619
+ return;
1620
+ }
1621
+ case 'X': { // delete the character before the caret
1622
+ if (cursor === 0)
1623
+ return;
1624
+ const start = previousGraphemeBoundary(bounds, cursor);
1625
+ vimPushUndo();
1626
+ vimNormalEdit(value.slice(0, start) + value.slice(cursor), start);
1627
+ return;
1628
+ }
1629
+ case 'd':
1630
+ vimPendingRef.current = 'd';
1631
+ return;
1632
+ case 'u': { // undo the last vim edit
1633
+ const prev = vimUndoRef.current.pop();
1634
+ if (prev === undefined)
1635
+ return;
1636
+ updateFoldBlock(null);
1637
+ setInput(prev.value, prev.cursor);
1638
+ setSelectedCommand(0);
1639
+ setFileSelected(0);
1640
+ return;
1641
+ }
1642
+ case 'i': // insert at the caret
1643
+ vimInsertRef.current = true;
1644
+ setVimInsert(true);
1645
+ return;
1646
+ case 'I': { // insert at the line's first non-blank (vim `I`)
1647
+ setInput(value, vimLineFirstNonBlank(value, cursor));
1648
+ vimInsertRef.current = true;
1649
+ setVimInsert(true);
1650
+ return;
1651
+ }
1652
+ case 'a': { // insert after the caret
1653
+ setInput(value, nextGraphemeBoundary(bounds, cursor));
1654
+ vimInsertRef.current = true;
1655
+ setVimInsert(true);
1656
+ return;
1657
+ }
1658
+ case 'A': { // insert at the line end
1659
+ setInput(value, vimLineEnd(value, cursor));
1660
+ vimInsertRef.current = true;
1661
+ setVimInsert(true);
1662
+ return;
1663
+ }
1664
+ case 'o': { // new line below, then insert
1665
+ vimPushUndo();
1666
+ const end = vimLineEnd(value, cursor);
1667
+ vimNormalEdit(value.slice(0, end) + '\n' + value.slice(end), end + 1);
1668
+ vimInsertRef.current = true;
1669
+ setVimInsert(true);
1670
+ return;
1671
+ }
1672
+ case 'O': { // new line above, then insert
1673
+ vimPushUndo();
1674
+ const start = vimLineStart(value, cursor);
1675
+ vimNormalEdit(value.slice(0, start) + '\n' + value.slice(start), start);
1676
+ vimInsertRef.current = true;
1677
+ setVimInsert(true);
1678
+ return;
1679
+ }
1680
+ default:
1681
+ return; // unrecognized key: ignored (never inserts in NORMAL)
1682
+ }
1683
+ };
1684
+ if (vimEnabledRef.current) {
1685
+ if (vimInsertRef.current) {
1686
+ // INSERT: Esc returns to NORMAL instead of the clear/rewind path.
1687
+ if (key.escape && !helpOpen && !overlayOpen && !fileOverlayOpen) {
1688
+ event.stopImmediatePropagation();
1689
+ vimInsertRef.current = false;
1690
+ setVimInsert(false);
1691
+ return;
1692
+ }
1693
+ }
1694
+ else if (!helpOpen && !overlayOpen && !fileOverlayOpen) {
1695
+ if (key.escape) {
1696
+ // NORMAL: Esc cancels a pending operator and otherwise no-ops
1697
+ // (the clear/rewind double-Esc semantics belong to non-vim mode).
1698
+ vimPendingRef.current = '';
1699
+ event.stopImmediatePropagation();
1700
+ return;
1701
+ }
1702
+ const plainChar = input.length > 0 &&
1703
+ !key.ctrl && !key.meta && !key.super && !key.tab && !key.return;
1704
+ if (plainChar) {
1705
+ // `?` on an empty input falls through to the help shortcut below.
1706
+ if (input === '?' && value.length === 0)
1707
+ return;
1708
+ // `/` opens the slash-command menu even in NORMAL: insert it and
1709
+ // switch to INSERT so the rest of the command types normally
1710
+ // (the menu then owns the keys while it is open).
1711
+ if (input === '/') {
1712
+ const text = valueRef.current;
1713
+ const pos = cursorRef.current;
1714
+ const next = text.slice(0, pos) + '/' + text.slice(pos);
1715
+ setInput(next, pos + 1);
1716
+ setSelectedCommand(0);
1717
+ setFileSelected(0);
1718
+ vimInsertRef.current = true;
1719
+ setVimInsert(true);
1720
+ return;
1721
+ }
1722
+ event.stopImmediatePropagation();
1723
+ // A merged multi-char event (fast typing) is one vim key per
1724
+ // character — handleVimNormal re-reads the refs each call, so a
1725
+ // batch like `dd` works exactly like two separate keypresses.
1726
+ // Once a key switches back to INSERT (i/a/o/…), the remaining
1727
+ // characters of the batch are ordinary typing and insert as text.
1728
+ for (let i = 0; i < input.length; i++) {
1729
+ if (vimInsertRef.current) {
1730
+ const rest = input.slice(i);
1731
+ const text = valueRef.current;
1732
+ const pos = cursorRef.current;
1733
+ const next = text.slice(0, pos) + rest + text.slice(pos);
1734
+ setInput(next, pos + rest.length);
1735
+ setSelectedCommand(0);
1736
+ setFileSelected(0);
1737
+ break;
1738
+ }
1739
+ handleVimNormal(input[i]);
1740
+ }
1741
+ return;
1742
+ }
1743
+ }
1744
+ }
1117
1745
  if (key.escape) {
1118
1746
  if (helpOpen) {
1119
1747
  onToggleHelp();
@@ -1182,7 +1810,7 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1182
1810
  }, 3000);
1183
1811
  return;
1184
1812
  }
1185
- if (input === '?' && value.length === 0) {
1813
+ if (input === '?' && value.length === 0 && !expandedRef.current) {
1186
1814
  onToggleHelp();
1187
1815
  return;
1188
1816
  }
@@ -1190,8 +1818,13 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1190
1818
  // Typing anything else dismisses the help menu (CC behavior).
1191
1819
  if (helpOpen)
1192
1820
  onToggleHelp();
1193
- const next = value.slice(0, cursor) + input + value.slice(cursor);
1194
- setInput(next, cursor + input.length);
1821
+ // An active selection is REPLACED by the typed text, caret after it.
1822
+ const sel = selectionRef.current;
1823
+ const at = sel ? sel.start : cursor;
1824
+ const next = sel
1825
+ ? value.slice(0, sel.start) + input + value.slice(sel.end)
1826
+ : value.slice(0, cursor) + input + value.slice(cursor);
1827
+ setInput(next, at + input.length);
1195
1828
  setSelectedCommand(0);
1196
1829
  setFileSelected(0);
1197
1830
  }
@@ -1202,8 +1835,28 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1202
1835
  // Narrow terminals: the usable width follows the real terminal down to a
1203
1836
  // single column — a fixed floor of 10 would wrap far too early and park
1204
1837
  // the declared cursor past the value box's actual width.
1205
- const inputWidth = Math.max(1, columns - 3);
1206
- const block = foldBlock;
1838
+ // The vim badge ('INSERT '/'NORMAL ', 7 cols) sits BEFORE the value box in
1839
+ // the same row, so the wrap budget must shrink by its width or long lines
1840
+ // would be clipped at the value box's right edge.
1841
+ const vimBadgeCols = vimEnabled ? 7 : 0;
1842
+ // 补全卡片边框与输入框 idle 边框同色(plan 模式下整套面板一起变 sage
1843
+ // 绿)。`/color` 会话强调色优先于主题 promptBorder(plan 模式仍整体走
1844
+ // sage 绿)。`?? ''` 防御最小 mock channel(只声明用到的字段的回归脚
1845
+ // 本)。声明在渲染派生区之前:展开编辑器的行号高亮与边框同用此色。
1846
+ const sessionAccent = sessionColorHex(channel.sessionColor ?? '');
1847
+ const promptAccent = channel.mode.plan === true ? 'planMode' : (sessionAccent ?? 'promptBorder');
1848
+ // 展开态布局参数:编辑器独占整屏 —— 行号槽(宽度随逻辑行数伸缩)+
1849
+ // 圆角边框 2 + 两侧 padding 各 1 占列,❯ 前缀 / vim 徽标 / ⛶ 按钮
1850
+ // 全部让位;收起态额外扣掉行尾 ⛶ 按钮的 2 列。
1851
+ const editorLogicalLines = expanded ? value.split('\n').length : 1;
1852
+ const editorNoWidth = Math.max(2, String(editorLogicalLines).length);
1853
+ const editorGutterCols = editorNoWidth + 3;
1854
+ const inputWidth = expanded
1855
+ ? Math.max(1, columns - 4 - editorGutterCols)
1856
+ : Math.max(1, columns - 3 - vimBadgeCols - (expandEnabled ? 2 : 0));
1857
+ // 展开态无视折叠块:全屏编辑就是为了看全文(foldBlock 状态保留,
1858
+ // 收起后折叠显示恢复)。
1859
+ const block = expanded ? null : foldBlock;
1207
1860
  // Fold-block model: the value renders as [head rows][chip row][tail rows]
1208
1861
  // — the block is ONE atomic visual row regardless of its text size; text
1209
1862
  // before/after it stays fully editable and never unfolds it.
@@ -1216,11 +1869,20 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1216
1869
  const visualLines = block
1217
1870
  ? [...headRows, '', ...tailRows]
1218
1871
  : wrapToWidth(value, inputWidth);
1219
- const caretVisualLine = block
1872
+ // Caret geometry uses the FULL wrap (not `value.slice(0, cursor)`):
1873
+ // word-wrap carry moves already-placed clusters, so a prefix wrap would
1874
+ // disagree with the displayed rows whenever the cursor sits inside a
1875
+ // carried word.
1876
+ const caretPlaced = block
1220
1877
  ? cursor <= block.start
1221
- ? wrapToWidth(value.slice(0, cursor), inputWidth).length - 1
1222
- : headRows.length + 1 + wrapToWidth(value.slice(block.end, cursor), inputWidth).length - 1
1223
- : wrapToWidth(value.slice(0, cursor), inputWidth).length - 1;
1878
+ ? caretInText(head, inputWidth, cursor)
1879
+ : caretInText(tail, inputWidth, Math.max(0, cursor - block.end))
1880
+ : caretInText(value, inputWidth, cursor);
1881
+ const caretVisualLine = block && cursor > block.start
1882
+ ? headRows.length + 1 + caretPlaced.line
1883
+ : caretPlaced.line;
1884
+ const caretCharCol = caretPlaced.charCol;
1885
+ const caretVisualCol = caretPlaced.visualCol;
1224
1886
  // Fold stats describe the BLOCK (or the whole input when expanded and
1225
1887
  // the ▾ prefix offers a manual whole-input fold). The chip shows the
1226
1888
  // block's own line/char count + first-line preview — the preview text
@@ -1230,26 +1892,40 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1230
1892
  ? t('input-fold-stats', { lines: foldText.split('\n').length, chars: foldText.length })
1231
1893
  : '';
1232
1894
  const peekOpen = block !== null && hovered && !selectionActive;
1233
- const windowStart = Math.max(0, Math.min(caretVisualLine - MAX_VISIBLE_LINES + 1, visualLines.length - MAX_VISIBLE_LINES));
1234
- const visibleLines = visualLines.slice(windowStart, windowStart + MAX_VISIBLE_LINES);
1235
- // Caret position in the caret's visual row, in two units:
1236
- // - char index (for slicing the row's characters in the render below)
1237
- // - visual column (for the physical cursor declaration — CJK characters
1238
- // occupy TWO terminal columns, so the raw char count would park the
1239
- // cursor mid-character and Windows Terminal would paint the IME
1240
- // preedit (pinyin) over the surrounding text).
1241
- const caretCharCol = () => {
1242
- const before = block && cursor >= block.end ? value.slice(block.end, cursor) : value.slice(0, cursor);
1243
- const rows = wrapToWidth(before, inputWidth);
1244
- const last = rows[rows.length - 1] ?? '';
1245
- return last.length;
1246
- };
1247
- const caretVisualCol = () => {
1248
- const before = block && cursor >= block.end ? value.slice(block.end, cursor) : value.slice(0, cursor);
1249
- const rows = wrapToWidth(before, inputWidth);
1250
- const last = rows[rows.length - 1] ?? '';
1251
- return stringWidth(last);
1252
- };
1895
+ // 展开态的编辑区行高预算:圆角边框 2 + 标题行 1 + 状态行 1 + 按钮行 1。
1896
+ const editorMaxRows = Math.max(1, terminalRows - EDITOR_CHROME_ROWS);
1897
+ if (expanded && caretVisualLine !== prevCaretLineRef.current) {
1898
+ // A real caret move re-engages following after wheel browsing.
1899
+ editorFreeScrollRef.current = false;
1900
+ prevCaretLineRef.current = caretVisualLine;
1901
+ }
1902
+ let windowStart;
1903
+ let visibleCount;
1904
+ if (expanded) {
1905
+ // 滚轮推动的窗口与 caret 跟随合并:自由滚动(滚轮浏览)期间不强制
1906
+ // caret 可见;caret 移动后恢复跟随。写回 ref,滚轮分支读到的就是
1907
+ // 合并后的基线。
1908
+ let win = expandedScrollRef.current;
1909
+ if (!editorFreeScrollRef.current) {
1910
+ if (caretVisualLine < win)
1911
+ win = caretVisualLine;
1912
+ if (caretVisualLine >= win + editorMaxRows)
1913
+ win = caretVisualLine - editorMaxRows + 1;
1914
+ }
1915
+ win = Math.max(0, Math.min(win, Math.max(0, visualLines.length - editorMaxRows)));
1916
+ expandedScrollRef.current = win;
1917
+ windowStart = win;
1918
+ visibleCount = editorMaxRows;
1919
+ }
1920
+ else {
1921
+ windowStart = Math.max(0, Math.min(caretVisualLine - MAX_VISIBLE_LINES + 1, visualLines.length - MAX_VISIBLE_LINES));
1922
+ visibleCount = MAX_VISIBLE_LINES;
1923
+ }
1924
+ const visibleLines = visualLines.slice(windowStart, windowStart + visibleCount);
1925
+ // useInput 的滚轮分支需要这份几何(它在这些派生之前注册)。
1926
+ if (expanded) {
1927
+ editorViewportRef.current = { maxRows: editorMaxRows, total: visualLines.length };
1928
+ }
1253
1929
  // Folded chip content: block stats + first-line preview + hover hint,
1254
1930
  // all pre-truncated to the input width (the row is one line, always).
1255
1931
  const foldBadge = `▸ ${stats}`;
@@ -1262,7 +1938,71 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1262
1938
  // row (only while the window is at the top and no block exists); its
1263
1939
  // cells fold the whole input into a block again on click.
1264
1940
  const prefixLabel = `▾ ${stats} · `;
1265
- const prefixCols = !block && big && windowStart === 0 ? stringWidth(prefixLabel) : 0;
1941
+ const prefixCols = !block && !expanded && big && windowStart === 0 ? stringWidth(prefixLabel) : 0;
1942
+ // Offset range [start, end) of every visual row — the SAME break rule as
1943
+ // wrapToWidth — so the selection highlight can intersect each row. With a
1944
+ // fold block, the chip row maps to the block's own span (never selected:
1945
+ // updateSelection clamps the selection into the head or the tail side).
1946
+ const lineRanges = block
1947
+ ? [
1948
+ ...visualLineRanges(head, inputWidth),
1949
+ [block.start, block.end],
1950
+ ...visualLineRanges(tail, inputWidth).map(([s, e]) => [s + block.end, e + block.end]),
1951
+ ]
1952
+ : visualLineRanges(value, inputWidth);
1953
+ /**
1954
+ * Inverse runs for one rendered row, shared by the inline prompt and the
1955
+ * expanded editor: the selection's intersection (if any) and the caret
1956
+ * cluster on the caret's row. Both render <Text inverse>; overlapping
1957
+ * intervals merge so a caret inside the selection stays one continuous
1958
+ * highlight. The caret row inverts the WHOLE cluster at the caret column
1959
+ * (solid block) — [col, next boundary) covers a surrogate pair or ZWJ
1960
+ * emoji as one glyph; at the text end it shows a blank inverse cell like
1961
+ * the empty-input caret (appended after everything, so a selection
1962
+ * ending there cannot swallow it).
1963
+ */
1964
+ const rowHighlightPieces = (text, absoluteLine) => {
1965
+ const intervals = [];
1966
+ const sel = selection;
1967
+ if (sel) {
1968
+ const [rowStart, rowEnd] = lineRanges[absoluteLine] ?? [0, 0];
1969
+ const lo = Math.min(Math.max(sel.start - rowStart, 0), text.length);
1970
+ const hi = Math.min(Math.max(sel.end - rowStart, 0), text.length);
1971
+ if (hi > lo)
1972
+ intervals.push([lo, hi]);
1973
+ }
1974
+ let endBlankCaret = false;
1975
+ if (absoluteLine === caretVisualLine) {
1976
+ const col = Math.min(caretCharCol, text.length);
1977
+ const clusterEnd = nextGraphemeBoundary(graphemeBoundaries(text), col);
1978
+ if (clusterEnd > col)
1979
+ intervals.push([col, clusterEnd]);
1980
+ else
1981
+ endBlankCaret = col === text.length;
1982
+ }
1983
+ intervals.sort((a, b) => a[0] - b[0]);
1984
+ const runs = [];
1985
+ for (const [s, e] of intervals) {
1986
+ const last = runs[runs.length - 1];
1987
+ if (last && s <= last[1])
1988
+ last[1] = Math.max(last[1], e);
1989
+ else
1990
+ runs.push([s, e]);
1991
+ }
1992
+ const pieces = [];
1993
+ let pos = 0;
1994
+ for (const [s, e] of runs) {
1995
+ if (s > pos)
1996
+ pieces.push({ text: text.slice(pos, s), inverse: false });
1997
+ pieces.push({ text: text.slice(s, e), inverse: true });
1998
+ pos = Math.max(pos, e);
1999
+ }
2000
+ if (pos < text.length)
2001
+ pieces.push({ text: text.slice(pos), inverse: false });
2002
+ if (endBlankCaret)
2003
+ pieces.push({ text: ' ', inverse: true });
2004
+ return pieces;
2005
+ };
1266
2006
  const rendered = visibleLines.map((line, index) => {
1267
2007
  const absoluteLine = windowStart + index;
1268
2008
  if (block && absoluteLine === chipRow) {
@@ -1276,23 +2016,35 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1276
2016
  const withPrefix = absoluteLine === 0 && prefixCols > 0;
1277
2017
  const text = withPrefix ? truncateToWidth(line, inputWidth - prefixCols) : line;
1278
2018
  const prefix = withPrefix ? _jsx(Text, { dimColor: true, children: prefixLabel }) : null;
1279
- if (absoluteLine !== caretVisualLine) {
1280
- return (_jsxs(Text, { wrap: "truncate-end", children: [prefix, text] }, absoluteLine));
1281
- }
1282
- // Caret row: invert the grapheme cluster at the caret column (solid
1283
- // block). `col` is a cluster boundary by construction (the cursor is
1284
- // normalized onto boundaries and wrapping only breaks between
1285
- // graphemes), so [col, next boundary) covers the WHOLE cluster — a
1286
- // surrogate pair or ZWJ emoji inverts as one glyph, never two broken
1287
- // halves. Clamped into the prefix-shortened row text so the block
1288
- // caret never renders under the fold prefix.
1289
- const col = Math.min(caretCharCol(), text.length);
1290
- const clusterEnd = nextGraphemeBoundary(graphemeBoundaries(text), col);
1291
- const before = text.slice(0, col);
1292
- const at = clusterEnd > col ? text.slice(col, clusterEnd) : ' ';
1293
- const after = text.slice(clusterEnd);
1294
- return (_jsxs(Text, { wrap: "truncate-end", children: [prefix, before, _jsx(Text, { inverse: true, children: at }), after] }, absoluteLine));
2019
+ const pieces = rowHighlightPieces(text, absoluteLine);
2020
+ return (_jsxs(Text, { wrap: "truncate-end", children: [prefix, pieces.length === 0 ? ' ' : pieces.map((piece, pieceIndex) => piece.inverse ? (_jsx(Text, { inverse: true, children: piece.text }, pieceIndex)) : (piece.text))] }, absoluteLine));
1295
2021
  });
2022
+ // ── 展开态编辑行 ────────────────────────────────────────────────────
2023
+ // Logical line number per visual row (a wrapped continuation keeps its
2024
+ // line's number): the row's range end sitting on a '\n' starts a new
2025
+ // logical line from the NEXT row.
2026
+ const editorRowLogical = [];
2027
+ if (expanded) {
2028
+ let count = 0;
2029
+ for (let i = 0; i < lineRanges.length; i++) {
2030
+ editorRowLogical.push(count);
2031
+ const rangeEnd = lineRanges[i][1];
2032
+ if (value[rangeEnd] === '\n')
2033
+ count++;
2034
+ }
2035
+ }
2036
+ const editorRows = expanded
2037
+ ? visibleLines.map((line, index) => {
2038
+ const absoluteLine = windowStart + index;
2039
+ const isCaretRow = absoluteLine === caretVisualLine;
2040
+ const logicalNo = editorRowLogical[absoluteLine];
2041
+ const gutterLabel = logicalNo === undefined
2042
+ ? ' '.repeat(editorNoWidth)
2043
+ : String(logicalNo + 1).padStart(editorNoWidth, ' ');
2044
+ const pieces = rowHighlightPieces(line, absoluteLine);
2045
+ return (_jsxs(Text, { wrap: "truncate-end", backgroundColor: isCaretRow ? 'toolCardBackgroundDim' : undefined, children: [_jsx(Text, { dimColor: !isCaretRow, bold: isCaretRow, color: isCaretRow ? promptAccent : undefined, children: `${gutterLabel} │ ` }), pieces.map((piece, pieceIndex) => piece.inverse ? (_jsx(Text, { inverse: true, children: piece.text }, pieceIndex)) : (piece.text))] }, absoluteLine));
2046
+ })
2047
+ : null;
1296
2048
  // Peek card content: the BLOCK's text wrapped to the card's inner width,
1297
2049
  // capped at PEEK_MAX_ROWS visual rows (a preview — click to expand and
1298
2050
  // edit the real input). The footer reports the clipped remainder.
@@ -1340,9 +2092,46 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1340
2092
  // Clamp the declared column to the wrap width: a grapheme wider than
1341
2093
  // the last remaining column (emoji at width 1) can push the visual
1342
2094
  // column past inputWidth, and the park must stay inside the value box.
1343
- column: Math.min(caretVisualCol() + (caretVisualLine === 0 && prefixCols > 0 ? prefixCols : 0), inputWidth),
2095
+ // In the expanded editor the declared box starts at its outer edge
2096
+ // (padding + gutter included), so those columns ride along and the
2097
+ // clamp grows with them.
2098
+ column: Math.min(caretVisualCol +
2099
+ (expanded
2100
+ ? editorGutterCols + 1
2101
+ : caretVisualLine === 0 && prefixCols > 0
2102
+ ? prefixCols
2103
+ : 0), expanded ? editorGutterCols + 1 + inputWidth : inputWidth),
1344
2104
  active: !selectionActive,
1345
2105
  });
2106
+ /**
2107
+ * Map a pointer position relative to the value box to a UTF-16 offset
2108
+ * via the same grapheme walk the renderer wraps with — exact under CJK
2109
+ * widths, wrapped rows and multi-codepoint clusters. Rows are clamped to
2110
+ * the VISIBLE window (drag moves never auto-scroll past it in v1); the
2111
+ * fold chip row maps to null (its cells belong to the expand affordance,
2112
+ * not to text).
2113
+ */
2114
+ const localToOffset = (localCol, localRow) => {
2115
+ const lastVisible = Math.min(visualLines.length - 1, windowStart + visibleCount - 1);
2116
+ const clamped = Math.max(windowStart, Math.min(windowStart + localRow, lastVisible));
2117
+ // Fold prefix (▾) row: the rendered first row is truncated by prefixCols,
2118
+ // so both the column and the wrap budget shift — without the correction
2119
+ // a drag starting on the first row lands prefixCols to the right of the
2120
+ // pointer (parity with handleValueClick's click mapping). Presses ON the
2121
+ // prefix cells clamp to the row start (drag-from-0, like selecting the
2122
+ // whole first row backwards).
2123
+ const isPrefixRow = !block && clamped === 0 && prefixCols > 0;
2124
+ const col = isPrefixRow ? Math.max(0, localCol - prefixCols) : localCol;
2125
+ const width = isPrefixRow ? inputWidth - prefixCols : inputWidth;
2126
+ if (block) {
2127
+ if (clamped === chipRow)
2128
+ return null;
2129
+ return clamped < chipRow
2130
+ ? clickToCursorOffset(head, width, clamped, col)
2131
+ : block.end + clickToCursorOffset(tail, width, clamped - chipRow - 1, col);
2132
+ }
2133
+ return clickToCursorOffset(value, width, clamped, col);
2134
+ };
1346
2135
  /**
1347
2136
  * Click-to-position the caret: map a click inside the value box (local
1348
2137
  * row/column relative to the box) to a UTF-16 cursor offset via the same
@@ -1351,38 +2140,141 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1351
2140
  * the clicked cell (mid-grapheme snaps to its start). With a fold block,
1352
2141
  * clicks map into the head/tail text (the chip row has its own expand
1353
2142
  * onClick); without one, the fold prefix's cells fold the whole input.
2143
+ *
2144
+ * The drag protocol resets ink's multi-click chain on every press inside
2145
+ * the value box (it carries onDragStart), so double-click word selection
2146
+ * is self-detected here: two clicks within DOUBLE_CLICK_MS and one cell.
2147
+ * Shift+click extends the selection from its start edge (or the caret)
2148
+ * to the clicked offset; a plain click clears the selection and moves
2149
+ * the caret.
2150
+ *
2151
+ * `colOffset` shifts the local column origin: the expanded editor's
2152
+ * value box starts at the line-number gutter, so its callers subtract
2153
+ * the gutter width (0 for the inline prompt).
1354
2154
  */
1355
- const handleValueClick = React.useCallback((e) => {
2155
+ const handleValueClick = (e, colOffset = 0) => {
2156
+ const now = Date.now();
2157
+ const modified = e.shift || e.alt || e.ctrl;
2158
+ const isDouble = !modified &&
2159
+ now - lastClickAtRef.current < DOUBLE_CLICK_MS &&
2160
+ Math.abs(e.col - lastClickColRef.current) <= 1 &&
2161
+ Math.abs(e.row - lastClickRowRef.current) <= 1;
2162
+ if (modified) {
2163
+ // Shift+click is range extension, never a word-select click. It also
2164
+ // breaks the local chain so a following plain click cannot complete a
2165
+ // double-click that started under a modifier.
2166
+ lastClickAtRef.current = 0;
2167
+ lastClickColRef.current = -1;
2168
+ lastClickRowRef.current = -1;
2169
+ }
2170
+ else {
2171
+ lastClickAtRef.current = now;
2172
+ lastClickColRef.current = e.col;
2173
+ lastClickRowRef.current = e.row;
2174
+ }
2175
+ const localCol = e.localCol - colOffset;
1356
2176
  const clickedVisual = windowStart + e.localRow;
1357
2177
  const clamped = Math.max(0, Math.min(clickedVisual, visualLines.length - 1));
1358
2178
  if (block) {
1359
2179
  if (clamped === chipRow)
1360
2180
  return;
1361
- if (clamped < chipRow) {
1362
- setCursor(clickToCursorOffset(head, inputWidth, clamped, e.localCol));
2181
+ const offset = clamped < chipRow
2182
+ ? clickToCursorOffset(head, inputWidth, clamped, localCol, isDouble ? 'grapheme-start' : 'nearest')
2183
+ : block.end + clickToCursorOffset(tail, inputWidth, clamped - chipRow - 1, localCol, isDouble ? 'grapheme-start' : 'nearest');
2184
+ if (isDouble) {
2185
+ // Word select stays inside the clicked side: the block is atomic.
2186
+ const side = offset <= block.start;
2187
+ const w = wordSelectionAt(value, offset, side ? 0 : block.end, side ? block.start : value.length);
2188
+ if (w) {
2189
+ updateSelection(w.start, w.end);
2190
+ setCursor(w.end);
2191
+ }
2192
+ return;
1363
2193
  }
1364
- else {
1365
- setCursor(block.end + clickToCursorOffset(tail, inputWidth, clamped - chipRow - 1, e.localCol));
2194
+ if (e.shift) {
2195
+ const base = selectionRef.current ? selectionRef.current.start : cursorRef.current;
2196
+ updateSelection(base, offset);
2197
+ setCursor(offset);
2198
+ return;
1366
2199
  }
2200
+ clearSelection();
2201
+ setCursor(offset);
1367
2202
  return;
1368
2203
  }
1369
- if (clamped === 0 && prefixCols > 0 && e.localCol < prefixCols) {
2204
+ if (clamped === 0 && prefixCols > 0 && localCol < prefixCols) {
2205
+ // Folding hides the entire editable projection, so no selection may
2206
+ // survive invisibly inside the chip and keep owning Ctrl+C/Delete.
2207
+ clearSelection();
2208
+ dragAnchorRef.current = null;
2209
+ setCursor(value.length);
1370
2210
  updateFoldBlock({ start: 0, end: value.length });
1371
2211
  return;
1372
2212
  }
1373
- const col = clamped === 0 && prefixCols > 0 ? Math.max(0, e.localCol - prefixCols) : e.localCol;
1374
- setCursor(clickToCursorOffset(value, clamped === 0 && prefixCols > 0 ? inputWidth - prefixCols : inputWidth, clamped, col));
1375
- }, [windowStart, visualLines.length, value, inputWidth, prefixCols, block, chipRow, head, tail]);
2213
+ const col = clamped === 0 && prefixCols > 0 ? Math.max(0, localCol - prefixCols) : localCol;
2214
+ const offset = clickToCursorOffset(value, clamped === 0 && prefixCols > 0 ? inputWidth - prefixCols : inputWidth, clamped, col, isDouble ? 'grapheme-start' : 'nearest');
2215
+ if (isDouble) {
2216
+ const w = wordSelectionAt(value, offset, 0, value.length);
2217
+ if (w) {
2218
+ updateSelection(w.start, w.end);
2219
+ setCursor(w.end);
2220
+ }
2221
+ return;
2222
+ }
2223
+ if (e.shift) {
2224
+ const base = selectionRef.current ? selectionRef.current.start : cursorRef.current;
2225
+ updateSelection(base, offset);
2226
+ setCursor(offset);
2227
+ return;
2228
+ }
2229
+ clearSelection();
2230
+ setCursor(offset);
2231
+ };
2232
+ /**
2233
+ * Drag selection (component-level drag protocol): the press origin is
2234
+ * derived from the event's start/current delta (dragstart fires on the
2235
+ * FIRST motion), the focus follows every dragmove. The caret rides the
2236
+ * focus edge; updateSelection clamps both ends into one fold side, so a
2237
+ * drag can never cross the chip row. `colOffset` as in handleValueClick.
2238
+ */
2239
+ const handleDragStart = (e, colOffset = 0) => {
2240
+ const anchorLocalCol = e.localCol - (e.col - e.startCol) - colOffset;
2241
+ const anchorLocalRow = e.localRow - (e.row - e.startRow);
2242
+ const anchor = localToOffset(anchorLocalCol, anchorLocalRow);
2243
+ if (anchor === null) {
2244
+ dragAnchorRef.current = null;
2245
+ return;
2246
+ }
2247
+ dragAnchorRef.current = anchor;
2248
+ setCursor(anchor);
2249
+ };
2250
+ const handleDragMove = (e, colOffset = 0) => {
2251
+ const anchor = dragAnchorRef.current;
2252
+ if (anchor === null)
2253
+ return;
2254
+ const focus = localToOffset(e.localCol - colOffset, e.localRow);
2255
+ if (focus === null)
2256
+ return;
2257
+ // The caret rides the focus edge, clamped into the anchor's fold side
2258
+ // exactly like updateSelection clamps the range — the caret must never
2259
+ // jump across the chip row while the selection stays behind.
2260
+ let caret = focus;
2261
+ const block = foldBlockRef.current;
2262
+ if (block) {
2263
+ caret = anchor <= block.start ? Math.min(focus, block.start) : Math.max(focus, block.end);
2264
+ }
2265
+ updateSelection(anchor, focus);
2266
+ setCursor(normalizeCursorOffset(valueRef.current, caret));
2267
+ };
2268
+ const handleDragEnd = () => {
2269
+ dragAnchorRef.current = null;
2270
+ };
1376
2271
  // 浮层整体挂载条件:与内部面板可见条件精确同值。关闭时必须把整个
1377
2272
  // absolute 浮层移除——渲染器的 absolute-removed 检测只看被移除节点自身
1378
2273
  // 的 style.position,常驻浮层 + 移除普通子节点不会触发 blit 解毒,被
1379
- // 覆盖的转录行会留空(见 Chat.tsx dialogOverlayOpen 注释)。
1380
- const floatersOpen = helpOpen || channel.pending.length > 0 || fileOverlayOpen || overlayOpen || peekOpen;
1381
- // 补全卡片边框与输入框 idle 边框同色(plan 模式下整套面板一起变 sage 绿)。
1382
- // `/color` 会话强调色优先于主题 promptBorder(plan 模式仍整体走 sage 绿)。
1383
- // `?? ''` 防御最小 mock channel(只声明用到的字段的回归脚本)。
1384
- const sessionAccent = sessionColorHex(channel.sessionColor ?? '');
1385
- const promptAccent = channel.mode.plan === true ? 'planMode' : (sessionAccent ?? 'promptBorder');
2274
+ // 覆盖的转录行会留空(见 Chat.tsx dialogOverlayOpen 注释)。展开态由
2275
+ // 全屏编辑器接管,内联浮层全部撤下。
2276
+ const floatersOpen = !expanded &&
2277
+ (helpOpen || channel.pending.length > 0 || fileOverlayOpen || overlayOpen || peekOpen);
1386
2278
  // 顶边框右侧的会话名标签(CC 风格 chip):色随强调色;超宽截断,宽度
1387
2279
  // 随终端列数伸缩但不超过 28 显示单元。默认关闭——`/settings` 的
1388
2280
  // 「会话名标签」开关(dsh-tui.promptSessionLabel)开启后显示。
@@ -1410,6 +2302,60 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1410
2302
  ink?.invalidatePrevFrame();
1411
2303
  ink?.reanchorViewport();
1412
2304
  }, [floatersOpen]);
2305
+ // 展开态开关 = 全屏覆盖增删:与浮层开关同款的视口重锚(inline 模式的
2306
+ // 虚屏↔scrollback 映射不漂移),并在展开首帧把滚动窗口归零。
2307
+ React.useLayoutEffect(() => {
2308
+ if (expanded === prevExpandedRef.current)
2309
+ return;
2310
+ prevExpandedRef.current = expanded;
2311
+ if (expanded)
2312
+ expandedScrollRef.current = 0;
2313
+ const ink = instances.get(process.stdout) ?? instances.values().next().value;
2314
+ ink?.invalidatePrevFrame();
2315
+ ink?.reanchorViewport();
2316
+ }, [expanded]);
2317
+ // Feature turned off mid-session while the editor is up: withdraw the
2318
+ // cover (the draft and every other editing state survive).
2319
+ React.useEffect(() => {
2320
+ if (expanded && !expandEnabled)
2321
+ collapseEditor();
2322
+ }, [expanded, expandEnabled]);
2323
+ // ── 全屏草稿编辑节点 ────────────────────────────────────────────────
2324
+ // 每次渲染构造新鲜闭包(value/caret/handlers),经 module store 发布给
2325
+ // Chat 根部末尾的 PromptEditorLayer(树序最后 → 盖住全部后绘兄弟)。
2326
+ // useInsertionEffect 发布:sink 的同步重渲染发生在 layout 阶段之前,
2327
+ // useDeclaredCursor(layout effect)读到的新 ref 已指向编辑区 Box。
2328
+ // 点击/拖拽坐标以内容区为原点(localCol 去掉行号槽宽度)。
2329
+ const editorNode = expanded ? (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", borderStyle: "round", borderColor: promptAccent, backgroundColor: "toolCardBackground", children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { bold: true, color: promptAccent, children: `✎ ${t('input-expand-editor-title')}` }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, children: t('input-fold-stats', {
2330
+ lines: value.split('\n').length,
2331
+ chars: value.length,
2332
+ }) })] }), _jsx(Box, { ref: valueBoxRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, paddingLeft: 1, paddingRight: 1, onClick: (event) => {
2333
+ handleValueClick(event, editorGutterCols + 1);
2334
+ }, onDragStart: (event) => {
2335
+ handleDragStart(event, editorGutterCols + 1);
2336
+ }, onDragMove: (event) => {
2337
+ handleDragMove(event, editorGutterCols + 1);
2338
+ }, onDragEnd: handleDragEnd, onWheel: (event) => {
2339
+ const viewport = editorViewportRef.current;
2340
+ if (!viewport)
2341
+ return;
2342
+ editorFreeScrollRef.current = true;
2343
+ const max = Math.max(0, viewport.total - viewport.maxRows);
2344
+ expandedScrollRef.current = Math.max(0, Math.min(expandedScrollRef.current + Math.round(event.deltaY), max));
2345
+ setExpandedTick(tick => tick + 1);
2346
+ }, children: editorRows }), _jsxs(Box, { flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1, children: [vimEnabled && (_jsxs(Text, { bold: true, color: vimInsert ? 'success' : 'warning', children: [vimInsert ? 'INSERT' : 'NORMAL', ' '] })), _jsx(Text, { dimColor: true, children: t('input-expand-editor-position', {
2347
+ line: cursorLine(value, cursor) + 1,
2348
+ col: cursorColumn(value, cursor) + 1,
2349
+ }) }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, children: `${t('input-expand-editor-hint-send')} · ${t('input-expand-editor-hint-collapse')}` })] }), _jsxs(Box, { flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1, columnGap: 1, children: [_jsx(EditorButton, { label: `⏎ ${t('input-expand-editor-send')}`, hint: "Ctrl+Enter", primary: true, accent: promptAccent, onClick: submitFromEditor }), _jsx(EditorButton, { label: t('input-expand-editor-collapse'), hint: "Esc", onClick: collapseEditor }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, children: t('input-expand-editor-scroll') })] })] })) : null;
2350
+ React.useInsertionEffect(() => {
2351
+ setPromptEditorNode(editorNode);
2352
+ });
2353
+ React.useEffect(() => {
2354
+ // 卸载保险:PromptInput 被替换(问卷/面板接管)时撤下全屏浮层。
2355
+ return () => {
2356
+ setPromptEditorNode(null);
2357
+ };
2358
+ }, []);
1413
2359
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [floatersOpen && (_jsxs(OverlayAbove, { maxHeight: Math.max(terminalRows - 6, 1), children: [helpOpen && (_jsx(Box, { marginBottom: 1, children: _jsx(HelpMenu, { commands: channel.commandList, viewportHeight: helpViewportHeight, viewportWidth: columns, scrollRef: helpScrollRef, onCommandPick: (name) => {
1414
2360
  // 点击命令行 = 填入 /name 并关闭帮助(Tab 补全的鼠标等价)
1415
2361
  updateFoldBlock(null);
@@ -1444,85 +2390,174 @@ export function PromptInput({ channel, helpOpen, onToggleHelp, onRunCommand, sel
1444
2390
  : undefined, rows: peekVisualLines.map((row, index) => (_jsx(Text, { wrap: "truncate-end", children: row }, index))), onRowPick: () => {
1445
2391
  updateFoldBlock(null);
1446
2392
  setHovered(false);
1447
- } }) }))] })), lastNotification && (_jsx(Box, { position: "absolute", marginTop: -1, height: 1, width: "100%", paddingLeft: 2, paddingRight: 1, flexDirection: "column", justifyContent: "flex-end", overflow: "hidden", children: _jsx(Box, { justifyContent: "flex-end", children: _jsx(Text, { color: lastNotification.color, dimColor: !lastNotification.color, wrap: "truncate", children: lastNotification.text }) }) })), _jsx(EffortInputBorder, { effort: channel.reasoningEffort, levels: channel.effortLevels, columns: columns, onLight: isLightThemeActive(themeName), idleColor: promptAccent, topRightLabel: topRightLabel, children: _jsxs(Box, { flexDirection: "row", alignItems: "flex-start", width: "100%", children: [_jsx(EffortChargeGlyph, { effort: channel.reasoningEffort, levels: channel.effortLevels, working: channel.working }), _jsx(Box, { ref: valueBoxRef, flexGrow: 1, flexShrink: 1, onClick: handleValueClick, children: value.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(EffortTierBadge, { effort: channel.reasoningEffort, levels: channel.effortLevels, onLight: isLightThemeActive(themeName), columns: columns, leadingColumns: 3 })] })) : (_jsx(Box, { flexDirection: "column", children: rendered })) })] }) })] }));
2393
+ } }) }))] })), lastNotification && (_jsx(Box, { position: "absolute", marginTop: -1, height: 1, width: "100%", paddingLeft: 2, paddingRight: 1, flexDirection: "column", justifyContent: "flex-end", overflow: "hidden", children: _jsx(Box, { justifyContent: "flex-end", children: _jsx(Text, { color: lastNotification.color, dimColor: !lastNotification.color, wrap: "truncate", children: lastNotification.text }) }) })), _jsx(EffortInputBorder, { effort: channel.reasoningEffort, levels: channel.effortLevels, columns: columns, onLight: isLightThemeActive(themeName), idleColor: promptAccent, topRightLabel: topRightLabel, children: _jsxs(Box, { flexDirection: "row", alignItems: "flex-start", width: "100%", children: [_jsx(EffortChargeGlyph, { effort: channel.reasoningEffort, levels: channel.effortLevels, working: channel.working }), vimEnabled && (_jsxs(Text, { bold: true, color: vimInsert ? 'success' : 'warning', children: [vimInsert ? 'INSERT' : 'NORMAL', " "] })), _jsx(Box, { ref: expanded ? undefined : valueBoxRef, flexGrow: 1, flexShrink: 1, onClick: handleValueClick, onDragStart: handleDragStart, onDragMove: handleDragMove, onDragEnd: handleDragEnd, children: value.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(EffortTierBadge, { effort: channel.reasoningEffort, levels: channel.effortLevels, onLight: isLightThemeActive(themeName), columns: columns, leadingColumns: 3 })] })) : (_jsx(Box, { flexDirection: "column", children: rendered })) }), expandEnabled && (_jsx(Box, { flexShrink: 0, onClick: (event) => {
2394
+ event.stopImmediatePropagation();
2395
+ toggleExpand();
2396
+ }, onMouseEnter: () => {
2397
+ setExpandHovered(true);
2398
+ }, onMouseLeave: () => {
2399
+ setExpandHovered(false);
2400
+ }, children: _jsx(Text, { dimColor: !expandHovered, bold: expandHovered, color: expandHovered ? promptAccent : undefined, children: "\u26F6" }) }))] }) })] }));
1448
2401
  }
1449
2402
  /**
1450
- * Hard-wrap text into visual rows of at most `width` columns (CJK-aware via
1451
- * stringWidth). Used by the input renderer so long lines wrap instead of
1452
- * truncating, with exact caret-row mapping. Wrapping only ever breaks
1453
- * BETWEEN grapheme clusters: iterating code points would split ZWJ emoji
1454
- * and combining sequences across rows, leaving a broken half at each edge
1455
- * and desyncing the caret's row arithmetic (which walks cluster
1456
- * boundaries).
2403
+ * Grapheme word-wrap for one logical line: break at the last space when
2404
+ * the row overflows; hard-wrap a word that cannot fit; never split a
2405
+ * cluster (ZWJ emoji, combining sequences, CJK wide cells stay whole).
1457
2406
  */
1458
- function wrapToWidth(text, width) {
2407
+ function wrapLineRows(line, width) {
2408
+ if (line === '')
2409
+ return [{ start: 0, end: 0 }];
1459
2410
  const rows = [];
1460
2411
  const segmenter = getGraphemeSegmenter();
1461
- for (const line of text.split('\n')) {
1462
- if (line === '') {
1463
- rows.push('');
1464
- continue;
1465
- }
1466
- let current = '';
1467
- let currentWidth = 0;
1468
- for (const { segment } of segmenter.segment(line)) {
1469
- const w = stringWidth(segment);
1470
- if (currentWidth + w > width && current !== '') {
1471
- rows.push(current);
1472
- current = segment;
1473
- currentWidth = w;
2412
+ let rowStart = 0;
2413
+ let currentWidth = 0;
2414
+ let offset = 0;
2415
+ let lastBreak = -1;
2416
+ for (const { segment } of segmenter.segment(line)) {
2417
+ const w = stringWidth(segment);
2418
+ while (currentWidth + w > width && offset > rowStart) {
2419
+ if (lastBreak > rowStart) {
2420
+ rows.push({ start: rowStart, end: lastBreak });
2421
+ rowStart = lastBreak;
2422
+ currentWidth = stringWidth(line.slice(rowStart, offset));
2423
+ lastBreak = -1;
1474
2424
  }
1475
2425
  else {
1476
- current += segment;
1477
- currentWidth += w;
2426
+ rows.push({ start: rowStart, end: offset });
2427
+ rowStart = offset;
2428
+ currentWidth = 0;
2429
+ lastBreak = -1;
1478
2430
  }
1479
2431
  }
1480
- rows.push(current);
2432
+ currentWidth += w;
2433
+ offset += segment.length;
2434
+ if (segment === ' ')
2435
+ lastBreak = offset;
1481
2436
  }
2437
+ rows.push({ start: rowStart, end: offset });
1482
2438
  return rows;
1483
2439
  }
2440
+ /** Wrap text to `width` columns via {@link wrapLineRows} (newlines honoured). */
2441
+ function wrapToWidth(text, width) {
2442
+ return text.split('\n').flatMap(line => wrapLineRows(line, width).map(r => line.slice(r.start, r.end)));
2443
+ }
2444
+ /**
2445
+ * Locate `offset` in the wrapped layout of `text`. Uses the full wrap —
2446
+ * not a prefix — so a cursor inside a carried word maps to the displayed
2447
+ * row. At a wrap join the caret stays on the earlier row (after the space).
2448
+ */
2449
+ function caretInText(text, width, offset) {
2450
+ let line = 0;
2451
+ let lineBase = 0;
2452
+ let last = { line: 0, charCol: 0, visualCol: 0 };
2453
+ for (const logical of text.split('\n')) {
2454
+ for (const r of wrapLineRows(logical, width)) {
2455
+ const start = lineBase + r.start;
2456
+ const charCol = Math.max(0, offset - start);
2457
+ last = {
2458
+ line,
2459
+ charCol,
2460
+ visualCol: stringWidth(logical.slice(r.start, r.start + charCol)),
2461
+ };
2462
+ if (offset <= lineBase + r.end)
2463
+ return last;
2464
+ line++;
2465
+ }
2466
+ lineBase += logical.length + 1;
2467
+ }
2468
+ return last;
2469
+ }
1484
2470
  /**
1485
2471
  * Inverse of {@link wrapToWidth}: map a click position (visual row index +
1486
- * visual column) back to a UTF-16 offset in the original text. Walks the
1487
- * same grapheme boundaries with the same break rule, so every visual row's
1488
- * start offset is known exactly. Within the clicked row, the caret snaps to
1489
- * the boundary nearest the click: a grapheme whose midpoint lies past the
2472
+ * visual column) back to a UTF-16 offset in the original text. Uses the
2473
+ * same {@link wrapLineRows} row boundaries, so every visual row's start
2474
+ * offset is known exactly. Within the clicked row, the caret snaps to the
2475
+ * boundary nearest the click: a grapheme whose midpoint lies past the
1490
2476
  * click column takes the caret before it, otherwise after.
1491
2477
  */
1492
- function clickToCursorOffset(text, width, visualLine, visualCol) {
2478
+ function clickToCursorOffset(text, width, visualLine, visualCol, snapWithin = 'nearest') {
1493
2479
  const segmenter = getGraphemeSegmenter();
1494
2480
  let row = 0;
1495
- let offset = 0;
2481
+ let lineBase = 0;
1496
2482
  for (const line of text.split('\n')) {
1497
- if (line === '') {
1498
- if (row === visualLine)
1499
- return offset;
1500
- row++;
1501
- offset++; // the '\n'
1502
- continue;
1503
- }
1504
- let currentWidth = 0;
1505
- for (const { segment } of segmenter.segment(line)) {
1506
- const w = stringWidth(segment);
1507
- if (currentWidth + w > width && currentWidth > 0) {
1508
- if (row === visualLine)
1509
- return offset; // end of the clicked wrapped row
1510
- row++;
1511
- currentWidth = 0;
1512
- }
2483
+ for (const r of wrapLineRows(line, width)) {
1513
2484
  if (row === visualLine) {
1514
- if (currentWidth + w / 2 > visualCol)
1515
- return offset;
1516
- if (currentWidth + w > visualCol)
1517
- return offset + segment.length;
2485
+ let currentWidth = 0;
2486
+ let local = 0;
2487
+ for (const { segment } of segmenter.segment(line.slice(r.start, r.end))) {
2488
+ const w = stringWidth(segment);
2489
+ // Caret positioning uses the nearest edge. Double-click selection
2490
+ // instead needs the grapheme UNDER the cell: on the second cell of a
2491
+ // CJK/emoji glyph, nearest-edge would point after the glyph (and the
2492
+ // final glyph would select nothing).
2493
+ if (snapWithin === 'grapheme-start' && currentWidth + w > visualCol) {
2494
+ return lineBase + r.start + local;
2495
+ }
2496
+ if (currentWidth + w / 2 > visualCol)
2497
+ return lineBase + r.start + local;
2498
+ if (currentWidth + w > visualCol)
2499
+ return lineBase + r.start + local + segment.length;
2500
+ currentWidth += w;
2501
+ local += segment.length;
2502
+ }
2503
+ return lineBase + r.end;
1518
2504
  }
1519
- currentWidth += w;
1520
- offset += segment.length;
2505
+ row++;
1521
2506
  }
1522
- if (row === visualLine)
1523
- return offset;
1524
- row++;
1525
- offset++; // the '\n'
2507
+ lineBase += line.length + 1;
2508
+ }
2509
+ return lineBase;
2510
+ }
2511
+ /**
2512
+ * Offset range [start, end) of every visual row, using the same {@link
2513
+ * wrapLineRows} boundaries as `wrapToWidth`. The selection highlight
2514
+ * intersects each row with these ranges so offsets map 1:1 onto the
2515
+ * rendered row strings.
2516
+ */
2517
+ function visualLineRanges(text, width) {
2518
+ const ranges = [];
2519
+ let lineBase = 0;
2520
+ for (const line of text.split('\n')) {
2521
+ for (const r of wrapLineRows(line, width))
2522
+ ranges.push([lineBase + r.start, lineBase + r.end]);
2523
+ lineBase += line.length + 1;
1526
2524
  }
1527
- return offset;
2525
+ return ranges;
2526
+ }
2527
+ /** Word characters for double-click selection: letters (any script),
2528
+ * digits and the punctuation set terminal emulators treat as word-part by
2529
+ * default (`/usr/bin/bash` selects whole — iTerm2 defaults). */
2530
+ const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u;
2531
+ /** Character class for double-click word expansion: whitespace, word
2532
+ * char, everything else (punctuation/symbols). A same-class grapheme
2533
+ * run is one word. */
2534
+ function selectionCharClass(c) {
2535
+ if (c === '' || /\s/.test(c))
2536
+ return 0;
2537
+ if (WORD_CHAR.test(c))
2538
+ return 1;
2539
+ return 2;
2540
+ }
2541
+ /**
2542
+ * Word selection around `offset` within [lo, hi): expand left/right over
2543
+ * grapheme clusters of the same class as the clicked one (terminal
2544
+ * double-click semantics — punctuation runs select as one). Returns null
2545
+ * when the offset sits at the range's end (nothing to select).
2546
+ */
2547
+ function wordSelectionAt(text, offset, lo, hi) {
2548
+ if (offset < lo || offset >= hi)
2549
+ return null;
2550
+ const bounds = graphemeBoundaries(text.slice(lo, hi)).map(b => b + lo);
2551
+ let i = 0;
2552
+ while (i < bounds.length - 1 && bounds[i + 1] <= offset)
2553
+ i++;
2554
+ const clusterAt = (index) => text.slice(bounds[index], bounds[index + 1]);
2555
+ const cls = selectionCharClass(clusterAt(i));
2556
+ let a = i;
2557
+ while (a > 0 && selectionCharClass(clusterAt(a - 1)) === cls)
2558
+ a--;
2559
+ let b = i;
2560
+ while (b < bounds.length - 2 && selectionCharClass(clusterAt(b + 1)) === cls)
2561
+ b++;
2562
+ return { start: bounds[a], end: bounds[b + 1] };
1528
2563
  }