dsh-code 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.en.md +338 -286
  2. package/README.md +68 -16
  3. package/bin/deepseek.mjs +204 -4
  4. package/cordis.patch.yml +105 -7
  5. package/lib/index.mjs +2148 -439
  6. package/lib/session-query.mjs +149 -0
  7. package/lib/types/app.d.ts +34 -8
  8. package/lib/types/attachments.d.ts +36 -4
  9. package/lib/types/index.d.ts +38 -2
  10. package/lib/types/kernel-panels.d.ts +23 -0
  11. package/lib/types/provider-settings.d.ts +6 -11
  12. package/lib/types/render/animations.d.ts +74 -7
  13. package/lib/types/render/editor.d.ts +4 -3
  14. package/lib/types/render/export.d.ts +0 -6
  15. package/lib/types/render/fuzzy.d.ts +21 -0
  16. package/lib/types/render/ime-cursor.d.ts +60 -0
  17. package/lib/types/render/projection.d.ts +80 -4
  18. package/lib/types/render/status.d.ts +1 -1
  19. package/lib/types/session-directory.d.ts +48 -13
  20. package/lib/types/session-query.d.ts +92 -0
  21. package/lib/types/store.d.ts +3 -0
  22. package/lib/types/terminal-title.d.ts +58 -0
  23. package/lib/types/update-panel.d.ts +49 -0
  24. package/lib/types/update.d.ts +66 -0
  25. package/package.json +307 -162
  26. package/src/app.ts +730 -266
  27. package/src/attachments.ts +110 -11
  28. package/src/commands.ts +35 -5
  29. package/src/index.ts +1986 -1779
  30. package/src/internals.ts +66 -40
  31. package/src/kernel-panels.ts +89 -3
  32. package/src/provider-settings.ts +12 -12
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/editor.ts +5 -4
  35. package/src/render/export.ts +13 -3
  36. package/src/render/fuzzy.ts +83 -0
  37. package/src/render/ime-cursor.ts +147 -0
  38. package/src/render/projection.ts +1974 -1621
  39. package/src/render/status.ts +18 -4
  40. package/src/session-directory.ts +94 -16
  41. package/src/session-query.ts +235 -0
  42. package/src/skills.ts +23 -9
  43. package/src/store.ts +39 -1
  44. package/src/subagents.ts +26 -3
  45. package/src/terminal-title.ts +173 -0
  46. package/src/update-panel.ts +246 -0
  47. package/src/update.ts +110 -0
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'
@@ -34,14 +34,19 @@ import {
34
34
  type ThemeName,
35
35
  } from './theme.ts'
36
36
  import { ThemePanel } from './theme-panel.ts'
