dsh-code 1.0.5 → 1.0.6

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
@@ -20,7 +20,7 @@ import {
20
20
  } from 'react'
21
21
  import { Box, Static, Text, useInput, useStdin, useStdout, type Key } from 'ink'
22
22
  import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
- import type { ImageBlock } from '@deepseek-ai/dsh-llm'
23
+ import type { ContentBlock, FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
24
24
  import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
25
25
  import type { AskUserQuestionAnswerItem, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions'
26
26
  import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
@@ -42,6 +42,7 @@ import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
42
  import {
43
43
  busyChaseFrame,
44
44
  BUSY_CHASE_TICK_MS,
45
+ CARET_BLINK_TICK_MS,
45
46
  caretVisible,
46
47
  DEEP_DIVING_SHIMMER_TICK_MS,
47
48
  DEEPSEEK_WAVE_TICK_MS,
@@ -56,11 +57,13 @@ import {
56
57
  deepDivingSparkColor,
57
58
  effortAboveHigh,
58
59
  isOfficialDeepSeekLabel,
60
+ parseAnimationsArgument,
59
61
  type DeepseekWaveStyle,
60
62
  type DeepseekWaveTier,
61
63
  } from './render/animations.ts'
62
64
  import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
63
- import { submissionPayload, type CommandsView } from './commands.ts'
65
+ import { isSlashLine, submissionPayload, type CommandsView } from './commands.ts'
66
+ import { rankByName } from './render/fuzzy.ts'
64
67
  import type { ModelDirectory, ModelRow } from './models.ts'
65
68
  import {
66
69
  isDeclaredReasoningEfforts,
@@ -100,7 +103,8 @@ import {
100
103
  import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
101
104
  import {
102
105
  looksLikeImagePath,
103
- parsePastedImagePaths,
106
+ parsePastedAttachmentPaths,
107
+ type FilePathInspection,
104
108
  type ImagePathInspection,
105
109
  } from './attachments.ts'
106
110
 
@@ -196,6 +200,7 @@ import {
196
200
  editorRowParts,
197
201
  insertText,
198
202
  type EditResult,
203
+ type EditorRowModel,
199
204
  killToLineEnd,
200
205
  killToLineStart,
201
206
  moveCursorBy,
@@ -228,6 +233,7 @@ const LOCAL_COMMANDS = [
228
233
  { label: '/jobs', description: 'inspect background jobs' },
229
234
  { label: '/statusline', description: 'customize the status line items' },
230
235
  { label: '/theme', description: 'switch the color theme' },
236
+ { label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
231
237
  { label: '/history', description: 'search and recall past prompts' },
232
238
  { label: '/agents', description: 'inspect subagent sessions of this conversation' },
233
239
  { label: '/todos', description: 'inspect the full todo list' },
@@ -277,10 +283,20 @@ export interface AppProps {
277
283
  mode: string
278
284
  /** Permission preset selected for the current or pending first session. */
279
285
  permission: string
280
- /** Submit one line: slash commands to the registry, other text to the agent. */
281
- dispatch(text: string, images?: readonly ImageBlock[]): void
282
- /** Submit steering: consumed at the running turn's next step boundary. */
283
- steer(text: string, images?: readonly ImageBlock[]): void
286
+ /**
287
+ * Submit one line: slash commands to the registry, other text to the agent.
288
+ * The optional origin names the session the submission was composed for —
289
+ * an attachment prepare resolves after the app remounted onto another
290
+ * session, and the runner drops the stale delivery then.
291
+ */
292
+ dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
293
+ /** Submit steering, with the same stale-delivery guard as {@link dispatch}. */
294
+ steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
295
+ /**
296
+ * The FULL current session identity ('' while the first session is pending)
297
+ * — the stale-delivery origin above. Distinct from the short display id.
298
+ */
299
+ sessionKey: string
284
300
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
285
301
  interrupt(): boolean
286
302
  /** Quit: unmount, flush, and request process exit. */
@@ -293,6 +309,10 @@ export interface AppProps {
293
309
  inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
294
310
  /** Validate, normalize and persist images immediately before submission. */
295
311
  prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
312
+ /** Validate draft non-image file paths without committing attachment objects. */
313
+ inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
314
+ /** Persist non-image files immediately before submission as durable file blocks. */
315
+ prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
296
316
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
297
317
  selectModel(row: ModelRow, effortId?: string): string
298
318
  /** The /subagent override label, '' when delegated agents follow the current model. */
@@ -377,6 +397,11 @@ export interface AppProps {
377
397
  saveStatusline(items: readonly string[]): void
378
398
  /** Apply and persist one /theme selection; the runner owns the theme.json file. */
379
399
  saveTheme?(name: ThemeName): void
400
+ /** Whether timed animations run at startup (animations.json; on by default
401
+ * — like parseAnimationsPref, only an explicit false disables them). */
402
+ animations?: boolean
403
+ /** Apply and persist one /animation toggle; the runner owns the file. */
404
+ saveAnimations?(enabled: boolean): void
380
405
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
381
406
  history: readonly string[]
382
407
  /** Persist one submitted prompt to the global history file. */
@@ -393,12 +418,23 @@ function padColumns(text: string, width: number): string {
393
418
  return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
394
419
  }
395
420
 
396
- /** Interval-driven frame counter for one self-contained animated leaf. */
421
+ /**
422
+ * Wall-clock frame counter for one self-contained animated leaf. Each fire
423
+ * derives the tick from elapsed time instead of counting intervals, so a
424
+ * stretched interval (busy event loop, slow SSH) skips the animation ahead
425
+ * rather than slowing it down; the tick always tracks real time.
426
+ */
397
427
  function useFrames(intervalMs: number, active = true): number {
398
428
  const [tick, setTick] = useState(0)
399
429
  useEffect(() => {
400
430
  if (!active) return
401
- const id = setInterval(() => setTick(current => current + 1), intervalMs)
431
+ const startedAt = Date.now()
432
+ setTick(0)
433
+ const id = setInterval(() => {
434
+ // Clock setback (NTP resync) must not produce negative ticks — the
435
+ // blink parity check would flip the caret off for a full period.
436
+ setTick(Math.max(0, Math.floor((Date.now() - startedAt) / intervalMs)))
437
+ }, intervalMs)
402
438
  return () => {
403
439
  clearInterval(id)
404
440
  }
@@ -421,15 +457,18 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
421
457
  useInput(stableHandler, { isActive: active })
422
458
  }
423
459
 
424
- /** The original web StateDot chase used by the busy composer marker. */
425
- function BusyChase(): ReactElement {
426
- const tick = useFrames(BUSY_CHASE_TICK_MS)
460
+ /**
461
+ * The original web StateDot chase used by the busy composer marker. With
462
+ * animations off it freezes on the first frame (still visibly busy).
463
+ */
464
+ function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
465
+ const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
427
466
  return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
428
467
  }
429
468
 
430
- /** Blinking block caret appended to streaming text. */
431
- function Caret(): ReactElement {
432
- const tick = useFrames(530)
469
+ /** Blinking block caret appended to streaming text; solid when frozen. */
470
+ function Caret({ animated = true }: { animated?: boolean }): ReactElement {
471
+ const tick = useFrames(CARET_BLINK_TICK_MS, animated)
433
472
  return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
434
473
  }
435
474
 
@@ -440,7 +479,7 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
440
479
  useEffect(() => {
441
480
  setVisible(true)
442
481
  if (!active) return
443
- const id = setInterval(() => setVisible(current => !current), 530)
482
+ const id = setInterval(() => setVisible(current => !current), CARET_BLINK_TICK_MS)
444
483
  return () => {
445
484
  clearInterval(id)
446
485
  }
@@ -456,10 +495,12 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
456
495
  * One bounded line painted with the deep-diving shimmer: a continuously
457
496
  * moving blue gradient across graphemes, the `✻` glyph in the breathing
458
497
  * spark color. Shared by the busy line and the collapsed thinking marker;
459
- * always exactly one row (truncate-end) so the live budget stays exact.
498
+ * always exactly one row (truncate-end) so the live budget stays exact. With
499
+ * animations off the same spans render in fixed colors — no timer, no
500
+ * per-frame repaint, the `✻` keeps its highlight.
460
501
  */
461
- function ShimmerLine({ text }: { text: string }): ReactElement {
462
- const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
502
+ function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
503
+ const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
463
504
  const palette = getPalette()
464
505
  const graphemes = splitGraphemes(text)
465
506
  return createElement(
@@ -471,7 +512,11 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
471
512
  Text,
472
513
  {
473
514
  key: `${grapheme.start}-${grapheme.end}`,
474
- color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
515
+ color: inkColor(!animated
516
+ ? (sparkle ? palette.brandBright : palette.brandDeep)
517
+ : sparkle
518
+ ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
519
+ : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
475
520
  bold: sparkle || undefined,
476
521
  },
477
522
  grapheme.text,
@@ -486,10 +531,10 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
486
531
  * only once the turn has clearly been running (15s) — anchored to `turn/start`
487
532
  * so a resumed mid-turn keeps the real time.
488
533
  */
489
- function DeepDivingLine({ since }: { since: number }): ReactElement {
534
+ function DeepDivingLine({ since, animated = true }: { since: number; animated?: boolean }): ReactElement {
490
535
  const elapsed = since === 0 ? 0 : Date.now() - since
491
536
  const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
492
- return createElement(ShimmerLine, { text })
537
+ return createElement(ShimmerLine, { text, animated })
493
538
  }
494
539
 
495
540
  /**
@@ -1908,7 +1953,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1908
1953
  const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
1909
1954
  const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
1910
1955
  const authLabel = showAuthorization ? ' · ' + providerAuthorizationStatus(authorization) : ''
1911
- const label = identity + ' · ' + providerStateLabel(row) + authLabel + (row.removable ? ' · custom' : '')
1956
+ // The adapter's configuration diagnostic rides the row (the provider
1957
+ // stays listed and repairable — this is why it did not vanish).
1958
+ const diagnostic = row.diagnostic === undefined ? '' : ' · ! ' + singleLineText(row.diagnostic)
1959
+ const label = identity + ' · ' + providerStateLabel(row) + authLabel + (row.removable ? ' · custom' : '') + diagnostic
1912
1960
  // Configured rows render in the intermediate brand blue so the in-use
1913
1961
  // group reads at a glance; the dormant tail keeps the dim caption gray.
1914
1962
  const idleColor = row.configured ? inkColor(getPalette().brandMid) : inkColor(getPalette().dim)
@@ -2249,7 +2297,9 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2249
2297
  const keyBullets = '•'.repeat(Math.min([...keyDraft].length, Math.max(1, viewport.contentColumns - 14)))
2250
2298
  const keyRow = createElement(Text, { key: 'key', color: zone === 'key' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns((' ' + (zone === 'key' ? '>' : ' ') + ' key ' + keyBullets + (zone === 'key' && !busy ? '▏' : '') + (keyDraft === '' ? ' (' + keyStatus + ')' : busy ? ' saving…' : '')).replace(/ +$/u, ''), viewport.contentColumns))
2251
2299
  const urlRow = createElement(Text, { key: 'url', color: zone === 'url' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (zone === 'url' ? '>' : ' ') + ' url ' + (baseURL === '' ? '(official default)' : baseURL) + (zone === 'url' ? '▏' : ''), viewport.contentColumns))
2252
- const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 3)
2300
+ // The fixed diagnostic row (present only with an adapter error) joins the
2301
+ // same height budget as the state rows — it must never overflow the panel.
2302
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - (target.diagnostic === undefined ? 0 : 1) - 3)
2253
2303
  const first = selectionWindow(cursor, models.length + 1, rowBudget)
2254
2304
  const modelRows: ReactElement[] = []
2255
2305
  for (let index = first; index < first + Math.max(0, Math.min(models.length + 1 - first, rowBudget)); index += 1) {
@@ -2271,6 +2321,13 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2271
2321
  Box,
2272
2322
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2273
2323
  createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — configure ' + target.displayName, viewport.contentColumns)),
2324
+ // The adapter's configuration diagnostic heads the editor: the provider
2325
+ // is here precisely because it stayed listed for repair.
2326
+ ...(target.diagnostic === undefined ? [] : [createElement(
2327
+ Text,
2328
+ { key: 'diagnostic', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
2329
+ truncateColumns('! ' + displayText(singleLineText(target.diagnostic)), viewport.contentColumns),
2330
+ )]),
2274
2331
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2275
2332
  keyRow,
2276
2333
  urlRow,
@@ -2610,6 +2667,197 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
2610
2667
  return spans
2611
2668
  }
2612
2669
 
2670
+ /** Index of the cell STARTING at a display column, if one does. */
2671
+ function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): number | undefined {
2672
+ let column = 0
2673
+ for (let index = 0; index < cells.length; index += 1) {
2674
+ if (column === target) return index
2675
+ column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
2676
+ if (column > target) return undefined
2677
+ }
2678
+ return undefined
2679
+ }
2680
+
2681
+ /**
2682
+ * Wall-clock wave frames — strictly ONE sweep per MOUNT; the mount-spanning
2683
+ * one-shot latch (surviving modal unmounts) lives in Input as `wavePlayedKey`.
2684
+ * The first gate-off after the sweep has started (it completed, a turn went
2685
+ * busy, image preparation began, animations were toggled off) latches `done`
2686
+ * for this mount, so the same mount can never resume or replay. A trigger
2687
+ * that lands while the gate is already down stays pending until the gate
2688
+ * rises once, then plays.
2689
+ */
2690
+ function useWaveFrames(active: boolean, durationMs: number): { tick: number; done: boolean } {
2691
+ const [tick, setTick] = useState(0)
2692
+ const [done, setDone] = useState(false)
2693
+ const startedRef = useRef(false)
2694
+ useEffect(() => {
2695
+ if (done) return
2696
+ if (!active) {
2697
+ // A sweep that already started is cancelled permanently, never resumed.
2698
+ if (startedRef.current) setDone(true)
2699
+ return
2700
+ }
2701
+ startedRef.current = true
2702
+ const startedAt = Date.now()
2703
+ const id = setInterval(() => {
2704
+ const elapsed = Date.now() - startedAt
2705
+ if (elapsed >= durationMs) {
2706
+ clearInterval(id)
2707
+ setDone(true)
2708
+ return
2709
+ }
2710
+ setTick(Math.max(0, Math.floor(elapsed / DEEPSEEK_WAVE_TICK_MS)))
2711
+ }, DEEPSEEK_WAVE_TICK_MS)
2712
+ return () => {
2713
+ clearInterval(id)
2714
+ }
2715
+ }, [active, durationMs, done])
2716
+ return { tick, done }
2717
+ }
2718
+
2719
+ /** The wave-painted composer band: everything the sweep needs, as data. */
2720
+ interface ComposerWaveProps {
2721
+ /** Wave tier of the applied route (flash / deepseek / unknown). */
2722
+ tier: DeepseekWaveTier
2723
+ /** Ignition style App picked for this trigger. */
2724
+ style: DeepseekWaveStyle
2725
+ /** False while busy, preparing images, or animations are off; the fallback
2726
+ * band renders instead (non-wave routes keep it false permanently). */
2727
+ active: boolean
2728
+ /** The static band to render before, after, and instead of the sweep. */
2729
+ fallback: ReactElement
2730
+ /** Composer band width in columns (terminal width minus the last column). */
2731
+ bandWidth: number
2732
+ /** Ink color of the static band background (the transparent-cell base). */
2733
+ bandBg: string
2734
+ /** The editor's visible physical rows (already windowed). */
2735
+ rows: readonly EditorRowModel[]
2736
+ /** Index of `rows[0]` in the full editor model (keying + caret row math). */
2737
+ windowStart: number
2738
+ /** Absolute caret row in the editor model. */
2739
+ caretRow: number
2740
+ /** The authoritative cursor offset. */
2741
+ cursor: number
2742
+ /** Caret blink visibility (shared with the static path). */
2743
+ caretVisible: boolean
2744
+ /** The draft text (placeholder detection on row 0). */
2745
+ value: string
2746
+ /** Tier prompt glyph and accent color (persistent, like Codex's charge). */
2747
+ promptGlyph: string
2748
+ promptColor: string
2749
+ /** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
2750
+ * cancelled by the gate, or unmounted (a modal panel froze the composer) —
2751
+ * so Input's played-key latch survives the leaf's unmount/remount cycle. */
2752
+ onSettled(): void
2753
+ }
2754
+
2755
+ /**
2756
+ * The self-contained wave leaf: it owns its 33ms tick, so the sweep
2757
+ * re-renders ONLY this component at ~30fps — Input's derived editor state
2758
+ * never re-runs per frame. Graphemes stay atomic and every background sample
2759
+ * advances by terminal display columns, so CJK and emoji cannot move the
2760
+ * caret or wrap the band. The duration gate renders the fallback band on the
2761
+ * frame the sweep completes.
2762
+ */
2763
+ function ComposerWave(props: ComposerWaveProps): ReactElement {
2764
+ const { tier, style } = props
2765
+ const durationMs = deepseekWaveDuration(tier, style)
2766
+ const { tick, done } = useWaveFrames(props.active, durationMs)
2767
+ // Report the sweep's end exactly once — completion, gate cancellation, or
2768
+ // unmount (a modal opened and froze the composer) — latching Input's
2769
+ // played-key so this trigger can never replay after a remount.
2770
+ const settledRef = useRef(false)
2771
+ const onSettledRef = useRef(props.onSettled)
2772
+ onSettledRef.current = props.onSettled
2773
+ const settle = (): void => {
2774
+ if (settledRef.current) return
2775
+ settledRef.current = true
2776
+ onSettledRef.current()
2777
+ }
2778
+ useEffect(() => {
2779
+ if (done) settle()
2780
+ }, [done])
2781
+ useEffect(() => () => {
2782
+ settle()
2783
+ }, [])
2784
+ if (!props.active || done || tick * DEEPSEEK_WAVE_TICK_MS >= durationMs) return props.fallback
2785
+ const hues = deepseekWaveHues(tier)
2786
+ const bandRgb = getPalette().composerBand
2787
+ const totalBandRows = props.rows.length + 2
2788
+ const waveBg = (row: number, column: number): string => {
2789
+ const rgb = deepseekWaveColumnBg(tick, column, props.bandWidth, tier, style, hues, bandRgb, row, totalBandRows)
2790
+ return rgb === null ? props.bandBg : inkColor(rgb)
2791
+ }
2792
+ const blankBandRow = (row: number): ReactElement => {
2793
+ const blanks: ComposerCell[] = []
2794
+ for (let column = 0; column < props.bandWidth; column += 1) {
2795
+ blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
2796
+ }
2797
+ return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
2798
+ }
2799
+ const editorWaveRows = props.rows.map((row, visibleIndex) => {
2800
+ const sourceIndex = props.windowStart + visibleIndex
2801
+ const bandRow = visibleIndex + 1
2802
+ const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
2803
+ const placeholder = sourceIndex === 0 && props.value === ''
2804
+ const cells: ComposerCell[] = []
2805
+ let usedColumns = 0
2806
+ const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
2807
+ const width = visibleColumns(char)
2808
+ cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
2809
+ usedColumns += width
2810
+ }
2811
+ if (sourceIndex === 0) {
2812
+ push(props.promptGlyph, { color: props.promptColor, bold: true })
2813
+ push(' ', { color: props.promptColor })
2814
+ } else {
2815
+ push(' ')
2816
+ push(' ')
2817
+ }
2818
+ for (const span of splitGraphemes(parts.before)) push(span.text)
2819
+ if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
2820
+ const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
2821
+ for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
2822
+ while (usedColumns < props.bandWidth) push(' ')
2823
+
2824
+ const middleBandRow = Math.floor(totalBandRows / 2)
2825
+ if (bandRow === middleBandRow && deepseekWaveWordVisible(tick, tier, style)) {
2826
+ const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
2827
+ const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
2828
+ const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
2829
+ if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
2830
+ for (let at = 0; at < word.length; at += 1) {
2831
+ const cell = cells[indices[at]!]!
2832
+ cell.char = word[at]!
2833
+ cell.width = 1
2834
+ cell.color = inkColor(deepseekWaveWordHue(at, hues))
2835
+ cell.bold = true
2836
+ cell.dim = false
2837
+ }
2838
+ }
2839
+ }
2840
+ if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
2841
+ const spark = deepseekWaveSpark(tick)
2842
+ const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
2843
+ if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
2844
+ cells[lastIndex]!.char = spark
2845
+ cells[lastIndex]!.color = props.promptColor
2846
+ cells[lastIndex]!.bold = true
2847
+ cells[lastIndex]!.dim = false
2848
+ }
2849
+ }
2850
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
2851
+ })
2852
+ return createElement(
2853
+ Box,
2854
+ { flexDirection: 'column', width: props.bandWidth },
2855
+ blankBandRow(0),
2856
+ ...editorWaveRows,
2857
+ blankBandRow(totalBandRows - 1),
2858
+ )
2859
+ }
2860
+
2613
2861
  /**
2614
2862
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
2615
2863
  * with independent history selection and content scrolling. The complete
@@ -2821,8 +3069,12 @@ export function completionCandidates(
2821
3069
  seen.add(name)
2822
3070
  all.push(candidate)
2823
3071
  }
2824
- if (prefix === '') return all
2825
- return all.filter(candidate => candidate.label.slice(1).startsWith(prefix))
3072
+ // Fuzzy ranking (the web menu's discovery feel): the query must be a
3073
+ // case-insensitive ordered subsequence of a name; prefix hits first, then
3074
+ // alignment score, then this composition order. An empty query keeps the
3075
+ // full list.
3076
+ return rankByName(all.map(candidate => ({ name: candidate.label.slice(1), candidate })), prefix)
3077
+ .map(entry => entry.candidate)
2826
3078
  }
2827
3079
 
2828
3080
  /**
@@ -2923,20 +3175,28 @@ interface DraftImage extends ImagePathInspection {
2923
3175
  readonly marker: string
2924
3176
  }
2925
3177
 
3178
+ /** One attached non-image file held in the editor until submission persists it. */
3179
+ interface DraftFile extends FilePathInspection {
3180
+ /** Visible draft token; deleting it also detaches the hidden path. */
3181
+ readonly marker: string
3182
+ }
3183
+
2926
3184
  /**
2927
3185
  * The prompt box: TUI-local slash commands handled locally, other lines
2928
3186
  * dispatched; input editing keeps a cursor with history and completion.
2929
3187
  * While a modal (approval / question / model panel) owns the keys, the
2930
3188
  * box passes every key through untouched.
2931
3189
  */
2932
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle, maxRows, onEditorRows, onMenuRows }: {
3190
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, onEditorRows, onMenuRows, sessionKey }: {
2933
3191
  active: boolean
2934
3192
  frozen: boolean
2935
3193
  busy: boolean
2936
3194
  descriptors: readonly CommandDescriptor[]
2937
3195
  skills: readonly SkillRow[]
2938
- dispatch(text: string, images?: readonly ImageBlock[]): void
2939
- steer(text: string, images?: readonly ImageBlock[]): void
3196
+ dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3197
+ steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3198
+ /** The full current session identity ('' while pending); the delivery origin. */
3199
+ sessionKey: string
2940
3200
  interrupt(): boolean
2941
3201
  quit(): void
2942
3202
  openModel(): void
@@ -2981,6 +3241,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2981
3241
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
2982
3242
  inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
2983
3243
  prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
3244
+ inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
3245
+ prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
2984
3246
  cyclePermission(): string
2985
3247
  exportTranscript(argument: string): Promise<void>
2986
3248
  renameTitle(argument: string): string
@@ -2999,6 +3261,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2999
3261
  historyFill: { text: string; index: number } | undefined
3000
3262
  /** Marks the accepted entry consumed (called after the fill is applied). */
3001
3263
  historyConsumed(): void
3264
+ /** Whether timed animations run (shimmer, chase, blink, wave). */
3265
+ animations: boolean
3266
+ /** Apply and report one /animation toggle (App persists through the runner). */
3267
+ applyAnimations(enabled: boolean): void
3002
3268
  /** DeepSeek easter-egg wave tier of the applied route (null otherwise):
3003
3269
  * official DeepSeek models drive their flash/pro tiers, non-DeepSeek
3004
3270
  * models running an effort above high drive the "Into the Unknown"
@@ -3029,10 +3295,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3029
3295
  const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
3030
3296
  const draftImagesRef = useRef(draftImages)
3031
3297
  draftImagesRef.current = draftImages
3298
+ const [draftFiles, setDraftFiles] = useState<readonly DraftFile[]>([])
3299
+ const draftFilesRef = useRef(draftFiles)
3300
+ draftFilesRef.current = draftFiles
3032
3301
  const [preparingImages, setPreparingImages] = useState(false)
3033
3302
  const prepareAbortRef = useRef<AbortController | undefined>(undefined)
3034
3303
  const prepareEpochRef = useRef(0)
3035
- const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
3304
+ const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages && animations)
3036
3305
  useEffect(() => () => {
3037
3306
  prepareEpochRef.current += 1
3038
3307
  prepareAbortRef.current?.abort()
@@ -3066,6 +3335,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3066
3335
  const safe = sanitizeDraftText(historyFill.text)
3067
3336
  draftImagesRef.current = []
3068
3337
  setDraftImages([])
3338
+ draftFilesRef.current = []
3339
+ setDraftFiles([])
3069
3340
  valueRef.current = safe
3070
3341
  cursorRef.current = safe.length
3071
3342
  setValue(safe)
@@ -3088,6 +3359,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3088
3359
  draftImagesRef.current = next
3089
3360
  return next.length === current.length ? current : next
3090
3361
  })
3362
+ setDraftFiles((current) => {
3363
+ const next = current.filter(file => value.includes(file.marker))
3364
+ draftFilesRef.current = next
3365
+ return next.length === current.length ? current : next
3366
+ })
3091
3367
  }, [value])
3092
3368
 
3093
3369
  // Home/End and the Backspace-vs-Delete family never survive Ink's parser
@@ -3150,13 +3426,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3150
3426
  process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
3151
3427
  )
3152
3428
 
3153
- const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
3429
+ const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = [], kind: 'image' | 'file' = 'image'): string => {
3154
3430
  const safeName = singleLineText(sanitizeDraftText(name))
3155
- const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
3431
+ const label = kind === 'file' ? 'file' : 'image'
3432
+ const base = source === 'mention' ? `@${safeName}` : `[${label}: ${safeName}]`
3156
3433
  let marker = base
3157
3434
  let suffix = 2
3158
- while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
3159
- marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
3435
+ const taken = (candidate: string): boolean =>
3436
+ valueRef.current.includes(candidate)
3437
+ || draftImagesRef.current.some(image => image.marker === candidate)
3438
+ || draftFilesRef.current.some(file => file.marker === candidate)
3439
+ || reserved.includes(candidate)
3440
+ while (taken(marker)) {
3441
+ marker = source === 'mention' ? `@${safeName} (${suffix})` : `[${label}: ${safeName} ${suffix}]`
3160
3442
  suffix += 1
3161
3443
  }
3162
3444
  return marker
@@ -3173,27 +3455,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3173
3455
  return true
3174
3456
  }
3175
3457
 
3176
- const insertDroppedImages = (paths: readonly string[]): void => {
3458
+ /**
3459
+ * Attach a paste/drop split into image and non-image paths: images ride the
3460
+ * durable image blocks, files ride the 0.1.5 file blocks, and both register
3461
+ * visible draft markers anchored at the drop point.
3462
+ */
3463
+ const insertDroppedAttachments = (imagePaths: readonly string[], filePaths: readonly string[]): void => {
3177
3464
  const originalValue = valueRef.current
3178
3465
  const originalCursor = cursorRef.current
3179
- notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
3180
- void inspectImages(paths).then((inspected) => {
3181
- const additions: DraftImage[] = []
3466
+ const total = imagePaths.length + filePaths.length
3467
+ if (total === 0) return
3468
+ notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
3469
+ void Promise.all([
3470
+ imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
3471
+ filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
3472
+ ]).then(([inspectedImages, inspectedFiles]) => {
3473
+ const imageAdditions: DraftImage[] = []
3474
+ const fileAdditions: DraftFile[] = []
3182
3475
  const markers: string[] = []
3183
- for (const inspection of inspected) {
3184
- if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
3476
+ for (const inspection of inspectedImages) {
3477
+ if ([...draftImagesRef.current, ...imageAdditions].some(image => sameImagePath(image.path, inspection.path))) continue
3185
3478
  const marker = uniqueImageMarker(inspection.name, 'drop', markers)
3186
- additions.push({ ...inspection, marker })
3479
+ imageAdditions.push({ ...inspection, marker })
3187
3480
  markers.push(marker)
3188
3481
  }
3189
- if (additions.length === 0) {
3190
- notify('those images are already attached', 'warning')
3482
+ for (const inspection of inspectedFiles) {
3483
+ if ([...draftFilesRef.current, ...fileAdditions].some(file => sameImagePath(file.path, inspection.path))) continue
3484
+ const marker = uniqueImageMarker(inspection.name, 'drop', markers, 'file')
3485
+ fileAdditions.push({ ...inspection, marker })
3486
+ markers.push(marker)
3487
+ }
3488
+ if (imageAdditions.length === 0 && fileAdditions.length === 0) {
3489
+ notify('those attachments are already attached', 'warning')
3191
3490
  return
3192
3491
  }
3193
3492
  const current = valueRef.current
3194
3493
  const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
3195
3494
  if (anchor === undefined) {
3196
- notify('draft changed at the image drop point; drop the images again', 'warning')
3495
+ notify('draft changed at the attachment drop point; drop the files again', 'warning')
3197
3496
  return
3198
3497
  }
3199
3498
  const at = anchor.start
@@ -3207,12 +3506,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3207
3506
  setValue(edit.value)
3208
3507
  setCursor(nextCursor)
3209
3508
  resetCursorBlink()
3210
- const nextImages = [...draftImagesRef.current, ...additions]
3509
+ const nextImages = [...draftImagesRef.current, ...imageAdditions]
3211
3510
  draftImagesRef.current = nextImages
3212
3511
  setDraftImages(nextImages)
3213
- notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
3512
+ const nextFiles = [...draftFilesRef.current, ...fileAdditions]
3513
+ draftFilesRef.current = nextFiles
3514
+ setDraftFiles(nextFiles)
3515
+ const count = imageAdditions.length + fileAdditions.length
3516
+ notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
3214
3517
  }, (reason: unknown) => {
3215
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3518
+ notify(`attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3216
3519
  })
3217
3520
  }
3218
3521
 
@@ -3253,8 +3556,21 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3253
3556
  const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
3254
3557
  ? mentionRows.filter(row => row.kind !== 'session')
3255
3558
  : mentionRows
3559
+ // Fuzzy ordering over the upstream candidates (≤20 per page, cheaper than
3560
+ // the slash menu): rows whose name contains the typed query as an ordered
3561
+ // subsequence rise to the top by alignment, and every other upstream row
3562
+ // keeps its place after them — the upstream matcher has its own relevance
3563
+ // semantics (path segments), so ranking reorders but never drops rows. A
3564
+ // path-like query keeps the upstream order entirely.
3565
+ let rankedMentionRows = visibleMentionRows
3566
+ if (mentionToken !== undefined && !isPathLikeMentionQuery(mentionToken.query) && mentionToken.query !== '') {
3567
+ const hits = rankByName(visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row })), mentionToken.query)
3568
+ .map(entry => entry.row)
3569
+ const hitSet = new Set(hits)
3570
+ rankedMentionRows = [...hits, ...visibleMentionRows.filter(row => !hitSet.has(row))]
3571
+ }
3256
3572
  const menuRows: readonly CompletionCandidate[] = mentionActive
3257
- ? visibleMentionRows.map(row => ({
3573
+ ? rankedMentionRows.map(row => ({
3258
3574
  label: row.label.startsWith('@')
3259
3575
  ? row.label
3260
3576
  : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
@@ -3270,8 +3586,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3270
3586
  /** Accept the highlighted completion-menu candidate into the draft. */
3271
3587
  const acceptMenuCandidate = (): void => {
3272
3588
  if (mentionActive && mentionToken !== undefined) {
3273
- if (visibleMentionRows.length === 0) return
3274
- const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
3589
+ if (rankedMentionRows.length === 0) return
3590
+ const row = rankedMentionRows[completionIndex % rankedMentionRows.length]
3275
3591
  if (row !== undefined) {
3276
3592
  if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
3277
3593
  const tokenText = value.slice(mentionToken.start, cursor)
@@ -3510,6 +3826,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3510
3826
  resetCursorBlink()
3511
3827
  draftImagesRef.current = []
3512
3828
  setDraftImages([])
3829
+ draftFilesRef.current = []
3830
+ setDraftFiles([])
3513
3831
  setCompletionIndex(0)
3514
3832
  setDismissedMenuValue(undefined)
3515
3833
  } else {
@@ -3578,15 +3896,29 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3578
3896
  // completion-inserted trailing space still routes `/quit ` correctly.
3579
3897
  const trimmed = liveValue.trim()
3580
3898
  const text = submissionPayload(liveValue)
3581
- if (draftImagesRef.current.length > 0) {
3899
+ if (draftImagesRef.current.length > 0 || draftFilesRef.current.length > 0) {
3900
+ // Slash semantics with attachments are unchanged: commands cannot
3901
+ // carry attachments, so the line goes to the model as a prompt —
3902
+ // warn instead of surprising the user with a literal "/export".
3903
+ if (isSlashLine(text)) notify('commands cannot carry attachments; the line will be sent to the model as a prompt', 'warning')
3904
+ // Attachment prepares resolve asynchronously; the app remounts onto
3905
+ // another session in the meantime, and this (old) instance's unmount
3906
+ // cleanup runs too late on the microtask timeline. Tag the delivery
3907
+ // with the composing session so the runner can drop the stale one.
3908
+ const originSession = sessionKey
3582
3909
  const controller = new AbortController()
3583
3910
  const epoch = prepareEpochRef.current + 1
3584
3911
  prepareEpochRef.current = epoch
3585
3912
  prepareAbortRef.current = controller
3586
3913
  setPreparingImages(true)
3587
- notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
3588
- const snapshot = draftImagesRef.current
3589
- void prepareImages(snapshot.map(image => image.path), controller.signal).then((images) => {
3914
+ const imageSnapshot = draftImagesRef.current
3915
+ const fileSnapshot = draftFilesRef.current
3916
+ const total = imageSnapshot.length + fileSnapshot.length
3917
+ notify(`processing ${total} attachment${total === 1 ? '' : 's'}…`)
3918
+ void Promise.all([
3919
+ imageSnapshot.length === 0 ? Promise.resolve([]) : prepareImages(imageSnapshot.map(image => image.path), controller.signal),
3920
+ fileSnapshot.length === 0 ? Promise.resolve([]) : prepareFiles(fileSnapshot.map(file => file.path), controller.signal),
3921
+ ]).then(([images, files]) => {
3590
3922
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3591
3923
  prepareAbortRef.current = undefined
3592
3924
  setPreparingImages(false)
@@ -3596,6 +3928,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3596
3928
  setCursor(0)
3597
3929
  draftImagesRef.current = []
3598
3930
  setDraftImages([])
3931
+ draftFilesRef.current = []
3932
+ setDraftFiles([])
3599
3933
  setCompletionIndex(0)
3600
3934
  setDismissedMenuValue(undefined)
3601
3935
  dismissNotice()
@@ -3604,13 +3938,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3604
3938
  recordHistory(text)
3605
3939
  }
3606
3940
  recall.current = beginRecall(recallSpace, '')
3607
- if (busy) steer(text, images)
3608
- else dispatch(text, images)
3941
+ const blocks: readonly ContentBlock[] = [...images, ...files]
3942
+ if (busy) steer(text, blocks, originSession)
3943
+ else dispatch(text, blocks, originSession)
3609
3944
  }, (reason: unknown) => {
3610
3945
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3611
3946
  prepareAbortRef.current = undefined
3612
3947
  setPreparingImages(false)
3613
- notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3948
+ notify(`attachment submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3614
3949
  })
3615
3950
  return
3616
3951
  }
@@ -3733,6 +4068,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3733
4068
  openTheme()
3734
4069
  return
3735
4070
  }
4071
+ if (text === '/animation' || text.startsWith('/animation ')) {
4072
+ const parsed = parseAnimationsArgument(text.slice('/animation'.length))
4073
+ if (parsed === 'toggle') applyAnimations(!animations)
4074
+ else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
4075
+ else applyAnimations(parsed.enabled)
4076
+ return
4077
+ }
3736
4078
  if (text === '/history') {
3737
4079
  openHistory()
3738
4080
  return
@@ -3906,50 +4248,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3906
4248
  text = text.replaceAll(PASTE_END_MARKER, '')
3907
4249
  }
3908
4250
  if (text === '') return
3909
- const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
3910
- if (droppedPaths.length > 0) {
3911
- insertDroppedImages(droppedPaths)
3912
- return
4251
+ if (text.length > 1) {
4252
+ // A path-list paste splits into images and files; prose falls through
4253
+ // as ordinary text (the splitter returns empty groups for non-paths).
4254
+ const dropped = parsePastedAttachmentPaths(text)
4255
+ if (dropped.images.length > 0 || dropped.files.length > 0) {
4256
+ insertDroppedAttachments(dropped.images, dropped.files)
4257
+ return
4258
+ }
3913
4259
  }
3914
4260
  applyEdit(insertText(valueRef.current, cursorRef.current, text))
3915
4261
  }
3916
4262
  }, active)
3917
4263
 
3918
- // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
3919
- // the interval re-renders only the composer band at 30fps, never the whole
3920
- // tree. App drives the tier/style pair on a model switch; this local effect
3921
- // starts the sweep whenever that pair changes (App picks a NEW random style
3922
- // for every replay including effort changes on the same route — so the
3923
- // pair always differs when a new wave should run) and stops it when the
3924
- // route leaves every wave tier (tier becomes null).
3925
- const [waveTick, setWaveTick] = useState<number | null>(null)
3926
- const wavePrevious = useRef<{ tier: DeepseekWaveTier | null; style: DeepseekWaveStyle | null }>({ tier: null, style: null })
3927
- useEffect(() => {
3928
- const previous = wavePrevious.current
3929
- wavePrevious.current = { tier: waveTier, style: waveStyle }
3930
- if (waveTier === null) {
3931
- setWaveTick(null)
3932
- return
3933
- }
3934
- if (previous.tier !== waveTier || previous.style !== waveStyle) {
3935
- setWaveTick(0)
3936
- }
3937
- }, [waveTier, waveStyle])
3938
- const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
3939
- && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
3940
- useEffect(() => {
3941
- if (!waveActive) return
3942
- const id = setInterval(() => {
3943
- setWaveTick(current => (current === null ? 0 : current + 1))
3944
- }, DEEPSEEK_WAVE_TICK_MS)
3945
- return () => {
3946
- clearInterval(id)
3947
- }
3948
- }, [waveActive])
4264
+ // The DeepSeek easter-egg wave renders through the ComposerWave leaf
4265
+ // below, which owns its 33ms tick: the sweep re-renders only that child at
4266
+ // 30fps this component's editor model, menu, and derived state never
4267
+ // re-run per frame. App drives the tier/style pair on a model switch, and
4268
+ // the child remounts whenever that pair changes (App picks a NEW random
4269
+ // style for every replay, so the pair always differs when a wave should
4270
+ // run), resetting the timeline to frame 0 before the first paint.
4271
+ //
4272
+ // The sweep is strictly one-shot per trigger, and the latch lives HERE
4273
+ // not in the leaf — because modal panels freeze the composer and UNMOUNT
4274
+ // ComposerWave; a mount-scoped latch would reset on every panel close and
4275
+ // replay a finished sweep. Keying `wavePlayedKey` by the tier:style pair
4276
+ // survives those unmounts: only a NEW trigger (which always changes the
4277
+ // pair) re-arms the sweep. Busy turns, image preparation, /animation
4278
+ // toggles, and panel open/close on an UNCHANGED model+effort pair never
4279
+ // fire it again.
4280
+ const waveKey = waveTier !== null && waveStyle !== null ? `${waveTier}:${waveStyle}` : null
4281
+ const [wavePlayedKey, setWavePlayedKey] = useState<string | null>(null)
3949
4282
  useEffect(() => {
3950
- if (waveTick !== null && waveTier !== null && waveStyle !== null
3951
- && waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
3952
- }, [waveTick, waveTier, waveStyle])
4283
+ // While animations are off, any pending trigger is consumed silently:
4284
+ // re-enabling must never queue or replay a celebration the user opted
4285
+ // out of watching.
4286
+ if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
4287
+ }, [animations, waveKey, wavePlayedKey])
4288
+ const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
3953
4289
 
3954
4290
  // Every exclusive panel keeps the composer as a stable visual anchor, but
3955
4291
  // freezes it to one row: no menu, multiline wrap, or animation.
@@ -4049,7 +4385,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4049
4385
  ? preparingImages
4050
4386
  ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
4051
4387
  : busy
4052
- ? createElement(BusyChase)
4388
+ ? createElement(BusyChase, { animated: animations })
4053
4389
  : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
4054
4390
  : ' ',
4055
4391
  parts.before,
@@ -4064,104 +4400,35 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4064
4400
  }
4065
4401
  const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
4066
4402
 
4067
- // The wave paints the SAME visible rows and caret site as the static path.
4068
- // Graphemes remain atomic and every background sample advances by terminal
4069
- // display columns, so CJK and emoji cannot move the caret or wrap the band.
4070
- const waveRow = (): ReactElement => {
4071
- const hues = deepseekWaveHues(waveTier!)
4072
- const style = waveStyle!
4073
- const bandRgb = getPalette().composerBand
4074
- const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
4075
- const totalBandRows = visibleRows.length + 2
4076
- const waveBg = (row: number, column: number): string => {
4077
- const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
4078
- return rgb === null ? bandBg : inkColor(rgb)
4079
- }
4080
- const blankBandRow = (row: number): ReactElement => {
4081
- const blanks: ComposerCell[] = []
4082
- for (let column = 0; column < bandWidth; column += 1) {
4083
- blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
4084
- }
4085
- return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
4086
- }
4087
- const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
4088
- let column = 0
4089
- for (let index = 0; index < cells.length; index += 1) {
4090
- if (column === target) return index
4091
- column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
4092
- if (column > target) return undefined
4093
- }
4094
- return undefined
4095
- }
4096
- const editorWaveRows = visibleRows.map((row, visibleIndex) => {
4097
- const sourceIndex = editorWindowStart + visibleIndex
4098
- const bandRow = visibleIndex + 1
4099
- const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
4100
- const placeholder = sourceIndex === 0 && value === '' && !busy
4101
- const cells: ComposerCell[] = []
4102
- let usedColumns = 0
4103
- const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
4104
- const width = visibleColumns(char)
4105
- cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
4106
- usedColumns += width
4107
- }
4108
- if (sourceIndex === 0) {
4109
- push(promptGlyph, { color: promptColor, bold: true })
4110
- push(' ', { color: promptColor })
4111
- } else {
4112
- push(' ')
4113
- push(' ')
4114
- }
4115
- for (const span of splitGraphemes(parts.before)) push(span.text)
4116
- if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
4117
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
4118
- for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
4119
- while (usedColumns < bandWidth) push(' ')
4120
-
4121
- const middleBandRow = Math.floor(totalBandRows / 2)
4122
- if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
4123
- const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
4124
- const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
4125
- const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
4126
- if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
4127
- for (let at = 0; at < word.length; at += 1) {
4128
- const cell = cells[indices[at]!]!
4129
- cell.char = word[at]!
4130
- cell.width = 1
4131
- cell.color = inkColor(deepseekWaveWordHue(at, hues))
4132
- cell.bold = true
4133
- cell.dim = false
4134
- }
4135
- }
4136
- }
4137
- if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
4138
- const spark = deepseekWaveSpark(waveTick!)
4139
- const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
4140
- if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
4141
- cells[lastIndex]!.char = spark
4142
- cells[lastIndex]!.color = promptColor
4143
- cells[lastIndex]!.bold = true
4144
- cells[lastIndex]!.dim = false
4145
- }
4146
- }
4147
- return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
4148
- })
4149
- return createElement(
4150
- Box,
4151
- { flexDirection: 'column', width: bandWidth },
4152
- blankBandRow(0),
4153
- ...editorWaveRows,
4154
- blankBandRow(totalBandRows - 1),
4155
- )
4156
- }
4157
-
4403
+ // The wave paints the SAME visible rows and caret site as the static path
4404
+ // through the ComposerWave leaf (see its comment). The child remounts on
4405
+ // every tier/style change, so its timeline always starts at frame 0, and
4406
+ // its gate cancels never freezes — the sweep while busy, preparing
4407
+ // images, or animations are off.
4158
4408
  return createElement(
4159
4409
  Box,
4160
4410
  { flexDirection: 'column' },
4161
4411
  menu,
4162
- waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
4163
- ? waveRow()
4164
- : band(staticEditor),
4412
+ createElement(ComposerWave, {
4413
+ key: waveKey ?? 'static',
4414
+ tier: waveTier ?? 'deepseek',
4415
+ style: waveStyle ?? 'wave',
4416
+ active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
4417
+ onSettled: () => {
4418
+ if (waveKey !== null) setWavePlayedKey(waveKey)
4419
+ },
4420
+ fallback: band(staticEditor),
4421
+ bandWidth,
4422
+ bandBg,
4423
+ rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
4424
+ windowStart: editorWindowStart,
4425
+ caretRow: caret.row,
4426
+ cursor: clampedCursor,
4427
+ caretVisible: cursorVisible,
4428
+ value,
4429
+ promptGlyph,
4430
+ promptColor,
4431
+ }),
4165
4432
  )
4166
4433
  }
4167
4434
 
@@ -4431,12 +4698,21 @@ export function App(props: AppProps): ReactElement {
4431
4698
  * to static while the prompt marker keeps the tier accent. The trigger
4432
4699
  * follows the applied model label (what the status bar actually shows),
4433
4700
  * never the initial paint, and the tier is derived from the label and
4434
- * cached at the switch. The 33ms tick itself lives inside Input, so the
4435
- * sweep re-renders only the composer band, not the whole tree, at 30fps;
4436
- * App owns the rarely-changing tier/style and Input starts the sweep
4437
- * whenever that pair changes. */
4701
+ * cached at the switch. The 33ms tick itself lives inside the ComposerWave
4702
+ * leaf, so the sweep re-renders only the composer band, not the whole tree,
4703
+ * at 30fps; App owns the rarely-changing tier/style and the leaf plays the
4704
+ * sweep exactly ONCE per pair change — an unchanged model+effort pair
4705
+ * (ordinary turns, image preparation, /animation toggles) never replays. */
4438
4706
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
4439
4707
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
4708
+ // /animation toggle: applies immediately, persists through the runner, and
4709
+ // gates every timed leaf (shimmer, chase, blink, wave) for this render.
4710
+ const [animations, setAnimations] = useState(props.animations ?? true)
4711
+ const applyAnimations = (enabled: boolean): void => {
4712
+ setAnimations(enabled)
4713
+ props.saveAnimations?.(enabled)
4714
+ notify(`animations ${enabled ? 'on' : 'off'}`)
4715
+ }
4440
4716
  const previousModel = useRef<string | undefined>(undefined)
4441
4717
  const previousEffort = useRef<string | undefined>(props.effort)
4442
4718
  const previousStyle = useRef<DeepseekWaveStyle | undefined>(undefined)
@@ -4668,7 +4944,7 @@ export function App(props: AppProps): ReactElement {
4668
4944
  // contract that lets arbitrarily long conversations scroll instead of
4669
4945
  // freezing when the live tree exceeds the terminal height. The dynamic
4670
4946
  // region below stays small: the streaming tail, modals, composer, and its
4671
- // status footer. `assistant/chunk` preserves `entries` identity.
4947
+ // status footer. Live stream frames preserve `entries` identity.
4672
4948
  //
4673
4949
  // `computeSettledRows` extends the cached row set incrementally: the
4674
4950
  // settled prefix is permanently final, so a grown boundary builds ONLY the
@@ -5114,7 +5390,7 @@ export function App(props: AppProps): ReactElement {
5114
5390
  // marker falls back to the static dim row — same as Deep diving
5115
5391
  // always yields the live region to streaming content.
5116
5392
  : view.streaming === ''
5117
- ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)' })
5393
+ ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)', animated: animations })
5118
5394
  : createElement(StreamTail, {
5119
5395
  text: 'Thinking… (Ctrl/Alt+R to expand)',
5120
5396
  prefix: '✻ ',
@@ -5129,10 +5405,10 @@ export function App(props: AppProps): ReactElement {
5129
5405
  // The same two-column gutter as settled replies: streamed text
5130
5406
  // lands exactly where the assembled message will render.
5131
5407
  { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
5132
- busy ? createElement(Caret) : undefined,
5408
+ busy ? createElement(Caret, { animated: animations }) : undefined,
5133
5409
  )
5134
5410
  : undefined,
5135
- deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
5411
+ deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince, animated: animations }) : undefined,
5136
5412
  )
5137
5413
  : undefined,
5138
5414
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
@@ -5419,6 +5695,9 @@ export function App(props: AppProps): ReactElement {
5419
5695
  loadMentions: props.loadMentions,
5420
5696
  inspectImages: props.inspectImages,
5421
5697
  prepareImages: props.prepareImages,
5698
+ inspectFiles: props.inspectFiles,
5699
+ prepareFiles: props.prepareFiles,
5700
+ sessionKey: props.sessionKey,
5422
5701
  cyclePermission: props.cyclePermission,
5423
5702
  exportTranscript: props.exportTranscript,
5424
5703
  renameTitle: props.renameTitle,
@@ -5430,6 +5709,8 @@ export function App(props: AppProps): ReactElement {
5430
5709
  cancelQueued: props.cancelQueued,
5431
5710
  historyFill,
5432
5711
  historyConsumed,
5712
+ animations,
5713
+ applyAnimations,
5433
5714
  waveTier,
5434
5715
  waveStyle,
5435
5716
  maxRows: composerEditorCap,