dsh-code 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -40,16 +40,20 @@ import type { TranscriptStore } from './store.ts'
40
40
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
41
41
  import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
42
  import {
43
- busyChaseFrame,
44
- caretVisible,
45
- DEEPSEEK_WAVE_TICK_MS,
46
- deepseekWaveColumnBg,
47
- deepseekWaveDuration,
48
- deepseekWaveSpark,
49
- deepseekWaveStyleRandom,
50
- deepseekWaveTier,
51
- deepseekWaveWordHue,
52
- deepseekWaveWordVisible,
43
+ busyChaseFrame,
44
+ BUSY_CHASE_TICK_MS,
45
+ caretVisible,
46
+ DEEP_DIVING_SHIMMER_TICK_MS,
47
+ DEEPSEEK_WAVE_TICK_MS,
48
+ deepseekWaveColumnBg,
49
+ deepseekWaveDuration,
50
+ deepseekWaveSpark,
51
+ deepseekWaveStyleRandom,
52
+ deepseekWaveTier,
53
+ deepseekWaveWordHue,
54
+ deepseekWaveWordVisible,
55
+ deepDivingGradientColor,
56
+ deepDivingSparkColor,
53
57
  effortAboveHigh,
54
58
  isOfficialDeepSeekLabel,
55
59
  type DeepseekWaveStyle,
@@ -61,7 +65,7 @@ import type { ModelDirectory, ModelRow } from './models.ts'
61
65
  import type { ProviderConfiguration, ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
62
66
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
63
67
  import type { SkillsView, SkillRow } from './skills.ts'
64
- import type { MentionCandidate } from './mentions.ts'
68
+ import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
65
69
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
66
70
  import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
67
71
  import type { PresetRow } from './presets.ts'
@@ -140,7 +144,16 @@ import {
140
144
  type StatusTone,
141
145
  } from './render/status.ts'
142
146
  import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
143
- import { normalizeKeyboardChunk, PASTE_END_MARKER, PASTE_START_MARKER, stripPasteMarkers } from './keyboard.ts'
147
+ import {
148
+ isVsCodeTerminalEnv,
149
+ normalizeKeyboardChunk,
150
+ PASTE_END_MARKER,
151
+ PASTE_START_MARKER,
152
+ stripPasteMarkers,
153
+ stripTerminalFocusEvents,
154
+ tokenizeRawEditorChunk,
155
+ type RawEditorToken,
156
+ } from './keyboard.ts'
144
157
  import {
145
158
  clampScroll,
146
159
  followInspectorCursor,
@@ -168,18 +181,22 @@ import {
168
181
  deleteBackward,
169
182
  deleteForward,
170
183
  deleteLastGrapheme,
171
- deleteWordBackward,
172
- deleteWordForward,
173
- editorModel,
184
+ deleteWordBackward,
185
+ deleteWordForward,
186
+ editorModel,
187
+ editorRowParts,
174
188
  insertText,
175
189
  type EditResult,
176
190
  killToLineEnd,
177
191
  killToLineStart,
178
- lineBounds,
179
- moveCursorBy,
180
- moveCursorVertically,
181
- moveWordLeft,
182
- moveWordRight,
192
+ moveCursorBy,
193
+ moveCursorVertically,
194
+ moveToLineEnd,
195
+ moveToLineStart,
196
+ moveWordLeft,
197
+ moveWordRight,
198
+ remapStableRange,
199
+ replaceRangePreservingCursor,
183
200
  sanitizeDraftText,
184
201
  shouldRecallNavigate,
185
202
  splitGraphemes,
@@ -265,7 +282,7 @@ export interface AppProps {
265
282
  /** Validate draft image paths without committing attachment objects. */
266
283
  inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
267
284
  /** Validate, normalize and persist images immediately before submission. */
268
- prepareImages(paths: readonly string[]): Promise<readonly ImageBlock[]>
285
+ prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
269
286
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
270
287
  selectModel(row: ModelRow, effortId?: string): string
271
288
  /** The /subagent override label, '' when delegated agents follow the current model. */
@@ -355,14 +372,15 @@ function padColumns(text: string, width: number): string {
355
372
  }
356
373
 
357
374
  /** Interval-driven frame counter for one self-contained animated leaf. */
358
- function useFrames(intervalMs: number): number {
359
- const [tick, setTick] = useState(0)
360
- useEffect(() => {
361
- const id = setInterval(() => setTick(current => current + 1), intervalMs)
375
+ function useFrames(intervalMs: number, active = true): number {
376
+ const [tick, setTick] = useState(0)
377
+ useEffect(() => {
378
+ if (!active) return
379
+ const id = setInterval(() => setTick(current => current + 1), intervalMs)
362
380
  return () => {
363
381
  clearInterval(id)
364
382
  }
365
- }, [intervalMs])
383
+ }, [active, intervalMs])
366
384
  return tick
367
385
  }
368
386
 
@@ -381,16 +399,11 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
381
399
  useInput(stableHandler, { isActive: active })
382
400
  }
383
401
 
384
- /**
385
- * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
386
- * ring trail clockwise around the eight outer positions (8 frames × 125ms =
387
- * the web's 1s cycle). Replaces the plain busy ellipsis as the composer's
388
- * prompt marker and leads the Deep-diving line.
389
- */
390
- function BusyChase(): ReactElement {
391
- const tick = useFrames(125)
392
- return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
393
- }
402
+ /** The original web StateDot chase used by the busy composer marker. */
403
+ function BusyChase(): ReactElement {
404
+ const tick = useFrames(BUSY_CHASE_TICK_MS)
405
+ return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
406
+ }
394
407
 
395
408
  /** Blinking block caret appended to streaming text. */
396
409
  function Caret(): ReactElement {
@@ -398,32 +411,54 @@ function Caret(): ReactElement {
398
411
  return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
399
412
  }
400
413
 
401
- /** Blinking input cursor: inverse block while the caret phase is on. */
402
- function CursorBlock({ char }: { char: string }): ReactElement {
403
- const tick = useFrames(530)
404
- return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
405
- }
414
+ /** One resettable input-caret phase shared by the entire composer. */
415
+ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
416
+ const [epoch, setEpoch] = useState(0)
417
+ const [visible, setVisible] = useState(true)
418
+ useEffect(() => {
419
+ setVisible(true)
420
+ if (!active) return
421
+ const id = setInterval(() => setVisible(current => !current), 530)
422
+ return () => {
423
+ clearInterval(id)
424
+ }
425
+ }, [active, epoch])
426
+ const reset = useCallback((): void => {
427
+ setVisible(true)
428
+ setEpoch(current => current + 1)
429
+ }, [])
430
+ return { visible, reset }
431
+ }
406
432
 
407
433
  /**
408
- * The busy line, web TurnStatus contract: the StateDot chase leads the plain
409
- * `Deep diving...` label, with the elapsed clock appended only once the turn
410
- * has clearly been running (15s) — anchored to `turn/start` so a resumed
411
- * mid-turn keeps the real time.
412
- */
413
- function DeepDivingLine({ since }: { since: number }): ReactElement {
414
- useFrames(1000)
415
- const elapsed = since === 0 ? 0 : Date.now() - since
416
- return createElement(
417
- Box,
418
- { flexDirection: 'row' },
419
- createElement(BusyChase),
420
- createElement(
421
- Text,
422
- { dimColor: true },
423
- elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
424
- ),
425
- )
426
- }
434
+ * The busy line, web TurnStatus contract: a continuously moving blue gradient
435
+ * paints the complete `Deep diving...` label, with the elapsed clock appended
436
+ * only once the turn has clearly been running (15s) — anchored to `turn/start`
437
+ * so a resumed mid-turn keeps the real time.
438
+ */
439
+ function DeepDivingLine({ since }: { since: number }): ReactElement {
440
+ const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
441
+ const elapsed = since === 0 ? 0 : Date.now() - since
442
+ const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
443
+ const palette = getPalette()
444
+ const graphemes = splitGraphemes(text)
445
+ return createElement(
446
+ Text,
447
+ { wrap: 'truncate-end' },
448
+ ...graphemes.map((grapheme, index) => {
449
+ const sparkle = grapheme.text === ''
450
+ return createElement(
451
+ Text,
452
+ {
453
+ key: `${grapheme.start}-${grapheme.end}`,
454
+ color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
455
+ bold: sparkle || undefined,
456
+ },
457
+ grapheme.text,
458
+ )
459
+ }),
460
+ )
461
+ }
427
462
 
428
463
  /**
429
464
  * The streaming buffer rendered with a hard size cap: the live region must
@@ -2054,83 +2089,13 @@ function verboseLine(text: string, columns: number): string {
2054
2089
  return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
2055
2090
  }
2056
2091
 
2057
- /**
2058
- * Keys Ink 5's parser cannot express at the useInput boundary: Home/End
2059
- * arrive with `input === ''` and no flag, and Backspace vs Delete both
2060
- * collapse onto `key.delete`. The composer patches `stdin.read` — the one
2061
- * choke point every Ink input chunk already passes through — and annotates
2062
- * the exact sequences the editor must own; Ink's own view of the same chunk
2063
- * is a no-op for every one of them.
2064
- */
2065
- type RawKeyAnnotation =
2066
- | 'home'
2067
- | 'end'
2068
- | 'delete-backward'
2069
- | 'delete-word-backward'
2070
- | 'delete-forward'
2071
- | 'delete-word-forward'
2072
- | undefined
2073
-
2074
- /** Identify one whole-chunk key sequence Ink drops or blurs. */
2075
- function annotateRawKey(chunk: string): RawKeyAnnotation {
2076
- switch (chunk) {
2077
- case '':
2078
- return 'delete-backward'
2079
- case '':
2080
- case '':
2081
- return 'delete-word-backward'
2082
- case '[3~':
2083
- case '[3;2~':
2084
- return 'delete-forward'
2085
- case '[3;3~':
2086
- case '[3;5~':
2087
- return 'delete-word-forward'
2088
- case '':
2089
- case '[1~':
2090
- case '[7~':
2091
- case 'OH':
2092
- return 'home'
2093
- case '':
2094
- case '[4~':
2095
- case '[8~':
2096
- case 'OF':
2097
- return 'end'
2098
- default:
2099
- return undefined
2100
- }
2101
- }
2102
-
2103
- /**
2104
- * One-row editor window keeping the logical cursor visible in long drafts.
2105
- * The caret and its surroundings slice at grapheme boundaries: splitting a
2106
- * star-plane surrogate pair would render an isolated half under the block
2107
- * caret with a width the terminal never draws.
2108
- */
2109
- export function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
2110
- const width = Math.max(1, columns)
2111
- const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
2112
- const site = clampCursor(value, cursor)
2113
- const caretSpan = splitGraphemes(value).find(span => span.start === site)
2114
- const caret = caretSpan === undefined ? ' ' : normalize(caretSpan.text)
2115
- const rest = value.slice(caretSpan === undefined ? site : caretSpan.end)
2116
- const remaining = Math.max(0, width - visibleColumns(caret))
2117
- const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(rest)))
2118
- const beforeBudget = Math.max(0, remaining - afterBudget)
2119
- const before = beforeBudget === 0
2120
- ? ''
2121
- : displayTail(normalize(value.slice(0, site)), beforeBudget, 1).text
2122
- const after = afterBudget === 0
2123
- ? ''
2124
- : truncateColumns(normalize(rest), afterBudget)
2125
- return { before, caret, after }
2126
- }
2127
-
2128
- /** The empty-composer placeholder text (shared by the static and wave paths). */
2129
- const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2092
+ /** The empty-composer placeholder text (shared by the static and wave paths). */
2093
+ const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2130
2094
 
2131
2095
  /** One physical cell of the wave-painted composer row: a char plus styles. */
2132
- interface ComposerCell {
2133
- char: string
2096
+ interface ComposerCell {
2097
+ char: string
2098
+ width?: number
2134
2099
  color?: string
2135
2100
  backgroundColor?: string
2136
2101
  bold?: boolean
@@ -2515,7 +2480,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2515
2480
  refresh(): void
2516
2481
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
2517
2482
  inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
2518
- prepareImages(paths: readonly string[]): Promise<readonly ImageBlock[]>
2483
+ prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
2519
2484
  cyclePermission(): string
2520
2485
  exportTranscript(argument: string): Promise<void>
2521
2486
  renameTitle(argument: string): string
@@ -2546,9 +2511,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2546
2511
  maxRows: number
2547
2512
  /** Reports the editor's current physical row count so the live budget stays exact. */
2548
2513
  onEditorRows(rows: number): void
2549
- }): ReactElement {
2550
- const columns = useStdout().stdout?.columns ?? 80
2514
+ }): ReactElement {
2515
+ const columns = useStdout().stdout?.columns ?? 80
2516
+ const editorColumns = Math.max(1, columns - 6)
2551
2517
  const stdin = useStdin().stdin
2518
+ const focusReporting = isVsCodeTerminalEnv()
2552
2519
  const [value, setValue] = useState('')
2553
2520
  const [cursor, setCursor] = useState(0)
2554
2521
  const valueRef = useRef(value)
@@ -2559,6 +2526,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2559
2526
  const draftImagesRef = useRef(draftImages)
2560
2527
  draftImagesRef.current = draftImages
2561
2528
  const [preparingImages, setPreparingImages] = useState(false)
2529
+ const prepareAbortRef = useRef<AbortController | undefined>(undefined)
2530
+ const prepareEpochRef = useRef(0)
2531
+ const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
2532
+ useEffect(() => () => {
2533
+ prepareEpochRef.current += 1
2534
+ prepareAbortRef.current?.abort()
2535
+ }, [])
2562
2536
  // Codex textarea editing state: a single-entry kill buffer, the vertical
2563
2537
  // move's preferred display column, the editor's scroll window, and the
2564
2538
  // bracketed-paste marker state. All of it is editor-local; nothing here
@@ -2569,11 +2543,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2569
2543
  const pasteBracketRef = useRef(false)
2570
2544
  /** Cancels the pending lost-paste safety timer (undefined when disarmed). */
2571
2545
  const pasteBracketCancelRef = useRef<(() => void) | undefined>(undefined)
2572
- /** Annotation of the stdin chunk Ink is about to deliver to useInput. */
2573
- const rawAnnotation = useRef<RawKeyAnnotation>(undefined)
2546
+ /** Ordered editor tokens from the stdin chunk Ink is about to deliver. */
2547
+ const rawEditorTokens = useRef<readonly RawEditorToken[] | undefined>(undefined)
2548
+ /** VS Code focus state from xterm focus-report events; starts focused. */
2549
+ const terminalFocusedRef = useRef(true)
2574
2550
  // Codex shell-style recall: the navigation cursor, the saved draft restored
2575
2551
  // on Down past the newest entry, and the boundary-gate anchor.
2576
- const recall = useRef<RecallState>(beginRecall([], ''))
2552
+ const recall = useRef<RecallState>(beginRecall([], ''))
2553
+
2554
+ useEffect(() => {
2555
+ preferredColumnRef.current = null
2556
+ }, [editorColumns])
2577
2557
 
2578
2558
  // A /history panel acceptance lands as a fill: place the sanitized text at
2579
2559
  // the end of the composer and resume recall from that entry.
@@ -2582,8 +2562,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2582
2562
  const safe = sanitizeDraftText(historyFill.text)
2583
2563
  draftImagesRef.current = []
2584
2564
  setDraftImages([])
2585
- setValue(safe)
2586
- setCursor(safe.length)
2565
+ valueRef.current = safe
2566
+ cursorRef.current = safe.length
2567
+ setValue(safe)
2568
+ setCursor(safe.length)
2569
+ resetCursorBlink()
2587
2570
  preferredColumnRef.current = null
2588
2571
  setDismissedMenuValue(undefined)
2589
2572
  recall.current = {
@@ -2593,7 +2576,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2593
2576
  lastRecalled: safe,
2594
2577
  }
2595
2578
  historyConsumed()
2596
- }, [historyFill, recallSpace, historyConsumed])
2579
+ }, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
2597
2580
 