37
+ import { UpdatePanel } from './update-panel.ts'
38
+ import type { LauncherUpdateStatus } from './update.ts'
37
39
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
38
40
  import { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
39
41
  import type { TranscriptStore } from './store.ts'
42
+ import { DEFAULT_TERMINAL_TITLE, sanitizeTerminalTitle, terminalTitleSequence, useTerminalTitle } from './terminal-title.ts'
40
43
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
44
+ import { imeCursorRowsUp, useImeCursorAnchor } from './render/ime-cursor.ts'
41
45
  import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
46
  import {
43
47
  busyChaseFrame,
44
48
  BUSY_CHASE_TICK_MS,
49
+ CARET_BLINK_TICK_MS,
45
50
  caretVisible,
46
51
  DEEP_DIVING_SHIMMER_TICK_MS,
47
52
  DEEPSEEK_WAVE_TICK_MS,
@@ -56,11 +61,13 @@ import {
56
61
  deepDivingSparkColor,
57
62
  effortAboveHigh,
58
63
  isOfficialDeepSeekLabel,
64
+ parseAnimationsArgument,
59
65
  type DeepseekWaveStyle,
60
66
  type DeepseekWaveTier,
61
67
  } from './render/animations.ts'
62
68
  import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
63
- import { submissionPayload, type CommandsView } from './commands.ts'
69
+ import { isSlashLine, submissionPayload, type CommandsView } from './commands.ts'
70
+ import { rankByName } from './render/fuzzy.ts'
64
71
  import type { ModelDirectory, ModelRow } from './models.ts'
65
72
  import {
66
73
  isDeclaredReasoningEfforts,
@@ -77,7 +84,7 @@ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
77
84
  import type { SkillsView, SkillRow } from './skills.ts'
78
85
  import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
79
86
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
80
- import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
87
+ import { AgentsPanel, editQuery, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, SchedulePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
81
88
  import type { PresetRow } from './presets.ts'
82
89
  import type { PermissionRow } from './permissions.ts'
83
90
  import type { PluginRow } from './plugin-inventory.ts'
@@ -100,7 +107,8 @@ import {
100
107
  import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
101
108
  import {
102
109
  looksLikeImagePath,
103
- parsePastedImagePaths,
110
+ parsePastedAttachmentPaths,
111
+ type FilePathInspection,
104
112
  type ImagePathInspection,
105
113
  } from './attachments.ts'
106
114
 
@@ -196,6 +204,7 @@ import {
196
204
  editorRowParts,
197
205
  insertText,
198
206
  type EditResult,
207
+ type EditorRowModel,
199
208
  killToLineEnd,
200
209
  killToLineStart,
201
210
  moveCursorBy,
@@ -225,9 +234,12 @@ const LOCAL_COMMANDS = [
225
234
  { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
226
235
  { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
227
236
  { label: '/plugin', description: 'inspect the live plugin composition' },
237
+ { label: '/update', description: 'update dsh-code, the harness host, and profile plugins in one aligned step' },
228
238
  { label: '/jobs', description: 'inspect background jobs' },
239
+ { label: '/schedule', description: 'inspect active reminders (created through schedule tools)' },
229
240
  { label: '/statusline', description: 'customize the status line items' },
230
241
  { label: '/theme', description: 'switch the color theme' },
242
+ { label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
231
243
  { label: '/history', description: 'search and recall past prompts' },
232
244
  { label: '/agents', description: 'inspect subagent sessions of this conversation' },
233
245
  { label: '/todos', description: 'inspect the full todo list' },
@@ -277,10 +289,20 @@ export interface AppProps {
277
289
  mode: string
278
290
  /** Permission preset selected for the current or pending first session. */
279
291
  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
292
+ /**
293
+ * Submit one line: slash commands to the registry, other text to the agent.
294
+ * The optional origin names the session the submission was composed for —
295
+ * an attachment prepare resolves after the app remounted onto another
296
+ * session, and the runner drops the stale delivery then.
297
+ */
298
+ dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
299
+ /** Submit steering, with the same stale-delivery guard as {@link dispatch}. */
300
+ steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
301
+ /**
302
+ * The FULL current session identity ('' while the first session is pending)
303
+ * — the stale-delivery origin above. Distinct from the short display id.
304
+ */
305
+ sessionKey: string
284
306
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
285
307
  interrupt(): boolean
286
308
  /** Quit: unmount, flush, and request process exit. */
@@ -293,6 +315,10 @@ export interface AppProps {
293
315
  inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
294
316
  /** Validate, normalize and persist images immediately before submission. */
295
317
  prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
318
+ /** Validate draft non-image file paths without committing attachment objects. */
319
+ inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
320
+ /** Persist non-image files immediately before submission as durable file blocks. */
321
+ prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
296
322
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
297
323
  selectModel(row: ModelRow, effortId?: string): string
298
324
  /** The /subagent override label, '' when delegated agents follow the current model. */
@@ -338,8 +364,10 @@ export interface AppProps {
338
364
  logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
339
365
  openAuthorizationUrl?(url: string): boolean
340
366
  copyTextValue?(text: string): Promise<void>
341
- /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
342
- cyclePermission(): string
367
+ /** Cycle to the next mode station (Shift+Tab): a permission preset or a plan switch; returns the notice label. */
368
+ cycleMode(): string
369
+ /** Pre-session plan choice: shows the plan badge before the first session exists. */
370
+ pendingPlan?: boolean
343
371
  /** Select or inspect a permission preset without requiring a pre-existing session. */
344
372
  setPermission(id: string): string
345
373
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
@@ -369,6 +397,10 @@ export interface AppProps {
369
397
  loadPlugins(): readonly PluginRow[]
370
398
  /** Caller-visible background jobs (the host jobs registry, read-only). */
371
399
  loadJobs(): readonly JobRow[]
400
+ /** Probe the launcher's aligned update plan (read-only; never installs). */
401
+ probeUpdate(): Promise<LauncherUpdateStatus>
402
+ /** Run the launcher's aligned update; streams sanitized lines; resolves with the exit code. */
403
+ applyUpdate(onLine: (line: string) => void): Promise<number>
372
404
  /** Registers the app's notice channel with the runner (called once on mount). */
373
405
  onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
374
406
  /** Ordered enabled status items (/statusline config); the runner owns persistence. */
@@ -377,6 +409,11 @@ export interface AppProps {
377
409
  saveStatusline(items: readonly string[]): void
378
410
  /** Apply and persist one /theme selection; the runner owns the theme.json file. */
379
411
  saveTheme?(name: ThemeName): void
412
+ /** Whether timed animations run at startup (animations.json; on by default
413
+ * — like parseAnimationsPref, only an explicit false disables them). */
414
+ animations?: boolean
415
+ /** Apply and persist one /animation toggle; the runner owns the file. */
416
+ saveAnimations?(enabled: boolean): void
380
417
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
381
418
  history: readonly string[]
382
419
  /** Persist one submitted prompt to the global history file. */
@@ -393,12 +430,23 @@ function padColumns(text: string, width: number): string {
393
430
  return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
394
431
  }
395
432
 
396
- /** Interval-driven frame counter for one self-contained animated leaf. */
433
+ /**
434
+ * Wall-clock frame counter for one self-contained animated leaf. Each fire
435
+ * derives the tick from elapsed time instead of counting intervals, so a
436
+ * stretched interval (busy event loop, slow SSH) skips the animation ahead
437
+ * rather than slowing it down; the tick always tracks real time.
438
+ */
397
439
  function useFrames(intervalMs: number, active = true): number {
398
440
  const [tick, setTick] = useState(0)
399
441
  useEffect(() => {
400
442
  if (!active) return
401
- const id = setInterval(() => setTick(current => current + 1), intervalMs)
443
+ const startedAt = Date.now()
444
+ setTick(0)
445
+ const id = setInterval(() => {
446
+ // Clock setback (NTP resync) must not produce negative ticks — the
447
+ // blink parity check would flip the caret off for a full period.
448
+ setTick(Math.max(0, Math.floor((Date.now() - startedAt) / intervalMs)))
449
+ }, intervalMs)
402
450
  return () => {
403
451
  clearInterval(id)
404
452
  }
@@ -421,15 +469,18 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
421
469
  useInput(stableHandler, { isActive: active })
422
470
  }
423
471
 
424
- /** The original web StateDot chase used by the busy composer marker. */
425
- function BusyChase(): ReactElement {
426
- const tick = useFrames(BUSY_CHASE_TICK_MS)
472
+ /**
473
+ * The original web StateDot chase used by the busy composer marker. With
474
+ * animations off it freezes on the first frame (still visibly busy).
475
+ */
476
+ function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
477
+ const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
427
478
  return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
428
479
  }
429
480
 
430
- /** Blinking block caret appended to streaming text. */
431
- function Caret(): ReactElement {
432
- const tick = useFrames(530)
481
+ /** Blinking block caret appended to streaming text; solid when frozen. */
482
+ function Caret({ animated = true }: { animated?: boolean }): ReactElement {
483
+ const tick = useFrames(CARET_BLINK_TICK_MS, animated)
433
484
  return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
434
485
  }
435
486
 
@@ -440,7 +491,7 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
440
491
  useEffect(() => {
441
492
  setVisible(true)
442
493
  if (!active) return
443
- const id = setInterval(() => setVisible(current => !current), 530)
494
+ const id = setInterval(() => setVisible(current => !current), CARET_BLINK_TICK_MS)
444
495
  return () => {
445
496
  clearInterval(id)
446
497
  }
@@ -456,10 +507,12 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
456
507
  * One bounded line painted with the deep-diving shimmer: a continuously
457
508
  * moving blue gradient across graphemes, the `✻` glyph in the breathing
458
509
  * 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.
510
+ * always exactly one row (truncate-end) so the live budget stays exact. With
511
+ * animations off the same spans render in fixed colors — no timer, no
512
+ * per-frame repaint, the `✻` keeps its highlight.
460
513
  */
461
- function ShimmerLine({ text }: { text: string }): ReactElement {
462
- const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
514
+ function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
515
+ const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
463
516
  const palette = getPalette()
464
517
  const graphemes = splitGraphemes(text)
465
518
  return createElement(
@@ -471,7 +524,11 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
471
524
  Text,
472
525
  {
473
526
  key: `${grapheme.start}-${grapheme.end}`,
474
- color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
527
+ color: inkColor(!animated
528
+ ? (sparkle ? palette.brandBright : palette.brandDeep)
529
+ : sparkle
530
+ ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
531
+ : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
475
532
  bold: sparkle || undefined,
476
533
  },
477
534
  grapheme.text,
@@ -486,10 +543,10 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
486
543
  * only once the turn has clearly been running (15s) — anchored to `turn/start`
487
544
  * so a resumed mid-turn keeps the real time.
488
545
  */
489
- function DeepDivingLine({ since }: { since: number }): ReactElement {
546
+ function DeepDivingLine({ since, animated = true }: { since: number; animated?: boolean }): ReactElement {
490
547
  const elapsed = since === 0 ? 0 : Date.now() - since
491
548
  const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
492
- return createElement(ShimmerLine, { text })
549
+ return createElement(ShimmerLine, { text, animated })
493
550
  }
494
551
 
495
552
  /**
@@ -883,6 +940,10 @@ function statusToneProps(tone: StatusTone): {
883
940
  return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
884
941
  case 'success':
885
942
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
943
+ // The plan station's dedicated green: the status bar otherwise speaks in
944
+ // blues, but the fourth cycle station IS a distinct green mode marker.
945
+ case 'plan':
946
+ return { color: inkColor(getPalette().success), bold: true, dimColor: undefined }
886
947
  case 'warn':
887
948
  return { color: inkColor(getPalette().warn), bold: true, dimColor: undefined }
888
949
  case 'error':
@@ -924,12 +985,15 @@ function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTripl
924
985
  : [palette.brandBright, palette.code, palette.brandMid]
925
986
  }
926
987
 
927
- function StatusLine({ facts, stats, busy, columns, items }: {
988
+ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
928
989
  facts: StatusFacts
929
990
  stats: Parameters<typeof layoutStatusBar>[1]
930
991
  busy: boolean
931
992
  columns: number
932
993
  items: readonly string[]
994
+ /** Reports the footer's exact physical row count (1 or 2) so the IME
995
+ * anchor ledger below the composer stays exact. */
996
+ onRows?: (rows: 1 | 2) => void
933
997
  }): ReactElement {
934
998
  const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
935
999
  busy,
@@ -955,6 +1019,13 @@ function StatusLine({ facts, stats, busy, columns, items }: {
955
1019
  columns,
956
1020
  items,
957
1021
  ])
1022
+ // The IME anchor below the composer counts every row between the caret and
1023
+ // Ink's parked cursor, so the footer reports its exact row count one-way
1024
+ // (same contract as the composer's row report).
1025
+ const statusRowCount: 1 | 2 = layout.row2.left.length > 0 ? 2 : 1
1026
+ useEffect(() => {
1027
+ onRows?.(statusRowCount)
1028
+ }, [onRows, statusRowCount])
958
1029
 
959
1030
  const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
960
1031
  const leftParts: ReactElement[] = []
@@ -1593,38 +1664,52 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1593
1664
  onRetry(): void
1594
1665
  onClose(): void
1595
1666
  }): ReactElement {
1667
+ const [query, setQuery] = useState('')
1596
1668
  const [cursor, setCursor] = useState(0)
1597
1669
  const stdout = useStdout().stdout
1598
1670
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1599
1671
  const rows = directory?.rows ?? []
1672
+ // Direct-typing filter over provider and model names (the /mode contract):
1673
+ // printable keys edit the query, so a long directory is searchable without
1674
+ // a separate search mode. With a query active, q/r/g/G stop acting as
1675
+ // commands and become query text instead.
1676
+ const filtered = useMemo(() => {
1677
+ if (query === '') return rows
1678
+ const needle = query.toLowerCase()
1679
+ return rows.filter(row => `${row.provider} ${row.providerName ?? ''} ${row.model} ${row.modelName}`.toLowerCase().includes(needle))
1680
+ }, [rows, query])
1600
1681
  const positioned = useRef(false)
1601
1682
 
1602
1683
  useEffect(() => {
1603
1684
  // Open ON the applied model (Codex resumes the previous pick): the first
1604
1685
  // non-empty directory positions the cursor once, never on later refreshes.
1605
1686
  if (positioned.current || rows.length === 0 || current === undefined) {
1606
- if (rows.length === 0) {
1687
+ if (filtered.length === 0) {
1607
1688
  if (cursor !== 0) setCursor(0)
1608
1689
  return
1609
1690
  }
1610
- if (cursor >= rows.length) setCursor(rows.length - 1)
1691
+ if (cursor >= filtered.length) setCursor(filtered.length - 1)
1611
1692
  return
1612
1693
  }
1613
1694
  const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
1614
1695
  if (index >= 0) {
1615
1696
  positioned.current = true
1616
- setCursor(index)
1617
- } else if (cursor >= rows.length) {
1618
- setCursor(Math.max(0, rows.length - 1))
1697
+ // Position within the ACTIVE filter: the full-row index means nothing
1698
+ // when the query already narrowed the list while the directory loaded
1699
+ // (a late resolve must not place the cursor outside `filtered`).
1700
+ const filteredIndex = filtered.indexOf(rows[index]!)
1701
+ setCursor(filteredIndex >= 0 ? filteredIndex : 0)
1702
+ } else if (cursor >= filtered.length) {
1703
+ setCursor(Math.max(0, filtered.length - 1))
1619
1704
  }
1620
- }, [rows, cursor, current])
1705
+ }, [rows, filtered, cursor, current])
1621
1706
 
1622
1707
  useInput((input, key) => {
1623
- if (key.escape || input === 'q') {
1708
+ if (key.escape || (input === 'q' && query === '')) {
1624
1709
  onClose()
1625
1710
  return
1626
1711
  }
1627
- if (input === 'r') {
1712
+ if (input === 'r' && query === '') {
1628
1713
  onRetry()
1629
1714
  return
1630
1715
  }
@@ -1637,13 +1722,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1637
1722
  onClose()
1638
1723
  return
1639
1724
  }
1640
- if (rows.length === 0) return
1725
+ const next = editQuery(query, input, key)
1726
+ if (next !== undefined) {
1727
+ setQuery(next)
1728
+ setCursor(0)
1729
+ return
1730
+ }
1731
+ if (filtered.length === 0) return
1641
1732
  if (key.upArrow) {
1642
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
1733
+ setCursor(cursor > 0 ? cursor - 1 : filtered.length - 1)
1643
1734
  return
1644
1735
  }
1645
1736
  if (key.downArrow) {
1646
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
1737
+ setCursor(cursor < filtered.length - 1 ? cursor + 1 : 0)
1647
1738
  return
1648
1739
  }
1649
1740
  if (key.pageUp) {
@@ -1651,32 +1742,27 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1651
1742
  return
1652
1743
  }
1653
1744
  if (key.pageDown) {
1654
- setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1745
+ setCursor(current => Math.min(filtered.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1655
1746
  return
1656
1747
  }
1657
- if (input === 'g') {
1658
- setCursor(0)
1659
- return
1660
- }
1661
- if (input === 'G') {
1662
- setCursor(rows.length - 1)
1663
- return
1664
- }
1665
- if (key.return && rows[cursor] !== undefined) {
1666
- onSelect(rows[cursor])
1748
+ if (key.return && filtered[cursor] !== undefined) {
1749
+ onSelect(filtered[cursor])
1667
1750
  }
1668
1751
  })
1669
1752
 
1670
1753
  if (viewport.maxHeight === 0 || viewport.compact) {
1671
1754
  const providers = onProviders === undefined ? '' : ' · tab providers'
1672
- const state = rows.length === 0
1755
+ const state = filtered.length === 0
1673
1756
  ? directory === undefined && error === undefined
1674
1757
  ? 'loading…'
1675
1758
  : error !== undefined
1676
1759
  ? 'error'
1677
- : 'no models'
1678
- : `❯ ${rows[cursor]?.modelName ?? rows[cursor]?.model ?? ''}`
1679
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model · ${state}${providers} · r retry · esc/q close`, viewport.contentColumns))
1760
+ : query === '' ? 'no models' : `no match for '${singleLineText(query)}'`
1761
+ : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ''}`
1762
+ const tail = query === ''
1763
+ ? 'type to filter · r retry · esc/q close'
1764
+ : 'backspace edits · esc close'
1765
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model · ${state}${providers} · ${tail}`, viewport.contentColumns))
1680
1766
  }
1681
1767
 
1682
1768
  const stateRows: ReactElement[] = directory === undefined && error === undefined
@@ -1697,23 +1783,27 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1697
1783
  )]),
1698
1784
  ...(rows.length === 0
1699
1785
  ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
1700
- : []),
1786
+ : filtered.length === 0
1787
+ ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` no models match '${singleLineText(query)}'`, viewport.contentColumns))]
1788
+ : []),
1701
1789
  ]
1702
1790
  // Measurement and rendering share the same physical-row budget: state
1703
1791
  // messages consume body rows before selectable entries, as in Codex's
1704
1792
  // list-selection views.
1705
1793
  const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1706
1794
  const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1707
- const first = selectionWindow(cursor, rows.length, rowBudget)
1708
- const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1795
+ const first = selectionWindow(cursor, filtered.length, rowBudget)
1796
+ const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
1709
1797
  return createElement(
1710
1798
  Box,
1711
1799
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1712
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1800
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(query === ''
1801
+ ? `/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`
1802
+ : `/model — select model · ${filtered.length} of ${rows.length} match '${singleLineText(query)}'`, viewport.contentColumns)),
1713
1803
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1714
1804
  ...visibleStateRows,
1715
1805
  ...visible.map((row) => {
1716
- const index = rows.indexOf(row)
1806
+ const index = filtered.indexOf(row)
1717
1807
  const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
1718
1808
  const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
1719
1809
  return createElement(
@@ -1727,7 +1817,9 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1727
1817
  )
1728
1818
  }),
1729
1819
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1730
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · tab providers'} · r retry · esc/q close`, viewport.contentColumns))),
1820
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(query === ''
1821
+ ? `type to filter · ↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · tab providers'} · r retry · esc/q close`
1822
+ : `↑↓ move · pgup/pgdn page · enter select · backspace edits · esc close`, viewport.contentColumns))),
1731
1823
  )
1732
1824
  }
1733
1825
 
@@ -1908,7 +2000,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1908
2000
  const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
1909
2001
  const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
1910
2002
  const authLabel = showAuthorization ? ' · ' + providerAuthorizationStatus(authorization) : ''
1911
- const label = identity + ' · ' + providerStateLabel(row) + authLabel + (row.removable ? ' · custom' : '')
2003
+ // The adapter's configuration diagnostic rides the row (the provider
2004
+ // stays listed and repairable — this is why it did not vanish).
2005
+ const diagnostic = row.diagnostic === undefined ? '' : ' · ! ' + singleLineText(row.diagnostic)
2006
+ const label = identity + ' · ' + providerStateLabel(row) + authLabel + (row.removable ? ' · custom' : '') + diagnostic
1912
2007
  // Configured rows render in the intermediate brand blue so the in-use
1913
2008
  // group reads at a glance; the dormant tail keeps the dim caption gray.
1914
2009
  const idleColor = row.configured ? inkColor(getPalette().brandMid) : inkColor(getPalette().dim)
@@ -2249,7 +2344,9 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2249
2344
  const keyBullets = '•'.repeat(Math.min([...keyDraft].length, Math.max(1, viewport.contentColumns - 14)))
2250
2345
  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
2346
  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)
2347
+ // The fixed diagnostic row (present only with an adapter error) joins the
2348
+ // same height budget as the state rows — it must never overflow the panel.
2349
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - (target.diagnostic === undefined ? 0 : 1) - 3)
2253
2350
  const first = selectionWindow(cursor, models.length + 1, rowBudget)
2254
2351
  const modelRows: ReactElement[] = []
2255
2352
  for (let index = first; index < first + Math.max(0, Math.min(models.length + 1 - first, rowBudget)); index += 1) {
@@ -2271,6 +2368,13 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2271
2368
  Box,
2272
2369
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2273
2370
  createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — configure ' + target.displayName, viewport.contentColumns)),
2371
+ // The adapter's configuration diagnostic heads the editor: the provider
2372
+ // is here precisely because it stayed listed for repair.
2373
+ ...(target.diagnostic === undefined ? [] : [createElement(
2374
+ Text,
2375
+ { key: 'diagnostic', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
2376
+ truncateColumns('! ' + displayText(singleLineText(target.diagnostic)), viewport.contentColumns),
2377
+ )]),
2274
2378
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2275
2379
  keyRow,
2276
2380
  urlRow,
@@ -2610,6 +2714,197 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
2610
2714
  return spans
2611
2715
  }
2612
2716
 
2717
+ /** Index of the cell STARTING at a display column, if one does. */
2718
+ function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): number | undefined {
2719
+ let column = 0
2720
+ for (let index = 0; index < cells.length; index += 1) {
2721
+ if (column === target) return index
2722
+ column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
2723
+ if (column > target) return undefined
2724
+ }
2725
+ return undefined
2726
+ }
2727
+
2728
+ /**
2729
+ * Wall-clock wave frames — strictly ONE sweep per MOUNT; the mount-spanning
2730
+ * one-shot latch (surviving modal unmounts) lives in Input as `wavePlayedKey`.
2731
+ * The first gate-off after the sweep has started (it completed, a turn went
2732
+ * busy, image preparation began, animations were toggled off) latches `done`
2733
+ * for this mount, so the same mount can never resume or replay. A trigger
2734
+ * that lands while the gate is already down stays pending until the gate
2735
+ * rises once, then plays.
2736
+ */
2737
+ function useWaveFrames(active: boolean, durationMs: number): { tick: number; done: boolean } {
2738
+ const [tick, setTick] = useState(0)
2739
+ const [done, setDone] = useState(false)
2740
+ const startedRef = useRef(false)
2741
+ useEffect(() => {
2742
+ if (done) return
2743
+ if (!active) {
2744
+ // A sweep that already started is cancelled permanently, never resumed.
2745
+ if (startedRef.current) setDone(true)
2746
+ return
2747
+ }
2748
+ startedRef.current = true
2749
+ const startedAt = Date.now()
2750
+ const id = setInterval(() => {
2751
+ const elapsed = Date.now() - startedAt
2752
+ if (elapsed >= durationMs) {
2753
+ clearInterval(id)
2754
+ setDone(true)
2755
+ return
2756
+ }
2757
+ setTick(Math.max(0, Math.floor(elapsed / DEEPSEEK_WAVE_TICK_MS)))
2758
+ }, DEEPSEEK_WAVE_TICK_MS)
2759
+ return () => {
2760
+ clearInterval(id)
2761
+ }
2762
+ }, [active, durationMs, done])
2763
+ return { tick, done }
2764
+ }
2765
+
2766
+ /** The wave-painted composer band: everything the sweep needs, as data. */
2767
+ interface ComposerWaveProps {
2768
+ /** Wave tier of the applied route (flash / deepseek / unknown). */
2769
+ tier: DeepseekWaveTier
2770
+ /** Ignition style App picked for this trigger. */
2771
+ style: DeepseekWaveStyle
2772
+ /** False while busy, preparing images, or animations are off; the fallback
2773
+ * band renders instead (non-wave routes keep it false permanently). */
2774
+ active: boolean
2775
+ /** The static band to render before, after, and instead of the sweep. */
2776
+ fallback: ReactElement
2777
+ /** Composer band width in columns (terminal width minus the last column). */
2778
+ bandWidth: number
2779
+ /** Ink color of the static band background (the transparent-cell base). */
2780
+ bandBg: string
2781
+ /** The editor's visible physical rows (already windowed). */
2782
+ rows: readonly EditorRowModel[]
2783
+ /** Index of `rows[0]` in the full editor model (keying + caret row math). */
2784
+ windowStart: number
2785
+ /** Absolute caret row in the editor model. */
2786
+ caretRow: number
2787
+ /** The authoritative cursor offset. */
2788
+ cursor: number
2789
+ /** Caret blink visibility (shared with the static path). */
2790
+ caretVisible: boolean
2791
+ /** The draft text (placeholder detection on row 0). */
2792
+ value: string
2793
+ /** Tier prompt glyph and accent color (persistent, like Codex's charge). */
2794
+ promptGlyph: string
2795
+ promptColor: string
2796
+ /** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
2797
+ * cancelled by the gate, or unmounted (a modal panel froze the composer) —
2798
+ * so Input's played-key latch survives the leaf's unmount/remount cycle. */
2799
+ onSettled(): void
2800
+ }
2801
+
2802
+ /**
2803
+ * The self-contained wave leaf: it owns its 33ms tick, so the sweep
2804
+ * re-renders ONLY this component at ~30fps — Input's derived editor state
2805
+ * never re-runs per frame. Graphemes stay atomic and every background sample
2806
+ * advances by terminal display columns, so CJK and emoji cannot move the
2807
+ * caret or wrap the band. The duration gate renders the fallback band on the
2808
+ * frame the sweep completes.
2809
+ */
2810
+ function ComposerWave(props: ComposerWaveProps): ReactElement {
2811
+ const { tier, style } = props
2812
+ const durationMs = deepseekWaveDuration(tier, style)
2813
+ const { tick, done } = useWaveFrames(props.active, durationMs)
2814
+ // Report the sweep's end exactly once — completion, gate cancellation, or
2815
+ // unmount (a modal opened and froze the composer) — latching Input's
2816
+ // played-key so this trigger can never replay after a remount.
2817
+ const settledRef = useRef(false)
2818
+ const onSettledRef = useRef(props.onSettled)
2819
+ onSettledRef.current = props.onSettled
2820
+ const settle = (): void => {
2821
+ if (settledRef.current) return
2822
+ settledRef.current = true
2823
+ onSettledRef.current()
2824
+ }
2825
+ useEffect(() => {
2826
+ if (done) settle()
2827
+ }, [done])
2828
+ useEffect(() => () => {
2829
+ settle()
2830
+ }, [])
2831
+ if (!props.active || done || tick * DEEPSEEK_WAVE_TICK_MS >= durationMs) return props.fallback
2832
+ const hues = deepseekWaveHues(tier)
2833
+ const bandRgb = getPalette().composerBand
2834
+ const totalBandRows = props.rows.length + 2
2835
+ const waveBg = (row: number, column: number): string => {
2836
+ const rgb = deepseekWaveColumnBg(tick, column, props.bandWidth, tier, style, hues, bandRgb, row, totalBandRows)
2837
+ return rgb === null ? props.bandBg : inkColor(rgb)
2838
+ }
2839
+ const blankBandRow = (row: number): ReactElement => {
2840
+ const blanks: ComposerCell[] = []
2841
+ for (let column = 0; column < props.bandWidth; column += 1) {
2842
+ blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
2843
+ }
2844
+ return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
2845
+ }
2846
+ const editorWaveRows = props.rows.map((row, visibleIndex) => {
2847
+ const sourceIndex = props.windowStart + visibleIndex
2848
+ const bandRow = visibleIndex + 1
2849
+ const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
2850
+ const placeholder = sourceIndex === 0 && props.value === ''
2851
+ const cells: ComposerCell[] = []
2852
+ let usedColumns = 0
2853
+ const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
2854
+ const width = visibleColumns(char)
2855
+ cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
2856
+ usedColumns += width
2857
+ }
2858
+ if (sourceIndex === 0) {
2859
+ push(props.promptGlyph, { color: props.promptColor, bold: true })
2860
+ push(' ', { color: props.promptColor })
2861
+ } else {
2862
+ push(' ')
2863
+ push(' ')
2864
+ }
2865
+ for (const span of splitGraphemes(parts.before)) push(span.text)
2866
+ if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
2867
+ const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
2868
+ for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
2869
+ while (usedColumns < props.bandWidth) push(' ')
2870
+
2871
+ const middleBandRow = Math.floor(totalBandRows / 2)
2872
+ if (bandRow === middleBandRow && deepseekWaveWordVisible(tick, tier, style)) {
2873
+ const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
2874
+ const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
2875
+ const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
2876
+ if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
2877
+ for (let at = 0; at < word.length; at += 1) {
2878
+ const cell = cells[indices[at]!]!
2879
+ cell.char = word[at]!
2880
+ cell.width = 1
2881
+ cell.color = inkColor(deepseekWaveWordHue(at, hues))
2882
+ cell.bold = true
2883
+ cell.dim = false
2884
+ }
2885
+ }
2886
+ }
2887
+ if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
2888
+ const spark = deepseekWaveSpark(tick)
2889
+ const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
2890
+ if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
2891
+ cells[lastIndex]!.char = spark
2892
+ cells[lastIndex]!.color = props.promptColor
2893
+ cells[lastIndex]!.bold = true
2894
+ cells[lastIndex]!.dim = false
2895
+ }
2896
+ }
2897
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
2898
+ })
2899
+ return createElement(
2900
+ Box,
2901
+ { flexDirection: 'column', width: props.bandWidth },
2902
+ blankBandRow(0),
2903
+ ...editorWaveRows,
2904
+ blankBandRow(totalBandRows - 1),
2905
+ )
2906
+ }
2907
+
2613
2908
  /**
2614
2909
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
2615
2910
  * with independent history selection and content scrolling. The complete
@@ -2821,8 +3116,12 @@ export function completionCandidates(
2821
3116
  seen.add(name)
2822
3117
  all.push(candidate)
2823
3118
  }
2824
- if (prefix === '') return all
2825
- return all.filter(candidate => candidate.label.slice(1).startsWith(prefix))
3119
+ // Fuzzy ranking (the web menu's discovery feel): the query must be a
3120
+ // case-insensitive ordered subsequence of a name; prefix hits first, then
3121
+ // alignment score, then this composition order. An empty query keeps the
3122
+ // full list.
3123
+ return rankByName(all.map(candidate => ({ name: candidate.label.slice(1), candidate })), prefix)
3124
+ .map(entry => entry.candidate)
2826
3125
  }
2827
3126
 
2828
3127
  /**
@@ -2923,20 +3222,31 @@ interface DraftImage extends ImagePathInspection {
2923
3222
  readonly marker: string
2924
3223
  }
2925
3224
 
3225
+ /** One attached non-image file held in the editor until submission persists it. */
3226
+ interface DraftFile extends FilePathInspection {
3227
+ /** Visible draft token; deleting it also detaches the hidden path. */
3228
+ readonly marker: string
3229
+ }
3230
+
2926
3231
  /**
2927
3232
  * The prompt box: TUI-local slash commands handled locally, other lines
2928
3233
  * dispatched; input editing keeps a cursor with history and completion.
2929
3234
  * While a modal (approval / question / model panel) owns the keys, the
2930
3235
  * box passes every key through untouched.
2931
3236
  */
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 }: {
3237
+ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openUpdate, openSchedule, 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, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }: {
2933
3238
  active: boolean
2934
3239
  frozen: boolean
3240
+ /** Frozen-band hint naming the surface that owns the keyboard; an empty
3241
+ * draft otherwise advertises typing that the composer cannot accept. */
3242
+ frozenHint?: string
2935
3243
  busy: boolean
2936
3244
  descriptors: readonly CommandDescriptor[]
2937
3245
  skills: readonly SkillRow[]
2938
- dispatch(text: string, images?: readonly ImageBlock[]): void
2939
- steer(text: string, images?: readonly ImageBlock[]): void
3246
+ dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3247
+ steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3248
+ /** The full current session identity ('' while pending); the delivery origin. */
3249
+ sessionKey: string
2940
3250
  interrupt(): boolean
2941
3251
  quit(): void
2942
3252
  openModel(): void
@@ -2946,6 +3256,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2946
3256
  openPermission(): void
2947
3257
  openResume(): void
2948
3258
  openPlugin(query?: string): void
3259
+ /** Open the /update panel (aligned upgrade surface). */
3260
+ openUpdate(): void
3261
+ /** Open the /schedule reminder panel (read-only catalog). */
3262
+ openSchedule(): void
2949
3263
  openJobs(): void
2950
3264
  openStatusline(): void
2951
3265
  openTheme(): void
@@ -2981,7 +3295,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2981
3295
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
2982
3296
  inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
2983
3297
  prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
2984
- cyclePermission(): string
3298
+ inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
3299
+ prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
3300
+ cycleMode(): string
2985
3301
  exportTranscript(argument: string): Promise<void>
2986
3302
  renameTitle(argument: string): string
2987
3303
  copyLastResponse(): Promise<string>
@@ -2999,6 +3315,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2999
3315
  historyFill: { text: string; index: number } | undefined
3000
3316
  /** Marks the accepted entry consumed (called after the fill is applied). */
3001
3317
  historyConsumed(): void
3318
+ /** Whether timed animations run (shimmer, chase, blink, wave). */
3319
+ animations: boolean
3320
+ /** Apply and report one /animation toggle (App persists through the runner). */
3321
+ applyAnimations(enabled: boolean): void
3002
3322
  /** DeepSeek easter-egg wave tier of the applied route (null otherwise):
3003
3323
  * official DeepSeek models drive their flash/pro tiers, non-DeepSeek
3004
3324
  * models running an effort above high drive the "Into the Unknown"
@@ -3009,14 +3329,25 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3009
3329
  waveStyle: DeepseekWaveStyle | null
3010
3330
  /** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
3011
3331
  maxRows: number
3332
+ /** Terminal rows below the composer the editor does not own: the status
3333
+ * footer and Ink's parked cursor row. The IME anchor adds these to the
3334
+ * caret's in-band offset to reach that parked position. */
3335
+ anchorRowsBelow: number
3336
+ /** The managed terminal tab label; re-asserted on terminal focus-in so a
3337
+ * background process sharing the console cannot keep it overwritten. */
3338
+ tabTitle: string
3012
3339
  /** Reports the editor's current physical row count so the live budget stays exact. */
3013
3340
  onEditorRows(rows: number): void
3014
3341
  /** Reports the open completion menu's physical row count (0 when closed)
3015
3342
  * for the same reason: the dynamic budget must reserve it, not overflow. */
3016
3343
  onMenuRows(rows: number): void
3017
3344
  }): ReactElement {
3018
- const columns = useStdout().stdout?.columns ?? 80
3019
- const inputTerminalRows = useStdout().stdout?.rows ?? 30
3345
+ const { stdout: inputStdout } = useStdout()
3346
+ const columns = inputStdout?.columns ?? 80
3347
+ const inputTerminalRows = inputStdout?.rows ?? 30
3348
+ // The managed tab label, kept current for the focus-in re-assert below.
3349
+ const tabTitleRef = useRef(tabTitle)
3350
+ tabTitleRef.current = tabTitle
3020
3351
  const editorColumns = Math.max(1, columns - 6)
3021
3352
  const stdin = useStdin().stdin
3022
3353
  const focusReporting = isVsCodeTerminalEnv()
@@ -3029,10 +3360,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3029
3360
  const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
3030
3361
  const draftImagesRef = useRef(draftImages)
3031
3362
  draftImagesRef.current = draftImages
3363
+ const [draftFiles, setDraftFiles] = useState<readonly DraftFile[]>([])
3364
+ const draftFilesRef = useRef(draftFiles)
3365
+ draftFilesRef.current = draftFiles
3032
3366
  const [preparingImages, setPreparingImages] = useState(false)
3033
3367
  const prepareAbortRef = useRef<AbortController | undefined>(undefined)
3034
3368
  const prepareEpochRef = useRef(0)
3035
- const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
3369
+ const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages && animations)
3036
3370
  useEffect(() => () => {
3037
3371
  prepareEpochRef.current += 1
3038
3372
  prepareAbortRef.current?.abort()
@@ -3066,6 +3400,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3066
3400
  const safe = sanitizeDraftText(historyFill.text)
3067
3401
  draftImagesRef.current = []
3068
3402
  setDraftImages([])
3403
+ draftFilesRef.current = []
3404
+ setDraftFiles([])
3069
3405
  valueRef.current = safe
3070
3406
  cursorRef.current = safe.length
3071
3407
  setValue(safe)
@@ -3088,6 +3424,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3088
3424
  draftImagesRef.current = next
3089
3425
  return next.length === current.length ? current : next
3090
3426
  })
3427
+ setDraftFiles((current) => {
3428
+ const next = current.filter(file => value.includes(file.marker))
3429
+ draftFilesRef.current = next
3430
+ return next.length === current.length ? current : next
3431
+ })
3091
3432
  }, [value])
3092
3433
 
3093
3434
  // Home/End and the Backspace-vs-Delete family never survive Ink's parser
@@ -3108,6 +3449,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3108
3449
  const input = focusReporting
3109
3450
  ? stripTerminalFocusEvents(normalized, focused => {
3110
3451
  terminalFocusedRef.current = focused
3452
+ // Focus-in re-asserts the managed tab label on both channels: a
3453
+ // background process sharing this console (a test-runner worker,
3454
+ // for example) may have overwritten the console title while the
3455
+ // terminal was unfocused.
3456
+ if (focused && inputStdout !== undefined) {
3457
+ inputStdout.write(terminalTitleSequence(tabTitleRef.current))
3458
+ process.title = sanitizeTerminalTitle(tabTitleRef.current)
3459
+ }
3111
3460
  })
3112
3461
  : normalized
3113
3462
  rawEditorTokens.current = tokenizeRawEditorChunk(input)
@@ -3150,13 +3499,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3150
3499
  process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
3151
3500
  )
3152
3501
 
3153
- const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
3502
+ const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = [], kind: 'image' | 'file' = 'image'): string => {
3154
3503
  const safeName = singleLineText(sanitizeDraftText(name))
3155
- const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
3504
+ const label = kind === 'file' ? 'file' : 'image'
3505
+ const base = source === 'mention' ? `@${safeName}` : `[${label}: ${safeName}]`
3156
3506
  let marker = base
3157
3507
  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}]`
3508
+ const taken = (candidate: string): boolean =>
3509
+ valueRef.current.includes(candidate)
3510
+ || draftImagesRef.current.some(image => image.marker === candidate)
3511
+ || draftFilesRef.current.some(file => file.marker === candidate)
3512
+ || reserved.includes(candidate)
3513
+ while (taken(marker)) {
3514
+ marker = source === 'mention' ? `@${safeName} (${suffix})` : `[${label}: ${safeName} ${suffix}]`
3160
3515
  suffix += 1
3161
3516
  }
3162
3517
  return marker
@@ -3173,27 +3528,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3173
3528
  return true
3174
3529
  }
3175
3530
 
3176
- const insertDroppedImages = (paths: readonly string[]): void => {
3531
+ /**
3532
+ * Attach a paste/drop split into image and non-image paths: images ride the
3533
+ * durable image blocks, files ride the 0.1.5 file blocks, and both register
3534
+ * visible draft markers anchored at the drop point.
3535
+ */
3536
+ const insertDroppedAttachments = (imagePaths: readonly string[], filePaths: readonly string[]): void => {
3177
3537
  const originalValue = valueRef.current
3178
3538
  const originalCursor = cursorRef.current
3179
- notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
3180
- void inspectImages(paths).then((inspected) => {
3181
- const additions: DraftImage[] = []
3539
+ const total = imagePaths.length + filePaths.length
3540
+ if (total === 0) return
3541
+ notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
3542
+ void Promise.all([
3543
+ imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
3544
+ filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
3545
+ ]).then(([inspectedImages, inspectedFiles]) => {
3546
+ const imageAdditions: DraftImage[] = []
3547
+ const fileAdditions: DraftFile[] = []
3182
3548
  const markers: string[] = []
3183
- for (const inspection of inspected) {
3184
- if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
3549
+ for (const inspection of inspectedImages) {
3550
+ if ([...draftImagesRef.current, ...imageAdditions].some(image => sameImagePath(image.path, inspection.path))) continue
3185
3551
  const marker = uniqueImageMarker(inspection.name, 'drop', markers)
3186
- additions.push({ ...inspection, marker })
3552
+ imageAdditions.push({ ...inspection, marker })
3187
3553
  markers.push(marker)
3188
3554
  }
3189
- if (additions.length === 0) {
3190
- notify('those images are already attached', 'warning')
3555
+ for (const inspection of inspectedFiles) {
3556
+ if ([...draftFilesRef.current, ...fileAdditions].some(file => sameImagePath(file.path, inspection.path))) continue
3557
+ const marker = uniqueImageMarker(inspection.name, 'drop', markers, 'file')
3558
+ fileAdditions.push({ ...inspection, marker })
3559
+ markers.push(marker)
3560
+ }
3561
+ if (imageAdditions.length === 0 && fileAdditions.length === 0) {
3562
+ notify('those attachments are already attached', 'warning')
3191
3563
  return
3192
3564
  }
3193
3565
  const current = valueRef.current
3194
3566
  const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
3195
3567
  if (anchor === undefined) {
3196
- notify('draft changed at the image drop point; drop the images again', 'warning')
3568
+ notify('draft changed at the attachment drop point; drop the files again', 'warning')
3197
3569
  return
3198
3570
  }
3199
3571
  const at = anchor.start
@@ -3207,12 +3579,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3207
3579
  setValue(edit.value)
3208
3580
  setCursor(nextCursor)
3209
3581
  resetCursorBlink()
3210
- const nextImages = [...draftImagesRef.current, ...additions]
3582
+ const nextImages = [...draftImagesRef.current, ...imageAdditions]
3211
3583
  draftImagesRef.current = nextImages
3212
3584
  setDraftImages(nextImages)
3213
- notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
3585
+ const nextFiles = [...draftFilesRef.current, ...fileAdditions]
3586
+ draftFilesRef.current = nextFiles
3587
+ setDraftFiles(nextFiles)
3588
+ const count = imageAdditions.length + fileAdditions.length
3589
+ notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
3214
3590
  }, (reason: unknown) => {
3215
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3591
+ notify(`attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3216
3592
  })
3217
3593
  }
3218
3594
 
@@ -3253,8 +3629,21 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3253
3629
  const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
3254
3630
  ? mentionRows.filter(row => row.kind !== 'session')
3255
3631
  : mentionRows
3632
+ // Fuzzy ordering over the upstream candidates (≤20 per page, cheaper than
3633
+ // the slash menu): rows whose name contains the typed query as an ordered
3634
+ // subsequence rise to the top by alignment, and every other upstream row
3635
+ // keeps its place after them — the upstream matcher has its own relevance
3636
+ // semantics (path segments), so ranking reorders but never drops rows. A
3637
+ // path-like query keeps the upstream order entirely.
3638
+ let rankedMentionRows = visibleMentionRows
3639
+ if (mentionToken !== undefined && !isPathLikeMentionQuery(mentionToken.query) && mentionToken.query !== '') {
3640
+ const hits = rankByName(visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row })), mentionToken.query)
3641
+ .map(entry => entry.row)
3642
+ const hitSet = new Set(hits)
3643
+ rankedMentionRows = [...hits, ...visibleMentionRows.filter(row => !hitSet.has(row))]
3644
+ }
3256
3645
  const menuRows: readonly CompletionCandidate[] = mentionActive
