@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.
- package/README.md +24 -6
- package/cordis.patch.yml +117 -39
- package/cordis.yml +13 -10
- package/lib/types/api.d.ts +7 -0
- package/lib/types/api.d.ts.map +1 -0
- package/lib/types/api.js +1 -0
- package/lib/types/cc/hyperlink.d.ts.map +1 -1
- package/lib/types/cc/hyperlink.js +42 -4
- package/lib/types/cc/markdown.d.ts.map +1 -1
- package/lib/types/cc/markdown.js +15 -5
- package/lib/types/commands.d.ts +6 -0
- package/lib/types/commands.d.ts.map +1 -1
- package/lib/types/commands.js +12 -8
- package/lib/types/components/MessageList.d.ts +8 -4
- package/lib/types/components/MessageList.d.ts.map +1 -1
- package/lib/types/components/MessageList.js +94 -32
- package/lib/types/components/PermissionsPicker.d.ts +6 -7
- package/lib/types/components/PermissionsPicker.d.ts.map +1 -1
- package/lib/types/components/PermissionsPicker.js +20 -14
- package/lib/types/components/PromptEditor.d.ts +46 -0
- package/lib/types/components/PromptEditor.d.ts.map +1 -0
- package/lib/types/components/PromptEditor.js +60 -0
- package/lib/types/components/PromptInput.d.ts +12 -0
- package/lib/types/components/PromptInput.d.ts.map +1 -1
- package/lib/types/components/PromptInput.js +1240 -205
- package/lib/types/components/RewindPicker.js +1 -1
- package/lib/types/components/SplitDiffView.d.ts +11 -1
- package/lib/types/components/SplitDiffView.d.ts.map +1 -1
- package/lib/types/components/SplitDiffView.js +9 -2
- package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
- package/lib/types/components/StreamingMarkdown.js +140 -3
- package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
- package/lib/types/components/SubagentDashboard.js +2 -2
- package/lib/types/components/SubagentDetailScene.js +1 -1
- package/lib/types/components/ThemePicker.d.ts +13 -15
- package/lib/types/components/ThemePicker.d.ts.map +1 -1
- package/lib/types/components/ThemePicker.js +24 -26
- package/lib/types/components/Tooltip.d.ts +67 -0
- package/lib/types/components/Tooltip.d.ts.map +1 -0
- package/lib/types/components/Tooltip.js +220 -0
- package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
- package/lib/types/components/approvals/ApprovalPanel.js +1 -1
- package/lib/types/components/design-system/Divider.d.ts +14 -1
- package/lib/types/components/design-system/Divider.d.ts.map +1 -1
- package/lib/types/components/design-system/Divider.js +86 -8
- package/lib/types/components/design-system/ThemeProvider.d.ts +5 -2
- package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
- package/lib/types/components/design-system/ThemeProvider.js +52 -16
- package/lib/types/components/design-system/ThemedBox.d.ts +23 -2
- package/lib/types/components/design-system/ThemedBox.d.ts.map +1 -1
- package/lib/types/components/messages/AssistantThinkingMessage.d.ts +11 -6
- package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
- package/lib/types/components/messages/AssistantThinkingMessage.js +10 -6
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts +13 -1
- package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
- package/lib/types/components/messages/AssistantToolUseMessage.js +37 -6
- package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
- package/lib/types/components/messages/UserPromptMessage.js +7 -1
- package/lib/types/components/questions/AskUserQuestionPanel.js +1 -1
- package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
- package/lib/types/components/questions/PlanReviewPanel.js +29 -3
- package/lib/types/components/sessions/SessionListRow.d.ts +8 -1
- package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
- package/lib/types/components/sessions/SessionListRow.js +9 -2
- package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -1
- package/lib/types/components/sessions/SessionPreview.js +5 -3
- package/lib/types/components/sessions/WorkspaceListRow.d.ts +17 -0
- package/lib/types/components/sessions/WorkspaceListRow.d.ts.map +1 -0
- package/lib/types/components/sessions/WorkspaceListRow.js +27 -0
- package/lib/types/components/smoothReveal.d.ts +97 -0
- package/lib/types/components/smoothReveal.d.ts.map +1 -0
- package/lib/types/components/smoothReveal.js +279 -0
- package/lib/types/customTheme.d.ts +4 -0
- package/lib/types/customTheme.d.ts.map +1 -1
- package/lib/types/customTheme.js +9 -5
- package/lib/types/dsh-adapter/approvals.d.ts +114 -2
- package/lib/types/dsh-adapter/approvals.d.ts.map +1 -1
- package/lib/types/dsh-adapter/approvals.js +241 -3
- package/lib/types/dsh-adapter/channel.d.ts +59 -2
- package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel.js +499 -80
- package/lib/types/dsh-adapter/childStderr.d.ts.map +1 -1
- package/lib/types/dsh-adapter/childStderr.js +9 -0
- package/lib/types/dsh-adapter/contract.d.ts +17 -13
- package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
- package/lib/types/dsh-adapter/contract.js +36 -27
- package/lib/types/dsh-adapter/credentialRefGuard.d.ts +40 -0
- package/lib/types/dsh-adapter/credentialRefGuard.d.ts.map +1 -0
- package/lib/types/dsh-adapter/credentialRefGuard.js +43 -0
- package/lib/types/dsh-adapter/effect-ledger.d.ts +1 -1
- package/lib/types/dsh-adapter/effect-ledger.d.ts.map +1 -1
- package/lib/types/dsh-adapter/effect-ledger.js +1 -0
- package/lib/types/dsh-adapter/extensions.d.ts +4 -1
- package/lib/types/dsh-adapter/extensions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/extensions.js +8 -1
- package/lib/types/dsh-adapter/index.d.ts +11 -1
- package/lib/types/dsh-adapter/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.js +2 -0
- package/lib/types/dsh-adapter/plugin-test-utils.d.ts +62 -0
- package/lib/types/dsh-adapter/plugin-test-utils.d.ts.map +1 -0
- package/lib/types/dsh-adapter/plugin-test-utils.js +61 -0
- package/lib/types/dsh-adapter/plugin.d.ts +20 -0
- package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugin.js +174 -39
- package/lib/types/dsh-adapter/plugins-info.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugins-info.js +12 -0
- package/lib/types/dsh-adapter/preset-resolution.d.ts +49 -0
- package/lib/types/dsh-adapter/preset-resolution.d.ts.map +1 -0
- package/lib/types/dsh-adapter/preset-resolution.js +42 -0
- package/lib/types/dsh-adapter/presets.d.ts +0 -26
- package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
- package/lib/types/dsh-adapter/presets.js +4 -14
- package/lib/types/dsh-adapter/promptDebug.d.ts.map +1 -1
- package/lib/types/dsh-adapter/promptDebug.js +4 -1
- package/lib/types/dsh-adapter/providerGuard.d.ts +75 -0
- package/lib/types/dsh-adapter/providerGuard.d.ts.map +1 -0
- package/lib/types/dsh-adapter/providerGuard.js +104 -0
- package/lib/types/dsh-adapter/questions-answerer.d.ts +49 -0
- package/lib/types/dsh-adapter/questions-answerer.d.ts.map +1 -0
- package/lib/types/dsh-adapter/questions-answerer.js +61 -0
- package/lib/types/dsh-adapter/recap.d.ts.map +1 -1
- package/lib/types/dsh-adapter/recap.js +12 -6
- package/lib/types/dsh-adapter/sanitize.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sanitize.js +6 -1
- package/lib/types/dsh-adapter/sessions/digest.d.ts +21 -1
- package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sessions/digest.js +229 -3
- package/lib/types/dsh-adapter/sessions/frames.d.ts +3 -0
- package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sessions/frames.js +34 -19
- package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sessions/list.js +148 -31
- package/lib/types/dsh-adapter/sessions/store.d.ts +8 -0
- package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sessions/store.js +24 -4
- package/lib/types/dsh-adapter/sessions/types.d.ts +3 -0
- package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -1
- package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -1
- package/lib/types/dsh-adapter/settings-sections.js +13 -1
- package/lib/types/dsh-adapter/settingsEditor.d.ts +6 -0
- package/lib/types/dsh-adapter/settingsEditor.d.ts.map +1 -1
- package/lib/types/dsh-adapter/settingsEditor.js +15 -3
- package/lib/types/dsh-adapter/subagents.d.ts +4 -0
- package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
- package/lib/types/dsh-adapter/subagents.js +8 -0
- package/lib/types/dsh-adapter/themes.d.ts +51 -0
- package/lib/types/dsh-adapter/themes.d.ts.map +1 -0
- package/lib/types/dsh-adapter/themes.js +272 -0
- package/lib/types/dsh-adapter/toast.d.ts +67 -0
- package/lib/types/dsh-adapter/toast.d.ts.map +1 -0
- package/lib/types/dsh-adapter/toast.js +141 -0
- package/lib/types/dsh-adapter/types.d.ts +1 -1
- package/lib/types/extensions.d.ts +4 -0
- package/lib/types/extensions.d.ts.map +1 -1
- package/lib/types/extensions.js +5 -1
- package/lib/types/history.d.ts.map +1 -1
- package/lib/types/history.js +7 -2
- package/lib/types/hooks/useRuntimeThemeSnapshot.d.ts +10 -0
- package/lib/types/hooks/useRuntimeThemeSnapshot.d.ts.map +1 -0
- package/lib/types/hooks/useRuntimeThemeSnapshot.js +32 -0
- package/lib/types/i18n.d.ts +199 -41
- package/lib/types/i18n.d.ts.map +1 -1
- package/lib/types/i18n.js +82 -20
- package/lib/types/ink/components/App.d.ts +20 -0
- package/lib/types/ink/components/App.d.ts.map +1 -1
- package/lib/types/ink/components/App.js +142 -13
- package/lib/types/ink/components/Box.d.ts +39 -2
- package/lib/types/ink/components/Box.d.ts.map +1 -1
- package/lib/types/ink/components/Box.js +61 -37
- package/lib/types/ink/events/context-menu-event.d.ts +19 -0
- package/lib/types/ink/events/context-menu-event.d.ts.map +1 -0
- package/lib/types/ink/events/context-menu-event.js +20 -0
- package/lib/types/ink/events/drag-event.d.ts +44 -0
- package/lib/types/ink/events/drag-event.d.ts.map +1 -0
- package/lib/types/ink/events/drag-event.js +39 -0
- package/lib/types/ink/events/event-handlers.d.ts +8 -0
- package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
- package/lib/types/ink/events/event-handlers.js +8 -0
- package/lib/types/ink/hit-test.d.ts +69 -0
- package/lib/types/ink/hit-test.d.ts.map +1 -1
- package/lib/types/ink/hit-test.js +260 -6
- package/lib/types/ink/ink.d.ts +25 -0
- package/lib/types/ink/ink.d.ts.map +1 -1
- package/lib/types/ink/ink.js +74 -10
- package/lib/types/ink/parse-keypress.d.ts +20 -0
- package/lib/types/ink/parse-keypress.d.ts.map +1 -1
- package/lib/types/ink/parse-keypress.js +190 -22
- package/lib/types/ink/renderer.d.ts.map +1 -1
- package/lib/types/ink/renderer.js +15 -1
- package/lib/types/ink/screen.d.ts +7 -0
- package/lib/types/ink/screen.d.ts.map +1 -1
- package/lib/types/ink/screen.js +8 -1
- package/lib/types/ink/termio/osc.d.ts +2 -1
- package/lib/types/ink/termio/osc.d.ts.map +1 -1
- package/lib/types/ink/termio/osc.js +26 -5
- package/lib/types/plugin-spec/registry.d.ts +1 -1
- package/lib/types/plugin-spec/registry.js +1 -1
- package/lib/types/presetPrefs.d.ts +6 -5
- package/lib/types/presetPrefs.d.ts.map +1 -1
- package/lib/types/presetPrefs.js +10 -5
- package/lib/types/screens/Chat.d.ts +4 -1
- package/lib/types/screens/Chat.d.ts.map +1 -1
- package/lib/types/screens/Chat.js +169 -68
- package/lib/types/screens/SessionBrowser.d.ts +9 -4
- package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
- package/lib/types/screens/SessionBrowser.js +483 -75
- package/lib/types/screens/SessionTree.d.ts.map +1 -1
- package/lib/types/screens/SessionTree.js +5 -1
- package/lib/types/screens/Settings.d.ts.map +1 -1
- package/lib/types/screens/Settings.js +5 -1
- package/lib/types/screens/StatusLine.d.ts.map +1 -1
- package/lib/types/screens/StatusLine.js +5 -1
- package/lib/types/screens/chatOverlay.d.ts +3 -2
- package/lib/types/screens/chatOverlay.d.ts.map +1 -1
- package/lib/types/screens/chatOverlay.js +2 -0
- package/lib/types/sessionPins.d.ts +33 -0
- package/lib/types/sessionPins.d.ts.map +1 -0
- package/lib/types/sessionPins.js +181 -0
- package/lib/types/sessions/format.d.ts +2 -0
- package/lib/types/sessions/format.d.ts.map +1 -1
- package/lib/types/sessions/format.js +9 -0
- package/lib/types/sessions/view.d.ts +42 -6
- package/lib/types/sessions/view.d.ts.map +1 -1
- package/lib/types/sessions/view.js +133 -35
- package/lib/types/test-utils.d.ts +2 -0
- package/lib/types/test-utils.d.ts.map +1 -0
- package/lib/types/test-utils.js +8 -0
- package/lib/types/theme.d.ts +22 -5
- package/lib/types/theme.d.ts.map +1 -1
- package/lib/types/theme.js +57 -7
- package/lib/types/themeCatalog.d.ts +36 -0
- package/lib/types/themeCatalog.d.ts.map +1 -0
- package/lib/types/themeCatalog.js +157 -0
- package/lib/types/tips.d.ts.map +1 -1
- package/lib/types/tips.js +114 -18
- package/lib/types/update.d.ts +159 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +497 -16
- package/lib/types/utils/debug.d.ts.map +1 -1
- package/lib/types/utils/debug.js +2 -1
- package/lib/types/utils/keymap.d.ts +1 -1
- package/lib/types/utils/keymap.d.ts.map +1 -1
- package/lib/types/utils/keymap.js +19 -2
- package/lib/types/utils/urlGuard.d.ts +28 -0
- package/lib/types/utils/urlGuard.d.ts.map +1 -0
- package/lib/types/utils/urlGuard.js +21 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +3 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.d.ts +7 -11
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.d.ts +16 -1
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.js +23 -2
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.d.ts +5 -5
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/pi-ai.d.ts +23 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/pi-ai.js +27 -0
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.d.ts +17 -3
- package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.js +36 -4
- package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +11 -12
- package/package.json +114 -74
- package/lib/types/dsh-adapter/packaged-skills.d.ts +0 -19
- package/lib/types/dsh-adapter/packaged-skills.d.ts.map +0 -1
- package/lib/types/dsh-adapter/packaged-skills.js +0 -78
- package/skills/audit/SKILL.md +0 -24
- package/skills/bug/SKILL.md +0 -27
- package/skills/pr-comments/SKILL.md +0 -20
- package/skills/practice/SKILL.md +0 -22
- package/skills/release-notes/SKILL.md +0 -25
- package/skills/review/SKILL.md +0 -25
- 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
|
-
*
|
|
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
|
|
551
|
-
const
|
|
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
|
|
622
|
-
|
|
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
|
-
//
|
|
628
|
-
// NOT Enter — so this branch runs before
|
|
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
|
-
|
|
635
|
-
|
|
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
|
|
689
|
-
//
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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:
|
|
830
|
-
//
|
|
831
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
1222
|
-
:
|
|
1223
|
-
:
|
|
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
|
-
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
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 &&
|
|
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,
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
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
|
|
2407
|
+
function wrapLineRows(line, width) {
|
|
2408
|
+
if (line === '')
|
|
2409
|
+
return [{ start: 0, end: 0 }];
|
|
1459
2410
|
const rows = [];
|
|
1460
2411
|
const segmenter = getGraphemeSegmenter();
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
2426
|
+
rows.push({ start: rowStart, end: offset });
|
|
2427
|
+
rowStart = offset;
|
|
2428
|
+
currentWidth = 0;
|
|
2429
|
+
lastBreak = -1;
|
|
1478
2430
|
}
|
|
1479
2431
|
}
|
|
1480
|
-
|
|
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.
|
|
1487
|
-
* same
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
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
|
|
2481
|
+
let lineBase = 0;
|
|
1496
2482
|
for (const line of text.split('\n')) {
|
|
1497
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
1520
|
-
offset += segment.length;
|
|
2505
|
+
row++;
|
|
1521
2506
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
|
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
|
}
|