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.
Files changed (102) hide show
  1. package/README.en.md +5 -5
  2. package/README.md +5 -5
  3. package/lib/index.mjs +6679 -5232
  4. package/lib/startup.mjs +1 -1
  5. package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
  6. package/lib/types/app.d.ts +32 -45
  7. package/lib/types/attachments.d.ts +16 -7
  8. package/lib/types/completion.d.ts +29 -0
  9. package/lib/types/composer.d.ts +150 -0
  10. package/lib/types/git-workflow.d.ts +6 -0
  11. package/lib/types/index.d.ts +6 -188
  12. package/lib/types/locales/en.d.ts +65 -5
  13. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  14. package/lib/types/panels/completion-panel.d.ts +13 -0
  15. package/lib/types/panels/interaction-bars.d.ts +38 -0
  16. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
  17. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  18. package/lib/types/panels/model-panels.d.ts +86 -0
  19. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  20. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
  21. package/lib/types/provider-settings.d.ts +11 -0
  22. package/lib/types/render/inspector.d.ts +8 -0
  23. package/lib/types/render/status.d.ts +4 -1
  24. package/lib/types/render/text.d.ts +4 -0
  25. package/lib/types/runner/harness-gate.d.ts +83 -0
  26. package/lib/types/runner/input-history.d.ts +31 -0
  27. package/lib/types/runner/mode-cycle.d.ts +44 -0
  28. package/lib/types/runner/preferences.d.ts +40 -0
  29. package/lib/types/runner/quit.d.ts +27 -0
  30. package/lib/types/runner/search-rows.d.ts +38 -0
  31. package/lib/types/runner/session-io.d.ts +46 -0
  32. package/lib/types/runner/session-target.d.ts +42 -0
  33. package/lib/types/runner/startup-config.d.ts +33 -0
  34. package/lib/types/runner/submissions.d.ts +87 -0
  35. package/lib/types/session/attach.d.ts +39 -0
  36. package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
  37. package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
  38. package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
  39. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  40. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  41. package/lib/types/settings-file.d.ts +10 -0
  42. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  43. package/lib/types/ui/panel-gap.d.ts +6 -0
  44. package/lib/types/ui/query-editor.d.ts +10 -0
  45. package/lib/types/ui/styled-rows.d.ts +8 -0
  46. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  47. package/lib/types/ui/ui-contract.d.ts +12 -0
  48. package/lib/types/ui/use-frames.d.ts +6 -0
  49. package/lib/types/ui/use-stable-input.d.ts +7 -0
  50. package/lib/types/version.d.ts +2 -0
  51. package/package.json +7 -5
  52. package/src/app.ts +707 -4108
  53. package/src/attachments.ts +65 -19
  54. package/src/completion.ts +117 -0
  55. package/src/composer.ts +1956 -0
  56. package/src/git-workflow.ts +18 -0
  57. package/src/index.ts +164 -645
  58. package/src/input-split.ts +24 -4
  59. package/src/internals.ts +1 -1
  60. package/src/locales/en.ts +66 -5
  61. package/src/locales/zh.ts +66 -5
  62. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
  63. package/src/panels/completion-panel.ts +79 -0
  64. package/src/panels/interaction-bars.ts +567 -0
  65. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
  66. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  67. package/src/panels/model-panels.ts +1021 -0
  68. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  69. package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
  70. package/src/provider-settings.ts +38 -0
  71. package/src/rainbow.ts +13 -3
  72. package/src/render/inspector.ts +23 -0
  73. package/src/render/status.ts +80 -29
  74. package/src/render/text.ts +10 -1
  75. package/src/runner/harness-gate.ts +168 -0
  76. package/src/runner/input-history.ts +77 -0
  77. package/src/runner/mode-cycle.ts +49 -0
  78. package/src/runner/preferences.ts +67 -0
  79. package/src/runner/quit.ts +53 -0
  80. package/src/runner/search-rows.ts +55 -0
  81. package/src/runner/session-io.ts +206 -0
  82. package/src/runner/session-target.ts +81 -0
  83. package/src/runner/startup-config.ts +54 -0
  84. package/src/runner/submissions.ts +157 -0
  85. package/src/session/attach.ts +87 -0
  86. package/src/{fork.ts → session/fork.ts} +11 -7
  87. package/src/{history.ts → session/history.ts} +14 -0
  88. package/src/{session-directory.ts → session/session-directory.ts} +83 -4
  89. package/src/{session-switch.ts → session/session-switch.ts} +14 -0
  90. package/src/{store.ts → session/store.ts} +20 -2
  91. package/src/{subagents.ts → session/subagents.ts} +12 -1
  92. package/src/settings-file.ts +19 -1
  93. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  94. package/src/ui/panel-gap.ts +9 -0
  95. package/src/ui/query-editor.ts +16 -0
  96. package/src/ui/styled-rows.ts +124 -0
  97. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  98. package/src/ui/ui-contract.ts +10 -0
  99. package/src/ui/use-frames.ts +23 -0
  100. package/src/ui/use-stable-input.ts +17 -0
  101. package/src/version.ts +5 -0
  102. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
@@ -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. */