3257
- ? visibleMentionRows.map(row => ({
3646
+ ? rankedMentionRows.map(row => ({
3258
3647
  label: row.label.startsWith('@')
3259
3648
  ? row.label
3260
3649
  : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
@@ -3270,8 +3659,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3270
3659
  /** Accept the highlighted completion-menu candidate into the draft. */
3271
3660
  const acceptMenuCandidate = (): void => {
3272
3661
  if (mentionActive && mentionToken !== undefined) {
3273
- if (visibleMentionRows.length === 0) return
3274
- const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
3662
+ if (rankedMentionRows.length === 0) return
3663
+ const row = rankedMentionRows[completionIndex % rankedMentionRows.length]
3275
3664
  if (row !== undefined) {
3276
3665
  if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
3277
3666
  const tokenText = value.slice(mentionToken.start, cursor)
@@ -3416,20 +3805,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3416
3805
  notify('image submission cancelled', 'warning')
3417
3806
  }
3418
3807
 
3419
- /** Move through visual rows first, then cross history at the true edge. */
3808
+ /** Cross history while an unchanged recalled draft rests its caret on
3809
+ * either text edge; between the edges (or inside ordinary drafts) the
3810
+ * arrows move through visual rows first. */
3420
3811
  const navigateVertical = (direction: -1 | 1): void => {
3421
3812
  const currentValue = valueRef.current
3422
3813
  const currentCursor = cursorRef.current
3423
- const model = editorModel(currentValue, editorColumns)
3424
- const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
3425
- const next = moveCursorVertically(model, currentCursor, preferred, direction)
3426
- if (next !== currentCursor) {
3427
- cursorRef.current = next
3428
- setCursor(next)
3429
- resetCursorBlink()
3430
- preferredColumnRef.current = preferred
3431
- return
3432
- }
3814
+ // History owns the arrows while an unchanged recalled draft rests its
3815
+ // caret on either text edge (start or end). Everywhere else - edited
3816
+ // drafts, interior carets, ordinary typing - the arrows move through
3817
+ // visual rows as plain editing.
3433
3818
  if (recall.current.entries.length > 0
3434
3819
  && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
3435
3820
  const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
@@ -3441,10 +3826,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3441
3826
  setValue(safe)
3442
3827
  setCursor(safe.length)
3443
3828
  preferredColumnRef.current = null
3444
- setDismissedMenuValue(undefined)
3829
+ // Suppress the completion menu for the recalled text: a recalled
3830
+ // command would otherwise reopen the menu, whose Up/Down navigation
3831
+ // then traps the walk before it reaches older history entries. Any
3832
+ // edit re-opens the menu; submitting resets the dismissal.
3833
+ setDismissedMenuValue(safe)
3445
3834
  }
3835
+ resetCursorBlink()
3836
+ return
3837
+ }
3838
+ const model = editorModel(currentValue, editorColumns)
3839
+ const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
3840
+ const next = moveCursorVertically(model, currentCursor, preferred, direction)
3841
+ if (next !== currentCursor) {
3842
+ cursorRef.current = next
3843
+ setCursor(next)
3844
+ resetCursorBlink()
3845
+ preferredColumnRef.current = preferred
3446
3846
  }
3447
- resetCursorBlink()
3448
3847
  }
3449
3848
 
3450
3849
  useStableInput((input, key) => {
@@ -3471,11 +3870,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3471
3870
  }
3472
3871
  return
3473
3872
  }
3474
- // Shift+Tab cycles the permission preset (Claude-Code convention).
3873
+ // Shift+Tab cycles the mode stations: permission presets, then the
3874
+ // plan station when the composition offers it (Claude-Code convention).
3475
3875
  if (key.tab && key.shift) {
3476
3876
  try {
3477
- const next = cyclePermission()
3478
- if (next !== '') notify(`permission → ${next}`)
3877
+ const label = cycleMode()
3878
+ if (label !== '') notify(label)
3479
3879
  } catch (error: unknown) {
3480
3880
  notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
3481
3881
  }
@@ -3510,6 +3910,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3510
3910
  resetCursorBlink()
3511
3911
  draftImagesRef.current = []
3512
3912
  setDraftImages([])
3913
+ draftFilesRef.current = []
3914
+ setDraftFiles([])
3513
3915
  setCompletionIndex(0)
3514
3916
  setDismissedMenuValue(undefined)
3515
3917
  } else {
@@ -3578,15 +3980,29 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3578
3980
  // completion-inserted trailing space still routes `/quit ` correctly.
3579
3981
  const trimmed = liveValue.trim()
3580
3982
  const text = submissionPayload(liveValue)
3581
- if (draftImagesRef.current.length > 0) {
3983
+ if (draftImagesRef.current.length > 0 || draftFilesRef.current.length > 0) {
3984
+ // Slash semantics with attachments are unchanged: commands cannot
3985
+ // carry attachments, so the line goes to the model as a prompt —
3986
+ // warn instead of surprising the user with a literal "/export".
3987
+ if (isSlashLine(text)) notify('commands cannot carry attachments; the line will be sent to the model as a prompt', 'warning')
3988
+ // Attachment prepares resolve asynchronously; the app remounts onto
3989
+ // another session in the meantime, and this (old) instance's unmount
3990
+ // cleanup runs too late on the microtask timeline. Tag the delivery
3991
+ // with the composing session so the runner can drop the stale one.
3992
+ const originSession = sessionKey
3582
3993
  const controller = new AbortController()
3583
3994
  const epoch = prepareEpochRef.current + 1
3584
3995
  prepareEpochRef.current = epoch
3585
3996
  prepareAbortRef.current = controller
3586
3997
  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) => {
3998
+ const imageSnapshot = draftImagesRef.current
3999
+ const fileSnapshot = draftFilesRef.current
4000
+ const total = imageSnapshot.length + fileSnapshot.length
4001
+ notify(`processing ${total} attachment${total === 1 ? '' : 's'}…`)
4002
+ void Promise.all([
4003
+ imageSnapshot.length === 0 ? Promise.resolve([]) : prepareImages(imageSnapshot.map(image => image.path), controller.signal),
4004
+ fileSnapshot.length === 0 ? Promise.resolve([]) : prepareFiles(fileSnapshot.map(file => file.path), controller.signal),
4005
+ ]).then(([images, files]) => {
3590
4006
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3591
4007
  prepareAbortRef.current = undefined
3592
4008
  setPreparingImages(false)
@@ -3596,6 +4012,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3596
4012
  setCursor(0)
3597
4013
  draftImagesRef.current = []
3598
4014
  setDraftImages([])
4015
+ draftFilesRef.current = []
4016
+ setDraftFiles([])
3599
4017
  setCompletionIndex(0)
3600
4018
  setDismissedMenuValue(undefined)
3601
4019
  dismissNotice()
@@ -3604,13 +4022,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3604
4022
  recordHistory(text)
3605
4023
  }
3606
4024
  recall.current = beginRecall(recallSpace, '')
3607
- if (busy) steer(text, images)
3608
- else dispatch(text, images)
4025
+ const blocks: readonly ContentBlock[] = [...images, ...files]
4026
+ if (busy) steer(text, blocks, originSession)
4027
+ else dispatch(text, blocks, originSession)
3609
4028
  }, (reason: unknown) => {
3610
4029
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3611
4030
  prepareAbortRef.current = undefined
3612
4031
  setPreparingImages(false)
3613
- notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
4032
+ notify(`attachment submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3614
4033
  })
3615
4034
  return
3616
4035
  }
@@ -3623,13 +4042,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3623
4042
  setDismissedMenuValue(undefined)
3624
4043
  if (trimmed === '') return
3625
4044
  dismissNotice()
3626
- // Global recall records non-slash submissions only (slash lines are
3627
- // commands, not prompts) Codex record_local_submission semantics;
3628
- // the submission resets any active recall browsing.
3629
- if (!text.startsWith('/')) {
3630
- recordLocal(text)
3631
- recordHistory(text)
3632
- }
4045
+ // Global recall records every submission - prompts and typed slash
4046
+ // commands share one history, so Up/Down and /history recall commands
4047
+ // exactly like prompts; the submission resets any active recall
4048
+ // browsing.
4049
+ recordLocal(text)
4050
+ recordHistory(text)
3633
4051
  recall.current = beginRecall(recallSpace, '')
3634
4052
  if (text === '/quit') {
3635
4053
  quit()
@@ -3721,6 +4139,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3721
4139
  openPlugin(text.slice(7).trim())
3722
4140
  return
3723
4141
  }
4142
+ if (text === '/update') {
4143
+ openUpdate()
4144
+ return
4145
+ }
4146
+ if (text === '/schedule') {
4147
+ openSchedule()
4148
+ return
4149
+ }
3724
4150
  if (text === '/jobs' || text.startsWith('/jobs ')) {
3725
4151
  openJobs()
3726
4152
  return
@@ -3733,6 +4159,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3733
4159
  openTheme()
3734
4160
  return
3735
4161
  }
4162
+ if (text === '/animation' || text.startsWith('/animation ')) {
4163
+ const parsed = parseAnimationsArgument(text.slice('/animation'.length))
4164
+ if (parsed === 'toggle') applyAnimations(!animations)
4165
+ else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
4166
+ else applyAnimations(parsed.enabled)
4167
+ return
4168
+ }
3736
4169
  if (text === '/history') {
3737
4170
  openHistory()
3738
4171
  return
@@ -3906,50 +4339,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3906
4339
  text = text.replaceAll(PASTE_END_MARKER, '')
3907
4340
  }
3908
4341
  if (text === '') return
3909
- const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
3910
- if (droppedPaths.length > 0) {
3911
- insertDroppedImages(droppedPaths)
3912
- return
4342
+ if (text.length > 1) {
4343
+ // A path-list paste splits into images and files; prose falls through
4344
+ // as ordinary text (the splitter returns empty groups for non-paths).
4345
+ const dropped = parsePastedAttachmentPaths(text)
4346
+ if (dropped.images.length > 0 || dropped.files.length > 0) {
4347
+ insertDroppedAttachments(dropped.images, dropped.files)
4348
+ return
4349
+ }
3913
4350
  }
3914
4351
  applyEdit(insertText(valueRef.current, cursorRef.current, text))
3915
4352
  }
3916
4353
  }, active)
3917
4354
 
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])
4355
+ // The DeepSeek easter-egg wave renders through the ComposerWave leaf
4356
+ // below, which owns its 33ms tick: the sweep re-renders only that child at
4357
+ // 30fps this component's editor model, menu, and derived state never
4358
+ // re-run per frame. App drives the tier/style pair on a model switch, and
4359
+ // the child remounts whenever that pair changes (App picks a NEW random
4360
+ // style for every replay, so the pair always differs when a wave should
4361
+ // run), resetting the timeline to frame 0 before the first paint.
4362
+ //
4363
+ // The sweep is strictly one-shot per trigger, and the latch lives HERE
4364
+ // not in the leaf — because modal panels freeze the composer and UNMOUNT
4365
+ // ComposerWave; a mount-scoped latch would reset on every panel close and
4366
+ // replay a finished sweep. Keying `wavePlayedKey` by the tier:style pair
4367
+ // survives those unmounts: only a NEW trigger (which always changes the
4368
+ // pair) re-arms the sweep. Busy turns, image preparation, /animation
4369
+ // toggles, and panel open/close on an UNCHANGED model+effort pair never
4370
+ // fire it again.
4371
+ const waveKey = waveTier !== null && waveStyle !== null ? `${waveTier}:${waveStyle}` : null
4372
+ const [wavePlayedKey, setWavePlayedKey] = useState<string | null>(null)
3949
4373
  useEffect(() => {
3950
- if (waveTick !== null && waveTier !== null && waveStyle !== null
3951
- && waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
3952
- }, [waveTick, waveTier, waveStyle])
4374
+ // While animations are off, any pending trigger is consumed silently:
4375
+ // re-enabling must never queue or replay a celebration the user opted
4376
+ // out of watching.
4377
+ if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
4378
+ }, [animations, waveKey, wavePlayedKey])
4379
+ const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
3953
4380
 
3954
4381
  // Every exclusive panel keeps the composer as a stable visual anchor, but
3955
4382
  // freezes it to one row: no menu, multiline wrap, or animation.
@@ -3979,6 +4406,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3979
4406
  useEffect(() => {
3980
4407
  onEditorRows(editorRowCount)
3981
4408
  }, [editorRowCount, onEditorRows])
4409
+ // IME anchor: park the real terminal cursor on the caret cell while the
4410
+ // composer accepts input. IME composition and candidate windows anchor to
4411
+ // that real cursor cell, which otherwise sits below the status row where
4412
+ // Ink leaves it, so Chinese input never appears at the caret. Frozen bands
4413
+ // release the anchor; the wrapper keeps Ink's relative erase ledger exact.
4414
+ const caretRowInWindow = Math.max(0, Math.min(caret.row - editorWindowStart, editorWindowRows - 1))
4415
+ useImeCursorAnchor(
4416
+ !frozen,
4417
+ imeCursorRowsUp({ editorWindowRows, caretRowInWindow, rowsBelowComposer: anchorRowsBelow }),
4418
+ 2 + caret.column,
4419
+ )
3982
4420
  // The menu's physical rows ride the same one-way report; the cleanup keeps
3983
4421
  // the reserve from outliving the menu (unmount or inactive handoff).
3984
4422
  useEffect(() => {
@@ -4014,7 +4452,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4014
4452
  ))
4015
4453
  }
4016
4454
  const frozenLine = value === ''
4017
- ? 'type a message'
4455
+ ? frozenHint ?? 'type a message'
4018
4456
  : verboseLine(value, Math.max(1, columns - 6))
4019
4457
  return band(createElement(
4020
4458
  Text,
@@ -4049,7 +4487,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4049
4487
  ? preparingImages
4050
4488
  ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
4051
4489
  : busy
4052
- ? createElement(BusyChase)
4490
+ ? createElement(BusyChase, { animated: animations })
4053
4491
  : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
4054
4492
  : ' ',
4055
4493
  parts.before,
@@ -4064,104 +4502,35 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4064
4502
  }
4065
4503
  const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
4066
4504
 
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
-
4505
+ // The wave paints the SAME visible rows and caret site as the static path
4506
+ // through the ComposerWave leaf (see its comment). The child remounts on
4507
+ // every tier/style change, so its timeline always starts at frame 0, and
4508
+ // its gate cancels never freezes — the sweep while busy, preparing
4509
+ // images, or animations are off.
4158
4510
  return createElement(
4159
4511
  Box,
4160
4512
  { flexDirection: 'column' },
4161
4513
  menu,
4162
- waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
4163
- ? waveRow()
4164
- : band(staticEditor),
4514
+ createElement(ComposerWave, {
4515
+ key: waveKey ?? 'static',
4516
+ tier: waveTier ?? 'deepseek',
4517
+ style: waveStyle ?? 'wave',
4518
+ active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
4519
+ onSettled: () => {
4520
+ if (waveKey !== null) setWavePlayedKey(waveKey)
4521
+ },
4522
+ fallback: band(staticEditor),
4523
+ bandWidth,
4524
+ bandBg,
4525
+ rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
4526
+ windowStart: editorWindowStart,
4527
+ caretRow: caret.row,
4528
+ cursor: clampedCursor,
4529
+ caretVisible: cursorVisible,
4530
+ value,
4531
+ promptGlyph,
4532
+ promptColor,
4533
+ }),
4165
4534
  )
4166
4535
  }
4167
4536
 
@@ -4431,12 +4800,21 @@ export function App(props: AppProps): ReactElement {
4431
4800
  * to static while the prompt marker keeps the tier accent. The trigger
4432
4801
  * follows the applied model label (what the status bar actually shows),
4433
4802
  * 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. */
4803
+ * cached at the switch. The 33ms tick itself lives inside the ComposerWave
4804
+ * leaf, so the sweep re-renders only the composer band, not the whole tree,
4805
+ * at 30fps; App owns the rarely-changing tier/style and the leaf plays the
4806
+ * sweep exactly ONCE per pair change — an unchanged model+effort pair
4807
+ * (ordinary turns, image preparation, /animation toggles) never replays. */
4438
4808
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
4439
4809
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
4810
+ // /animation toggle: applies immediately, persists through the runner, and
4811
+ // gates every timed leaf (shimmer, chase, blink, wave) for this render.
4812
+ const [animations, setAnimations] = useState(props.animations ?? true)
4813
+ const applyAnimations = (enabled: boolean): void => {
4814
+ setAnimations(enabled)
4815
+ props.saveAnimations?.(enabled)
4816
+ notify(`animations ${enabled ? 'on' : 'off'}`)
4817
+ }
4440
4818
  const previousModel = useRef<string | undefined>(undefined)
4441
4819
  const previousEffort = useRef<string | undefined>(props.effort)
4442
4820
  const previousStyle = useRef<DeepseekWaveStyle | undefined>(undefined)
@@ -4557,6 +4935,8 @@ export function App(props: AppProps): ReactElement {
4557
4935
  const [resumeOpen, setResumeOpen] = useState(false)
4558
4936
  const [pluginOpen, setPluginOpen] = useState(false)
4559
4937
  const [pluginQuery, setPluginQuery] = useState('')
4938
+ const [updateOpen, setUpdateOpen] = useState(false)
4939
+ const [scheduleOpen, setScheduleOpen] = useState(false)
4560
4940
  const [jobsOpen, setJobsOpen] = useState(false)
4561
4941
  const [statuslineOpen, setStatuslineOpen] = useState(false)
4562
4942
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
@@ -4636,7 +5016,7 @@ export function App(props: AppProps): ReactElement {
4636
5016
  // panel keypress.
4637
5017
  const inputActive = deleteConfirmId !== undefined
4638
5018
  ? !approvalPending && !questionPending
4639
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5019
+ : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
4640
5020
 
4641
5021
  // Human questions outrank local inspectors. Close the lower modal instead
4642
5022
  // of leaving an approval/question visible but keyboard-locked behind it.
@@ -4651,6 +5031,8 @@ export function App(props: AppProps): ReactElement {
4651
5031
  setPermissionOpen(false)
4652
5032
  setResumeOpen(false)
4653
5033
  setPluginOpen(false)
5034
+ setUpdateOpen(false)
5035
+ setScheduleOpen(false)
4654
5036
  setStatuslineOpen(false)
4655
5037
  setThemeOpen(false)
4656
5038
  setHistoryOpen(false)
@@ -4668,7 +5050,7 @@ export function App(props: AppProps): ReactElement {
4668
5050
  // contract that lets arbitrarily long conversations scroll instead of
4669
5051
  // freezing when the live tree exceeds the terminal height. The dynamic
4670
5052
  // region below stays small: the streaming tail, modals, composer, and its
4671
- // status footer. `assistant/chunk` preserves `entries` identity.
5053
+ // status footer. Live stream frames preserve `entries` identity.
4672
5054
  //
4673
5055
  // `computeSettledRows` extends the cached row set incrementally: the
4674
5056
  // settled prefix is permanently final, so a grown boundary builds ONLY the
@@ -4753,6 +5135,16 @@ export function App(props: AppProps): ReactElement {
4753
5135
  const handleMenuRows = useCallback((rows: number): void => {
4754
5136
  setMenuRows(current => (current === rows ? current : rows))
4755
5137
  }, [])
5138
+ // The status footer's exact row count, reported one-way by StatusLine (the
5139
+ // second row renders only while it has content). The IME cursor anchor
5140
+ // counts every row between the composer caret and Ink's parked cursor: the
5141
+ // status footer plus Ink's own below-frame row. The gutter rows sit ABOVE
5142
+ // the composer and never enter this distance.
5143
+ const [statusBarRows, setStatusBarRows] = useState<1 | 2>(1)
5144
+ const handleStatusRows = useCallback((rows: 1 | 2): void => {
5145
+ setStatusBarRows(current => (current === rows ? current : rows))
5146
+ }, [])
5147
+ const imeRowsBelowComposer = statusBarRows + 1
4756
5148
  const composerEditorCap = composerMaxRows(terminalRows)
4757
5149
  // Bottom chrome is composer (2 borders + composerRows) + status (up to 2
4758
5150
  // rows) + todo/agents/notice (3) = 8 resting rows, plus the historical
@@ -4764,6 +5156,10 @@ export function App(props: AppProps): ReactElement {
4764
5156
  const dynamicRows = Math.max(1, terminalRows - 8 - MENU_RESERVE_ROWS - composerGutterRows - (composerRows - 1) - Math.max(0, menuRows - MENU_RESERVE_ROWS))
4765
5157
  const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
4766
5158
  const deepDivingVisible = busy && !streamingActive
5159
+ // Terminal tab label: "deepseek" until the session carries a name, then the
5160
+ // session title; cleared on unmount so the host shell regains its default.
5161
+ const tabTitle = view.title === '' ? DEFAULT_TERMINAL_TITLE : view.title
5162
+ useTerminalTitle(tabTitle)
4767
5163
  const allLiveLines = useMemo(
4768
5164
  () => view.entries.slice(settled).flatMap(
4769
5165
  // Width shrinks with the real terminal (no 10-column floor: on a
@@ -4812,9 +5208,55 @@ export function App(props: AppProps): ReactElement {
4812
5208
  : visibleLiveLines.slice(-liveAudit.allocation.live)
4813
5209
  const auditedReasoningRows = liveAudit.allocation.reasoning
4814
5210
  const auditedAnswerRows = liveAudit.allocation.answer
4815
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5211
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
4816
5212
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
4817
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
5213
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
5214
+ // The surface that currently owns the keyboard, named in the frozen band:
5215
+ // an empty composer under a panel must not advertise typing it cannot
5216
+ // accept — every key actually feeds the panel (which may or may not
5217
+ // filter with it), so the honest hint names the owner and the way out.
5218
+ const keyboardOwner = approvalPending
5219
+ ? 'the approval prompt'
5220
+ : questionPending
5221
+ ? 'the question'
5222
+ : diffView !== undefined
5223
+ ? 'the diff review'
5224
+ : modelOpen
5225
+ ? '/model'
5226
+ : helpOpen
5227
+ ? '/help'
5228
+ : modeOpen
5229
+ ? '/mode'
5230
+ : permissionOpen
5231
+ ? '/permission'
5232
+ : resumeOpen
5233
+ ? '/resume'
5234
+ : pluginOpen
5235
+ ? '/plugin'
5236
+ : updateOpen
5237
+ ? '/update'
5238
+ : scheduleOpen
5239
+ ? '/schedule'
5240
+ : jobsOpen
5241
+ ? '/jobs'
5242
+ : statuslineOpen
5243
+ ? '/statusline'
5244
+ : themeOpen
5245
+ ? '/theme'
5246
+ : historyOpen
5247
+ ? '/history'
5248
+ : agentsOpen
5249
+ ? '/agents'
5250
+ : subagentOpen
5251
+ ? '/subagent'
5252
+ : todosOpen
5253
+ ? '/todos'
5254
+ : inspectorVisible
5255
+ ? 'history details'
5256
+ : undefined
5257
+ const frozenHint = keyboardOwner === undefined
5258
+ ? undefined
5259
+ : `keys go to ${keyboardOwner} · esc ${approvalPending ? 'rejects' : questionPending ? 'cancels' : 'closes'}`
4818
5260
  const closeInspector = useCallback((): void => {
4819
5261
  setVerboseOpen(false)
4820
5262
  }, [])
@@ -5114,7 +5556,7 @@ export function App(props: AppProps): ReactElement {
5114
5556
  // marker falls back to the static dim row — same as Deep diving
5115
5557
  // always yields the live region to streaming content.
5116
5558
  : view.streaming === ''
5117
- ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)' })
5559
+ ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)', animated: animations })
5118
5560
  : createElement(StreamTail, {
5119
5561
  text: 'Thinking… (Ctrl/Alt+R to expand)',
5120
5562
  prefix: '✻ ',
@@ -5129,10 +5571,10 @@ export function App(props: AppProps): ReactElement {
5129
5571
  // The same two-column gutter as settled replies: streamed text
5130
5572
  // lands exactly where the assembled message will render.
5131
5573
  { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
5132
- busy ? createElement(Caret) : undefined,
5574
+ busy ? createElement(Caret, { animated: animations }) : undefined,
5133
5575
  )
5134
5576
  : undefined,
5135
- deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
5577
+ deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince, animated: animations }) : undefined,
5136
5578
  )
5137
5579
  : undefined,
5138
5580
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
@@ -5216,6 +5658,17 @@ export function App(props: AppProps): ReactElement {
5216
5658
  pluginOpen && !approvalPending && !questionPending
5217
5659
  ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
5218
5660
  : undefined,
5661
+ updateOpen && !approvalPending && !questionPending
5662
+ ? createElement(UpdatePanel, {
5663
+ probe: props.probeUpdate,
5664
+ apply: props.applyUpdate,
5665
+ notify: (text: string, tone?: NoticeTone) => notify(text, tone),
5666
+ close: () => setUpdateOpen(false),
5667
+ })
5668
+ : undefined,
5669
+ scheduleOpen && !approvalPending && !questionPending
5670
+ ? createElement(SchedulePanel, { rows: () => view.schedules, close: () => setScheduleOpen(false) })
5671
+ : undefined,
5219
5672
  jobsOpen && !approvalPending && !questionPending
5220
5673
  ? createElement(JobsPanel, { load: props.loadJobs, close: () => setJobsOpen(false) })
5221
5674
  : undefined,
@@ -5301,6 +5754,7 @@ export function App(props: AppProps): ReactElement {
5301
5754
  createElement(Input, {
5302
5755
  active: inputActive,
5303
5756
  frozen: modalVisible,
5757
+ frozenHint,
5304
5758
  busy,
5305
5759
  descriptors,
5306
5760
  skills,
@@ -5368,6 +5822,8 @@ export function App(props: AppProps): ReactElement {
5368
5822
  openPermission: () => setPermissionOpen(true),
5369
5823
  openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
5370
5824
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
5825
+ openUpdate: () => setUpdateOpen(true),
5826
+ openSchedule: () => setScheduleOpen(true),
5371
5827
  openJobs: () => setJobsOpen(true),
5372
5828
  openStatusline: () => setStatuslineOpen(true),
5373
5829
  openTheme: () => setThemeOpen(true),
@@ -5419,7 +5875,10 @@ export function App(props: AppProps): ReactElement {
5419
5875
  loadMentions: props.loadMentions,
5420
5876
  inspectImages: props.inspectImages,
5421
5877
  prepareImages: props.prepareImages,
5422
- cyclePermission: props.cyclePermission,
5878
+ inspectFiles: props.inspectFiles,
5879
+ prepareFiles: props.prepareFiles,
5880
+ sessionKey: props.sessionKey,
5881
+ cycleMode: props.cycleMode,
5423
5882
  exportTranscript: props.exportTranscript,
5424
5883
  renameTitle: props.renameTitle,
5425
5884
  copyLastResponse: props.copyLastResponse,
@@ -5430,9 +5889,13 @@ export function App(props: AppProps): ReactElement {
5430
5889
  cancelQueued: props.cancelQueued,
5431
5890
  historyFill,
5432
5891
  historyConsumed,
5892
+ animations,
5893
+ applyAnimations,
5433
5894
  waveTier,
5434
5895
  waveStyle,
5435
5896
  maxRows: composerEditorCap,
5897
+ anchorRowsBelow: imeRowsBelowComposer,
5898
+ tabTitle,
5436
5899
  onEditorRows: handleEditorRows,
5437
5900
  onMenuRows: handleMenuRows,
5438
5901
  }),
@@ -5444,7 +5907,7 @@ export function App(props: AppProps): ReactElement {
5444
5907
  branch: props.branch,
5445
5908
  sessionId: props.sessionId,
5446
5909
  title: view.title,
5447
- plan: view.plan,
5910
+ plan: view.plan || props.pendingPlan === true,
5448
5911
  permission: view.permission !== '' ? view.permission : props.permission,
5449
5912
  sandbox: view.sandbox,
5450
5913
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
@@ -5453,6 +5916,7 @@ export function App(props: AppProps): ReactElement {
5453
5916
  busy,
5454
5917
  columns: terminalColumns,
5455
5918
  items: statuslineItems,
5919
+ onRows: handleStatusRows,
5456
5920
  }),
5457
5921
  ),
5458
5922
  )