2598
2581
  useEffect(() => {
2599
2582
  setDraftImages((current) => {
@@ -2607,26 +2590,30 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2607
2590
  // as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
2608
2591
  // insert as draft text.
2609
2592
  // Patch stdin.read — the single choke point Ink's input loop pulls every
2610
- // chunk through — to first rewrite decodable CSI-u sequences to their
2611
- // legacy bytes, then annotate the resulting chunk before Ink emits the
2612
- // matching 'input' event, so the useInput handler below reads the
2613
- // annotation for exactly the chunk it is processing. Ink receives and
2614
- // parses the normalized string; every other byte passes through untouched.
2593
+ // chunk through — to first rewrite decodable CSI-u sequences to their
2594
+ // legacy bytes, then tokenize editor-only sequences before Ink emits the
2595
+ // matching input event. Batched Home/End/Delete/Backspace actions remain
2596
+ // ordered even though Ink invokes useInput only once for the whole chunk.
2615
2597
  useEffect(() => {
2616
2598
  if (stdin === undefined) return
2617
2599
  const originalRead = stdin.read.bind(stdin)
2618
2600
  const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
2619
- const chunk = originalRead(...args)
2620
- if (chunk === null) return chunk
2621
- const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
2622
- rawAnnotation.current = annotateRawKey(normalized)
2623
- return normalized
2601
+ const chunk = originalRead(...args)
2602
+ if (chunk === null) return chunk
2603
+ const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
2604
+ const input = focusReporting
2605
+ ? stripTerminalFocusEvents(normalized, focused => {
2606
+ terminalFocusedRef.current = focused
2607
+ })
2608
+ : normalized
2609
+ rawEditorTokens.current = tokenizeRawEditorChunk(input)
2610
+ return input
2624
2611
  } as typeof stdin.read
2625
2612
  stdin.read = patchedRead
2626
2613
  return () => {
2627
2614
  stdin.read = originalRead as typeof stdin.read
2628
2615
  }
2629
- }, [stdin])
2616
+ }, [focusReporting, stdin])
2630
2617
 
2631
2618
  // Keep the navigation's recall space fresh while browsing state survives
2632
2619
  // (new local submissions extend the space; the index stays valid unless
@@ -2651,6 +2638,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2651
2638
  : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
2652
2639
  const mentionActive = mentionToken !== undefined
2653
2640
  const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
2641
+ const mentionRequestRef = useRef(0)
2654
2642
 
2655
2643
  const sameImagePath = (left: string, right: string): boolean => (
2656
2644
  process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
@@ -2680,6 +2668,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2680
2668
  }
2681
2669
 
2682
2670
  const insertDroppedImages = (paths: readonly string[]): void => {
2671
+ const originalValue = valueRef.current
2672
+ const originalCursor = cursorRef.current
2683
2673
  notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
2684
2674
  void inspectImages(paths).then((inspected) => {
2685
2675
  const additions: DraftImage[] = []
@@ -2694,14 +2684,23 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2694
2684
  notify('those images are already attached', 'warning')
2695
2685
  return
2696
2686
  }
2697
- const at = cursorRef.current
2698
2687
  const current = valueRef.current
2688
+ const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
2689
+ if (anchor === undefined) {
2690
+ notify('draft changed at the image drop point; drop the images again', 'warning')
2691
+ return
2692
+ }
2693
+ const at = anchor.start
2699
2694
  const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
2700
- const next = current.slice(0, at) + insertion + current.slice(at)
2701
- valueRef.current = next
2702
- cursorRef.current = at + insertion.length
2703
- setValue(next)
2704
- setCursor(cursorRef.current)
2695
+ const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, insertion)
2696
+ const nextCursor = current === originalValue && cursorRef.current === originalCursor
2697
+ ? at + insertion.length
2698
+ : edit.cursor
2699
+ valueRef.current = edit.value
2700
+ cursorRef.current = nextCursor
2701
+ setValue(edit.value)
2702
+ setCursor(nextCursor)
2703
+ resetCursorBlink()
2705
2704
  const nextImages = [...draftImagesRef.current, ...additions]
2706
2705
  draftImagesRef.current = nextImages
2707
2706
  setDraftImages(nextImages)
@@ -2711,28 +2710,38 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2711
2710
  })
