dsh-code 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -22,20 +22,49 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
22
22
  import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
23
  import type { TodoItem } from '@deepseek-ai/dsh-session'
24
24
  import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
25
- import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
25
+ import {
26
+ brand,
27
+ dim,
28
+ error as paintError,
29
+ getPalette,
30
+ getTheme,
31
+ inkColor,
32
+ setTheme,
33
+ type RgbTriple,
34
+ type ThemeName,
35
+ } from './theme.ts'
36
+ import { ThemePanel } from './theme-panel.ts'
26
37
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
27
38
  import type { TranscriptStore } from './store.ts'
28
39
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
29
40
  import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
41
  import type { ToolDetail } from './render/tool-detail.ts'
31
- import { busyChaseFrame, caretVisible, pulseFrame } from './render/animations.ts'
42
+ import {
43
+ busyChaseFrame,
44
+ caretVisible,
45
+ DEEPSEEK_WAVE_TICK_MS,
46
+ deepseekWaveBorderColor,
47
+ deepseekWaveColumnBg,
48
+ deepseekWaveDuration,
49
+ deepseekWaveSpark,
50
+ deepseekWaveStyleRandom,
51
+ deepseekWaveTier,
52
+ deepseekWaveWordHue,
53
+ deepseekWaveWordVisible,
54
+ isOfficialDeepSeekLabel,
55
+ pulseFrame,
56
+ WAVE_BASE_DARK,
57
+ WAVE_BASE_LIGHT,
58
+ type DeepseekWaveStyle,
59
+ type DeepseekWaveTier,
60
+ } from './render/animations.ts'
32
61
  import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
33
62
  import type { CommandsView } from './commands.ts'
34
63
  import type { ModelDirectory, ModelRow } from './models.ts'
35
64
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
36
65
  import type { SkillsView, SkillRow } from './skills.ts'
37
66
  import type { MentionCandidate } from './mentions.ts'
