dsh-code 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +6679 -5232
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +32 -45
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +65 -5
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +8 -0
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +46 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
- package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
- package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +707 -4108
- package/src/attachments.ts +65 -19
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +164 -645
- package/src/input-split.ts +24 -4
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +66 -5
- package/src/locales/zh.ts +66 -5
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
- package/src/provider-settings.ts +38 -0
- package/src/rainbow.ts +13 -3
- package/src/render/inspector.ts +23 -0
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +10 -1
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +206 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{fork.ts → session/fork.ts} +11 -7
- package/src/{history.ts → session/history.ts} +14 -0
- package/src/{session-directory.ts → session/session-directory.ts} +83 -4
- package/src/{session-switch.ts → session/session-switch.ts} +14 -0
- package/src/{store.ts → session/store.ts} +20 -2
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- package/src/version.ts +5 -0
- /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
package/src/composer.ts
ADDED
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
/** Composer input, command routing, attachments, and one-shot composer animations. */
|
|
2
|
+
|
|
3
|
+
import { basename } from 'node:path'
|
|
4
|
+
import { createElement, useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
|
|
5
|
+
import { Box, Text, useStdin, useStdout } from 'ink'
|
|
6
|
+
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
|
|
7
|
+
import type { ContentBlock, FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
8
|
+
import {
|
|
9
|
+
looksLikeImagePath,
|
|
10
|
+
looksLikePathDraft,
|
|
11
|
+
parsePastedAttachmentPaths,
|
|
12
|
+
type FilePathInspection,
|
|
13
|
+
type ImagePathInspection,
|
|
14
|
+
} from './attachments.ts'
|
|
15
|
+
import { CompletionMenu, completionMenuRowCount } from './panels/completion-panel.ts'
|
|
16
|
+
import {
|
|
17
|
+
BARE_LOCAL_COMMANDS,
|
|
18
|
+
completionCandidates,
|
|
19
|
+
slashNameAndArgs,
|
|
20
|
+
stepCompletionIndex,
|
|
21
|
+
type CompletionCandidate,
|
|
22
|
+
} from './completion.ts'
|
|
23
|
+
import { isSlashLine, submissionPayload } from './commands.ts'
|
|
24
|
+
import type { SkillRow } from './skills.ts'
|
|
25
|
+
import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
|
|
26
|
+
import {
|
|
27
|
+
beginRecall,
|
|
28
|
+
recallNewer,
|
|
29
|
+
recallOlder,
|
|
30
|
+
appendRecall,
|
|
31
|
+
type RecallState,
|
|
32
|
+
} from './session/history.ts'
|
|
33
|
+
import { parseReviewArgument, type ReviewSelection } from './git-workflow.ts'
|
|
34
|
+
import { parseLanguageName, t, type LanguageName } from './i18n.ts'
|
|
35
|
+
import {
|
|
36
|
+
isVsCodeTerminalEnv,
|
|
37
|
+
normalizeKeyboardChunk,
|
|
38
|
+
PASTE_BRACKET_TIMEOUT_MS,
|
|
39
|
+
PASTE_END_MARKER,
|
|
40
|
+
PASTE_START_MARKER,
|
|
41
|
+
stripPasteMarkers,
|
|
42
|
+
stripTerminalFocusEvents,
|
|
43
|
+
tokenizeRawEditorChunk,
|
|
44
|
+
type RawEditorToken,
|
|
45
|
+
} from './keyboard.ts'
|
|
46
|
+
import { parseRainbowArgument, rainbowRoll } from './rainbow.ts'
|
|
47
|
+
import {
|
|
48
|
+
BUSY_CHASE_TICK_MS,
|
|
49
|
+
CARET_BLINK_TICK_MS,
|
|
50
|
+
DEEPSEEK_WAVE_TICK_MS,
|
|
51
|
+
busyChaseFrame,
|
|
52
|
+
deepseekWaveColumnBg,
|
|
53
|
+
deepseekWaveDuration,
|
|
54
|
+
deepseekWaveSpark,
|
|
55
|
+
deepseekWaveWordHue,
|
|
56
|
+
deepseekWaveWordVisible,
|
|
57
|
+
flowColor,
|
|
58
|
+
parseAnimationsArgument,
|
|
59
|
+
RAINBOW_BURST_DURATION_MS,
|
|
60
|
+
RAINBOW_BURST_TICK_MS,
|
|
61
|
+
rainbowBurstColumnBg,
|
|
62
|
+
type DeepseekWaveStyle,
|
|
63
|
+
type DeepseekWaveTier,
|
|
64
|
+
} from './render/animations.ts'
|
|
65
|
+
import {
|
|
66
|
+
caretSite,
|
|
67
|
+
clampCursor,
|
|
68
|
+
deleteBackward,
|
|
69
|
+
deleteForward,
|
|
70
|
+
deleteWordBackward,
|
|
71
|
+
deleteWordForward,
|
|
72
|
+
editorModel,
|
|
73
|
+
editorRowParts,
|
|
74
|
+
insertText,
|
|
75
|
+
killToLineEnd,
|
|
76
|
+
killToLineStart,
|
|
77
|
+
moveCursorBy,
|
|
78
|
+
moveCursorVertically,
|
|
79
|
+
moveToLineEnd,
|
|
80
|
+
moveToLineStart,
|
|
81
|
+
moveWordLeft,
|
|
82
|
+
moveWordRight,
|
|
83
|
+
remapStableRange,
|
|
84
|
+
replaceRangePreservingCursor,
|
|
85
|
+
sanitizeDraftText,
|
|
86
|
+
shouldRecallNavigate,
|
|
87
|
+
splitGraphemes,
|
|
88
|
+
type EditResult,
|
|
89
|
+
type EditorRowModel,
|
|
90
|
+
} from './render/editor.ts'
|
|
91
|
+
import { imeCursorRowsUp, useImeCursorAnchor } from './render/ime-cursor.ts'
|
|
92
|
+
import { rankByName } from './render/fuzzy.ts'
|
|
93
|
+
import { visibleColumns } from './render/markdown.ts'
|
|
94
|
+
import { displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
95
|
+
import type { TranscriptEntry } from './render/projection.ts'
|
|
96
|
+
import { sanitizeTerminalTitle, terminalTitleSequence } from './ui/terminal-title.ts'
|
|
97
|
+
import {
|
|
98
|
+
FLOW_ANCHORS,
|
|
99
|
+
getPalette,
|
|
100
|
+
inkColor,
|
|
101
|
+
isPrismatic,
|
|
102
|
+
isRainbow,
|
|
103
|
+
themeFlow,
|
|
104
|
+
type RgbTriple,
|
|
105
|
+
} from './theme.ts'
|
|
106
|
+
import type { NoticeTone, QueueMutation } from './ui/ui-contract.ts'
|
|
107
|
+
import { useFrames } from './ui/use-frames.ts'
|
|
108
|
+
import { useStableInput } from './ui/use-stable-input.ts'
|
|
109
|
+
|
|
110
|
+
/** Theme-coordinated hues for the one-shot composer wave. */
|
|
111
|
+
function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTriple, RgbTriple] {
|
|
112
|
+
const palette = getPalette()
|
|
113
|
+
if (isRainbow()) {
|
|
114
|
+
const anchors = rainbowRoll().flowAnchors
|
|
115
|
+
const stride = Math.max(1, Math.floor(anchors.length / 3))
|
|
116
|
+
return [anchors[0], anchors[stride], anchors[stride * 2]]
|
|
117
|
+
}
|
|
118
|
+
if (isPrismatic()) return [FLOW_ANCHORS[0], FLOW_ANCHORS[1], FLOW_ANCHORS[2]]
|
|
119
|
+
return tier === 'flash'
|
|
120
|
+
? [palette.brandBright, palette.brand, palette.brandMid]
|
|
121
|
+
: [palette.brandBright, palette.code, palette.brandMid]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Original web StateDot chase used by the busy composer marker. */
|
|
125
|
+
function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
|
|
126
|
+
const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
|
|
127
|
+
// Flowing themes (prismatic, rainbow) ride their anchor walk while busy;
|
|
128
|
+
// every other theme (and the frozen state) keeps the palette's live accent.
|
|
129
|
+
const flow = themeFlow()
|
|
130
|
+
const marker = flow !== undefined && animated
|
|
131
|
+
? flowColor(tick * BUSY_CHASE_TICK_MS + flow.phaseMs, flow.anchors)
|
|
132
|
+
: getPalette().brandBright
|
|
133
|
+
return createElement(Text, { color: inkColor(marker) }, busyChaseFrame(tick) + ' ')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One resettable input-caret phase shared by the entire composer. */
|
|
137
|
+
function useCursorBlink(active: boolean): { visible: boolean; reset: () => void } {
|
|
138
|
+
const [epoch, setEpoch] = useState(0)
|
|
139
|
+
const [visible, setVisible] = useState(true)
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setVisible(true)
|
|
142
|
+
if (!active) return
|
|
143
|
+
const id = setInterval(() => setVisible(current => !current), CARET_BLINK_TICK_MS)
|
|
144
|
+
return () => {
|
|
145
|
+
clearInterval(id)
|
|
146
|
+
}
|
|
147
|
+
}, [active, epoch])
|
|
148
|
+
const reset = useCallback((): void => {
|
|
149
|
+
setVisible(true)
|
|
150
|
+
setEpoch(current => current + 1)
|
|
151
|
+
}, [])
|
|
152
|
+
return { visible, reset }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Collapse the verbose editor draft into one bounded, display-safe row. */
|
|
156
|
+
function verboseLine(text: string, columns: number): string {
|
|
157
|
+
return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The empty-composer placeholder text (shared by the static and wave paths). */
|
|
161
|
+
const composerPlaceholder = (mode: 'queue' | 'steer'): string =>
|
|
162
|
+
t(mode === 'steer' ? 'composer.placeholderSteer' : 'composer.placeholder')
|
|
163
|
+
|
|
164
|
+
/** One physical cell of the wave-painted composer row: a char plus styles. */
|
|
165
|
+
interface ComposerCell {
|
|
166
|
+
char: string
|
|
167
|
+
width?: number
|
|
168
|
+
color?: string
|
|
169
|
+
backgroundColor?: string
|
|
170
|
+
bold?: boolean
|
|
171
|
+
inverse?: boolean
|
|
172
|
+
dim?: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Adjacent cells with identical styling merge into one styled Text span. */
|
|
176
|
+
function sameCellStyle(a: ComposerCell, b: ComposerCell): boolean {
|
|
177
|
+
return a.color === b.color
|
|
178
|
+
&& a.backgroundColor === b.backgroundColor
|
|
179
|
+
&& a.bold === b.bold
|
|
180
|
+
&& a.inverse === b.inverse
|
|
181
|
+
&& a.dim === b.dim
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render the wave row as one Text whose cells carry per-column
|
|
186
|
+
* `backgroundColor` runs: the Codex Wave crest paints a smooth gradient
|
|
187
|
+
* (one SGR run per sampled column) over the prompt, draft, cursor,
|
|
188
|
+
* placeholder, and the trailing blank fill — the draft stays readable
|
|
189
|
+
* because the tint blends at ≤ 0.55 toward the theme's blank-cell base.
|
|
190
|
+
*/
|
|
191
|
+
function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
|
|
192
|
+
const spans: ReactElement[] = []
|
|
193
|
+
let start = 0
|
|
194
|
+
while (start < cells.length) {
|
|
195
|
+
const cell = cells[start]
|
|
196
|
+
let end = start + 1
|
|
197
|
+
while (end < cells.length && sameCellStyle(cells[end], cell)) end += 1
|
|
198
|
+
spans.push(createElement(
|
|
199
|
+
Text,
|
|
200
|
+
{
|
|
201
|
+
key: start,
|
|
202
|
+
color: cell.color,
|
|
203
|
+
backgroundColor: cell.backgroundColor,
|
|
204
|
+
bold: cell.bold,
|
|
205
|
+
inverse: cell.inverse,
|
|
206
|
+
dimColor: cell.dim,
|
|
207
|
+
},
|
|
208
|
+
cells.slice(start, end).map(c => c.char).join(''),
|
|
209
|
+
))
|
|
210
|
+
start = end
|
|
211
|
+
}
|
|
212
|
+
return spans
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Index of the cell STARTING at a display column, if one does. */
|
|
216
|
+
function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): number | undefined {
|
|
217
|
+
let column = 0
|
|
218
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
219
|
+
if (column === target) return index
|
|
220
|
+
column += cells[index].width ?? visibleColumns(cells[index].char)
|
|
221
|
+
if (column > target) return undefined
|
|
222
|
+
}
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Wall-clock wave frames — strictly ONE sweep per MOUNT; the mount-spanning
|
|
228
|
+
* one-shot latch (surviving modal unmounts) lives in Composer as `wavePlayedKey`.
|
|
229
|
+
* The first gate-off after the sweep has started (it completed, a turn went
|
|
230
|
+
* busy, image preparation began, animations were toggled off) latches `done`
|
|
231
|
+
* for this mount, so the same mount can never resume or replay. A trigger
|
|
232
|
+
* that lands while the gate is already down stays pending until the gate
|
|
233
|
+
* rises once, then plays.
|
|
234
|
+
*/
|
|
235
|
+
function useWaveFrames(active: boolean, durationMs: number): { tick: number; done: boolean } {
|
|
236
|
+
const [tick, setTick] = useState(0)
|
|
237
|
+
const [done, setDone] = useState(false)
|
|
238
|
+
const startedRef = useRef(false)
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (done) return
|
|
241
|
+
if (!active) {
|
|
242
|
+
// A sweep that already started is cancelled permanently, never resumed.
|
|
243
|
+
if (startedRef.current) setDone(true)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
startedRef.current = true
|
|
247
|
+
const startedAt = Date.now()
|
|
248
|
+
const id = setInterval(() => {
|
|
249
|
+
const elapsed = Date.now() - startedAt
|
|
250
|
+
if (elapsed >= durationMs) {
|
|
251
|
+
clearInterval(id)
|
|
252
|
+
setDone(true)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
setTick(Math.max(0, Math.floor(elapsed / DEEPSEEK_WAVE_TICK_MS)))
|
|
256
|
+
}, DEEPSEEK_WAVE_TICK_MS)
|
|
257
|
+
return () => {
|
|
258
|
+
clearInterval(id)
|
|
259
|
+
}
|
|
260
|
+
}, [active, durationMs, done])
|
|
261
|
+
return { tick, done }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The wave-painted composer band: everything the sweep needs, as data. */
|
|
265
|
+
interface ComposerWaveProps {
|
|
266
|
+
/** Wave tier of the applied route (flash / deepseek / unknown). */
|
|
267
|
+
tier: DeepseekWaveTier
|
|
268
|
+
/** Ignition style App picked for this trigger. */
|
|
269
|
+
style: DeepseekWaveStyle
|
|
270
|
+
/** False while busy, preparing images, or animations are off; the fallback
|
|
271
|
+
* band renders instead (non-wave routes keep it false permanently). */
|
|
272
|
+
active: boolean
|
|
273
|
+
/** The static band to render before, after, and instead of the sweep. */
|
|
274
|
+
fallback: ReactElement
|
|
275
|
+
/** Composer band width in columns (terminal width minus the last column). */
|
|
276
|
+
bandWidth: number
|
|
277
|
+
/** Ink color of the static band background (the transparent-cell base). */
|
|
278
|
+
bandBg: string
|
|
279
|
+
/** The editor's visible physical rows (already windowed). */
|
|
280
|
+
rows: readonly EditorRowModel[]
|
|
281
|
+
/** Index of `rows[0]` in the full editor model (keying + caret row math). */
|
|
282
|
+
windowStart: number
|
|
283
|
+
/** Absolute caret row in the editor model. */
|
|
284
|
+
caretRow: number
|
|
285
|
+
/** The authoritative cursor offset. */
|
|
286
|
+
cursor: number
|
|
287
|
+
/** Caret blink visibility (shared with the static path). */
|
|
288
|
+
caretVisible: boolean
|
|
289
|
+
/** The draft text (placeholder detection on row 0). */
|
|
290
|
+
value: string
|
|
291
|
+
/** Tier prompt glyph and accent color (persistent, like Codex's charge). */
|
|
292
|
+
promptGlyph: string
|
|
293
|
+
/** Empty-composer placeholder for the delivery mode in force. */
|
|
294
|
+
placeholder: string
|
|
295
|
+
promptColor: string
|
|
296
|
+
/** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
|
|
297
|
+
* cancelled by the gate, or unmounted (a modal panel froze the composer) —
|
|
298
|
+
* so Composer's played-key latch survives the leaf's unmount/remount cycle. */
|
|
299
|
+
onSettled: () => void
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The self-contained wave leaf: it owns its 33ms tick, so the sweep
|
|
304
|
+
* re-renders ONLY this component at ~30fps — Composer's derived editor state
|
|
305
|
+
* never re-runs per frame. Graphemes stay atomic and every background sample
|
|
306
|
+
* advances by terminal display columns, so CJK and emoji cannot move the
|
|
307
|
+
* caret or wrap the band. The duration gate renders the fallback band on the
|
|
308
|
+
* frame the sweep completes.
|
|
309
|
+
*/
|
|
310
|
+
function ComposerWave(props: ComposerWaveProps): ReactElement {
|
|
311
|
+
const { tier, style } = props
|
|
312
|
+
const durationMs = deepseekWaveDuration(tier, style)
|
|
313
|
+
const { tick, done } = useWaveFrames(props.active, durationMs)
|
|
314
|
+
// Report the sweep's end exactly once — completion, gate cancellation, or
|
|
315
|
+
// unmount (a modal opened and froze the composer) — latching Composer's
|
|
316
|
+
// played-key so this trigger can never replay after a remount.
|
|
317
|
+
const settledRef = useRef(false)
|
|
318
|
+
const onSettledRef = useRef(props.onSettled)
|
|
319
|
+
onSettledRef.current = props.onSettled
|
|
320
|
+
const settle = (): void => {
|
|
321
|
+
if (settledRef.current) return
|
|
322
|
+
settledRef.current = true
|
|
323
|
+
onSettledRef.current()
|
|
324
|
+
}
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (done) settle()
|
|
327
|
+
}, [done])
|
|
328
|
+
useEffect(() => () => {
|
|
329
|
+
settle()
|
|
330
|
+
}, [])
|
|
331
|
+
if (!props.active || done || tick * DEEPSEEK_WAVE_TICK_MS >= durationMs) return props.fallback
|
|
332
|
+
const hues = deepseekWaveHues(tier)
|
|
333
|
+
const bandRgb = getPalette().composerBand
|
|
334
|
+
const totalBandRows = props.rows.length + 2
|
|
335
|
+
const waveBg = (row: number, column: number): string => {
|
|
336
|
+
const rgb = deepseekWaveColumnBg(tick, column, props.bandWidth, tier, style, hues, bandRgb, row, totalBandRows)
|
|
337
|
+
return rgb === null ? props.bandBg : inkColor(rgb)
|
|
338
|
+
}
|
|
339
|
+
const blankBandRow = (row: number): ReactElement => {
|
|
340
|
+
const blanks: ComposerCell[] = []
|
|
341
|
+
for (let column = 0; column < props.bandWidth; column += 1) {
|
|
342
|
+
blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
|
|
343
|
+
}
|
|
344
|
+
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
345
|
+
}
|
|
346
|
+
const editorWaveRows = props.rows.map((row, visibleIndex) => {
|
|
347
|
+
const sourceIndex = props.windowStart + visibleIndex
|
|
348
|
+
const bandRow = visibleIndex + 1
|
|
349
|
+
const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
|
|
350
|
+
const placeholder = sourceIndex === 0 && props.value === ''
|
|
351
|
+
const cells: ComposerCell[] = []
|
|
352
|
+
let usedColumns = 0
|
|
353
|
+
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
354
|
+
const width = visibleColumns(char)
|
|
355
|
+
cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
|
|
356
|
+
usedColumns += width
|
|
357
|
+
}
|
|
358
|
+
if (sourceIndex === 0) {
|
|
359
|
+
push(props.promptGlyph, { color: props.promptColor, bold: true })
|
|
360
|
+
push(' ', { color: props.promptColor })
|
|
361
|
+
} else {
|
|
362
|
+
push(' ')
|
|
363
|
+
push(' ')
|
|
364
|
+
}
|
|
365
|
+
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
366
|
+
if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
|
|
367
|
+
const tail = placeholder ? props.placeholder : parts.after
|
|
368
|
+
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
369
|
+
while (usedColumns < props.bandWidth) push(' ')
|
|
370
|
+
|
|
371
|
+
const middleBandRow = Math.floor(totalBandRows / 2)
|
|
372
|
+
if (bandRow === middleBandRow && deepseekWaveWordVisible(tick, tier, style)) {
|
|
373
|
+
const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
374
|
+
const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
|
|
375
|
+
const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
|
|
376
|
+
if (indices.every(index => index !== undefined && (cells[index].char === ' ' || cells[index].dim === true))) {
|
|
377
|
+
for (let at = 0; at < word.length; at += 1) {
|
|
378
|
+
const cell = cells[indices[at]!]
|
|
379
|
+
cell.char = word[at]!
|
|
380
|
+
cell.width = 1
|
|
381
|
+
cell.color = inkColor(deepseekWaveWordHue(at, hues))
|
|
382
|
+
cell.bold = true
|
|
383
|
+
cell.dim = false
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
|
|
388
|
+
const spark = deepseekWaveSpark(tick)
|
|
389
|
+
const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
|
|
390
|
+
if (spark !== null && lastIndex !== undefined && cells[lastIndex].char === ' ') {
|
|
391
|
+
cells[lastIndex].char = spark
|
|
392
|
+
cells[lastIndex].color = props.promptColor
|
|
393
|
+
cells[lastIndex].bold = true
|
|
394
|
+
cells[lastIndex].dim = false
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
398
|
+
})
|
|
399
|
+
return createElement(
|
|
400
|
+
Box,
|
|
401
|
+
{ flexDirection: 'column', width: props.bandWidth },
|
|
402
|
+
blankBandRow(0),
|
|
403
|
+
...editorWaveRows,
|
|
404
|
+
blankBandRow(totalBandRows - 1),
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The /rainbow celebration leaf: a FIXED seven-color ribbon that slides
|
|
410
|
+
* across the three-row composer band. Same cell model as ComposerWave so
|
|
411
|
+
* CJK/emoji stay atomic; no wordmark, no sparkles — the spectrum is the
|
|
412
|
+
* show. Strictly one-shot per burst id (Composer latches onSettled).
|
|
413
|
+
*/
|
|
414
|
+
function ComposerRainbowBurst(props: Omit<ComposerWaveProps, 'tier' | 'style'>): ReactElement {
|
|
415
|
+
const durationMs = RAINBOW_BURST_DURATION_MS
|
|
416
|
+
const { tick, done } = useWaveFrames(props.active, durationMs)
|
|
417
|
+
const settledRef = useRef(false)
|
|
418
|
+
const onSettledRef = useRef(props.onSettled)
|
|
419
|
+
onSettledRef.current = props.onSettled
|
|
420
|
+
const settle = (): void => {
|
|
421
|
+
if (settledRef.current) return
|
|
422
|
+
settledRef.current = true
|
|
423
|
+
onSettledRef.current()
|
|
424
|
+
}
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
if (done) settle()
|
|
427
|
+
}, [done])
|
|
428
|
+
useEffect(() => () => {
|
|
429
|
+
settle()
|
|
430
|
+
}, [])
|
|
431
|
+
if (!props.active || done || tick * RAINBOW_BURST_TICK_MS >= durationMs) return props.fallback
|
|
432
|
+
const bandRgb = getPalette().composerBand
|
|
433
|
+
const totalBandRows = props.rows.length + 2
|
|
434
|
+
const burstBg = (row: number, column: number): string => {
|
|
435
|
+
const rgb = rainbowBurstColumnBg(tick, column, props.bandWidth, bandRgb, row, totalBandRows)
|
|
436
|
+
return rgb === null ? props.bandBg : inkColor(rgb)
|
|
437
|
+
}
|
|
438
|
+
const blankBandRow = (row: number): ReactElement => {
|
|
439
|
+
const blanks: ComposerCell[] = []
|
|
440
|
+
for (let column = 0; column < props.bandWidth; column += 1) {
|
|
441
|
+
blanks.push({ char: ' ', width: 1, backgroundColor: burstBg(row, column) })
|
|
442
|
+
}
|
|
443
|
+
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
444
|
+
}
|
|
445
|
+
const editorBurstRows = props.rows.map((row, visibleIndex) => {
|
|
446
|
+
const sourceIndex = props.windowStart + visibleIndex
|
|
447
|
+
const bandRow = visibleIndex + 1
|
|
448
|
+
const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
|
|
449
|
+
const placeholder = sourceIndex === 0 && props.value === ''
|
|
450
|
+
const cells: ComposerCell[] = []
|
|
451
|
+
let usedColumns = 0
|
|
452
|
+
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
453
|
+
const width = visibleColumns(char)
|
|
454
|
+
cells.push({ char, width, backgroundColor: burstBg(bandRow, usedColumns), ...extra })
|
|
455
|
+
usedColumns += width
|
|
456
|
+
}
|
|
457
|
+
if (sourceIndex === 0) {
|
|
458
|
+
push(props.promptGlyph, { color: props.promptColor, bold: true })
|
|
459
|
+
push(' ', { color: props.promptColor })
|
|
460
|
+
} else {
|
|
461
|
+
push(' ')
|
|
462
|
+
push(' ')
|
|
463
|
+
}
|
|
464
|
+
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
465
|
+
if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
|
|
466
|
+
const tail = placeholder ? props.placeholder : parts.after
|
|
467
|
+
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
468
|
+
while (usedColumns < props.bandWidth) push(' ')
|
|
469
|
+
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
470
|
+
})
|
|
471
|
+
return createElement(
|
|
472
|
+
Box,
|
|
473
|
+
{ flexDirection: 'column', width: props.bandWidth },
|
|
474
|
+
blankBandRow(0),
|
|
475
|
+
...editorBurstRows,
|
|
476
|
+
blankBandRow(totalBandRows - 1),
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** One-word kind label per entry, so the inspector's ←→ walk names what
|
|
481
|
+
* each step is instead of leaving the reader to infer it from the body. */
|
|
482
|
+
|
|
483
|
+
interface DraftImage extends ImagePathInspection {
|
|
484
|
+
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
485
|
+
readonly marker: string
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** One attached non-image file held in the editor until submission persists it. */
|
|
489
|
+
interface DraftFile extends FilePathInspection {
|
|
490
|
+
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
491
|
+
readonly marker: string
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
496
|
+
* dispatched; input editing keeps a cursor with history and completion.
|
|
497
|
+
* While a modal (approval / question / model panel) owns the keys, the
|
|
498
|
+
* box passes every key through untouched.
|
|
499
|
+
*/
|
|
500
|
+
export function Composer({ active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, submitMode, cycleSubmitMode, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openSearch, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openLanguage, saveLanguage, openHistory, openQueue, openAgents, openSubagent, openTodos, openUsage, openDelete, openDiff, openReviewPicker, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, updateQueued, historyFill, historyConsumed, animations, applyAnimations, applyRainbow, rainbowBurstId, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }: {
|
|
501
|
+
active: boolean
|
|
502
|
+
frozen: boolean
|
|
503
|
+
/** Frozen-band hint naming the surface that owns the keyboard; an empty
|
|
504
|
+
* draft otherwise advertises typing that the composer cannot accept. */
|
|
505
|
+
frozenHint?: string
|
|
506
|
+
busy: boolean
|
|
507
|
+
descriptors: readonly CommandDescriptor[]
|
|
508
|
+
skills: readonly SkillRow[]
|
|
509
|
+
dispatch: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
|
|
510
|
+
/** Submit as steering into the running turn (see {@link AppProps.steer}). */
|
|
511
|
+
steer: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
|
|
512
|
+
/** Delivery mode the next submission uses; Tab on an empty composer flips it. */
|
|
513
|
+
submitMode: 'queue' | 'steer'
|
|
514
|
+
/** Flip {@link submitMode} and report the new mode. */
|
|
515
|
+
cycleSubmitMode: () => void
|
|
516
|
+
/** The full current session identity ('' while pending); the delivery origin. */
|
|
517
|
+
sessionKey: string
|
|
518
|
+
interrupt: () => boolean
|
|
519
|
+
quit: () => void
|
|
520
|
+
openModel: () => void
|
|
521
|
+
openEffort: () => void
|
|
522
|
+
openHelp: () => void
|
|
523
|
+
openMode: () => void
|
|
524
|
+
openPermission: () => void
|
|
525
|
+
openResume: () => void
|
|
526
|
+
/** Open the /search panel with an optional seed query. */
|
|
527
|
+
openSearch: (query: string) => void
|
|
528
|
+
openPlugin: (query?: string) => void
|
|
529
|
+
/** Open the /update panel (aligned upgrade surface). */
|
|
530
|
+
openUpdate: () => void
|
|
531
|
+
/** Open the /schedule reminder panel (read-only catalog). */
|
|
532
|
+
openSchedule: () => void
|
|
533
|
+
openJobs: () => void
|
|
534
|
+
openStatusline: () => void
|
|
535
|
+
openTheme: () => void
|
|
536
|
+
/** Open the /language picker (bare /language). */
|
|
537
|
+
openLanguage: () => void
|
|
538
|
+
/** Apply and persist a language chosen by argument. */
|
|
539
|
+
saveLanguage: (name: LanguageName) => void
|
|
540
|
+
openHistory: () => void
|
|
541
|
+
openQueue: () => void
|
|
542
|
+
/** Open the /agents panel (live subagent feed + transcript entry). */
|
|
543
|
+
openAgents: () => void
|
|
544
|
+
/** Open the /subagent model panel. */
|
|
545
|
+
openSubagent: () => void
|
|
546
|
+
/** Open the /todos subpage (full todo list in one bounded panel). */
|
|
547
|
+
openTodos: () => void
|
|
548
|
+
openUsage: () => void
|
|
549
|
+
/** Open the dedicated /delete picker, optionally pre-armed on one id. */
|
|
550
|
+
openDelete: (id?: string) => void
|
|
551
|
+
openDiff: (argument: string) => void
|
|
552
|
+
reviewChanges: (selection: ReviewSelection) => void
|
|
553
|
+
/** Open the /review candidate picker (bare /review). */
|
|
554
|
+
openReviewPicker: () => void
|
|
555
|
+
/** The row id awaiting y/n in this box, when a deletion is pending. */
|
|
556
|
+
deleteConfirm?: string
|
|
557
|
+
/** Confirm the pending deletion (y in the box). */
|
|
558
|
+
confirmDelete: () => void
|
|
559
|
+
/** Cancel the pending deletion (any other key in the box). */
|
|
560
|
+
cancelDelete: () => void
|
|
561
|
+
createSession: (mode?: string) => void
|
|
562
|
+
forkSession: (argument: string) => void
|
|
563
|
+
cancelSessionSwitch: () => boolean
|
|
564
|
+
notify: (text: string, tone?: NoticeTone) => void
|
|
565
|
+
/** Apply the Ctrl+R passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
|
|
566
|
+
applyEditorKeys: () => Promise<string>
|
|
567
|
+
hasNotice: boolean
|
|
568
|
+
dismissNotice: () => void
|
|
569
|
+
toggleReasoning: () => void
|
|
570
|
+
openVerbose: () => void
|
|
571
|
+
clearView: () => void
|
|
572
|
+
refresh: () => void
|
|
573
|
+
loadMentions: (query: string, signal?: AbortSignal) => Promise<readonly MentionCandidate[]>
|
|
574
|
+
inspectImages: (paths: readonly string[]) => Promise<readonly ImagePathInspection[]>
|
|
575
|
+
prepareImages: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly ImageBlock[]>
|
|
576
|
+
inspectFiles: (paths: readonly string[]) => Promise<readonly FilePathInspection[]>
|
|
577
|
+
prepareFiles: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly FileBlock[]>
|
|
578
|
+
cycleMode: () => string
|
|
579
|
+
exportTranscript: (argument: string) => Promise<void>
|
|
580
|
+
renameTitle: (argument: string) => string
|
|
581
|
+
copyLastResponse: () => Promise<string>
|
|
582
|
+
/** Newest-first recall space (persistent + in-session, deduped). */
|
|
583
|
+
recallSpace: readonly string[]
|
|
584
|
+
/** Record one in-session submission (deduped, local only). */
|
|
585
|
+
recordLocal: (text: string) => void
|
|
586
|
+
/** Persist one submission to the global history file. */
|
|
587
|
+
recordHistory: (text: string) => void
|
|
588
|
+
/** Next-turn inbox rows, ordered exactly as the durable inbox. */
|
|
589
|
+
queued: readonly Extract<TranscriptEntry, { kind: 'pending' }>[]
|
|
590
|
+
updateQueued?: (messageId: string, action: QueueMutation) => void
|
|
591
|
+
/** Accepted /history entry waiting to be placed into the composer. */
|
|
592
|
+
historyFill: { text: string; index: number } | undefined
|
|
593
|
+
/** Marks the accepted entry consumed (called after the fill is applied). */
|
|
594
|
+
historyConsumed: () => void
|
|
595
|
+
/** Whether timed animations run (shimmer, chase, blink, wave). */
|
|
596
|
+
animations: boolean
|
|
597
|
+
/** Apply and report one /animation toggle (App persists through the runner). */
|
|
598
|
+
applyAnimations: (enabled: boolean) => void
|
|
599
|
+
/** Reroll or pin the rainbow palette (switches to rainbow if needed). */
|
|
600
|
+
applyRainbow: (seed?: number) => void
|
|
601
|
+
/** Monotonic id of the in-flight /rainbow composer burst; 0 means none. */
|
|
602
|
+
rainbowBurstId: number
|
|
603
|
+
/** DeepSeek easter-egg wave tier of the applied route (null otherwise):
|
|
604
|
+
* official DeepSeek models drive their flash/pro tiers, non-DeepSeek
|
|
605
|
+
* models running an effort above high drive the "Into the Unknown"
|
|
606
|
+
* variant. Drives the persistent prompt glyph/accent and the sparkle
|
|
607
|
+
* tier. */
|
|
608
|
+
waveTier: DeepseekWaveTier | null
|
|
609
|
+
/** The ignition style running, if any: Wave / Aurora / Pulse. */
|
|
610
|
+
waveStyle: DeepseekWaveStyle | null
|
|
611
|
+
/** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
|
|
612
|
+
maxRows: number
|
|
613
|
+
/** Terminal rows below the composer the editor does not own: the status
|
|
614
|
+
* footer and Ink's parked cursor row. The IME anchor adds these to the
|
|
615
|
+
* caret's in-band offset to reach that parked position. */
|
|
616
|
+
anchorRowsBelow: number
|
|
617
|
+
/** The managed terminal tab label; re-asserted on terminal focus-in so a
|
|
618
|
+
* background process sharing the console cannot keep it overwritten. */
|
|
619
|
+
tabTitle: string
|
|
620
|
+
/** Reports the editor's current physical row count so the live budget stays exact. */
|
|
621
|
+
onEditorRows: (rows: number) => void
|
|
622
|
+
/** Reports the open completion menu's physical row count (0 when closed)
|
|
623
|
+
* for the same reason: the dynamic budget must reserve it, not overflow. */
|
|
624
|
+
onMenuRows: (rows: number) => void
|
|
625
|
+
}): ReactElement {
|
|
626
|
+
const { stdout: inputStdout } = useStdout()
|
|
627
|
+
const columns = inputStdout?.columns ?? 80
|
|
628
|
+
const inputTerminalRows = inputStdout?.rows ?? 30
|
|
629
|
+
// The managed tab label, kept current for the focus-in re-assert below.
|
|
630
|
+
const tabTitleRef = useRef(tabTitle)
|
|
631
|
+
tabTitleRef.current = tabTitle
|
|
632
|
+
const editorColumns = Math.max(1, columns - 6)
|
|
633
|
+
const stdin = useStdin().stdin
|
|
634
|
+
const focusReporting = isVsCodeTerminalEnv()
|
|
635
|
+
const [value, setValue] = useState('')
|
|
636
|
+
const [cursor, setCursor] = useState(0)
|
|
637
|
+
const valueRef = useRef(value)
|
|
638
|
+
const cursorRef = useRef(cursor)
|
|
639
|
+
valueRef.current = value
|
|
640
|
+
cursorRef.current = cursor
|
|
641
|
+
const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
|
|
642
|
+
const draftImagesRef = useRef(draftImages)
|
|
643
|
+
draftImagesRef.current = draftImages
|
|
644
|
+
const [draftFiles, setDraftFiles] = useState<readonly DraftFile[]>([])
|
|
645
|
+
const draftFilesRef = useRef(draftFiles)
|
|
646
|
+
draftFilesRef.current = draftFiles
|
|
647
|
+
const [preparingImages, setPreparingImages] = useState(false)
|
|
648
|
+
const prepareAbortRef = useRef<AbortController | undefined>(undefined)
|
|
649
|
+
const prepareEpochRef = useRef(0)
|
|
650
|
+
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages && animations)
|
|
651
|
+
useEffect(() => () => {
|
|
652
|
+
prepareEpochRef.current += 1
|
|
653
|
+
prepareAbortRef.current?.abort()
|
|
654
|
+
}, [])
|
|
655
|
+
// Codex textarea editing state: a single-entry kill buffer, the vertical
|
|
656
|
+
// move's preferred display column, the editor's scroll window, and the
|
|
657
|
+
// bracketed-paste marker state. All of it is editor-local; nothing here
|
|
658
|
+
// ever reaches the App.
|
|
659
|
+
const killRef = useRef('')
|
|
660
|
+
const preferredColumnRef = useRef<number | null>(null)
|
|
661
|
+
const editorScrollRef = useRef(0)
|
|
662
|
+
const pasteBracketRef = useRef(false)
|
|
663
|
+
/** Cancels the pending lost-paste safety timer (undefined when disarmed). */
|
|
664
|
+
const pasteBracketCancelRef = useRef<(() => void) | undefined>(undefined)
|
|
665
|
+
/** Ordered editor tokens from the stdin chunk Ink is about to deliver. */
|
|
666
|
+
const rawEditorTokens = useRef<readonly RawEditorToken[] | undefined>(undefined)
|
|
667
|
+
/** VS Code focus state from xterm focus-report events; starts focused. */
|
|
668
|
+
const terminalFocusedRef = useRef(true)
|
|
669
|
+
// Codex shell-style recall: the navigation cursor, the saved draft restored
|
|
670
|
+
// on Down past the newest entry, and the boundary-gate anchor.
|
|
671
|
+
const recall = useRef<RecallState>(beginRecall([], ''))
|
|
672
|
+
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
preferredColumnRef.current = null
|
|
675
|
+
}, [editorColumns])
|
|
676
|
+
|
|
677
|
+
// A /history panel acceptance lands as a fill: append the sanitized text to
|
|
678
|
+
// the composer and resume recall from that entry. Appending (never
|
|
679
|
+
// replacing) is what keeps a half-written draft and its prepared
|
|
680
|
+
// attachments from being destroyed by picking a history entry.
|
|
681
|
+
useEffect(() => {
|
|
682
|
+
if (historyFill === undefined) return
|
|
683
|
+
const safe = sanitizeDraftText(historyFill.text)
|
|
684
|
+
const current = valueRef.current
|
|
685
|
+
const joined = appendRecall(current, safe)
|
|
686
|
+
valueRef.current = joined
|
|
687
|
+
cursorRef.current = joined.length
|
|
688
|
+
setValue(joined)
|
|
689
|
+
setCursor(joined.length)
|
|
690
|
+
resetCursorBlink()
|
|
691
|
+
preferredColumnRef.current = null
|
|
692
|
+
setDismissedMenuValue(undefined)
|
|
693
|
+
recall.current = {
|
|
694
|
+
entries: recallSpace,
|
|
695
|
+
index: historyFill.index,
|
|
696
|
+
// The draft this fill appended to stays reachable: Down walks back to it.
|
|
697
|
+
savedDraft: current,
|
|
698
|
+
lastRecalled: joined,
|
|
699
|
+
}
|
|
700
|
+
historyConsumed()
|
|
701
|
+
}, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
|
|
702
|
+
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
setDraftImages((current) => {
|
|
705
|
+
const next = current.filter(image => value.includes(image.marker))
|
|
706
|
+
draftImagesRef.current = next
|
|
707
|
+
return next.length === current.length ? current : next
|
|
708
|
+
})
|
|
709
|
+
setDraftFiles((current) => {
|
|
710
|
+
const next = current.filter(file => value.includes(file.marker))
|
|
711
|
+
draftFilesRef.current = next
|
|
712
|
+
return next.length === current.length ? current : next
|
|
713
|
+
})
|
|
714
|
+
}, [value])
|
|
715
|
+
|
|
716
|
+
// Home/End and the Backspace-vs-Delete family never survive Ink's parser
|
|
717
|
+
// as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
|
|
718
|
+
// insert as draft text.
|
|
719
|
+
// Patch stdin.read — the single choke point Ink's input loop pulls every
|
|
720
|
+
// chunk through — to first rewrite decodable CSI-u sequences to their
|
|
721
|
+
// legacy bytes, then tokenize editor-only sequences before Ink emits the
|
|
722
|
+
// matching input event. Batched Home/End/Delete/Backspace actions remain
|
|
723
|
+
// ordered even though Ink invokes useInput only once for the whole chunk.
|
|
724
|
+
useEffect(() => {
|
|
725
|
+
if (stdin === undefined) return
|
|
726
|
+
const originalRead = stdin.read.bind(stdin)
|
|
727
|
+
const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
|
|
728
|
+
// `Readable.read` is declared `any`; the assertion names its real result
|
|
729
|
+
// union once so the normalization and the focus-event stripping below
|
|
730
|
+
// stay type-checked. Node hands back a Buffer unless an encoding was set,
|
|
731
|
+
// and null once the stream ends.
|
|
732
|
+
const chunk = originalRead(...args) as string | Buffer | null
|
|
733
|
+
if (chunk === null) return chunk
|
|
734
|
+
const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
|
|
735
|
+
const input = focusReporting
|
|
736
|
+
? stripTerminalFocusEvents(normalized, focused => {
|
|
737
|
+
terminalFocusedRef.current = focused
|
|
738
|
+
// Focus-in re-asserts the managed tab label on both channels: a
|
|
739
|
+
// background process sharing this console (a test-runner worker,
|
|
740
|
+
// for example) may have overwritten the console title while the
|
|
741
|
+
// terminal was unfocused.
|
|
742
|
+
if (focused && inputStdout !== undefined) {
|
|
743
|
+
inputStdout.write(terminalTitleSequence(tabTitleRef.current))
|
|
744
|
+
process.title = sanitizeTerminalTitle(tabTitleRef.current)
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
: normalized
|
|
748
|
+
rawEditorTokens.current = tokenizeRawEditorChunk(input)
|
|
749
|
+
return input
|
|
750
|
+
} as typeof stdin.read
|
|
751
|
+
stdin.read = patchedRead
|
|
752
|
+
return () => {
|
|
753
|
+
stdin.read = originalRead
|
|
754
|
+
}
|
|
755
|
+
}, [focusReporting, inputStdout, stdin])
|
|
756
|
+
|
|
757
|
+
// Keep the navigation's recall space fresh while browsing state survives
|
|
758
|
+
// (new local submissions extend the space; the index stays valid unless
|
|
759
|
+
// the space shrank, in which case browsing ends at the current position).
|
|
760
|
+
if (recall.current.entries !== recallSpace) {
|
|
761
|
+
const index = recall.current.index === null || recall.current.index < recallSpace.length
|
|
762
|
+
? recall.current.index
|
|
763
|
+
: null
|
|
764
|
+
recall.current = { ...recall.current, entries: recallSpace, index }
|
|
765
|
+
}
|
|
766
|
+
const [completionIndex, setCompletionIndex] = useState(0)
|
|
767
|
+
const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
|
|
768
|
+
const candidates = completionCandidates(value, descriptors, skills)
|
|
769
|
+
const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n') && !looksLikePathDraft(value)
|
|
770
|
+
|
|
771
|
+
// @mention token: the last `@word` on the cursor's line before the cursor.
|
|
772
|
+
const beforeCursor = value.slice(0, cursor)
|
|
773
|
+
const lastLine = beforeCursor.split('\n').at(-1) ?? ''
|
|
774
|
+
const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
|
|
775
|
+
const mentionToken = tokenMatch === null
|
|
776
|
+
? undefined
|
|
777
|
+
: { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
|
|
778
|
+
const mentionActive = mentionToken !== undefined
|
|
779
|
+
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
780
|
+
/** Latest mention-discovery failure; shown in the menu instead of an empty list. */
|
|
781
|
+
const [mentionError, setMentionError] = useState<string | undefined>(undefined)
|
|
782
|
+
const mentionRequestRef = useRef(0)
|
|
783
|
+
|
|
784
|
+
const sameImagePath = (left: string, right: string): boolean => (
|
|
785
|
+
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = [], kind: 'image' | 'file' = 'image'): string => {
|
|
789
|
+
const safeName = singleLineText(sanitizeDraftText(name))
|
|
790
|
+
const label = kind === 'file' ? 'file' : 'image'
|
|
791
|
+
const base = source === 'mention' ? `@${safeName}` : `[${label}: ${safeName}]`
|
|
792
|
+
let marker = base
|
|
793
|
+
let suffix = 2
|
|
794
|
+
const taken = (candidate: string): boolean =>
|
|
795
|
+
valueRef.current.includes(candidate)
|
|
796
|
+
|| draftImagesRef.current.some(image => image.marker === candidate)
|
|
797
|
+
|| draftFilesRef.current.some(file => file.marker === candidate)
|
|
798
|
+
|| reserved.includes(candidate)
|
|
799
|
+
while (taken(marker)) {
|
|
800
|
+
marker = source === 'mention' ? `@${safeName} (${suffix})` : `[${label}: ${safeName} ${suffix}]`
|
|
801
|
+
suffix += 1
|
|
802
|
+
}
|
|
803
|
+
return marker
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
|
|
807
|
+
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
808
|
+
notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
|
|
809
|
+
return false
|
|
810
|
+
}
|
|
811
|
+
const next = [...draftImagesRef.current, { ...inspection, marker }]
|
|
812
|
+
draftImagesRef.current = next
|
|
813
|
+
setDraftImages(next)
|
|
814
|
+
return true
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Attach a paste/drop split into image and non-image paths: images ride the
|
|
819
|
+
* durable image blocks, files ride the 0.1.5 file blocks, and both register
|
|
820
|
+
* visible draft markers anchored at the drop point.
|
|
821
|
+
*/
|
|
822
|
+
const insertDroppedAttachments = (imagePaths: readonly string[], filePaths: readonly string[]): void => {
|
|
823
|
+
const originalValue = valueRef.current
|
|
824
|
+
const originalCursor = cursorRef.current
|
|
825
|
+
const total = imagePaths.length + filePaths.length
|
|
826
|
+
if (total === 0) return
|
|
827
|
+
notify(t('notice.attachmentsChecking', { count: total, plural: total === 1 ? '' : 's' }))
|
|
828
|
+
void Promise.all([
|
|
829
|
+
imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
|
|
830
|
+
filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
|
|
831
|
+
]).then(([inspectedImages, inspectedFiles]) => {
|
|
832
|
+
const imageAdditions: DraftImage[] = []
|
|
833
|
+
const fileAdditions: DraftFile[] = []
|
|
834
|
+
const markers: string[] = []
|
|
835
|
+
for (const inspection of inspectedImages) {
|
|
836
|
+
if ([...draftImagesRef.current, ...imageAdditions].some(image => sameImagePath(image.path, inspection.path))) continue
|
|
837
|
+
const marker = uniqueImageMarker(inspection.name, 'drop', markers)
|
|
838
|
+
imageAdditions.push({ ...inspection, marker })
|
|
839
|
+
markers.push(marker)
|
|
840
|
+
}
|
|
841
|
+
for (const inspection of inspectedFiles) {
|
|
842
|
+
if ([...draftFilesRef.current, ...fileAdditions].some(file => sameImagePath(file.path, inspection.path))) continue
|
|
843
|
+
const marker = uniqueImageMarker(inspection.name, 'drop', markers, 'file')
|
|
844
|
+
fileAdditions.push({ ...inspection, marker })
|
|
845
|
+
markers.push(marker)
|
|
846
|
+
}
|
|
847
|
+
if (imageAdditions.length === 0 && fileAdditions.length === 0) {
|
|
848
|
+
notify(t('notice.attachmentsAlready'), 'warning')
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
const current = valueRef.current
|
|
852
|
+
const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
|
|
853
|
+
if (anchor === undefined) {
|
|
854
|
+
notify(t('notice.attachmentDraftChanged'), 'warning')
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
const at = anchor.start
|
|
858
|
+
const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
|
|
859
|
+
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, insertion)
|
|
860
|
+
const nextCursor = current === originalValue && cursorRef.current === originalCursor
|
|
861
|
+
? at + insertion.length
|
|
862
|
+
: edit.cursor
|
|
863
|
+
valueRef.current = edit.value
|
|
864
|
+
cursorRef.current = nextCursor
|
|
865
|
+
setValue(edit.value)
|
|
866
|
+
setCursor(nextCursor)
|
|
867
|
+
resetCursorBlink()
|
|
868
|
+
const nextImages = [...draftImagesRef.current, ...imageAdditions]
|
|
869
|
+
draftImagesRef.current = nextImages
|
|
870
|
+
setDraftImages(nextImages)
|
|
871
|
+
const nextFiles = [...draftFilesRef.current, ...fileAdditions]
|
|
872
|
+
draftFilesRef.current = nextFiles
|
|
873
|
+
setDraftFiles(nextFiles)
|
|
874
|
+
const count = imageAdditions.length + fileAdditions.length
|
|
875
|
+
notify(t('notice.attachmentsReady', { count, plural: count === 1 ? '' : 's' }))
|
|
876
|
+
}, (reason: unknown) => {
|
|
877
|
+
notify(t('notice.attachmentFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
useEffect(() => {
|
|
882
|
+
const requestId = mentionRequestRef.current + 1
|
|
883
|
+
mentionRequestRef.current = requestId
|
|
884
|
+
if (!active || !mentionActive) {
|
|
885
|
+
setMentionRows(current => current.length === 0 ? current : [])
|
|
886
|
+
setMentionError(current => current === undefined ? current : undefined)
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
setMentionError(undefined)
|
|
890
|
+
const controller = new AbortController()
|
|
891
|
+
const query = mentionToken.query
|
|
892
|
+
const timer = setTimeout(() => {
|
|
893
|
+
void loadMentions(query, controller.signal).then(
|
|
894
|
+
rows => {
|
|
895
|
+
if (!controller.signal.aborted && mentionRequestRef.current === requestId) setMentionRows(rows)
|
|
896
|
+
},
|
|
897
|
+
(reason: unknown) => {
|
|
898
|
+
if (!controller.signal.aborted && mentionRequestRef.current === requestId) {
|
|
899
|
+
setMentionRows([])
|
|
900
|
+
setMentionError(reason instanceof Error ? reason.message : String(reason))
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
)
|
|
904
|
+
}, 50)
|
|
905
|
+
return () => {
|
|
906
|
+
clearTimeout(timer)
|
|
907
|
+
controller.abort()
|
|
908
|
+
}
|
|
909
|
+
}, [active, loadMentions, mentionActive, mentionToken?.query])
|
|
910
|
+
|
|
911
|
+
// Codex routes keys to the topmost surface first. Completion therefore
|
|
912
|
+
// remains available while a turn runs, and Esc dismisses it before the
|
|
913
|
+
// same key is allowed to interrupt the turn.
|
|
914
|
+
const menuActive = !preparingImages && (slashActive || mentionActive) && dismissedMenuValue !== value
|
|
915
|
+
const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
|
|
916
|
+
? mentionRows.filter(row => row.kind !== 'session')
|
|
917
|
+
: mentionRows
|
|
918
|
+
// Fuzzy ordering over the upstream candidates (≤20 per page, cheaper than
|
|
919
|
+
// the slash menu): rows whose name contains the typed query as an ordered
|
|
920
|
+
// subsequence rise to the top by alignment, and every other upstream row
|
|
921
|
+
// keeps its place after them — the upstream matcher has its own relevance
|
|
922
|
+
// semantics (path segments), so ranking reorders but never drops rows. A
|
|
923
|
+
// path-like query keeps the upstream order entirely.
|
|
924
|
+
let rankedMentionRows = visibleMentionRows
|
|
925
|
+
if (mentionToken !== undefined && !isPathLikeMentionQuery(mentionToken.query) && mentionToken.query !== '') {
|
|
926
|
+
const hits = rankByName(visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row })), mentionToken.query)
|
|
927
|
+
.map(entry => entry.row)
|
|
928
|
+
const hitSet = new Set(hits)
|
|
929
|
+
rankedMentionRows = [...hits, ...visibleMentionRows.filter(row => !hitSet.has(row))]
|
|
930
|
+
}
|
|
931
|
+
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
932
|
+
? rankedMentionRows.map(row => ({
|
|
933
|
+
label: row.label.startsWith('@')
|
|
934
|
+
? row.label
|
|
935
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
|
|
936
|
+
description: row.description,
|
|
937
|
+
origin: 'mention',
|
|
938
|
+
}))
|
|
939
|
+
: candidates
|
|
940
|
+
// Exact physical height of the open menu, derived from the same shared
|
|
941
|
+
// geometry the menu view uses — reported one-way (onEditorRows pattern) so
|
|
942
|
+
// the App's dynamic budget can reserve it instead of overflowing.
|
|
943
|
+
const menuHeightRows = menuActive ? completionMenuRowCount(inputTerminalRows, menuRows.length) : 0
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* What accepting the highlighted completion candidate would insert, or ''
|
|
947
|
+
* when nothing can be accepted (no rows, or an image row that resolves
|
|
948
|
+
* asynchronously). The Enter branch uses it to tell a real accept from a
|
|
949
|
+
* no-op that must fall through to submission.
|
|
950
|
+
*/
|
|
951
|
+
const highlightedCompletion = (): string => {
|
|
952
|
+
if (mentionActive) {
|
|
953
|
+
const row = rankedMentionRows[completionIndex % Math.max(1, rankedMentionRows.length)]
|
|
954
|
+
if (row === undefined) return ''
|
|
955
|
+
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) return ''
|
|
956
|
+
return row.label.startsWith('@') ? row.label : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
957
|
+
}
|
|
958
|
+
const candidate = candidates[completionIndex % Math.max(1, candidates.length)]
|
|
959
|
+
return candidate === undefined ? '' : `${candidate.label} `
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
963
|
+
const acceptMenuCandidate = (): void => {
|
|
964
|
+
if (mentionActive && mentionToken !== undefined) {
|
|
965
|
+
if (rankedMentionRows.length === 0) return
|
|
966
|
+
const row = rankedMentionRows[completionIndex % rankedMentionRows.length]
|
|
967
|
+
if (row !== undefined) {
|
|
968
|
+
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
|
|
969
|
+
const tokenText = value.slice(mentionToken.start, cursor)
|
|
970
|
+
const start = mentionToken.start
|
|
971
|
+
const originalValue = value
|
|
972
|
+
notify(`checking image ${basename(row.path)}…`)
|
|
973
|
+
void inspectImages([row.path]).then((inspected) => {
|
|
974
|
+
const inspection = inspected[0]
|
|
975
|
+
if (inspection === undefined) return
|
|
976
|
+
const current = valueRef.current
|
|
977
|
+
const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
|
|
978
|
+
if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
|
|
979
|
+
notify(t('notice.imageDraftChanged'), 'warning')
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
983
|
+
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, '')
|
|
984
|
+
valueRef.current = edit.value
|
|
985
|
+
cursorRef.current = edit.cursor
|
|
986
|
+
setValue(edit.value)
|
|
987
|
+
setCursor(edit.cursor)
|
|
988
|
+
resetCursorBlink()
|
|
989
|
+
setDismissedMenuValue(edit.value)
|
|
990
|
+
notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
const marker = uniqueImageMarker(inspection.name, 'mention')
|
|
994
|
+
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, marker)
|
|
995
|
+
valueRef.current = edit.value
|
|
996
|
+
cursorRef.current = edit.cursor
|
|
997
|
+
setValue(edit.value)
|
|
998
|
+
setCursor(edit.cursor)
|
|
999
|
+
resetCursorBlink()
|
|
1000
|
+
setDismissedMenuValue(edit.value)
|
|
1001
|
+
registerDraftImage(inspection, marker)
|
|
1002
|
+
notify(t('notice.imageReady', { name: inspection.name }))
|
|
1003
|
+
}, (reason: unknown) => {
|
|
1004
|
+
notify(t('notice.imageFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
|
|
1005
|
+
})
|
|
1006
|
+
setCompletionIndex(0)
|
|
1007
|
+
setDismissedMenuValue(undefined)
|
|
1008
|
+
return
|
|
1009
|
+
}
|
|
1010
|
+
// Session rows carry the canonical @[label](dsh-session:…) token;
|
|
1011
|
+
// file rows insert `@path` (directories keep their trailing slash).
|
|
1012
|
+
const insertion = row.label.startsWith('@')
|
|
1013
|
+
? row.label
|
|
1014
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
1015
|
+
const nextValue = value.slice(0, mentionToken.start) + insertion + value.slice(cursor)
|
|
1016
|
+
const nextCursor = mentionToken.start + insertion.length
|
|
1017
|
+
valueRef.current = nextValue
|
|
1018
|
+
cursorRef.current = nextCursor
|
|
1019
|
+
setValue(nextValue)
|
|
1020
|
+
setCursor(nextCursor)
|
|
1021
|
+
resetCursorBlink()
|
|
1022
|
+
}
|
|
1023
|
+
} else {
|
|
1024
|
+
if (candidates.length === 0) return
|
|
1025
|
+
const candidate = candidates[completionIndex % candidates.length]
|
|
1026
|
+
if (candidate !== undefined) {
|
|
1027
|
+
const nextValue = `${candidate.label} `
|
|
1028
|
+
const nextCursor = candidate.label.length + 1
|
|
1029
|
+
valueRef.current = nextValue
|
|
1030
|
+
cursorRef.current = nextCursor
|
|
1031
|
+
setValue(nextValue)
|
|
1032
|
+
setCursor(nextCursor)
|
|
1033
|
+
resetCursorBlink()
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
setCompletionIndex(0)
|
|
1037
|
+
setDismissedMenuValue(undefined)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
|
|
1041
|
+
const applyEdit = (edit: EditResult): void => {
|
|
1042
|
+
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
1043
|
+
valueRef.current = edit.value
|
|
1044
|
+
cursorRef.current = edit.cursor
|
|
1045
|
+
setValue(edit.value)
|
|
1046
|
+
setCursor(edit.cursor)
|
|
1047
|
+
resetCursorBlink()
|
|
1048
|
+
preferredColumnRef.current = null
|
|
1049
|
+
setCompletionIndex(0)
|
|
1050
|
+
setDismissedMenuValue(undefined)
|
|
1051
|
+
// A file drag (any OS / terminal) often writes the path without
|
|
1052
|
+
// bracketed-paste wrappers, so it lands as ordinary insert. If the whole
|
|
1053
|
+
// draft is one drop path, attach it the same way a copied pathname paste
|
|
1054
|
+
// would.
|
|
1055
|
+
const dropped = parsePastedAttachmentPaths(edit.value)
|
|
1056
|
+
if (dropped.images.length > 0 || dropped.files.length > 0) {
|
|
1057
|
+
valueRef.current = ''
|
|
1058
|
+
cursorRef.current = 0
|
|
1059
|
+
setValue('')
|
|
1060
|
+
setCursor(0)
|
|
1061
|
+
insertDroppedAttachments(dropped.images, dropped.files)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Move the cursor without editing; horizontal moves clear the column preference. */
|
|
1066
|
+
const moveCursorTo = (next: number): void => {
|
|
1067
|
+
resetCursorBlink()
|
|
1068
|
+
if (next === cursorRef.current) return
|
|
1069
|
+
cursorRef.current = next
|
|
1070
|
+
setCursor(next)
|
|
1071
|
+
preferredColumnRef.current = null
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Apply an ordered raw-key batch against one current draft snapshot. */
|
|
1075
|
+
const applyRawEditorTokens = (tokens: readonly RawEditorToken[]): void => {
|
|
1076
|
+
let nextValue = valueRef.current
|
|
1077
|
+
let nextCursor = cursorRef.current
|
|
1078
|
+
for (const token of tokens) {
|
|
1079
|
+
if (token.kind === 'text') {
|
|
1080
|
+
const edit = insertText(nextValue, nextCursor, token.text)
|
|
1081
|
+
nextValue = edit.value
|
|
1082
|
+
nextCursor = edit.cursor
|
|
1083
|
+
continue
|
|
1084
|
+
}
|
|
1085
|
+
if (token.kind === 'home') {
|
|
1086
|
+
nextCursor = moveToLineStart(nextValue, nextCursor, false)
|
|
1087
|
+
continue
|
|
1088
|
+
}
|
|
1089
|
+
if (token.kind === 'end') {
|
|
1090
|
+
nextCursor = moveToLineEnd(nextValue, nextCursor, false)
|
|
1091
|
+
continue
|
|
1092
|
+
}
|
|
1093
|
+
const edit = token.kind === 'delete-backward'
|
|
1094
|
+
? deleteBackward(nextValue, nextCursor)
|
|
1095
|
+
: token.kind === 'delete-word-backward'
|
|
1096
|
+
? deleteWordBackward(nextValue, nextCursor)
|
|
1097
|
+
: token.kind === 'delete-forward'
|
|
1098
|
+
? deleteForward(nextValue, nextCursor)
|
|
1099
|
+
: deleteWordForward(nextValue, nextCursor)
|
|
1100
|
+
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
1101
|
+
nextValue = edit.value
|
|
1102
|
+
nextCursor = edit.cursor
|
|
1103
|
+
}
|
|
1104
|
+
valueRef.current = nextValue
|
|
1105
|
+
cursorRef.current = nextCursor
|
|
1106
|
+
setValue(nextValue)
|
|
1107
|
+
setCursor(nextCursor)
|
|
1108
|
+
resetCursorBlink()
|
|
1109
|
+
preferredColumnRef.current = null
|
|
1110
|
+
setCompletionIndex(0)
|
|
1111
|
+
setDismissedMenuValue(undefined)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const cancelImageSubmission = (): void => {
|
|
1115
|
+
prepareEpochRef.current += 1
|
|
1116
|
+
prepareAbortRef.current?.abort()
|
|
1117
|
+
prepareAbortRef.current = undefined
|
|
1118
|
+
setPreparingImages(false)
|
|
1119
|
+
dismissNotice()
|
|
1120
|
+
notify(t('notice.imageCancelled'), 'warning')
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Cross history while an unchanged recalled draft rests its caret on
|
|
1124
|
+
* either text edge; between the edges (or inside ordinary drafts) the
|
|
1125
|
+
* arrows move through visual rows first. */
|
|
1126
|
+
const navigateVertical = (direction: -1 | 1): void => {
|
|
1127
|
+
const currentValue = valueRef.current
|
|
1128
|
+
const currentCursor = cursorRef.current
|
|
1129
|
+
// History owns the arrows while an unchanged recalled draft rests its
|
|
1130
|
+
// caret on either text edge (start or end). Everywhere else - edited
|
|
1131
|
+
// drafts, interior carets, ordinary typing - the arrows move through
|
|
1132
|
+
// visual rows as plain editing.
|
|
1133
|
+
if (recall.current.entries.length > 0
|
|
1134
|
+
&& shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
|
|
1135
|
+
const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
|
|
1136
|
+
recall.current = step.state
|
|
1137
|
+
if (step.entry !== undefined) {
|
|
1138
|
+
const safe = sanitizeDraftText(step.entry)
|
|
1139
|
+
valueRef.current = safe
|
|
1140
|
+
cursorRef.current = safe.length
|
|
1141
|
+
setValue(safe)
|
|
1142
|
+
setCursor(safe.length)
|
|
1143
|
+
preferredColumnRef.current = null
|
|
1144
|
+
// Suppress the completion menu for the recalled text: a recalled
|
|
1145
|
+
// command would otherwise reopen the menu, whose Up/Down navigation
|
|
1146
|
+
// then traps the walk before it reaches older history entries. Any
|
|
1147
|
+
// edit re-opens the menu; submitting resets the dismissal.
|
|
1148
|
+
setDismissedMenuValue(safe)
|
|
1149
|
+
}
|
|
1150
|
+
resetCursorBlink()
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
const model = editorModel(currentValue, editorColumns)
|
|
1154
|
+
const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
|
|
1155
|
+
const next = moveCursorVertically(model, currentCursor, preferred, direction)
|
|
1156
|
+
if (next !== currentCursor) {
|
|
1157
|
+
cursorRef.current = next
|
|
1158
|
+
setCursor(next)
|
|
1159
|
+
resetCursorBlink()
|
|
1160
|
+
preferredColumnRef.current = preferred
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
useStableInput((input, key) => {
|
|
1165
|
+
// Modal ownership: approval/question/model dialogs consume all keys.
|
|
1166
|
+
if (!active) return
|
|
1167
|
+
// React may not have committed the previous Tab completion render before
|
|
1168
|
+
// the next terminal byte arrives. Read the synchronous editor refs so a
|
|
1169
|
+
// completion followed immediately by text edits never uses stale closure
|
|
1170
|
+
// state.
|
|
1171
|
+
const liveValue = valueRef.current
|
|
1172
|
+
const liveCursor = cursorRef.current
|
|
1173
|
+
if (preparingImages) {
|
|
1174
|
+
if (key.escape || (key.ctrl && input === 'c')) cancelImageSubmission()
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
// Deletion confirm owns the box: y proceeds, anything else cancels.
|
|
1178
|
+
// Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
|
|
1179
|
+
// as the box's own prompt, not an invisible panel keypress.
|
|
1180
|
+
if (deleteConfirm !== undefined) {
|
|
1181
|
+
if (input === 'y' || input === 'Y') {
|
|
1182
|
+
confirmDelete()
|
|
1183
|
+
} else {
|
|
1184
|
+
cancelDelete()
|
|
1185
|
+
}
|
|
1186
|
+
return
|
|
1187
|
+
}
|
|
1188
|
+
// Shift+Tab cycles the mode stations: permission presets, then the
|
|
1189
|
+
// plan station when the composition offers it (Claude-Code convention).
|
|
1190
|
+
if (key.tab && key.shift) {
|
|
1191
|
+
try {
|
|
1192
|
+
const label = cycleMode()
|
|
1193
|
+
if (label !== '') notify(label)
|
|
1194
|
+
} catch (error: unknown) {
|
|
1195
|
+
notify(t('notice.permissionChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1196
|
+
}
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
1199
|
+
// Tab on an EMPTY composer picks how the next submission is delivered:
|
|
1200
|
+
// queue for the next turn, or steer into the turn already running. With a
|
|
1201
|
+
// draft present Tab stays the completion key (handled with the menu
|
|
1202
|
+
// below), so this only claims the keypress when nothing is being typed.
|
|
1203
|
+
if (key.tab && liveValue === '' && !menuActive) {
|
|
1204
|
+
cycleSubmitMode()
|
|
1205
|
+
return
|
|
1206
|
+
}
|
|
1207
|
+
// Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
|
|
1208
|
+
// Alt+R is the zero-config alias: VS Code never intercepts Alt chords,
|
|
1209
|
+
// so the toggle stays reachable before /vscode-keys has been applied.
|
|
1210
|
+
if ((key.ctrl || key.meta) && input === 'r') {
|
|
1211
|
+
if (focusReporting && !terminalFocusedRef.current) return
|
|
1212
|
+
toggleReasoning()
|
|
1213
|
+
return
|
|
1214
|
+
}
|
|
1215
|
+
// Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
|
|
1216
|
+
// adapted to append-only static rows): one history entry at a time with
|
|
1217
|
+
// tool cards and reasoning expanded, Esc returns.
|
|
1218
|
+
if (key.ctrl && input === 'o') {
|
|
1219
|
+
openVerbose()
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
// Ctrl+C is three-state (community-TUI convention): a running turn is
|
|
1223
|
+
// cancelled, a non-empty draft is cleared, and only an idle empty input
|
|
1224
|
+
// exits. Ctrl+D always means exit but refuses mid-turn.
|
|
1225
|
+
if (key.ctrl && input === 'c') {
|
|
1226
|
+
if (busy) {
|
|
1227
|
+
interrupt()
|
|
1228
|
+
} else if (liveValue !== '') {
|
|
1229
|
+
valueRef.current = ''
|
|
1230
|
+
cursorRef.current = 0
|
|
1231
|
+
setValue('')
|
|
1232
|
+
setCursor(0)
|
|
1233
|
+
resetCursorBlink()
|
|
1234
|
+
draftImagesRef.current = []
|
|
1235
|
+
setDraftImages([])
|
|
1236
|
+
draftFilesRef.current = []
|
|
1237
|
+
setDraftFiles([])
|
|
1238
|
+
setCompletionIndex(0)
|
|
1239
|
+
setDismissedMenuValue(undefined)
|
|
1240
|
+
} else {
|
|
1241
|
+
quit()
|
|
1242
|
+
}
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
if (key.ctrl && input === 'd') {
|
|
1246
|
+
// Codex: Ctrl+D deletes forward while a draft exists; the app-level
|
|
1247
|
+
// exit only fires from an empty composer.
|
|
1248
|
+
if (liveValue !== '') {
|
|
1249
|
+
applyEdit(deleteForward(liveValue, liveCursor))
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
if (busy) notify(t('notice.cancelBeforeExit'), 'warning')
|
|
1253
|
+
else quit()
|
|
1254
|
+
return
|
|
1255
|
+
}
|
|
1256
|
+
if (key.escape) {
|
|
1257
|
+
if (menuActive) {
|
|
1258
|
+
setDismissedMenuValue(liveValue)
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
if (hasNotice) {
|
|
1262
|
+
dismissNotice()
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
if (busy) interrupt()
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
// Delete on the empty composer cancels the newest queued message (the
|
|
1269
|
+
// web queue-mirror contract: the durable splice drops the pending row).
|
|
1270
|
+
if (key.delete && liveValue === '' && queued.length > 0) {
|
|
1271
|
+
// Ink gives Backspace (\x7f) and forward Delete the same `key.delete`
|
|
1272
|
+
// identity; only the raw editor tokens separate them. The destructive
|
|
1273
|
+
// queue cancel is Delete-only (the footer says "Delete on the empty
|
|
1274
|
+
// composer cancels") — a habitual Backspace must stay inert here.
|
|
1275
|
+
const forwardDelete = rawEditorTokens.current?.some(token =>
|
|
1276
|
+
token.kind === 'delete-forward' || token.kind === 'delete-word-forward') === true
|
|
1277
|
+
if (forwardDelete) {
|
|
1278
|
+
updateQueued?.(queued[queued.length - 1].messageId, { kind: 'remove' })
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (key.return) {
|
|
1283
|
+
// A newline inside an open bracketed paste inserts; it never submits.
|
|
1284
|
+
if (pasteBracketRef.current) {
|
|
1285
|
+
applyEdit(insertText(liveValue, liveCursor, '\n'))
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
// Enter on an open completion menu accepts the highlighted candidate
|
|
1289
|
+
// (Codex list parity: Tab and Enter are both accept keys — many users
|
|
1290
|
+
// never discover Tab) — UNLESS the draft already spells one candidate
|
|
1291
|
+
// exactly, in which case Enter submits it (typing a full "/effort" and
|
|
1292
|
+
// pressing return must run the command, not re-accept its own text).
|
|
1293
|
+
if (menuActive) {
|
|
1294
|
+
// Accepting can be a no-op: the menu is open with no matches yet, the
|
|
1295
|
+
// slash line already spells its candidate, or a mention insertion
|
|
1296
|
+
// repeats the token already in the draft. Enter must reach the submit
|
|
1297
|
+
// path in every one of those cases, or the message cannot be sent.
|
|
1298
|
+
const highlighted = highlightedCompletion()
|
|
1299
|
+
const repeatsToken = mentionActive && mentionToken !== undefined && cursor === liveValue.length
|
|
1300
|
+
&& liveValue.slice(mentionToken.start, cursor) === highlighted
|
|
1301
|
+
// The slash rule is a whole-list exact match, not a highlighted-row
|
|
1302
|
+
// comparison: typing `/mode` once the list is open must run the
|
|
1303
|
+
// command even when the highlight happens to rest on `/model`.
|
|
1304
|
+
const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
|
|
1305
|
+
const acceptNoop = highlighted === '' || repeatsToken || exactSlash
|
|
1306
|
+
if (!acceptNoop) {
|
|
1307
|
+
acceptMenuCandidate()
|
|
1308
|
+
return
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
// Payload fidelity: trim() is a blank check, not a rewrite. Ordinary
|
|
1312
|
+
// prompts keep their exact indentation and trailing whitespace (pasted
|
|
1313
|
+
// code must reach the model verbatim); slash lines normalize so the
|
|
1314
|
+
// completion-inserted trailing space still routes `/quit ` correctly.
|
|
1315
|
+
const trimmed = liveValue.trim()
|
|
1316
|
+
const text = submissionPayload(liveValue)
|
|
1317
|
+
if (draftImagesRef.current.length > 0 || draftFilesRef.current.length > 0) {
|
|
1318
|
+
// Slash semantics with attachments are unchanged: commands cannot
|
|
1319
|
+
// carry attachments, so the line goes to the model as a prompt —
|
|
1320
|
+
// warn instead of surprising the user with a literal "/export".
|
|
1321
|
+
if (isSlashLine(text)) notify(t('notice.commandAttachments'), 'warning')
|
|
1322
|
+
// Attachment prepares resolve asynchronously; the app remounts onto
|
|
1323
|
+
// another session in the meantime, and this (old) instance's unmount
|
|
1324
|
+
// cleanup runs too late on the microtask timeline. Tag the delivery
|
|
1325
|
+
// with the composing session so the runner can drop the stale one.
|
|
1326
|
+
const originSession = sessionKey
|
|
1327
|
+
const controller = new AbortController()
|
|
1328
|
+
const epoch = prepareEpochRef.current + 1
|
|
1329
|
+
prepareEpochRef.current = epoch
|
|
1330
|
+
prepareAbortRef.current = controller
|
|
1331
|
+
setPreparingImages(true)
|
|
1332
|
+
const imageSnapshot = draftImagesRef.current
|
|
1333
|
+
const fileSnapshot = draftFilesRef.current
|
|
1334
|
+
const total = imageSnapshot.length + fileSnapshot.length
|
|
1335
|
+
notify(`processing ${total} attachment${total === 1 ? '' : 's'}…`)
|
|
1336
|
+
void Promise.all([
|
|
1337
|
+
imageSnapshot.length === 0 ? Promise.resolve([]) : prepareImages(imageSnapshot.map(image => image.path), controller.signal),
|
|
1338
|
+
fileSnapshot.length === 0 ? Promise.resolve([]) : prepareFiles(fileSnapshot.map(file => file.path), controller.signal),
|
|
1339
|
+
]).then(([images, files]) => {
|
|
1340
|
+
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
1341
|
+
prepareAbortRef.current = undefined
|
|
1342
|
+
setPreparingImages(false)
|
|
1343
|
+
valueRef.current = ''
|
|
1344
|
+
cursorRef.current = 0
|
|
1345
|
+
setValue('')
|
|
1346
|
+
setCursor(0)
|
|
1347
|
+
draftImagesRef.current = []
|
|
1348
|
+
setDraftImages([])
|
|
1349
|
+
draftFilesRef.current = []
|
|
1350
|
+
setDraftFiles([])
|
|
1351
|
+
setCompletionIndex(0)
|
|
1352
|
+
setDismissedMenuValue(undefined)
|
|
1353
|
+
dismissNotice()
|
|
1354
|
+
if (trimmed !== '') {
|
|
1355
|
+
recordLocal(text)
|
|
1356
|
+
recordHistory(text)
|
|
1357
|
+
}
|
|
1358
|
+
recall.current = beginRecall(recallSpace, '')
|
|
1359
|
+
const blocks: readonly ContentBlock[] = [...images, ...files]
|
|
1360
|
+
if (submitMode === 'steer') steer(text, blocks, originSession)
|
|
1361
|
+
else dispatch(text, blocks, originSession)
|
|
1362
|
+
}, (reason: unknown) => {
|
|
1363
|
+
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
1364
|
+
prepareAbortRef.current = undefined
|
|
1365
|
+
setPreparingImages(false)
|
|
1366
|
+
notify(`attachment submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
1367
|
+
})
|
|
1368
|
+
return
|
|
1369
|
+
}
|
|
1370
|
+
valueRef.current = ''
|
|
1371
|
+
cursorRef.current = 0
|
|
1372
|
+
setValue('')
|
|
1373
|
+
setCursor(0)
|
|
1374
|
+
resetCursorBlink()
|
|
1375
|
+
setCompletionIndex(0)
|
|
1376
|
+
setDismissedMenuValue(undefined)
|
|
1377
|
+
if (trimmed === '') return
|
|
1378
|
+
dismissNotice()
|
|
1379
|
+
// Global recall records every submission - prompts and typed slash
|
|
1380
|
+
// commands share one history, so Up/Down and /history recall commands
|
|
1381
|
+
// exactly like prompts; the submission resets any active recall
|
|
1382
|
+
// browsing.
|
|
1383
|
+
recordLocal(text)
|
|
1384
|
+
recordHistory(text)
|
|
1385
|
+
recall.current = beginRecall(recallSpace, '')
|
|
1386
|
+
if (text === '/quit') {
|
|
1387
|
+
quit()
|
|
1388
|
+
return
|
|
1389
|
+
}
|
|
1390
|
+
if (text === '/help') {
|
|
1391
|
+
openHelp()
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
if (text === '/clear') {
|
|
1395
|
+
// Clear the screen AND drop the folded view: the raw ANSI clear + a
|
|
1396
|
+
// Static remount (refresh) so the ledger stays in sync, then the
|
|
1397
|
+
// store resets so the rebuilt transcript starts empty.
|
|
1398
|
+
refresh()
|
|
1399
|
+
clearView()
|
|
1400
|
+
dismissNotice()
|
|
1401
|
+
return
|
|
1402
|
+
}
|
|
1403
|
+
if (text === '/export' || text.startsWith('/export ')) {
|
|
1404
|
+
void exportTranscript(text.slice(8))
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
if (text === '/title' || text.startsWith('/title ')) {
|
|
1408
|
+
const outcome = renameTitle(text.slice(7))
|
|
1409
|
+
const tone: NoticeTone = outcome.startsWith('rename failed:')
|
|
1410
|
+
? 'error'
|
|
1411
|
+
: outcome.startsWith('usage:') || outcome.includes('unavailable')
|
|
1412
|
+
? 'warning'
|
|
1413
|
+
: 'info'
|
|
1414
|
+
notify(outcome, tone)
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1417
|
+
if (text === '/copy') {
|
|
1418
|
+
void copyLastResponse().then(
|
|
1419
|
+
outcome => notify(outcome),
|
|
1420
|
+
error => notify(t('notice.copyFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1421
|
+
)
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
if (text === '/diff' || text.startsWith('/diff ')) {
|
|
1425
|
+
openDiff(text.slice(5))
|
|
1426
|
+
return
|
|
1427
|
+
}
|
|
1428
|
+
if (text === '/review' || text.startsWith('/review ')) {
|
|
1429
|
+
const argument = text.slice(7).trim()
|
|
1430
|
+
if (argument === '') {
|
|
1431
|
+
openReviewPicker()
|
|
1432
|
+
return
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
reviewChanges(parseReviewArgument(argument))
|
|
1436
|
+
} catch (error: unknown) {
|
|
1437
|
+
notify(error instanceof Error ? error.message : String(error), 'warning')
|
|
1438
|
+
}
|
|
1439
|
+
return
|
|
1440
|
+
}
|
|
1441
|
+
if (text === '/model' || text.startsWith('/model ')) {
|
|
1442
|
+
openModel()
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
if (text === '/effort' || text.startsWith('/effort ')) {
|
|
1446
|
+
openEffort()
|
|
1447
|
+
return
|
|
1448
|
+
}
|
|
1449
|
+
if (text === '/permission') {
|
|
1450
|
+
openPermission()
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
if (text.startsWith('/permission ')) {
|
|
1454
|
+
dispatch(text)
|
|
1455
|
+
return
|
|
1456
|
+
}
|
|
1457
|
+
if (text === '/mode' || text.startsWith('/mode ')) {
|
|
1458
|
+
const mode = text.slice(5).trim()
|
|
1459
|
+
if (mode === '') openMode()
|
|
1460
|
+
else dispatch(text)
|
|
1461
|
+
return
|
|
1462
|
+
}
|
|
1463
|
+
if (text === '/resume cancel') {
|
|
1464
|
+
notify(cancelSessionSwitch() ? 'pending session switch cancelled' : 'no pending session switch', 'info')
|
|
1465
|
+
return
|
|
1466
|
+
}
|
|
1467
|
+
if (text === '/resume' || text.startsWith('/resume ')) {
|
|
1468
|
+
const id = text.slice(7).trim()
|
|
1469
|
+
if (id === '') openResume()
|
|
1470
|
+
else dispatch(text)
|
|
1471
|
+
return
|
|
1472
|
+
}
|
|
1473
|
+
if (text === '/search' || text.startsWith('/search ')) {
|
|
1474
|
+
openSearch(text.slice(7).trim())
|
|
1475
|
+
return
|
|
1476
|
+
}
|
|
1477
|
+
if (text === '/new' || text.startsWith('/new ')) {
|
|
1478
|
+
createSession(text.slice(4).trim() || undefined)
|
|
1479
|
+
return
|
|
1480
|
+
}
|
|
1481
|
+
if (text === '/fork' || text.startsWith('/fork ')) {
|
|
1482
|
+
forkSession(text.slice(5))
|
|
1483
|
+
return
|
|
1484
|
+
}
|
|
1485
|
+
if (text === '/plugin' || text.startsWith('/plugin ')) {
|
|
1486
|
+
openPlugin(text.slice(7).trim())
|
|
1487
|
+
return
|
|
1488
|
+
}
|
|
1489
|
+
if (text === '/update') {
|
|
1490
|
+
openUpdate()
|
|
1491
|
+
return
|
|
1492
|
+
}
|
|
1493
|
+
if (text === '/schedule') {
|
|
1494
|
+
openSchedule()
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
if (text === '/jobs' || text.startsWith('/jobs ')) {
|
|
1498
|
+
openJobs()
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
if (text === '/statusline') {
|
|
1502
|
+
openStatusline()
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1505
|
+
if (text === '/theme') {
|
|
1506
|
+
openTheme()
|
|
1507
|
+
return
|
|
1508
|
+
}
|
|
1509
|
+
if (text === '/language' || text.startsWith('/language ')) {
|
|
1510
|
+
const argument = text.slice('/language'.length).trim()
|
|
1511
|
+
if (argument === '') openLanguage()
|
|
1512
|
+
else if (argument === 'en' || argument === 'zh') {
|
|
1513
|
+
saveLanguage(parseLanguageName(argument))
|
|
1514
|
+
notify(t('notice.languageSaved', { name: argument }))
|
|
1515
|
+
refresh()
|
|
1516
|
+
} else notify(t('notice.usage.language'), 'warning')
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
if (text === '/animation' || text.startsWith('/animation ')) {
|
|
1520
|
+
const parsed = parseAnimationsArgument(text.slice('/animation'.length))
|
|
1521
|
+
if (parsed === 'toggle') applyAnimations(!animations)
|
|
1522
|
+
else if (parsed === 'usage') notify(t('notice.usage.animation'), 'info')
|
|
1523
|
+
else applyAnimations(parsed.enabled)
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1526
|
+
if (text === '/rainbow' || text.startsWith('/rainbow ')) {
|
|
1527
|
+
const parsed = parseRainbowArgument(text.slice('/rainbow'.length))
|
|
1528
|
+
if (parsed === 'usage') notify(t('notice.usage.rainbow'), 'warning')
|
|
1529
|
+
else applyRainbow(parsed === 'random' ? undefined : parsed.seed)
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
if (text === '/history') {
|
|
1533
|
+
openHistory()
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1536
|
+
if (text === '/queue') {
|
|
1537
|
+
openQueue()
|
|
1538
|
+
return
|
|
1539
|
+
}
|
|
1540
|
+
if (text === '/usage') {
|
|
1541
|
+
openUsage()
|
|
1542
|
+
return
|
|
1543
|
+
}
|
|
1544
|
+
if (text === '/agents') {
|
|
1545
|
+
openAgents()
|
|
1546
|
+
return
|
|
1547
|
+
}
|
|
1548
|
+
if (text === '/todos') {
|
|
1549
|
+
openTodos()
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
if (text === '/vscode-keys' || text.startsWith('/vscode-keys ')) {
|
|
1553
|
+
void applyEditorKeys().then(
|
|
1554
|
+
summary => notify(summary),
|
|
1555
|
+
error => notify(`vscode-keys failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
1556
|
+
)
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
1559
|
+
if (text === '/subagent') {
|
|
1560
|
+
openSubagent()
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
if (text === '/delete' || text.startsWith('/delete ')) {
|
|
1564
|
+
openDelete(text.slice(7).trim())
|
|
1565
|
+
return
|
|
1566
|
+
}
|
|
1567
|
+
const slash = slashNameAndArgs(text)
|
|
1568
|
+
if (slash !== undefined && BARE_LOCAL_COMMANDS.has(slash.name) && slash.args !== '') {
|
|
1569
|
+
notify(t('notice.usage.bareCommand', { name: slash.name }), 'warning')
|
|
1570
|
+
return
|
|
1571
|
+
}
|
|
1572
|
+
// Delivery mode: everything above this point is a local command or a
|
|
1573
|
+
// panel opener and always runs out of band. A real prompt follows the
|
|
1574
|
+
// composer's Tab choice — `steer` joins the running turn, the default
|
|
1575
|
+
// queues it for the next one.
|
|
1576
|
+
if (submitMode === 'steer') steer(text)
|
|
1577
|
+
else dispatch(text)
|
|
1578
|
+
return
|
|
1579
|
+
}
|
|
1580
|
+
// The modified-Enter newline family: Ctrl+J arrives as a bare LF (Ink
|
|
1581
|
+
// names it 'enter', not 'return'), and the kitty layer normalizes
|
|
1582
|
+
// Ctrl/Shift+Enter to the same byte. Alt+Enter reaches here as a bare CR
|
|
1583
|
+
// with no flags — Ink's parser drops the escape and reports no meta, and
|
|
1584
|
+
// plain Enter always carries key.return — so a flagless CR is Alt+Enter.
|
|
1585
|
+
// Only plain Enter submits. app.spec's "modified-Enter family" test pins
|
|
1586
|
+
// this exact parser shape; an Ink upgrade that changes it fails there.
|
|
1587
|
+
if (input === '\n' || input === '\r') {
|
|
1588
|
+
applyEdit(insertText(liveValue, liveCursor, '\n'))
|
|
1589
|
+
return
|
|
1590
|
+
}
|
|
1591
|
+
// A fast Tab followed by text can arrive as one readable chunk in an
|
|
1592
|
+
// integrated terminal. Accept the candidate first, then apply the
|
|
1593
|
+
// remaining characters against the synchronously updated editor refs.
|
|
1594
|
+
if (menuActive && (key.tab || input.startsWith('\t'))) {
|
|
1595
|
+
const remainder = key.tab ? '' : input.slice(1)
|
|
1596
|
+
acceptMenuCandidate()
|
|
1597
|
+
if (remainder !== '') applyEdit(insertText(valueRef.current, cursorRef.current, remainder))
|
|
1598
|
+
return
|
|
1599
|
+
}
|
|
1600
|
+
if (menuActive && key.upArrow) {
|
|
1601
|
+
setCompletionIndex(index => stepCompletionIndex(index, -1, menuRows.length))
|
|
1602
|
+
return
|
|
1603
|
+
}
|
|
1604
|
+
if (menuActive && key.downArrow) {
|
|
1605
|
+
setCompletionIndex(index => stepCompletionIndex(index, 1, menuRows.length))
|
|
1606
|
+
return
|
|
1607
|
+
}
|
|
1608
|
+
// Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
|
|
1609
|
+
// and reduce against one current editor snapshot in their original order.
|
|
1610
|
+
const rawTokens = rawEditorTokens.current
|
|
1611
|
+
rawEditorTokens.current = undefined
|
|
1612
|
+
if (rawTokens !== undefined) {
|
|
1613
|
+
applyRawEditorTokens(rawTokens)
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
if (key.upArrow || key.downArrow) {
|
|
1617
|
+
navigateVertical(key.upArrow ? -1 : 1)
|
|
1618
|
+
return
|
|
1619
|
+
}
|
|
1620
|
+
// Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
|
|
1621
|
+
// move_up/move_down, so the history gate applies first).
|
|
1622
|
+
if (key.ctrl && (input === 'p' || input === 'n')) {
|
|
1623
|
+
navigateVertical(input === 'p' ? -1 : 1)
|
|
1624
|
+
return
|
|
1625
|
+
}
|
|
1626
|
+
// Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
|
|
1627
|
+
// plain arrows and Ctrl+B/F move by grapheme.
|
|
1628
|
+
if (key.leftArrow) {
|
|
1629
|
+
moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
|
|
1630
|
+
return
|
|
1631
|
+
}
|
|
1632
|
+
if (key.rightArrow) {
|
|
1633
|
+
moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
|
|
1634
|
+
return
|
|
1635
|
+
}
|
|
1636
|
+
if (key.meta && input === 'b') {
|
|
1637
|
+
moveCursorTo(moveWordLeft(liveValue, liveCursor))
|
|
1638
|
+
return
|
|
1639
|
+
}
|
|
1640
|
+
if (key.meta && input === 'f') {
|
|
1641
|
+
moveCursorTo(moveWordRight(liveValue, liveCursor))
|
|
1642
|
+
return
|
|
1643
|
+
}
|
|
1644
|
+
if (key.ctrl && input === 'b') {
|
|
1645
|
+
moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
|
|
1646
|
+
return
|
|
1647
|
+
}
|
|
1648
|
+
if (key.ctrl && input === 'f') {
|
|
1649
|
+
moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
|
|
1650
|
+
return
|
|
1651
|
+
}
|
|
1652
|
+
// Ctrl+W and Alt+Backspace delete the previous word piece into the kill
|
|
1653
|
+
// buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
|
|
1654
|
+
if (key.ctrl && input === 'w') {
|
|
1655
|
+
applyEdit(deleteWordBackward(liveValue, liveCursor))
|
|
1656
|
+
return
|
|
1657
|
+
}
|
|
1658
|
+
if (key.meta && input === 'd') {
|
|
1659
|
+
applyEdit(deleteWordForward(liveValue, liveCursor))
|
|
1660
|
+
return
|
|
1661
|
+
}
|
|
1662
|
+
// Un-annotated backspace/delete (Ink maps both and here):
|
|
1663
|
+
// delete the grapheme before the cursor.
|
|
1664
|
+
if (key.backspace || key.delete) {
|
|
1665
|
+
applyEdit(deleteBackward(liveValue, liveCursor))
|
|
1666
|
+
return
|
|
1667
|
+
}
|
|
1668
|
+
// Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
|
|
1669
|
+
// them (filling the single kill buffer), Y yanks it back.
|
|
1670
|
+
if (key.ctrl && input === 'a') {
|
|
1671
|
+
moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
|
|
1672
|
+
return
|
|
1673
|
+
}
|
|
1674
|
+
if (key.ctrl && input === 'e') {
|
|
1675
|
+
moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
|
|
1676
|
+
return
|
|
1677
|
+
}
|
|
1678
|
+
if (key.ctrl && input === 'u') {
|
|
1679
|
+
applyEdit(killToLineStart(liveValue, liveCursor))
|
|
1680
|
+
return
|
|
1681
|
+
}
|
|
1682
|
+
if (key.ctrl && input === 'k') {
|
|
1683
|
+
applyEdit(killToLineEnd(liveValue, liveCursor))
|
|
1684
|
+
return
|
|
1685
|
+
}
|
|
1686
|
+
if (key.ctrl && input === 'y') {
|
|
1687
|
+
if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
|
|
1688
|
+
return
|
|
1689
|
+
}
|
|
1690
|
+
// Ctrl+L refreshes the screen (readline convention): raw ANSI clear
|
|
1691
|
+
// plus a Static remount so the flushed transcript re-emits (a bare
|
|
1692
|
+
// console.clear() would desync Ink's ledger against the static rows).
|
|
1693
|
+
if (key.ctrl && input === 'l') {
|
|
1694
|
+
refresh()
|
|
1695
|
+
return
|
|
1696
|
+
}
|
|
1697
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
1698
|
+
// Bracketed-paste wrappers arrive as escape sequences Ink has stripped
|
|
1699
|
+
// only ONE leading ESC from, so the tail marker still carries its own:
|
|
1700
|
+
// strip both spellings before the payload is read, or a dragged path
|
|
1701
|
+
// reaches the attachment parser with a trailing control byte and fails.
|
|
1702
|
+
// Markers may ride their own chunk or the edges of a content chunk; the
|
|
1703
|
+
// open-paste flag is tracked so a chunk that is exactly LF inserts
|
|
1704
|
+
// instead of submitting.
|
|
1705
|
+
let text = input
|
|
1706
|
+
if (text.includes(PASTE_START_MARKER)) {
|
|
1707
|
+
pasteBracketRef.current = true
|
|
1708
|
+
// Arm the lost-marker safety net: one timer per open paste, re-armed
|
|
1709
|
+
// if a second start marker rides the same burst.
|
|
1710
|
+
pasteBracketCancelRef.current?.()
|
|
1711
|
+
const timer = setTimeout(() => {
|
|
1712
|
+
pasteBracketRef.current = false
|
|
1713
|
+
pasteBracketCancelRef.current = undefined
|
|
1714
|
+
}, PASTE_BRACKET_TIMEOUT_MS)
|
|
1715
|
+
pasteBracketCancelRef.current = () => {
|
|
1716
|
+
clearTimeout(timer)
|
|
1717
|
+
pasteBracketCancelRef.current = undefined
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (text.includes(PASTE_END_MARKER)) {
|
|
1721
|
+
pasteBracketRef.current = false
|
|
1722
|
+
pasteBracketCancelRef.current?.()
|
|
1723
|
+
}
|
|
1724
|
+
text = stripPasteMarkers(text)
|
|
1725
|
+
if (text === '') return
|
|
1726
|
+
if (text.length > 1) {
|
|
1727
|
+
// A path-list paste splits into images and files; prose falls through
|
|
1728
|
+
// as ordinary text (the splitter returns empty groups for non-paths).
|
|
1729
|
+
const dropped = parsePastedAttachmentPaths(text)
|
|
1730
|
+
if (dropped.images.length > 0 || dropped.files.length > 0) {
|
|
1731
|
+
insertDroppedAttachments(dropped.images, dropped.files)
|
|
1732
|
+
return
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
applyEdit(insertText(valueRef.current, cursorRef.current, text))
|
|
1736
|
+
}
|
|
1737
|
+
}, active)
|
|
1738
|
+
|
|
1739
|
+
// The DeepSeek easter-egg wave renders through the ComposerWave leaf
|
|
1740
|
+
// below, which owns its 33ms tick: the sweep re-renders only that child at
|
|
1741
|
+
// 30fps — this component's editor model, menu, and derived state never
|
|
1742
|
+
// re-run per frame. App drives the tier/style pair on a model switch, and
|
|
1743
|
+
// the child remounts whenever that pair changes (App picks a NEW random
|
|
1744
|
+
// style for every replay, so the pair always differs when a wave should
|
|
1745
|
+
// run), resetting the timeline to frame 0 before the first paint.
|
|
1746
|
+
//
|
|
1747
|
+
// The sweep is strictly one-shot per trigger, and the latch lives HERE —
|
|
1748
|
+
// not in the leaf — because modal panels freeze the composer and UNMOUNT
|
|
1749
|
+
// ComposerWave; a mount-scoped latch would reset on every panel close and
|
|
1750
|
+
// replay a finished sweep. Keying `wavePlayedKey` by the tier:style pair
|
|
1751
|
+
// survives those unmounts: only a NEW trigger (which always changes the
|
|
1752
|
+
// pair) re-arms the sweep. Busy turns, image preparation, /animation
|
|
1753
|
+
// toggles, and panel open/close on an UNCHANGED model+effort pair never
|
|
1754
|
+
// fire it again.
|
|
1755
|
+
const waveKey = waveTier !== null && waveStyle !== null ? `${waveTier}:${waveStyle}` : null
|
|
1756
|
+
const [wavePlayedKey, setWavePlayedKey] = useState<string | null>(null)
|
|
1757
|
+
useEffect(() => {
|
|
1758
|
+
// While animations are off, any pending trigger is consumed silently:
|
|
1759
|
+
// re-enabling must never queue or replay a celebration the user opted
|
|
1760
|
+
// out of watching.
|
|
1761
|
+
if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
|
|
1762
|
+
}, [animations, waveKey, wavePlayedKey])
|
|
1763
|
+
const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
|
|
1764
|
+
const burstKey = rainbowBurstId > 0 ? `rainbow:${rainbowBurstId}` : null
|
|
1765
|
+
const [burstPlayedKey, setBurstPlayedKey] = useState<string | null>(null)
|
|
1766
|
+
useEffect(() => {
|
|
1767
|
+
if (!animations && burstKey !== null && burstKey !== burstPlayedKey) setBurstPlayedKey(burstKey)
|
|
1768
|
+
}, [animations, burstKey, burstPlayedKey])
|
|
1769
|
+
const burstArmed = burstKey !== null && burstKey !== burstPlayedKey
|
|
1770
|
+
|
|
1771
|
+
// Every exclusive panel keeps the composer as a stable visual anchor, but
|
|
1772
|
+
// freezes it to one row: no menu, multiline wrap, or animation.
|
|
1773
|
+
const tierActive = waveTier !== null
|
|
1774
|
+
const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
|
|
1775
|
+
const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
|
|
1776
|
+
const waveGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
|
|
1777
|
+
// Steer mode owns the prompt glyph in every paint path (static band, wave,
|
|
1778
|
+
// rainbow burst), so the mode is visible without reading the placeholder.
|
|
1779
|
+
const promptGlyph = submitMode === 'steer' ? '↳' : waveGlyph
|
|
1780
|
+
const placeholderText = composerPlaceholder(submitMode)
|
|
1781
|
+
// The multiline editor model: the sanitized draft hard-wrapped into
|
|
1782
|
+
// column-safe physical rows, with the caret mapped to its exact row and
|
|
1783
|
+
// column. Computed before the frozen path so the row report below runs
|
|
1784
|
+
// unconditionally.
|
|
1785
|
+
const editorViewModel = editorModel(value, editorColumns)
|
|
1786
|
+
const clampedCursor = clampCursor(value, cursor)
|
|
1787
|
+
const caret = caretSite(editorViewModel, clampedCursor)
|
|
1788
|
+
const editorWindowRows = Math.min(editorViewModel.rows.length, Math.max(1, maxRows))
|
|
1789
|
+
const maxEditorScroll = Math.max(0, editorViewModel.rows.length - editorWindowRows)
|
|
1790
|
+
const currentEditorScroll = Math.min(Math.max(0, editorScrollRef.current), maxEditorScroll)
|
|
1791
|
+
// Codex effective_scroll: no scrolling while the rows fit; otherwise the
|
|
1792
|
+
// window follows the caret row with as little movement as possible.
|
|
1793
|
+
const editorWindowStart = caret.row < currentEditorScroll
|
|
1794
|
+
? caret.row
|
|
1795
|
+
: caret.row >= currentEditorScroll + editorWindowRows
|
|
1796
|
+
? caret.row - editorWindowRows + 1
|
|
1797
|
+
: currentEditorScroll
|
|
1798
|
+
editorScrollRef.current = editorWindowStart
|
|
1799
|
+
const editorRowCount = frozen ? 1 : editorWindowRows
|
|
1800
|
+
useEffect(() => {
|
|
1801
|
+
onEditorRows(editorRowCount)
|
|
1802
|
+
}, [editorRowCount, onEditorRows])
|
|
1803
|
+
// IME anchor: park the real terminal cursor on the caret cell while the
|
|
1804
|
+
// composer accepts input. IME composition and candidate windows anchor to
|
|
1805
|
+
// that real cursor cell, which otherwise sits below the status row where
|
|
1806
|
+
// Ink leaves it, so Chinese input never appears at the caret. Frozen bands
|
|
1807
|
+
// release the anchor; the wrapper keeps Ink's relative erase ledger exact.
|
|
1808
|
+
const caretRowInWindow = Math.max(0, Math.min(caret.row - editorWindowStart, editorWindowRows - 1))
|
|
1809
|
+
useImeCursorAnchor(
|
|
1810
|
+
!frozen,
|
|
1811
|
+
imeCursorRowsUp({ editorWindowRows, caretRowInWindow, rowsBelowComposer: anchorRowsBelow }),
|
|
1812
|
+
2 + caret.column,
|
|
1813
|
+
)
|
|
1814
|
+
// The menu's physical rows ride the same one-way report; the cleanup keeps
|
|
1815
|
+
// the reserve from outliving the menu (unmount or inactive handoff).
|
|
1816
|
+
useEffect(() => {
|
|
1817
|
+
onMenuRows(menuHeightRows)
|
|
1818
|
+
return () => {
|
|
1819
|
+
if (menuHeightRows !== 0) onMenuRows(0)
|
|
1820
|
+
}
|
|
1821
|
+
}, [menuHeightRows, onMenuRows])
|
|
1822
|
+
// The composer band: the old border's three-row footprint repainted as a
|
|
1823
|
+
// background-color band (the Codex-style shaded composer strip) — one
|
|
1824
|
+
// blank band row above and below the content rows, full width minus the
|
|
1825
|
+
// final column. Row counts are untouched, so every height budget stays
|
|
1826
|
+
// exact.
|
|
1827
|
+
const bandWidth = Math.max(1, columns - 1)
|
|
1828
|
+
const bandBg = inkColor(getPalette().composerBand)
|
|
1829
|
+
const bandFill = (consumed: number): string => ' '.repeat(Math.max(0, bandWidth - consumed))
|
|
1830
|
+
const band = (content: ReactElement): ReactElement => createElement(
|
|
1831
|
+
Box,
|
|
1832
|
+
{ flexDirection: 'column', width: bandWidth },
|
|
1833
|
+
createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
|
|
1834
|
+
content,
|
|
1835
|
+
createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
|
|
1836
|
+
)
|
|
1837
|
+
if (frozen) {
|
|
1838
|
+
// A pending deletion turns the band into the confirm prompt: the y/n is
|
|
1839
|
+
// typed HERE; without a border the warn color carries the warning.
|
|
1840
|
+
if (deleteConfirm !== undefined) {
|
|
1841
|
+
const warning = 'y delete · any other key cancels'
|
|
1842
|
+
return band(createElement(
|
|
1843
|
+
Text,
|
|
1844
|
+
{ backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
1845
|
+
createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '❯ '),
|
|
1846
|
+
createElement(Text, { color: inkColor(getPalette().warn), bold: true }, warning),
|
|
1847
|
+
bandFill(2 + visibleColumns(warning)),
|
|
1848
|
+
))
|
|
1849
|
+
}
|
|
1850
|
+
const frozenLine = value === ''
|
|
1851
|
+
? frozenHint ?? 'type a message'
|
|
1852
|
+
: verboseLine(value, Math.max(1, columns - 6))
|
|
1853
|
+
return band(createElement(
|
|
1854
|
+
Text,
|
|
1855
|
+
{ backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
1856
|
+
createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, submitMode === 'steer' ? '↳ ' : busy ? '… ' : `${promptGlyph} `),
|
|
1857
|
+
frozenLine,
|
|
1858
|
+
bandFill(2 + visibleColumns(frozenLine)),
|
|
1859
|
+
))
|
|
1860
|
+
}
|
|
1861
|
+
const menu = createElement(CompletionMenu, {
|
|
1862
|
+
active: menuActive,
|
|
1863
|
+
mention: mentionActive,
|
|
1864
|
+
index: completionIndex,
|
|
1865
|
+
rows: menuRows,
|
|
1866
|
+
error: mentionActive ? mentionError : undefined,
|
|
1867
|
+
})
|
|
1868
|
+
|
|
1869
|
+
// Every state reuses this exact multiline editor window. Only the caret row
|
|
1870
|
+
// owns an inverse block; non-caret rows render their text without a hidden
|
|
1871
|
+
// spacer or a second blink timer.
|
|
1872
|
+
const editorRows: ReactElement[] = []
|
|
1873
|
+
for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
|
|
1874
|
+
const row = editorViewModel.rows[index]
|
|
1875
|
+
const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
|
|
1876
|
+
const placeholder = index === 0 && value === '' && !busy && !preparingImages
|
|
1877
|
+
const tail = placeholder ? placeholderText : parts.after
|
|
1878
|
+
const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
|
|
1879
|
+
editorRows.push(createElement(
|
|
1880
|
+
Text,
|
|
1881
|
+
{ key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
1882
|
+
index === 0
|
|
1883
|
+
? preparingImages
|
|
1884
|
+
? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
|
|
1885
|
+
: submitMode === 'steer'
|
|
1886
|
+
? createElement(Text, { color: promptColor, bold: true }, '↳ ')
|
|
1887
|
+
: busy
|
|
1888
|
+
? createElement(BusyChase, { animated: animations })
|
|
1889
|
+
: createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
|
|
1890
|
+
: ' ',
|
|
1891
|
+
parts.before,
|
|
1892
|
+
parts.hasCaret
|
|
1893
|
+
? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
|
|
1894
|
+
: null,
|
|
1895
|
+
placeholder
|
|
1896
|
+
? createElement(Text, { dimColor: true }, tail)
|
|
1897
|
+
: parts.after,
|
|
1898
|
+
bandFill(consumed),
|
|
1899
|
+
))
|
|
1900
|
+
}
|
|
1901
|
+
const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
|
|
1902
|
+
|
|
1903
|
+
// The wave paints the SAME visible rows and caret site as the static path
|
|
1904
|
+
// through the ComposerWave leaf (see its comment). The child remounts on
|
|
1905
|
+
// every tier/style change, so its timeline always starts at frame 0, and
|
|
1906
|
+
// its gate cancels — never freezes — the sweep while busy, preparing
|
|
1907
|
+
// images, or animations are off.
|
|
1908
|
+
return createElement(
|
|
1909
|
+
Box,
|
|
1910
|
+
{ flexDirection: 'column' },
|
|
1911
|
+
menu,
|
|
1912
|
+
burstArmed
|
|
1913
|
+
? createElement(ComposerRainbowBurst, {
|
|
1914
|
+
key: burstKey,
|
|
1915
|
+
active: !busy && !preparingImages && animations,
|
|
1916
|
+
onSettled: () => {
|
|
1917
|
+
if (burstKey !== null) setBurstPlayedKey(burstKey)
|
|
1918
|
+
},
|
|
1919
|
+
fallback: band(staticEditor),
|
|
1920
|
+
bandWidth,
|
|
1921
|
+
bandBg,
|
|
1922
|
+
rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
|
|
1923
|
+
windowStart: editorWindowStart,
|
|
1924
|
+
caretRow: caret.row,
|
|
1925
|
+
cursor: clampedCursor,
|
|
1926
|
+
caretVisible: cursorVisible,
|
|
1927
|
+
value,
|
|
1928
|
+
promptGlyph,
|
|
1929
|
+
placeholder: placeholderText,
|
|
1930
|
+
promptColor,
|
|
1931
|
+
})
|
|
1932
|
+
: createElement(ComposerWave, {
|
|
1933
|
+
key: waveKey ?? 'static',
|
|
1934
|
+
tier: waveTier ?? 'deepseek',
|
|
1935
|
+
style: waveStyle ?? 'wave',
|
|
1936
|
+
active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
|
|
1937
|
+
onSettled: () => {
|
|
1938
|
+
if (waveKey !== null) setWavePlayedKey(waveKey)
|
|
1939
|
+
},
|
|
1940
|
+
fallback: band(staticEditor),
|
|
1941
|
+
bandWidth,
|
|
1942
|
+
bandBg,
|
|
1943
|
+
rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
|
|
1944
|
+
windowStart: editorWindowStart,
|
|
1945
|
+
caretRow: caret.row,
|
|
1946
|
+
cursor: clampedCursor,
|
|
1947
|
+
caretVisible: cursorVisible,
|
|
1948
|
+
value,
|
|
1949
|
+
promptGlyph,
|
|
1950
|
+
placeholder: placeholderText,
|
|
1951
|
+
promptColor,
|
|
1952
|
+
}),
|
|
1953
|
+
)
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/** One cached settled row: the row Box plus its roomy-prompt spacers. */
|