2712
2711
  }
2713
2712
 
2714
- useEffect(() => {
2715
- if (!active || !mentionActive) {
2716
- setMentionRows([])
2717
- return
2718
- }
2719
- const controller = new AbortController()
2720
- setMentionRows([])
2721
- loadMentions(mentionToken.query, controller.signal).then(
2722
- rows => setMentionRows(rows),
2723
- () => {},
2724
- )
2725
- return () => {
2726
- controller.abort()
2727
- }
2728
- }, [active, mentionActive, mentionToken?.query])
2713
+ useEffect(() => {
2714
+ const requestId = mentionRequestRef.current + 1
2715
+ mentionRequestRef.current = requestId
2716
+ if (!active || !mentionActive) {
2717
+ setMentionRows([])
2718
+ return
2719
+ }
2720
+ const controller = new AbortController()
2721
+ const query = mentionToken.query
2722
+ const timer = setTimeout(() => {
2723
+ void loadMentions(query, controller.signal).then(
2724
+ rows => {
2725
+ if (!controller.signal.aborted && mentionRequestRef.current === requestId) setMentionRows(rows)
2726
+ },
2727
+ () => {},
2728
+ )
2729
+ }, 50)
2730
+ return () => {
2731
+ clearTimeout(timer)
2732
+ controller.abort()
2733
+ }
2734
+ }, [active, mentionActive, mentionToken?.query])
2729
2735
 
2730
2736
  // Codex routes keys to the topmost surface first. Completion therefore
2731
2737
  // remains available while a turn runs, and Esc dismisses it before the