38
- import { ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
67
+ import { EffortPanel, ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
39
68
  import type { PresetRow } from './presets.ts'
40
69
  import type { PluginRow } from './plugin-inventory.ts'
41
70
  import {
@@ -103,6 +132,8 @@ export interface AppProps {
103
132
  skills: SkillsView
104
133
  /** `provider/model` selection serving this session (updated on /model). */
105
134
  model: string
135
+ /** Effective reasoning effort in force ('' when none), for the /model picker mark. */
136
+ effort?: string
106
137
  /** Working-directory basename the session serves. */
107
138
  cwd: string
108
139
  /** Absolute working directory used by session filters and references. */
@@ -127,8 +158,8 @@ export interface AppProps {
127
158
  loadModels(): Promise<ModelDirectory>
128
159
  /** Load @mention candidates for the typed query (files + sessions). */
129
160
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
130
- /** Apply one /model selection; returns the display label. */
131
- selectModel(row: ModelRow): string
161
+ /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
162
+ selectModel(row: ModelRow, effortId?: string): string
132
163
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
133
164
  cyclePermission(): string
134
165
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
@@ -150,6 +181,8 @@ export interface AppProps {
150
181
  statusline: readonly string[]
151
182
  /** Persist a new statusline item set; the runner surfaces IO failures as notices. */
152
183
  saveStatusline(items: readonly string[]): void
184
+ /** Apply and persist one /theme selection; the runner owns the theme.json file. */
185
+ saveTheme?(name: ThemeName): void
153
186
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
154
187
  history: readonly string[]
155
188
  /** Persist one submitted prompt to the global history file. */
@@ -158,11 +191,6 @@ export interface AppProps {
158
191
  cancelQueued(messageId: string): void
159
192
  }
160
193
 
161
- /** Ink `color` string for one palette triple. */
162
- function inkColor(triple: readonly [number, number, number]): string {
163
- return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
164
- }
165
-
166
194
  /** Pad text with spaces to a visible-column target (menu name column). */
167
195
  function padColumns(text: string, width: number): string {
168
196
  const clipped = truncateColumns(singleLineText(text), width)
@@ -199,7 +227,7 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
199
227
  /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
200
228
  function Pulse(): ReactElement {
201
229
  const tick = useFrames(125)
202
- return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
230
+ return createElement(Text, { color: inkColor(getPalette().brandBright) }, pulseFrame(tick))
203
231
  }
204
232
 
205
233
  /**
@@ -210,7 +238,7 @@ function Pulse(): ReactElement {
210
238
  */
211
239
  function BusyChase(): ReactElement {
212
240
  const tick = useFrames(125)
213
- return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, busyChaseFrame(tick) + ' ')
241
+ return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
214
242
  }
215
243
 
216
244
  /** Blinking block caret appended to streaming text. */
@@ -298,11 +326,11 @@ function segmentProps(style: MdSegment['style']): {
298
326
  } {
299
327
  switch (style) {
300
328
  case 'accent':
301
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
329
+ return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
302
330
  case 'code':
303
- return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
331
+ return { color: inkColor(getPalette().code), bold: undefined, italic: undefined, strikethrough: undefined }
304
332
  case 'dim':
305
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
333
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: undefined, strikethrough: undefined }
306
334
  case 'bold':
307
335
  return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
308
336
  case 'italic':
@@ -310,7 +338,7 @@ function segmentProps(style: MdSegment['style']): {
310
338
  case 'boldItalic':
311
339
  return { color: undefined, bold: true, italic: true, strikethrough: undefined }
312
340
  case 'strike':
313
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
341
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: undefined, strikethrough: true }
314
342
  default:
315
343
  return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
316
344
  }
@@ -326,13 +354,13 @@ function lineStyleProps(style: LineStyle): {
326
354
  } {
327
355
  switch (style) {
328
356
  case 'brand':
329
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
357
+ return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
330
358
  case 'success':
331
- return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
359
+ return { color: inkColor(getPalette().success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
332
360
  case 'error':
333
- return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
361
+ return { color: inkColor(getPalette().error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
334
362
  case 'warn':
335
- return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
363
+ return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
336
364
  case 'dimItalic':
337
365
  return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
338
366
  default:
@@ -407,7 +435,7 @@ function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
407
435
  Text,
408
436
  {
409
437
  key: at,
410
- color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
438
+ color: line.mark === '+' ? inkColor(getPalette().success) : line.mark === '-' ? inkColor(getPalette().error) : inkColor(getPalette().dim),
411
439
  wrap: 'truncate-end',
412
440
  },
413
441
  ` ${line.mark}${displayText(line.text)}`,
@@ -482,8 +510,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
482
510
  const mark = entry.state === 'running'
483
511
  ? createElement(Pulse)
484
512
  : entry.state === 'error'
485
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
486
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
513
+ ? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
514
+ : createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
487
515
  return createElement(
488
516
  Box,
489
517
  { flexDirection: 'column' },
@@ -499,7 +527,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
499
527
  ? undefined
500
528
  : createElement(
501
529
  Text,
502
- { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
530
+ { color: entry.state === 'error' ? inkColor(getPalette().error) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
503
531
  ` ⎿ ${displayText(entry.summary)}`,
504
532
  ),
505
533
  verbose && entry.detail !== undefined
@@ -511,8 +539,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
511
539
  const mark = entry.state === 'running'
512
540
  ? createElement(Pulse)
513
541
  : entry.state === 'error'
514
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
515
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
542
+ ? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
543
+ : createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
516
544
  return createElement(
517
545
  Box,
518
546
  { flexDirection: 'column' },
@@ -526,7 +554,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
526
554
  ),
527
555
  entry.summary === ''
528
556
  ? undefined
529
- : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
557
+ : createElement(Text, { color: inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
530
558
  )
531
559
  }
532
560
  case 'turn-marker':
@@ -546,7 +574,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
546
574
  // next attempt is underway.
547
575
  return createElement(
548
576
  Text,
549
- { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
577
+ { color: entry.state === 'running' ? inkColor(getPalette().warn) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
550
578
  ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
551
579
  )
552
580
  case 'files': {
@@ -584,8 +612,8 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
584
612
  if (rows < 20) {
585
613
  return createElement(
586
614
  Box,
587
- { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
588
- createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
615
+ { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
616
+ createElement(Text, { color: inkColor(getPalette().brandBright), bold: true }, 'DeepSeek Harness'),
589
617
  createElement(Text, { dimColor: true }, hint),
590
618
  )
591
619
  }
@@ -594,16 +622,16 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
594
622
  // alignSelf shrinks the border to the whale-plus-wordmark content instead
595
623
  // of stretching across the terminal and stranding empty space on the right
596
624
  // (the compact-banner treatment the Claude Code welcome uses).
597
- { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
625
+ { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
598
626
  createElement(
599
627
  Box,
600
628
  { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
601
- ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
629
+ ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(getPalette().brand) }, row)),
602
630
  ),
603
631
  createElement(
604
632
  Box,
605
633
  { flexDirection: 'column', justifyContent: 'center' },
606
- createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
634
+ createElement(Text, { color: inkColor(getPalette().brandBright), bold: true }, 'DeepSeek Harness'),
607
635
  createElement(Text, { dimColor: true }, hint),
608
636
  ),
609
637
  )
@@ -626,10 +654,10 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
626
654
  { paddingX: 1 },
627
655
  createElement(
628
656
  Text,
629
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
657
+ { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
630
658
  `todos ${completed}/${todos.length}`,
631
659
  createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
632
- current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
660
+ current === undefined ? '' : createElement(Text, { color: inkColor(getPalette().brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
633
661
  ),
634
662
  )
635
663
  }
@@ -637,7 +665,7 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
637
665
  /**
638
666
  * Ink props for one status tone: the Codex status-line accent mapping over
639
667
  * the DeepSeek palette, all blue by design — the status bar speaks only in
640
- * degrees of blue (deep accent, primary figures, bright model identity, sky
668
+ * degrees of blue (deep accent, primary figures, model identity, sky
641
669
  * paths and done states), with amber/red reserved for warnings and errors.
642
670
  */
643
671
  function statusToneProps(tone: StatusTone): {
@@ -647,28 +675,47 @@ function statusToneProps(tone: StatusTone): {
647
675
  } {
648
676
  switch (tone) {
649
677
  case 'model':
650
- return { color: inkColor(TUI_RGB.brandBright), bold: true, dimColor: undefined }
678
+ // Same tone as the working-directory segment: the model name reads as
679
+ // a path fact, not a brand accent.
680
+ return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
651
681
  case 'live':
652
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, dimColor: undefined }
682
+ return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
653
683
  case 'path':
654
- return { color: inkColor(TUI_RGB.code), bold: undefined, dimColor: undefined }
684
+ return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
655
685
  case 'branch':
656
- return { color: inkColor(TUI_RGB.text), bold: undefined, dimColor: undefined }
686
+ return { color: inkColor(getPalette().text), bold: undefined, dimColor: undefined }
657
687
  case 'value':
658
- return { color: inkColor(TUI_RGB.brand), bold: undefined, dimColor: undefined }
688
+ return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
659
689
  case 'label':
660
690
  case 'meta':
661
- return { color: undefined, bold: undefined, dimColor: true }
691
+ // Explicit RGB gray, not SGR dim: Ink's token stream inherits an
692
+ // unclosed `dim` into the next span (the model name after the busy dot
693
+ // rendered dim+bold and looked gray), and a concrete color closes
694
+ // cleanly on the style transition. Theme-aware via the palette.
695
+ return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
662
696
  case 'accent':
663
- return { color: inkColor(TUI_RGB.brandDeep), bold: undefined, dimColor: undefined }
697
+ return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
698
+ // Context-bar segment shades, dark → light across the DeepSeek blues:
699
+ // system/prompt/assistant/thinking/tools. The tools segment borrows the
700
+ // code sky-blue as the fifth shade (no new theme token).
701
+ case 'ctxSystem':
702
+ return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
703
+ case 'ctxPrompt':
704
+ return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
705
+ case 'ctxAssistant':
706
+ return { color: inkColor(getPalette().brandMid), bold: undefined, dimColor: undefined }
707
+ case 'ctxThinking':
708
+ return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
709
+ case 'ctxTools':
710
+ return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
664
711
  case 'success':
665
- return { color: inkColor(TUI_RGB.code), bold: true, dimColor: undefined }
712
+ return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
666
713
  case 'warn':
667
- return { color: inkColor(TUI_RGB.warn), bold: true, dimColor: undefined }
714
+ return { color: inkColor(getPalette().warn), bold: true, dimColor: undefined }
668
715
  case 'error':
669
- return { color: inkColor(TUI_RGB.error), bold: true, dimColor: undefined }
716
+ return { color: inkColor(getPalette().error), bold: true, dimColor: undefined }
670
717
  default:
671
- return { color: undefined, bold: undefined, dimColor: true }
718
+ return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
672
719
  }
673
720
  }
674
721
 
@@ -681,7 +728,28 @@ function statusToneProps(tone: StatusTone): {
681
728
  * content, so the footer degrades to a single row on narrow terminals. Both
682
729
  * layouts arrive pre-measured from the pure reducer, so Ink only paints;
683
730
  * truncation degrades groups, it never wraps a row.
731
+ *
732
+ * The DeepSeek easter egg: when the model label *switches* to an official
733
+ * DeepSeek route, the composer's INPUT ROW (not the frame) plays Codex's
734
+ * effort-ignition "Wave" — a blue crest sweeping the content row column by
735
+ * column, with the `· ✦ ✧` sparkles on the deepseek tier — and the prompt
736
+ * marker keeps the tier accent afterwards. The border stays a constant
737
+ * static dim; only the row's per-column background tints during the wave,
738
+ * so the row and column budget is untouched throughout.
684
739
  */
740
+
741
+ /** Theme anchors for the one-shot composer wave, read from the active palette
742
+ * so the wave stays coordinated in both themes. The flash tier runs the
743
+ * brand blues; the deepseek tier swaps in the code sky-blue for a brighter,
744
+ * richer mix. Codex's Wave bands carry no hue index (only hues[0] tints the
745
+ * row), so the accent the prompt keeps is always hues[0]. */
746
+ function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTriple, RgbTriple] {
747
+ const palette = getPalette()
748
+ return tier === 'flash'
749
+ ? [palette.brandBright, palette.brand, palette.brandMid]
750
+ : [palette.brandBright, palette.code, palette.brandMid]
751
+ }
752
+
685
753
  function StatusLine({ facts, stats, busy, columns, items }: {
686
754
  facts: StatusFacts
687
755
  stats: Parameters<typeof layoutStatusBar>[1]
@@ -690,11 +758,12 @@ function StatusLine({ facts, stats, busy, columns, items }: {
690
758
  items: readonly string[]
691
759
  }): ReactElement {
692
760
  const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
761
+
693
762
  const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
694
763
  const leftParts: ReactElement[] = []
695
764
  row.left.forEach((group, groupIndex) => {
696
765
  if (groupIndex > 0) {
697
- leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, dimColor: true }, STATUS_GROUP_SEPARATOR))
766
+ leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, color: inkColor(getPalette().dim) }, STATUS_GROUP_SEPARATOR))
698
767
  }
699
768
  group.spans.forEach((span, spanIndex) => {
700
769
  leftParts.push(createElement(
@@ -707,7 +776,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
707
776
  const rightParts: ReactElement[] = []
708
777
  row.right.forEach((span, index) => {
709
778
  if (index > 0) {
710
- rightParts.push(createElement(Text, { key: key + 'rs' + index, dimColor: true }, STATUS_ITEM_SEPARATOR))
779
+ rightParts.push(createElement(Text, { key: key + 'rs' + index, color: inkColor(getPalette().dim) }, STATUS_ITEM_SEPARATOR))
711
780
  }
712
781
  rightParts.push(createElement(
713
782
  Text,
@@ -716,7 +785,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
716
785
  ))
717
786
  })
718
787
  if (row.hint) {
719
- rightParts.push(createElement(Text, { key: key + 'hint', dimColor: true }, STATUS_CYCLE_HINT))
788
+ rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, STATUS_CYCLE_HINT))
720
789
  }
721
790
  // Each row already fits the column budget; truncate-end stays as the
722
791
  // terminal-measurement backstop so a drifting cell count clips instead
@@ -751,10 +820,10 @@ function NoticeLine({ text, tone, columns }: {
751
820
  columns: number
752
821
  }): ReactElement {
753
822
  const color = tone === 'error'
754
- ? TUI_RGB.error
823
+ ? getPalette().error
755
824
  : tone === 'warning'
756
- ? TUI_RGB.warn
757
- : TUI_RGB.brandBright
825
+ ? getPalette().warn
826
+ : getPalette().brandBright
758
827
  const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•'
759
828
  return createElement(
760
829
  Box,
@@ -825,8 +894,8 @@ function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked:
825
894
  const { answered } = snapshot
826
895
  return createElement(
827
896
  Box,
828
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
829
- createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
897
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
898
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
830
899
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
831
900
  createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
832
901
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1093,10 +1162,10 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1093
1162
  : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
1094
1163
  return createElement(
1095
1164
  Box,
1096
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
1165
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep) },
1097
1166
  createElement(
1098
1167
  Text,
1099
- { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
1168
+ { color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep), bold: true, wrap: 'truncate-end' },
1100
1169
  truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
1101
1170
  ),
1102
1171
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1176,7 +1245,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1176
1245
  : error !== undefined
1177
1246
  ? [createElement(
1178
1247
  Text,
1179
- { key: 'error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1248
+ { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1180
1249
  truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
1181
1250
  )]
1182
1251
  : [
@@ -1184,7 +1253,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1184
1253
  ? []
1185
1254
  : [createElement(
1186
1255
  Text,
1187
- { key: 'failures', color: inkColor(TUI_RGB.warn), wrap: 'truncate-end' },
1256
+ { key: 'failures', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1188
1257
  truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
1189
1258
  )]),
1190
1259
  ...(rows.length === 0
@@ -1199,8 +1268,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1199
1268
  const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1200
1269
  return createElement(
1201
1270
  Box,
1202
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1203
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1271
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1272
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1204
1273
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1205
1274
  ...stateRows,
1206
1275
  ...visible.map((row) => {
@@ -1210,7 +1279,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1210
1279
  Text,
1211
1280
  {
1212
1281
  key: `${row.provider}/${row.model}`,
1213
- color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1282
+ color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
1214
1283
  wrap: 'truncate-end',
1215
1284
  },
1216
1285
  truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
@@ -1258,16 +1327,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1258
1327
  ? []
1259
1328
  : [createElement(
1260
1329
  Text,
1261
- { key: 'commands-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1330
+ { key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1262
1331
  truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
1263
1332
  )]),
1264
1333
  createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
1265
1334
  createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1335
+ createElement(Box, { key: 'local-effort' }, row('/effort', 'adjust reasoning effort for the current model')),
1266
1336
  createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
1267
1337
  createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1268
1338
  createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1269
1339
  createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
1270
1340
  createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
1341
+ createElement(Box, { key: 'local-theme' }, row('/theme', 'switch the color theme')),
1271
1342
  createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
1272
1343
  createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1273
1344
  createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
@@ -1288,7 +1359,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1288
1359
  ? []
1289
1360
  : [createElement(
1290
1361
  Text,
1291
- { key: 'skills-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1362
+ { key: 'skills-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1292
1363
  truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns),
1293
1364
  )]),
1294
1365
  ...skills.map(skill => createElement(
@@ -1326,8 +1397,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1326
1397
 
1327
1398
  return createElement(
1328
1399
  Box,
1329
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1330
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
1400
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1401
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
1331
1402
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1332
1403
  ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1333
1404
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1358,6 +1429,59 @@ function editorWindow(value: string, cursor: number, columns: number): { before:
1358
1429
  return { before, caret, after }
1359
1430
  }
1360
1431
 
1432
+ /** The empty-composer placeholder text (shared by the static and wave paths). */
1433
+ const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
1434
+
1435
+ /** One physical cell of the wave-painted composer row: a char plus styles. */
1436
+ interface ComposerCell {
1437
+ char: string
1438
+ color?: string
1439
+ backgroundColor?: string
1440
+ bold?: boolean
1441
+ inverse?: boolean
1442
+ dim?: boolean
1443
+ }
1444
+
1445
+ /** Adjacent cells with identical styling merge into one styled Text span. */
1446
+ function sameCellStyle(a: ComposerCell, b: ComposerCell): boolean {
1447
+ return a.color === b.color
1448
+ && a.backgroundColor === b.backgroundColor
1449
+ && a.bold === b.bold
1450
+ && a.inverse === b.inverse
1451
+ && a.dim === b.dim
1452
+ }
1453
+
1454
+ /**
1455
+ * Render the wave row as one Text whose cells carry per-column
1456
+ * `backgroundColor` runs: the Codex Wave crest paints a smooth gradient
1457
+ * (one SGR run per sampled column) over the prompt, draft, cursor,
1458
+ * placeholder, and the trailing blank fill — the draft stays readable
1459
+ * because the tint blends at ≤ 0.55 toward the theme's blank-cell base.
1460
+ */
1461
+ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
1462
+ const spans: ReactElement[] = []
1463
+ let start = 0
1464
+ while (start < cells.length) {
1465
+ const cell = cells[start]!
1466
+ let end = start + 1
1467
+ while (end < cells.length && sameCellStyle(cells[end]!, cell)) end += 1
1468
+ spans.push(createElement(
1469
+ Text,
1470
+ {
1471
+ key: start,
1472
+ color: cell.color,
1473
+ backgroundColor: cell.backgroundColor,
1474
+ bold: cell.bold,
1475
+ inverse: cell.inverse,
1476
+ dimColor: cell.dim,
1477
+ },
1478
+ cells.slice(start, end).map(c => c.char).join(''),
1479
+ ))
1480
+ start = end
1481
+ }
1482
+ return spans
1483
+ }
1484
+
1361
1485
  /**
1362
1486
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
1363
1487
  * with independent history selection and content scrolling. The complete
@@ -1474,11 +1598,11 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
1474
1598
  width: viewport.outerColumns,
1475
1599
  paddingX: 1,
1476
1600
  borderStyle: 'round',
1477
- borderColor: inkColor(TUI_RGB.brand),
1601
+ borderColor: inkColor(getPalette().brand),
1478
1602
  },
1479
1603
  createElement(
1480
1604
  Text,
1481
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1605
+ { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
1482
1606
  truncateColumns(title, viewport.contentColumns),
1483
1607
  ),
1484
1608
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1526,9 +1650,14 @@ interface CompletionCandidate {
1526
1650
  * Resolve completion candidates for the current input: TUI-local commands,
1527
1651
  * the live registry descriptors, and user-invocable skills, filtered by the
1528
1652
  * typed prefix. Command names win collisions (the dispatch tries the
1529
- * registry first and only then falls through to the skill gesture).
1653
+ * registry first and only then falls through to the skill gesture), and a
1654
+ * later duplicate name never renders twice.
1655
+ *
1656
+ * A bare `/` returns the FULL merged list — Codex's command popup shows every
1657
+ * command inside a scroll window on an empty filter, and the menu's own
1658
+ * selection window bounds the visible rows, so no slice cap is needed.
1530
1659
  */
1531
- function completionCandidates(
1660
+ export function completionCandidates(
1532
1661
  value: string,
1533
1662
  descriptors: readonly CommandDescriptor[],
1534
1663
  skills: readonly SkillRow[],
@@ -1538,11 +1667,13 @@ function completionCandidates(
1538
1667
  const local: CompletionCandidate[] = [
1539
1668
  { label: '/help', description: 'show commands', origin: 'command' },
1540
1669
  { label: '/model', description: 'switch the model', origin: 'command' },
1670
+ { label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
1541
1671
  { label: '/mode', description: 'select the agent preset', origin: 'command' },
1542
1672
  { label: '/new', description: 'start a fresh session', origin: 'command' },
1543
1673
  { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
1544
1674
  { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
1545
1675
  { label: '/statusline', description: 'customize the status line', origin: 'command' },
1676
+ { label: '/theme', description: 'switch the color theme', origin: 'command' },
1546
1677
  { label: '/history', description: 'search and recall past prompts', origin: 'command' },
1547
1678
  { label: '/clear', description: 'clear the screen', origin: 'command' },
1548
1679
  { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
@@ -1568,12 +1699,19 @@ function completionCandidates(
1568
1699
  description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
1569
1700
  origin: 'skill',
1570
1701
  }))
1571
- const all = [...local, ...registry, ...skillRows]
1572
- // The menu itself caps its visible rows behind a scroll window, so the
1573
- // candidate cap only bounds how many entries cycling can reach; 11 keeps
1574
- // every TUI-local command reachable with an empty prefix.
1575
- if (prefix === '') return all.slice(0, 11)
1576
- return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 11)
1702
+ // One row per name, first occurrence wins: local before registry before
1703
+ // skills, which is exactly the shadowing precedence above (defensive
1704
+ // against duplicate registry names across scopes).
1705
+ const seen = new Set<string>()
1706
+ const all: CompletionCandidate[] = []
1707
+ for (const candidate of [...local, ...registry, ...skillRows]) {
1708
+ const name = candidate.label.slice(1)
1709
+ if (seen.has(name)) continue
1710
+ seen.add(name)
1711
+ all.push(candidate)
1712
+ }
1713
+ if (prefix === '') return all
1714
+ return all.filter(candidate => candidate.label.slice(1).startsWith(prefix))
1577
1715
  }
1578
1716
 
1579
1717
  /**
@@ -1607,6 +1745,7 @@ function CompletionMenu({ active, mention, index, rows }: {
1607
1745
  const selected = rows.length === 0 ? 0 : index % rows.length
1608
1746
  const first = selectionWindow(selected, rows.length, limit)
1609
1747
  const visible = rows.slice(first, first + limit)
1748
+ const hidden = rows.length - visible.length
1610
1749
  return createElement(
1611
1750
  Box,
1612
1751
  { flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
@@ -1618,13 +1757,17 @@ function CompletionMenu({ active, mention, index, rows }: {
1618
1757
  Text,
1619
1758
  {
1620
1759
  key: candidate.label,
1621
- color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1760
+ color: absolute === selected ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
1622
1761
  wrap: 'truncate-end',
1623
1762
  },
1624
1763
  `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1625
1764
  )
1626
1765
  })),
1627
- showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
1766
+ // Scroll affordance: with the full merged catalog (commands + registry +
1767
+ // skills) the six-row window rarely shows the tail — count and hint keep
1768
+ // the rest discoverable without inflating the menu budget.
1769
+ hidden > 0 ? createElement(Text, { key: 'more', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, dim(` … +${hidden} more`)) : undefined,
1770
+ showFooter ? createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, dim(mention ? `↑↓ choose · ${rows.length} items · tab insert` : `↑↓ choose · ${rows.length} items · tab complete`)) : undefined,
1628
1771
  )
1629
1772
  }
1630
1773
 
@@ -1634,7 +1777,7 @@ function CompletionMenu({ active, mention, index, rows }: {
1634
1777
  * While a modal (approval / question / model panel) owns the keys, the
1635
1778
  * box passes every key through untouched.
1636
1779
  */
1637
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, openMode, openResume, openPlugin, openStatusline, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed }: {
1780
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTick, waveTier, waveStyle }: {
1638
1781
  active: boolean
1639
1782
  frozen: boolean
1640
1783
  busy: boolean
@@ -1645,11 +1788,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1645
1788
  interrupt(): boolean
1646
1789
  quit(): void
1647
1790
  openModel(): void
1791
+ openEffort(): void
1648
1792
  openHelp(): void
1649
1793
  openMode(): void
1650
1794
  openResume(): void
1651
1795
  openPlugin(query?: string): void
1652
1796
  openStatusline(): void
1797
+ openTheme(): void
1653
1798
  openHistory(): void
1654
1799
  createSession(mode?: string): void
1655
1800
  cancelSessionSwitch(): boolean
@@ -1678,6 +1823,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1678
1823
  historyFill: { text: string; index: number } | undefined
1679
1824
  /** Marks the accepted entry consumed (called after the fill is applied). */
1680
1825
  historyConsumed(): void
1826
+ /** DeepSeek easter-egg wave frame (null when static): the composer's input
1827
+ * row rides Codex's Wave sweep (33ms tick, 1.0s flash / 1.3s deepseek). */
1828
+ waveTick: number | null
1829
+ /** The wave tier of the applied official DeepSeek model (null otherwise):
1830
+ * drives the persistent prompt glyph/accent and the sparkle tier. */
1831
+ waveTier: DeepseekWaveTier | null
1832
+ /** The ignition style running, if any: Wave / Aurora / Pulse. */
1833
+ waveStyle: DeepseekWaveStyle | null
1681
1834
  }): ReactElement {
1682
1835
  const columns = useStdout().stdout?.columns ?? 80
1683
1836
  const [value, setValue] = useState('')
@@ -1915,6 +2068,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1915
2068
  openModel()
1916
2069
  return
1917
2070
  }
2071
+ if (text === '/effort' || text.startsWith('/effort ')) {
2072
+ openEffort()
2073
+ return
2074
+ }
1918
2075
  if (text === '/mode' || text.startsWith('/mode ')) {
1919
2076
  const mode = text.slice(5).trim()
1920
2077
  if (mode === '') openMode()
@@ -1943,6 +2100,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1943
2100
  openStatusline()
1944
2101
  return
1945
2102
  }
2103
+ if (text === '/theme') {
2104
+ openTheme()
2105
+ return
2106
+ }
1946
2107
  if (text === '/history') {
1947
2108
  openHistory()
1948
2109
  return
@@ -2077,55 +2238,136 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2077
2238
 
2078
2239
  // Every exclusive panel keeps the composer as a stable visual anchor, but
2079
2240
  // freezes it to one row: no menu, multiline wrap, or animation.
2241
+ const tierActive = waveTier !== null
2242
+ const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
2243
+ const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
2244
+ const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
2080
2245
  if (frozen) {
2081
2246
  const frozen = value === ''
2082
2247
  ? 'type a message'
2083
2248
  : verboseLine(value, Math.max(1, columns - 6))
2084
2249
  return createElement(
2085
2250
  Box,
2086
- { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
2251
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
2087
2252
  createElement(
2088
2253
  Text,
2089
2254
  { wrap: 'truncate-end' },
2090
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
2255
+ createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
2091
2256
  frozen,
2092
2257
  ),
2093
2258
  )
2094
2259
  }
2095
2260
 
2261
+ // The bordered frame: static dim at rest; while the wave runs the border
2262
+ // breathes with the sweep (dim blends toward the tier accent and back), so
2263
+ // the frame glows up while the crest crosses the row.
2264
+ const frame = (row: ReactElement, borderRgb?: RgbTriple): ReactElement => createElement(
2265
+ Box,
2266
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(borderRgb ?? getPalette().dim), paddingX: 1 },
2267
+ row,
2268
+ )
2269
+ const menu = createElement(CompletionMenu, {
2270
+ active: menuActive,
2271
+ mention: mentionActive,
2272
+ index: completionIndex,
2273
+ rows: menuRows,
2274
+ })
2275
+
2276
+ // Static row (idle, busy, or after the wave): prompt + editor window. The
2277
+ // prompt marker keeps the tier accent while an official DeepSeek model is
2278
+ // applied, restoring the static brand ❯ on any other route.
2096
2279
  const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
2280
+ const staticRow = createElement(
2281
+ Text,
2282
+ { wrap: 'truncate-end' },
2283
+ busy
2284
+ ? createElement(BusyChase)
2285
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `),
2286
+ value === '' ? undefined : editor.before,
2287
+ createElement(CursorBlock, { char: editor.caret }),
2288
+ value === '' && !busy
2289
+ ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
2290
+ : editor.after,
2291
+ )
2292
+
2293
+ // Wave row: the input row assembled column by column, each cell carrying
2294
+ // the sampled wave `backgroundColor` (null outside the crest → transparent),
2295
+ // so the crest sweeps the FULL content row — prompt, draft, cursor,
2296
+ // placeholder, and the trailing blank fill. The deepseek tier drops the
2297
+ // `· ✦ ✧` sparkles into the rightmost blank cell from 900ms on.
2298
+ const waveRow = (): ReactElement => {
2299
+ const contentWidth = Math.max(1, columns - 5)
2300
+ const waveEditor = editorWindow(value, cursor, Math.max(1, contentWidth - 2))
2301
+ const hues = deepseekWaveHues(waveTier!)
2302
+ const style = waveStyle!
2303
+ const base = getTheme() === 'light' ? WAVE_BASE_LIGHT : WAVE_BASE_DARK
2304
+ const waveBg = (column: number): string | undefined => {
2305
+ const rgb = deepseekWaveColumnBg(waveTick!, column, contentWidth, waveTier!, style, hues, base)
2306
+ return rgb === null ? undefined : inkColor(rgb)
2307
+ }
2308
+ const cells: ComposerCell[] = []
2309
+ cells.push({ char: promptGlyph, color: promptColor, bold: true, backgroundColor: waveBg(0) })
2310
+ cells.push({ char: ' ', color: promptColor, backgroundColor: waveBg(1) })
2311
+ for (const char of waveEditor.before) {
2312
+ cells.push({ char, backgroundColor: waveBg(cells.length) })
2313
+ }
2314
+ cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(cells.length) })
2315
+ if (value === '' && !busy) {
2316
+ for (let at = 0; at < COMPOSER_PLACEHOLDER.length; at += 1) {
2317
+ cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(cells.length) })
2318
+ }
2319
+ } else {
2320
+ for (const char of waveEditor.after) {
2321
+ cells.push({ char, backgroundColor: waveBg(cells.length) })
2322
+ }
2323
+ }
2324
+ while (cells.length < contentWidth) {
2325
+ cells.push({ char: ' ', backgroundColor: waveBg(cells.length) })
2326
+ }
2327
+ // The brand wordmark rides the wave's middle: `deepseek` in the tier's
2328
+ // cycled hues, placed in the row's mid-section and only over blank or
2329
+ // placeholder cells — real draft text is never covered.
2330
+ if (deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
2331
+ const word = 'deepseek'
2332
+ const start = Math.max(2, Math.floor((contentWidth - word.length) / 2))
2333
+ let clear = true
2334
+ for (let at = 0; at < word.length; at += 1) {
2335
+ const cell = cells[start + at]
2336
+ if (cell === undefined || (cell.char !== ' ' && cell.dim !== true)) { clear = false; break }
2337
+ }
2338
+ if (clear) {
2339
+ for (let at = 0; at < word.length; at += 1) {
2340
+ const cell = cells[start + at]!
2341
+ cell.char = word[at]!
2342
+ cell.color = inkColor(deepseekWaveWordHue(at, hues))
2343
+ cell.bold = true
2344
+ cell.dim = false
2345
+ }
2346
+ }
2347
+ }
2348
+ // The tail sparkles belong to the Wave style's deepseek tier only
2349
+ // (Codex paints spark_frame on Wave+Ultra).
2350
+ if (waveTier === 'deepseek' && style === 'wave') {
2351
+ const spark = deepseekWaveSpark(waveTick!)
2352
+ if (spark !== null) {
2353
+ const last = cells[cells.length - 1]
2354
+ if (last !== undefined && last.char === ' ') {
2355
+ last.char = spark
2356
+ last.color = promptColor
2357
+ last.bold = true
2358
+ last.dim = false
2359
+ }
2360
+ }
2361
+ }
2362
+ const borderRgb = deepseekWaveBorderColor(waveTick!, waveTier!, style, hues, getPalette().dim)
2363
+ return frame(createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)), borderRgb)
2364
+ }
2097
2365
 
2098
2366
  return createElement(
2099
2367
  Box,
2100
2368
  { flexDirection: 'column' },
2101
- // The completion dropdown rides directly above the box (Claude-Code
2102
- // anchor): rendered from the editor's own live state, never lifted.
2103
- createElement(CompletionMenu, {
2104
- active: menuActive,
2105
- mention: mentionActive,
2106
- index: completionIndex,
2107
- rows: menuRows,
2108
- }),
2109
- // The framed input box: a visible boundary so the prompt never blends
2110
- // into the transcript above it; the cursor block sits immediately after
2111
- // the prompt marker (leftmost), with the dim placeholder trailing it —
2112
- // no extra space, so the empty state reads `❯ ▮type a message…`.
2113
- createElement(
2114
- Box,
2115
- { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
2116
- createElement(
2117
- Text,
2118
- { wrap: 'truncate-end' },
2119
- busy
2120
- ? createElement(BusyChase)
2121
- : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '❯ '),
2122
- value === '' ? undefined : editor.before,
2123
- createElement(CursorBlock, { char: editor.caret }),
2124
- value === '' && !busy
2125
- ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
2126
- : editor.after,
2127
- ),
2128
- ),
2369
+ menu,
2370
+ waveTick !== null && waveTier !== null && !busy ? waveRow() : frame(staticRow),
2129
2371
  )
2130
2372
  }
2131
2373
 
@@ -2136,6 +2378,61 @@ export function App(props: AppProps): ReactElement {
2136
2378
  const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
2137
2379
  const [modelLabel, setModelLabel] = useState(props.model)
2138
2380
  const [modelOpen, setModelOpen] = useState(false)
2381
+ /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
2382
+ const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
2383
+ /** Effective reasoning effort, shown in the /model picker and switch notice. */
2384
+ const [effortLabel, setEffortLabel] = useState<string | undefined>(props.effort)
2385
+ /** DeepSeek easter egg: switching INTO an official DeepSeek route plays
2386
+ * one of Codex's three ignition styles (Wave / Aurora / Pulse, picked at
2387
+ * random without repeating) across the composer's padded band (33ms tick,
2388
+ * per-style durations), then the band returns to static while the prompt
2389
+ * marker keeps the tier accent. The trigger follows the applied model
2390
+ * label (what the status bar actually shows), never the initial paint,
2391
+ * and the tier is derived from the label and cached at the switch. */
2392
+ const [waveTick, setWaveTick] = useState<number | null>(null)
2393
+ const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
2394
+ const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
2395
+ const previousModel = useRef<string | undefined>(undefined)
2396
+ const previousEffort = useRef<string | undefined>(props.effort)
2397
+ const previousStyle = useRef<DeepseekWaveStyle | undefined>(undefined)
2398
+ useEffect(() => {
2399
+ const previous = previousModel.current
2400
+ previousModel.current = modelLabel
2401
+ // The wave replays when the applied model changes OR its effort level
2402
+ // changes on the same official DeepSeek route (Codex replays the
2403
+ // ignition on effort changes too).
2404
+ const effortChanged = previousEffort.current !== effortLabel
2405
+ previousEffort.current = effortLabel
2406
+ const modelChanged = previous !== undefined && previous !== modelLabel
2407
+ if (!isOfficialDeepSeekLabel(modelLabel)) {
2408
+ setWaveTier(null)
2409
+ setWaveStyle(null)
2410
+ setWaveTick(null)
2411
+ return
2412
+ }
2413
+ if (modelChanged || effortChanged) {
2414
+ setWaveTier(deepseekWaveTier(modelLabel))
2415
+ const nextStyle = deepseekWaveStyleRandom(previousStyle.current)
2416
+ previousStyle.current = nextStyle
2417
+ setWaveStyle(nextStyle)
2418
+ setWaveTick(0)
2419
+ }
2420
+ }, [modelLabel, effortLabel])
2421
+ const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
2422
+ && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
2423
+ useEffect(() => {
2424
+ if (!waveActive) return
2425
+ const id = setInterval(() => {
2426
+ setWaveTick(current => (current === null ? 0 : current + 1))
2427
+ }, DEEPSEEK_WAVE_TICK_MS)
2428
+ return () => {
2429
+ clearInterval(id)
2430
+ }
2431
+ }, [waveActive])
2432
+ useEffect(() => {
2433
+ if (waveTick !== null && waveTier !== null && waveStyle !== null
2434
+ && waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
2435
+ }, [waveTick, waveTier, waveStyle])
2139
2436
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
2140
2437
  const [modelError, setModelError] = useState<string | undefined>(undefined)
2141
2438
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
@@ -2175,6 +2472,7 @@ export function App(props: AppProps): ReactElement {
2175
2472
  const [pluginQuery, setPluginQuery] = useState('')
2176
2473
  const [statuslineOpen, setStatuslineOpen] = useState(false)
2177
2474
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
2475
+ const [themeOpen, setThemeOpen] = useState(false)
2178
2476
  const [historyOpen, setHistoryOpen] = useState(false)
2179
2477
  /** The /history panel's accepted entry: text plus its recall-space index. */
2180
2478
  const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
@@ -2202,18 +2500,20 @@ export function App(props: AppProps): ReactElement {
2202
2500
  const approvalPending = approvalSnapshot.pending !== undefined
2203
2501
  const questionPending = questionSnapshot.pending !== undefined
2204
2502
  // While any modal owns the keys, the prompt box passes everything through.
2205
- const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2503
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2206
2504
 
2207
2505
  // Human questions outrank local inspectors. Close the lower modal instead
2208
2506
  // of leaving an approval/question visible but keyboard-locked behind it.
2209
2507
  useEffect(() => {
2210
2508
  if (!approvalPending && !questionPending) return
2211
2509
  setModelOpen(false)
2510
+ setEffortFor(undefined)
2212
2511
  setHelpOpen(false)
2213
2512
  setModeOpen(false)
2214
2513
  setResumeOpen(false)
2215
2514
  setPluginOpen(false)
2216
2515
  setStatuslineOpen(false)
2516
+ setThemeOpen(false)
2217
2517
  setHistoryOpen(false)
2218
2518
  setVerboseOpen(false)
2219
2519
  }, [approvalPending, questionPending])
@@ -2319,9 +2619,9 @@ export function App(props: AppProps): ReactElement {
2319
2619
  ? Math.max(1, Math.floor(streamRows / 3))
2320
2620
  : 1
2321
2621
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2322
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2622
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2323
2623
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2324
- const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || historyOpen || inspectorVisible || approvalPending || questionPending
2624
+ const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
2325
2625
  const closeInspector = useCallback((): void => {
2326
2626
  setVerboseOpen(false)
2327
2627
  }, [])
@@ -2330,6 +2630,20 @@ export function App(props: AppProps): ReactElement {
2330
2630
  setRefreshEpoch(epoch => epoch + 1)
2331
2631
  }
2332
2632
 
2633
+ /** Apply one /model pick: record the selection, close the panel, report via notice. */
2634
+ const applyModel = (row: ModelRow, effortId: string | undefined): void => {
2635
+ try {
2636
+ const label = props.selectModel(row, effortId)
2637
+ setModelLabel(label)
2638
+ setEffortLabel(effortId)
2639
+ notify(`model → next step uses ${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`)
2640
+ setModelOpen(false)
2641
+ setEffortFor(undefined)
2642
+ } catch (error: unknown) {
2643
+ notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2644
+ }
2645
+ }
2646
+
2333
2647
  return createElement(
2334
2648
  Box,
2335
2649
  { flexDirection: 'column' },
@@ -2368,25 +2682,36 @@ export function App(props: AppProps): ReactElement {
2368
2682
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2369
2683
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
2370
2684
  modelOpen && !approvalPending && !questionPending
2371
- ? createElement(ModelPanel, {
2372
- directory,
2373
- error: modelError,
2374
- onSelect: (row: ModelRow) => {
2375
- try {
2376
- setModelLabel(props.selectModel(row))
2377
- notify(`model next step uses ${row.provider}/${row.model}`)
2685
+ ? effortFor === undefined
2686
+ ? createElement(ModelPanel, {
2687
+ directory,
2688
+ error: modelError,
2689
+ onSelect: (row: ModelRow) => {
2690
+ // A model advertising several levels opens the effort stage first;
2691
+ // one advertised level is its only option (Codex's
2692
+ // single-supported-effort shortcut), and no capability applies
2693
+ // the model default exactly as before.
2694
+ if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
2695
+ setEffortFor(row)
2696
+ return
2697
+ }
2698
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
2699
+ applyModel(row, effortId)
2700
+ },
2701
+ onRetry: () => {
2702
+ setModelLoadEpoch(epoch => epoch + 1)
2703
+ },
2704
+ onClose: () => {
2378
2705
  setModelOpen(false)
2379
- } catch (error: unknown) {
2380
- notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2381
- }
2382
- },
2383
- onRetry: () => {
2384
- setModelLoadEpoch(epoch => epoch + 1)
2385
- },
2386
- onClose: () => {
2387
- setModelOpen(false)
2388
- },
2389
- })
2706
+ setEffortFor(undefined)
2707
+ },
2708
+ })
2709
+ : createElement(EffortPanel, {
2710
+ row: effortFor,
2711
+ current: effortLabel,
2712
+ select: (effortId: string) => applyModel(effortFor, effortId),
2713
+ back: () => setEffortFor(undefined),
2714
+ })
2390
2715
  : undefined,
2391
2716
  helpOpen && !approvalPending && !questionPending
2392
2717
  ? createElement(HelpPanel, {
@@ -2440,6 +2765,21 @@ export function App(props: AppProps): ReactElement {
2440
2765
  close: () => setStatuslineOpen(false),
2441
2766
  })
2442
2767
  : undefined,
2768
+ themeOpen && !approvalPending && !questionPending
2769
+ ? createElement(ThemePanel, {
2770
+ current: getTheme(),
2771
+ select: (name: ThemeName) => {
2772
+ // Apply immediately (module-level palette), persist through the
2773
+ // runner, then close: the close re-render paints with the new
2774
+ // palette. `auto` stores as requested; detection is a later step.
2775
+ setTheme(name)
2776
+ props.saveTheme?.(name)
2777
+ notify(`theme → ${name}`)
2778
+ setThemeOpen(false)
2779
+ },
2780
+ close: () => setThemeOpen(false),
2781
+ })
2782
+ : undefined,
2443
2783
  historyOpen && !approvalPending && !questionPending
2444
2784
  ? createElement(HistoryPanel, {
2445
2785
  entries: recallSpace,
@@ -2476,8 +2816,44 @@ export function App(props: AppProps): ReactElement {
2476
2816
  openModel: () => {
2477
2817
  setDirectory(undefined)
2478
2818
  setModelError(undefined)
2819
+ setEffortFor(undefined)
2479
2820
  setModelOpen(true)
2480
2821
  },
2822
+ openEffort: () => {
2823
+ // /effort adjusts the CURRENT model's reasoning: resolve it from
2824
+ // the live catalog, then open the same effort stage the /model
2825
+ // picker would. Match on the applied label (what the status bar
2826
+ // shows) — `props.model` may still carry the deployment default
2827
+ // until the next request header lands. The model-id fallback
2828
+ // prefers a reasoning-capable row (several routes may serve the
2829
+ // same id), and a capability-lookup failure reads as "retry",
2830
+ // never as "the model has no efforts" — the adapter advertises
2831
+ // levels for every deepseek model, so "no efforts" is almost
2832
+ // always a failed resolveModelInfo, not a fact.
2833
+ void props.loadModels().then((loaded) => {
2834
+ const [provider, model] = modelLabel.split('/')
2835
+ const row = loaded.rows.find(candidate => candidate.provider === provider && candidate.model === model)
2836
+ ?? loaded.rows.find(candidate => candidate.model === model && candidate.reasoning !== undefined)
2837
+ ?? loaded.rows.find(candidate => candidate.model === model)
2838
+ if (row === undefined) {
2839
+ notify('current model is not in the catalog', 'warning')
2840
+ return
2841
+ }
2842
+ const rowTag = `${row.provider}/${row.model}`
2843
+ if (loaded.reasoningFailures?.includes(rowTag) === true) {
2844
+ notify('reasoning levels temporarily unavailable (capability lookup failed) — try again', 'warning')
2845
+ return
2846
+ }
2847
+ if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
2848
+ notify('current model does not expose reasoning efforts', 'warning')
2849
+ return
2850
+ }
2851
+ setEffortFor(row)
2852
+ setModelOpen(true)
2853
+ }, (error: unknown) => {
2854
+ notify(`model lookup failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2855
+ })
2856
+ },
2481
2857
  openHelp: () => {
2482
2858
  setHelpOpen(true)
2483
2859
  },
@@ -2485,6 +2861,7 @@ export function App(props: AppProps): ReactElement {
2485
2861
  openResume: () => setResumeOpen(true),
2486
2862
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2487
2863
  openStatusline: () => setStatuslineOpen(true),
2864
+ openTheme: () => setThemeOpen(true),
2488
2865
  openHistory: () => setHistoryOpen(true),
2489
2866
  createSession: props.createSession,
2490
2867
  cancelSessionSwitch: props.cancelSessionSwitch,
@@ -2519,6 +2896,9 @@ export function App(props: AppProps): ReactElement {
2519
2896
  cancelQueued: props.cancelQueued,
2520
2897
  historyFill,
2521
2898
  historyConsumed,
2899
+ waveTick,
2900
+ waveTier,
2901
+ waveStyle,
2522
2902
  }),
2523
2903
  createElement(StatusLine, {
2524
2904
  facts: {