2732
- // same key is allowed to interrupt the turn.
2733
- const menuActive = (slashActive || mentionActive) && dismissedMenuValue !== value
2734
- const menuRows: readonly CompletionCandidate[] = mentionActive
2735
- ? mentionRows.map(row => ({
2738
+ // same key is allowed to interrupt the turn.
2739
+ const menuActive = !preparingImages && (slashActive || mentionActive) && dismissedMenuValue !== value
2740
+ const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
2741
+ ? mentionRows.filter(row => row.kind !== 'session')
2742
+ : mentionRows
2743
+ const menuRows: readonly CompletionCandidate[] = mentionActive
2744
+ ? visibleMentionRows.map(row => ({
2736
2745
  label: row.label.startsWith('@')
2737
2746
  ? row.label
2738
2747
  : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
@@ -2741,37 +2750,45 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2741
2750
  }))
2742
2751
  : candidates
2743
2752
 
2744
- /** Accept the highlighted completion-menu candidate into the draft. */
2745
- const acceptMenuCandidate = (): void => {
2746
- if (mentionActive && mentionToken !== undefined) {
2747
- const row = mentionRows[completionIndex % mentionRows.length]
2753
+ /** Accept the highlighted completion-menu candidate into the draft. */
2754
+ const acceptMenuCandidate = (): void => {
2755
+ if (mentionActive && mentionToken !== undefined) {
2756
+ if (visibleMentionRows.length === 0) return
2757
+ const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
2748
2758
  if (row !== undefined) {
2749
2759
  if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
2750
2760
  const tokenText = value.slice(mentionToken.start, cursor)
2751
2761
  const start = mentionToken.start
2762
+ const originalValue = value
2752
2763
  notify(`checking image ${basename(row.path)}…`)
2753
2764
  void inspectImages([row.path]).then((inspected) => {
2754
2765
  const inspection = inspected[0]
2755
2766
  if (inspection === undefined) return
2756
2767
  const current = valueRef.current
2757
- if (current.slice(start, start + tokenText.length) !== tokenText) return
2768
+ const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
2769
+ if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
2770
+ notify('draft changed around the image mention; select it again', 'warning')
2771
+ return
2772
+ }
2758
2773
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2759
- const next = current.slice(0, start) + current.slice(start + tokenText.length)
2760
- valueRef.current = next
2761
- cursorRef.current = start
2762
- setValue(next)
2763
- setCursor(start)
2764
- setDismissedMenuValue(next)
2774
+ const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, '')
2775
+ valueRef.current = edit.value
2776
+ cursorRef.current = edit.cursor
2777
+ setValue(edit.value)
2778
+ setCursor(edit.cursor)
2779
+ resetCursorBlink()
2780
+ setDismissedMenuValue(edit.value)
2765
2781
  notify(`${inspection.name} is already attached`, 'warning')
2766
2782
  return
2767
2783
  }
2768
2784
  const marker = uniqueImageMarker(inspection.name, 'mention')
2769
- const next = current.slice(0, start) + marker + current.slice(start + tokenText.length)
2770
- valueRef.current = next
2771
- cursorRef.current = start + marker.length
2772
- setValue(next)
2773
- setCursor(cursorRef.current)
2774
- setDismissedMenuValue(next)
2785
+ const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, marker)
2786
+ valueRef.current = edit.value
2787
+ cursorRef.current = edit.cursor
2788
+ setValue(edit.value)
2789
+ setCursor(edit.cursor)
2790
+ resetCursorBlink()
2791
+ setDismissedMenuValue(edit.value)
2775
2792
  registerDraftImage(inspection, marker)
2776
2793
  notify(`${inspection.name} ready for the next message`)
2777
2794
  }, (reason: unknown) => {
@@ -2783,44 +2800,149 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2783
2800
  }
2784
2801
  // Session rows carry the canonical @[label](dsh-session:…) token;
2785
2802
  // file rows insert `@path` (directories keep their trailing slash).
2786
- const insertion = row.label.startsWith('@')
2787
- ? row.label
2788
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2789
- setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2790
- setCursor(mentionToken.start + insertion.length)
2791
- }
2792
- } else {
2793
- const candidate = candidates[completionIndex % candidates.length]
2794
- if (candidate !== undefined) {
2795
- setValue(`${candidate.label} `)
2796
- setCursor(candidate.label.length + 1)
2797
- }
2803
+ const insertion = row.label.startsWith('@')
2804
+ ? row.label
2805
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2806
+ const nextValue = value.slice(0, mentionToken.start) + insertion + value.slice(cursor)
2807
+ const nextCursor = mentionToken.start + insertion.length
2808
+ valueRef.current = nextValue
2809
+ cursorRef.current = nextCursor
2810
+ setValue(nextValue)
2811
+ setCursor(nextCursor)
2812
+ resetCursorBlink()
2813
+ }
2814
+ } else {
2815
+ if (candidates.length === 0) return
2816
+ const candidate = candidates[completionIndex % candidates.length]
2817
+ if (candidate !== undefined) {
2818
+ const nextValue = `${candidate.label} `
2819
+ const nextCursor = candidate.label.length + 1
2820
+ valueRef.current = nextValue
2821
+ cursorRef.current = nextCursor
2822
+ setValue(nextValue)
2823
+ setCursor(nextCursor)
2824
+ resetCursorBlink()
2825
+ }
2798
2826
  }
2799
2827
  setCompletionIndex(0)
2800
2828
  setDismissedMenuValue(undefined)
2801
2829
  }
2802
2830
 
2803
2831
  /** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
2804
- const applyEdit = (edit: EditResult): void => {
2805
- if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2806
- setValue(edit.value)
2807
- setCursor(edit.cursor)
2832
+ const applyEdit = (edit: EditResult): void => {
2833
+ if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2834
+ valueRef.current = edit.value
2835
+ cursorRef.current = edit.cursor
2836
+ setValue(edit.value)
2837
+ setCursor(edit.cursor)
2838
+ resetCursorBlink()
2808
2839
  preferredColumnRef.current = null
2809
2840
  setCompletionIndex(0)
2810
2841
  setDismissedMenuValue(undefined)
2811
2842
  }
2812
2843
 
2813
2844
  /** Move the cursor without editing; horizontal moves clear the column preference. */
2814
- const moveCursorTo = (next: number): void => {
2815
- if (next === cursor) return
2816
- setCursor(next)
2817
- preferredColumnRef.current = null
2818
- }
2819
-
2820
- useInput((input, key) => {
2845
+ const moveCursorTo = (next: number): void => {
2846
+ resetCursorBlink()
2847
+ if (next === cursorRef.current) return
2848
+ cursorRef.current = next
2849
+ setCursor(next)
2850
+ preferredColumnRef.current = null
2851
+ }
2852
+
2853
+ /** Apply an ordered raw-key batch against one current draft snapshot. */
2854
+ const applyRawEditorTokens = (tokens: readonly RawEditorToken[]): void => {
2855
+ let nextValue = valueRef.current
2856
+ let nextCursor = cursorRef.current
2857
+ for (const token of tokens) {
2858
+ if (token.kind === 'text') {
2859
+ const edit = insertText(nextValue, nextCursor, token.text)
2860
+ nextValue = edit.value
2861
+ nextCursor = edit.cursor
2862
+ continue
2863
+ }
2864
+ if (token.kind === 'home') {
2865
+ nextCursor = moveToLineStart(nextValue, nextCursor, false)
2866
+ continue
2867
+ }
2868
+ if (token.kind === 'end') {
2869
+ nextCursor = moveToLineEnd(nextValue, nextCursor, false)
2870
+ continue
2871
+ }
2872
+ const edit = token.kind === 'delete-backward'
2873
+ ? deleteBackward(nextValue, nextCursor)
2874
+ : token.kind === 'delete-word-backward'
2875
+ ? deleteWordBackward(nextValue, nextCursor)
2876
+ : token.kind === 'delete-forward'
2877
+ ? deleteForward(nextValue, nextCursor)
2878
+ : deleteWordForward(nextValue, nextCursor)
2879
+ if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2880
+ nextValue = edit.value
2881
+ nextCursor = edit.cursor
2882
+ }
2883
+ valueRef.current = nextValue
2884
+ cursorRef.current = nextCursor
2885
+ setValue(nextValue)
2886
+ setCursor(nextCursor)
2887
+ resetCursorBlink()
2888
+ preferredColumnRef.current = null
2889
+ setCompletionIndex(0)
2890
+ setDismissedMenuValue(undefined)
2891
+ }
2892
+
2893
+ const cancelImageSubmission = (): void => {
2894
+ prepareEpochRef.current += 1
2895
+ prepareAbortRef.current?.abort()
2896
+ prepareAbortRef.current = undefined
2897
+ setPreparingImages(false)
2898
+ dismissNotice()
2899
+ notify('image submission cancelled', 'warning')
2900
+ }
2901
+
2902
+ /** Move through visual rows first, then cross history at the true edge. */
2903
+ const navigateVertical = (direction: -1 | 1): void => {
2904
+ const currentValue = valueRef.current
2905
+ const currentCursor = cursorRef.current
2906
+ const model = editorModel(currentValue, editorColumns)
2907
+ const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
2908
+ const next = moveCursorVertically(model, currentCursor, preferred, direction)
2909
+ if (next !== currentCursor) {
2910
+ cursorRef.current = next
2911
+ setCursor(next)
2912
+ resetCursorBlink()
2913
+ preferredColumnRef.current = preferred
2914
+ return
2915
+ }
2916
+ if (recall.current.entries.length > 0
2917
+ && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
2918
+ const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
2919
+ recall.current = step.state
2920
+ if (step.entry !== undefined) {
2921
+ const safe = sanitizeDraftText(step.entry)
2922
+ valueRef.current = safe
2923
+ cursorRef.current = safe.length
2924
+ setValue(safe)
2925
+ setCursor(safe.length)
2926
+ preferredColumnRef.current = null
2927
+ setDismissedMenuValue(undefined)
2928
+ }
2929
+ }
2930
+ resetCursorBlink()
2931
+ }
2932
+
2933
+ useStableInput((input, key) => {
2821
2934
  // Modal ownership: approval/question/model dialogs consume all keys.
2822
2935
  if (!active) return
2823
- if (preparingImages) return
2936
+ // React may not have committed the previous Tab completion render before
2937
+ // the next terminal byte arrives. Read the synchronous editor refs so a
2938
+ // completion followed immediately by text edits never uses stale closure
2939
+ // state.
2940
+ const liveValue = valueRef.current
2941
+ const liveCursor = cursorRef.current
2942
+ if (preparingImages) {
2943
+ if (key.escape || (key.ctrl && input === 'c')) cancelImageSubmission()
2944
+ return
2945
+ }
2824
2946
  // Deletion confirm owns the box: y proceeds, anything else cancels.
2825
2947
  // Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
2826
2948
  // as the box's own prompt, not an invisible panel keypress.
@@ -2842,10 +2964,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2842
2964
  }
2843
2965
  return
2844
2966
  }
2845
- // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
2846
- if (key.ctrl && input === 'r') {
2847
- toggleReasoning()
2848
- return
2967
+ // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
2968
+ if (key.ctrl && input === 'r') {
2969
+ if (focusReporting && !terminalFocusedRef.current) return
2970
+ toggleReasoning()
2971
+ return
2849
2972
  }
2850
2973
  // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
2851
2974
  // adapted to append-only static rows): one history entry at a time with
@@ -2858,11 +2981,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2858
2981
  // cancelled, a non-empty draft is cleared, and only an idle empty input
2859
2982
  // exits. Ctrl+D always means exit but refuses mid-turn.
2860
2983
  if (key.ctrl && input === 'c') {
2861
- if (busy) {
2862
- interrupt()
2863
- } else if (value !== '') {
2984
+ if (busy) {
2985
+ interrupt()
2986
+ } else if (liveValue !== '') {
2987
+ valueRef.current = ''
2988
+ cursorRef.current = 0
2864
2989
  setValue('')
2865
2990
  setCursor(0)
2991
+ resetCursorBlink()
2866
2992
  draftImagesRef.current = []
2867
2993
  setDraftImages([])
2868
2994
  setCompletionIndex(0)
@@ -2875,8 +3001,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2875
3001
  if (key.ctrl && input === 'd') {
2876
3002
  // Codex: Ctrl+D deletes forward while a draft exists; the app-level
2877
3003
  // exit only fires from an empty composer.
2878
- if (value !== '') {
2879
- applyEdit(deleteForward(value, cursor))
3004
+ if (liveValue !== '') {
3005
+ applyEdit(deleteForward(liveValue, liveCursor))
2880
3006
  return
2881
3007
  }
2882
3008
  if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
@@ -2885,7 +3011,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2885
3011
  }
2886
3012
  if (key.escape) {
2887
3013
  if (menuActive) {
2888
- setDismissedMenuValue(value)
3014
+ setDismissedMenuValue(liveValue)
2889
3015
  return
2890
3016
  }
2891
3017
  if (hasNotice) {
@@ -2897,14 +3023,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2897
3023
  }
2898
3024
  // Delete on the empty composer cancels the newest queued message (the
2899
3025
  // web queue-mirror contract: the durable splice drops the pending row).
2900
- if (key.delete && value === '' && queued.length > 0) {
3026
+ if (key.delete && liveValue === '' && queued.length > 0) {
2901
3027
  cancelQueued(queued[queued.length - 1]!.messageId)
2902
3028
  return
2903
3029
  }
2904
3030
  if (key.return) {
2905
3031
  // A newline inside an open bracketed paste inserts; it never submits.
2906
3032
  if (pasteBracketRef.current) {
2907
- applyEdit(insertText(value, cursor, '\n'))
3033
+ applyEdit(insertText(liveValue, liveCursor, '\n'))
2908
3034
  return
2909
3035
  }
2910
3036
  // Enter on an open completion menu accepts the highlighted candidate
@@ -2913,18 +3039,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2913
3039
  // exactly, in which case Enter submits it (typing a full "/effort" and
2914
3040
  // pressing return must run the command, not re-accept its own text).
2915
3041
  if (menuActive) {
2916
- const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === value)
3042
+ const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
2917
3043
  if (!exactSlash) {
2918
3044
  acceptMenuCandidate()
2919
3045
  return
2920
3046
  }
2921
3047
  }
2922
- const text = value.trim()
3048
+ const text = liveValue.trim()
2923
3049
  if (draftImagesRef.current.length > 0) {
3050
+ const controller = new AbortController()
3051
+ const epoch = prepareEpochRef.current + 1
3052
+ prepareEpochRef.current = epoch
3053
+ prepareAbortRef.current = controller
2924
3054
  setPreparingImages(true)
2925
3055
  notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
2926
3056
  const snapshot = draftImagesRef.current
2927
- void prepareImages(snapshot.map(image => image.path)).then((images) => {
3057
+ void prepareImages(snapshot.map(image => image.path), controller.signal).then((images) => {
3058
+ if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3059
+ prepareAbortRef.current = undefined
2928
3060
  setPreparingImages(false)
2929
3061
  valueRef.current = ''
2930
3062
  cursorRef.current = 0
@@ -2943,14 +3075,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2943
3075
  if (busy) steer(text, images)
2944
3076
  else dispatch(text, images)
2945
3077
  }, (reason: unknown) => {
3078
+ if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3079
+ prepareAbortRef.current = undefined
2946
3080
  setPreparingImages(false)
2947
3081
  notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2948
3082
  })
2949
3083
  return
2950
3084
  }
3085
+ valueRef.current = ''
3086
+ cursorRef.current = 0
2951
3087
  setValue('')
2952
- setCursor(0)
2953
- setCompletionIndex(0)
3088
+ setCursor(0)
3089
+ resetCursorBlink()
3090
+ setCompletionIndex(0)
2954
3091
  setDismissedMenuValue(undefined)
2955
3092
  if (text === '') return
2956
3093
  dismissNotice()
@@ -3094,10 +3231,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3094
3231
  dispatch(text)
3095
3232
  return
3096
3233
  }
3097
- // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
3098
- // stripping the leading escape. Neither is a multiline shortcut.
3099
- if (input === '\n' || input === '\r') return
3100
- if (menuActive && key.upArrow) {
3234
+ // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
3235
+ // stripping the leading escape. Neither is a multiline shortcut.
3236
+ if (input === '\n' || input === '\r') return
3237
+ // A fast Tab followed by text can arrive as one readable chunk in an
3238
+ // integrated terminal. Accept the candidate first, then apply the
3239
+ // remaining characters against the synchronously updated editor refs.
3240
+ if (menuActive && (key.tab || input.startsWith('\t'))) {
3241
+ const remainder = key.tab ? '' : input.slice(1)
3242
+ acceptMenuCandidate()
3243
+ if (remainder !== '') applyEdit(insertText(valueRef.current, cursorRef.current, remainder))
3244
+ return
3245
+ }
3246
+ if (menuActive && key.upArrow) {
3101
3247
  setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
3102
3248
  return
3103
3249
  }
@@ -3105,135 +3251,86 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3105
3251
  setCompletionIndex(index => (index + 1) % menuRows.length)
3106
3252
  return
3107
3253
  }
3108
- // Raw-annotated keys (Home/End, the delete family): Ink's own flags for
3109
- // the same chunk are blank or blurred, so the read-patch annotation is
3110
- // authoritative whenever it is set.
3111
- const rawKey = rawAnnotation.current
3112
- if (rawKey !== undefined) {
3113
- if (rawKey === 'home') moveCursorTo(lineBounds(value, cursor).start)
3114
- else if (rawKey === 'end') moveCursorTo(lineBounds(value, cursor).end)
3115
- else if (rawKey === 'delete-backward') applyEdit(deleteBackward(value, cursor))
3116
- else if (rawKey === 'delete-word-backward') applyEdit(deleteWordBackward(value, cursor))
3117
- else if (rawKey === 'delete-forward') applyEdit(deleteForward(value, cursor))
3118
- else applyEdit(deleteWordForward(value, cursor))
3119
- return
3120
- }
3121
- if (key.upArrow || key.downArrow) {
3122
- // Codex boundary gate: shell recall runs from an empty draft, or from
3123
- // a boundary of a draft that still matches the last recalled entry;
3124
- // every interior Up/Down moves the caret across the multiline draft.
3125
- if (recall.current.entries.length > 0 && shouldRecallNavigate(value, cursor, recall.current.lastRecalled)) {
3126
- const step = key.upArrow ? recallOlder(recall.current, value) : recallNewer(recall.current)
3127
- recall.current = step.state
3128
- if (step.entry !== undefined) {
3129
- const safe = sanitizeDraftText(step.entry)
3130
- setValue(safe)
3131
- setCursor(safe.length)
3132
- preferredColumnRef.current = null
3133
- setDismissedMenuValue(undefined)
3134
- }
3135
- return
3136
- }
3137
- const model = editorModel(value, Math.max(1, columns - 6))
3138
- const preferred = preferredColumnRef.current ?? caretSite(model, cursor).column
3139
- const next = moveCursorVertically(model, cursor, preferred, key.upArrow ? -1 : 1)
3140
- if (next !== cursor) {
3141
- setCursor(next)
3142
- preferredColumnRef.current = preferred
3143
- }
3144
- return
3145
- }
3254
+ // Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
3255
+ // and reduce against one current editor snapshot in their original order.
3256
+ const rawTokens = rawEditorTokens.current
3257
+ rawEditorTokens.current = undefined
3258
+ if (rawTokens !== undefined) {
3259
+ applyRawEditorTokens(rawTokens)
3260
+ return
3261
+ }
3262
+ if (key.upArrow || key.downArrow) {
3263
+ navigateVertical(key.upArrow ? -1 : 1)
3264
+ return
3265
+ }
3146
3266
  // Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
3147
3267
  // move_up/move_down, so the history gate applies first).
3148
- if (key.ctrl && (input === 'p' || input === 'n')) {
3149
- const up = input === 'p'
3150
- if (recall.current.entries.length > 0 && shouldRecallNavigate(value, cursor, recall.current.lastRecalled)) {
3151
- const step = up ? recallOlder(recall.current, value) : recallNewer(recall.current)
3152
- recall.current = step.state
3153
- if (step.entry !== undefined) {
3154
- const safe = sanitizeDraftText(step.entry)
3155
- setValue(safe)
3156
- setCursor(safe.length)
3157
- preferredColumnRef.current = null
3158
- setDismissedMenuValue(undefined)
3159
- }
3160
- return
3161
- }
3162
- const model = editorModel(value, Math.max(1, columns - 6))
3163
- const preferred = preferredColumnRef.current ?? caretSite(model, cursor).column
3164
- const next = moveCursorVertically(model, cursor, preferred, up ? -1 : 1)
3165
- if (next !== cursor) {
3166
- setCursor(next)
3167
- preferredColumnRef.current = preferred
3168
- }
3169
- return
3170
- }
3171
- if (key.tab && menuActive) {
3172
- acceptMenuCandidate()
3173
- return
3174
- }
3175
- // Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
3176
- // plain arrows and Ctrl+B/F move by grapheme.
3177
- if (key.leftArrow) {
3178
- moveCursorTo(key.meta || key.ctrl ? moveWordLeft(value, cursor) : moveCursorBy(value, cursor, -1))
3179
- return
3180
- }
3181
- if (key.rightArrow) {
3182
- moveCursorTo(key.meta || key.ctrl ? moveWordRight(value, cursor) : moveCursorBy(value, cursor, 1))
3183
- return
3184
- }
3185
- if (key.meta && input === 'b') {
3186
- moveCursorTo(moveWordLeft(value, cursor))
3187
- return
3188
- }
3189
- if (key.meta && input === 'f') {
3190
- moveCursorTo(moveWordRight(value, cursor))
3191
- return
3192
- }
3193
- if (key.ctrl && input === 'b') {
3194
- moveCursorTo(moveCursorBy(value, cursor, -1))
3195
- return
3196
- }
3197
- if (key.ctrl && input === 'f') {
3198
- moveCursorTo(moveCursorBy(value, cursor, 1))
3199
- return
3268
+ if (key.ctrl && (input === 'p' || input === 'n')) {
3269
+ navigateVertical(input === 'p' ? -1 : 1)
3270
+ return
3200
3271
  }
3272
+ // Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
3273
+ // plain arrows and Ctrl+B/F move by grapheme.
3274
+ if (key.leftArrow) {
3275
+ moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
3276
+ return
3277
+ }
3278
+ if (key.rightArrow) {
3279
+ moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
3280
+ return
3281
+ }
3282
+ if (key.meta && input === 'b') {
3283
+ moveCursorTo(moveWordLeft(liveValue, liveCursor))
3284
+ return
3285
+ }
3286
+ if (key.meta && input === 'f') {
3287
+ moveCursorTo(moveWordRight(liveValue, liveCursor))
3288
+ return
3289
+ }
3290
+ if (key.ctrl && input === 'b') {
3291
+ moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
3292
+ return
3293
+ }
3294
+ if (key.ctrl && input === 'f') {
3295
+ moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
3296
+ return
3297
+ }
3201
3298
  // Ctrl+W and Alt+Backspace delete the previous word piece into the kill
3202
- // buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
3203
- if (key.ctrl && input === 'w') {
3204
- applyEdit(deleteWordBackward(value, cursor))
3205
- return
3206
- }
3207
- if (key.meta && input === 'd') {
3208
- applyEdit(deleteWordForward(value, cursor))
3209
- return
3299
+ // buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
3300
+ if (key.ctrl && input === 'w') {
3301
+ applyEdit(deleteWordBackward(liveValue, liveCursor))
3302
+ return
3303
+ }
3304
+ if (key.meta && input === 'd') {
3305
+ applyEdit(deleteWordForward(liveValue, liveCursor))
3306
+ return
3210
3307
  }
3211
3308
  // Un-annotated backspace/delete (Ink maps both  and  here):
3212
- // delete the grapheme before the cursor.
3213
- if (key.backspace || key.delete) {
3214
- applyEdit(deleteBackward(value, cursor))
3215
- return
3309
+ // delete the grapheme before the cursor.
3310
+ if (key.backspace || key.delete) {
3311
+ applyEdit(deleteBackward(liveValue, liveCursor))
3312
+ return
3216
3313
  }
3217
3314
  // Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
3218
- // them (filling the single kill buffer), Y yanks it back.
3219
- if (key.ctrl && input === 'a') {
3220
- moveCursorTo(lineBounds(value, cursor).start)
3221
- return
3222
- }
3223
- if (key.ctrl && input === 'e') {
3224
- moveCursorTo(lineBounds(value, cursor).end)
3225
- return
3226
- }
3227
- if (key.ctrl && input === 'u') {
3228
- applyEdit(killToLineStart(value, cursor))
3229
- return
3230
- }
3231
- if (key.ctrl && input === 'k') {
3232
- applyEdit(killToLineEnd(value, cursor))
3233
- return
3234
- }
3235
- if (key.ctrl && input === 'y') {
3236
- if (killRef.current !== '') applyEdit(insertText(value, cursor, killRef.current))
3315
+ // them (filling the single kill buffer), Y yanks it back.
3316
+ if (key.ctrl && input === 'a') {
3317
+ moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
3318
+ return
3319
+ }
3320
+ if (key.ctrl && input === 'e') {
3321
+ moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
3322
+ return
3323
+ }
3324
+ if (key.ctrl && input === 'u') {
3325
+ applyEdit(killToLineStart(liveValue, liveCursor))
3326
+ return
3327
+ }
3328
+ if (key.ctrl && input === 'k') {
3329
+ applyEdit(killToLineEnd(liveValue, liveCursor))
3330
+ return
3331
+ }
3332
+ if (key.ctrl && input === 'y') {
3333
+ if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
3237
3334
  return
3238
3335
  }
3239
3336
  // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
@@ -3275,11 +3372,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3275
3372
  insertDroppedImages(droppedPaths)
3276
3373
  return
3277
3374
  }
3278
- applyEdit(insertText(value, cursor, text))
3375
+ applyEdit(insertText(valueRef.current, cursorRef.current, text))
3279
3376
  }
3280
- })
3281
-
3282
- // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
3377
+ }, active)
3378
+
3379
+ // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
3283
3380
  // the interval re-renders only the composer band at 30fps, never the whole
3284
3381
  // tree. App drives the tier/style pair on a model switch; this local effect
3285
3382
  // starts the sweep whenever that pair changes (App picks a NEW random style
@@ -3299,7 +3396,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3299
3396
  setWaveTick(0)
3300
3397
  }
3301
3398
  }, [waveTier, waveStyle])
3302
- const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
3399
+ const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
3303
3400
  && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
3304
3401
  useEffect(() => {
3305
3402
  if (!waveActive) return
@@ -3325,8 +3422,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3325
3422
  // column-safe physical rows, with the caret mapped to its exact row and
3326
3423
  // column. Computed before the frozen path so the row report below runs
3327
3424
  // unconditionally.
3328
- const editorColumns = Math.max(1, columns - 6)
3329
- const editorViewModel = editorModel(value, editorColumns)
3425
+ const editorViewModel = editorModel(value, editorColumns)
3330
3426
  const clampedCursor = clampCursor(value, cursor)
3331
3427
  const caret = caretSite(editorViewModel, clampedCursor)
3332
3428
  const editorWindowRows = Math.min(editorViewModel.rows.length, Math.max(1, maxRows))
@@ -3352,7 +3448,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3352
3448
  const bandWidth = Math.max(1, columns - 1)
3353
3449
  const bandBg = inkColor(getPalette().composerBand)
3354
3450
  const bandFill = (consumed: number): string => ' '.repeat(Math.max(0, bandWidth - consumed))
3355
- const band = (content: ReactElement): ReactElement => createElement(
3451
+ const band = (content: ReactElement): ReactElement => createElement(
3356
3452
  Box,
3357
3453
  { flexDirection: 'column', width: bandWidth },
3358
3454
  createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
@@ -3390,141 +3486,138 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3390
3486
  rows: menuRows,
3391
3487
  })
3392
3488
 
3393
- // The multiline editor (idle, busy, or after the wave): every visible
3394
- // physical row renders inside the band, the prompt marker leading the
3395
- // first and a two-space indent aligning continuations under the text
3396
- // column the same gutter reply rows use. The caret is the inverse block
3397
- // on its exact grapheme, so wide CJK cells and emoji clusters position the
3398
- // block precisely. The prompt marker keeps the tier accent while an
3399
- // official DeepSeek model is applied, restoring the static brand ❯ on any
3400
- // other route.
3401
- const editorRows: ReactElement[] = []
3402
- for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
3403
- const row = editorViewModel.rows[index]!
3404
- const caretAt = index === caret.row ? row.offsets.indexOf(clampedCursor) : -1
3405
- const before = caretAt > 0 ? row.text.slice(0, row.cuts[caretAt]!) : ''
3406
- const caretChar = caretAt >= 0 && caretAt < row.cuts.length - 1 ? row.text.slice(row.cuts[caretAt]!, row.cuts[caretAt + 1]!) : ' '
3407
- const after = caretAt < 0
3408
- ? row.text
3409
- : caretAt < row.cuts.length - 1
3410
- ? row.text.slice(row.cuts[caretAt + 1]!)
3411
- : ''
3412
- const placeholder = index === 0 && value === '' && !busy
3413
- const tail = placeholder ? COMPOSER_PLACEHOLDER : after
3414
- const consumed = 2 + visibleColumns(before) + visibleColumns(caretChar) + visibleColumns(tail)
3415
- editorRows.push(createElement(
3416
- Text,
3417
- { key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
3418
- index === 0
3419
- ? busy
3420
- ? createElement(BusyChase)
3421
- : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
3422
- : ' ',
3423
- before,
3424
- createElement(CursorBlock, { key: 'caret', char: caretChar }),
3425
- placeholder
3426
- ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
3427
- : after,
3489
+ // Every state reuses this exact multiline editor window. Only the caret row
3490
+ // owns an inverse block; non-caret rows render their text without a hidden
3491
+ // spacer or a second blink timer.
3492
+ const editorRows: ReactElement[] = []
3493
+ for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
3494
+ const row = editorViewModel.rows[index]!
3495
+ const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
3496
+ const placeholder = index === 0 && value === '' && !busy && !preparingImages
3497
+ const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3498
+ const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
3499
+ editorRows.push(createElement(
3500
+ Text,
3501
+ { key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
3502
+ index === 0
3503
+ ? preparingImages
3504
+ ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
3505
+ : busy
3506
+ ? createElement(BusyChase)
3507
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
3508
+ : ' ',
3509
+ parts.before,
3510
+ parts.hasCaret
3511
+ ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
3512
+ : null,
3513
+ placeholder
3514
+ ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
3515
+ : parts.after,
3428
3516
  bandFill(consumed),
3429
3517
  ))
3430
- }
3431
- const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
3432
-
3433
- // Wave band: all three rows assembled column by column, each cell carrying
3434
- // the sampled wave `backgroundColor` (null outside the crest the band
3435
- // background), so the crest sweeps the FULL band blank rows, prompt,
3436
- // draft, cursor, placeholder, and the trailing fill — with a per-row phase
3437
- // offset that flows the wave down the band. The deepseek tier drops the
3438
- // ✧` sparkles into the rightmost blank cell from 900ms on.
3439
- const waveRow = (): ReactElement => {
3440
- const hues = deepseekWaveHues(waveTier!)
3441
- const style = waveStyle!
3442
- const bandRgb = getPalette().composerBand
3443
- const waveBg = (row: number, column: number): string => {
3444
- const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, 3)
3445
- return rgb === null ? bandBg : inkColor(rgb)
3446
- }
3447
- const blankBandRow = (row: number): ReactElement => {
3448
- const blanks: ComposerCell[] = []
3449
- while (blanks.length < bandWidth) blanks.push({ char: ' ', backgroundColor: waveBg(row, blanks.length) })
3450
- return createElement(Text, { key: row }, ...waveRowSpans(blanks))
3451
- }
3452
- const waveEditor = editorWindow(value, cursor, Math.max(1, columns - 7))
3453
- const cells: ComposerCell[] = [
3454
- { char: ' ', backgroundColor: waveBg(1, 0) },
3455
- { char: ' ', backgroundColor: waveBg(1, 1) },
3456
- { char: promptGlyph, color: promptColor, bold: true, backgroundColor: waveBg(1, 2) },
3457
- { char: ' ', color: promptColor, backgroundColor: waveBg(1, 3) },
3458
- ]
3459
- for (const char of waveEditor.before) {
3460
- cells.push({ char, backgroundColor: waveBg(1, cells.length) })
3461
- }
3462
- cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(1, cells.length) })
3463
- if (value === '' && !busy) {
3464
- for (let at = 0; at < COMPOSER_PLACEHOLDER.length; at += 1) {
3465
- cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(1, cells.length) })
3466
- }
3467
- } else {
3468
- for (const char of waveEditor.after) {
3469
- cells.push({ char, backgroundColor: waveBg(1, cells.length) })
3470
- }
3471
- }
3472
- while (cells.length < bandWidth) {
3473
- cells.push({ char: ' ', backgroundColor: waveBg(1, cells.length) })
3474
- }
3475
- // The wordmark rides the wave's middle: `deepseek` on the official
3476
- // tiers, `Into the Unknown` on the non-DeepSeek high-effort variant —
3477
- // in the tier's cycled hues, placed in the row's mid-section and only
3478
- // over blank or placeholder cells — real draft text is never covered.
3479
- if (deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
3480
- const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
3481
- const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
3482
- let clear = true
3483
- for (let at = 0; at < word.length; at += 1) {
3484
- const cell = cells[start + at]
3485
- if (cell === undefined || (cell.char !== ' ' && cell.dim !== true)) { clear = false; break }
3486
- }
3487
- if (clear) {
3488
- for (let at = 0; at < word.length; at += 1) {
3489
- const cell = cells[start + at]!
3490
- cell.char = word[at]!
3491
- cell.color = inkColor(deepseekWaveWordHue(at, hues))
3492
- cell.bold = true
3493
- cell.dim = false
3494
- }
3495
- }
3496
- }
3497
- // The tail sparkles belong to the Wave style's pro tiers only (the
3498
- // deepseek and unknown tiers share the Ultra parameters — Codex paints
3499
- // spark_frame on Wave+Ultra).
3500
- if ((waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
3501
- const spark = deepseekWaveSpark(waveTick!)
3502
- if (spark !== null) {
3503
- const last = cells[cells.length - 1]
3504
- if (last !== undefined && last.char === ' ') {
3505
- last.char = spark
3506
- last.color = promptColor
3507
- last.bold = true
3508
- last.dim = false
3509
- }
3510
- }
3511
- }
3512
- return createElement(
3513
- Box,
3514
- { flexDirection: 'column', width: bandWidth },
3515
- blankBandRow(0),
3516
- createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)),
3517
- blankBandRow(2),
3518
- )
3519
- }
3518
+ }
3519
+ const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
3520
+
3521
+ // The wave paints the SAME visible rows and caret site as the static path.
3522
+ // Graphemes remain atomic and every background sample advances by terminal
3523
+ // display columns, so CJK and emoji cannot move the caret or wrap the band.
3524
+ const waveRow = (): ReactElement => {
3525
+ const hues = deepseekWaveHues(waveTier!)
3526
+ const style = waveStyle!
3527
+ const bandRgb = getPalette().composerBand
3528
+ const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
3529
+ const totalBandRows = visibleRows.length + 2
3530
+ const waveBg = (row: number, column: number): string => {
3531
+ const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
3532
+ return rgb === null ? bandBg : inkColor(rgb)
3533
+ }
3534
+ const blankBandRow = (row: number): ReactElement => {
3535
+ const blanks: ComposerCell[] = []
3536
+ for (let column = 0; column < bandWidth; column += 1) {
3537
+ blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
3538
+ }
3539
+ return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
3540
+ }
3541
+ const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
3542
+ let column = 0
3543
+ for (let index = 0; index < cells.length; index += 1) {
3544
+ if (column === target) return index
3545
+ column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
3546
+ if (column > target) return undefined
3547
+ }
3548
+ return undefined
3549
+ }
3550
+ const editorWaveRows = visibleRows.map((row, visibleIndex) => {
3551
+ const sourceIndex = editorWindowStart + visibleIndex
3552
+ const bandRow = visibleIndex + 1
3553
+ const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
3554
+ const placeholder = sourceIndex === 0 && value === '' && !busy
3555
+ const cells: ComposerCell[] = []
3556
+ let usedColumns = 0
3557
+ const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
3558
+ const width = visibleColumns(char)
3559
+ cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
3560
+ usedColumns += width
3561
+ }
3562
+ if (sourceIndex === 0) {
3563
+ push(promptGlyph, { color: promptColor, bold: true })
3564
+ push(' ', { color: promptColor })
3565
+ } else {
3566
+ push(' ')
3567
+ push(' ')
3568
+ }
3569
+ for (const span of splitGraphemes(parts.before)) push(span.text)
3570
+ if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
3571
+ const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3572
+ for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
3573
+ while (usedColumns < bandWidth) push(' ')
3574
+
3575
+ const middleBandRow = Math.floor(totalBandRows / 2)
3576
+ if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
3577
+ const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
3578
+ const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
3579
+ const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
3580
+ if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
3581
+ for (let at = 0; at < word.length; at += 1) {
3582
+ const cell = cells[indices[at]!]!
3583
+ cell.char = word[at]!
3584
+ cell.width = 1
3585
+ cell.color = inkColor(deepseekWaveWordHue(at, hues))
3586
+ cell.bold = true
3587
+ cell.dim = false
3588
+ }
3589
+ }
3590
+ }
3591
+ if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
3592
+ const spark = deepseekWaveSpark(waveTick!)
3593
+ const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
3594
+ if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
3595
+ cells[lastIndex]!.char = spark
3596
+ cells[lastIndex]!.color = promptColor
3597
+ cells[lastIndex]!.bold = true
3598
+ cells[lastIndex]!.dim = false
3599
+ }
3600
+ }
3601
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
3602
+ })
3603
+ return createElement(
3604
+ Box,
3605
+ { flexDirection: 'column', width: bandWidth },
3606
+ blankBandRow(0),
3607
+ ...editorWaveRows,
3608
+ blankBandRow(totalBandRows - 1),
3609
+ )
3610
+ }
3520
3611
 
3521
- return createElement(
3522
- Box,
3523
- { flexDirection: 'column' },
3524
- menu,
3525
- waveTick !== null && waveTier !== null && !busy ? waveRow() : band(staticEditor),
3526
- )
3527
- }
3612
+ return createElement(
3613
+ Box,
3614
+ { flexDirection: 'column' },
3615
+ menu,
3616
+ waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
3617
+ ? waveRow()
3618
+ : band(staticEditor),
3619
+ )
3620
+ }
3528
3621
 
3529
3622
  /** One cached settled row: the row Box plus its roomy-prompt spacers. */
3530
3623
  interface SettledRowRecord {