dsh-code 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
package/src/app.ts CHANGED
@@ -22,21 +22,53 @@ 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'
38
+ import { DSH_CODE_VERSION } from './version.ts'
27
39
  import type { TranscriptStore } from './store.ts'
28
40
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
29
41
  import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
42
  import type { ToolDetail } from './render/tool-detail.ts'
31
- import { busyChaseFrame, caretVisible, pulseFrame } from './render/animations.ts'
43
+ import {
44
+ busyChaseFrame,
45
+ caretVisible,
46
+ DEEPSEEK_WAVE_TICK_MS,
47
+ deepseekWaveBorderColor,
48
+ deepseekWaveColumnBg,
49
+ deepseekWaveDuration,
50
+ deepseekWaveSpark,
51
+ deepseekWaveStyleRandom,
52
+ deepseekWaveTier,
53
+ deepseekWaveWordHue,
54
+ deepseekWaveWordVisible,
55
+ isOfficialDeepSeekLabel,
56
+ pulseFrame,
57
+ WAVE_BASE_DARK,
58
+ WAVE_BASE_LIGHT,
59
+ type DeepseekWaveStyle,
60
+ type DeepseekWaveTier,
61
+ } from './render/animations.ts'
32
62
  import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
33
63
  import type { CommandsView } from './commands.ts'
34
64
  import type { ModelDirectory, ModelRow } from './models.ts'
65
+ import type { ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
35
66
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
36
67
  import type { SkillsView, SkillRow } from './skills.ts'
37
68
  import type { MentionCandidate } from './mentions.ts'
38
- import { ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
69
+ import { EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
39
70
  import type { PresetRow } from './presets.ts'
71
+ import type { PermissionRow } from './permissions.ts'
40
72
  import type { PluginRow } from './plugin-inventory.ts'
41
73
  import {
42
74
  recallEntries,
@@ -59,6 +91,7 @@ import {
59
91
  STATUS_CYCLE_HINT,
60
92
  STATUS_GROUP_SEPARATOR,
61
93
  STATUS_ITEM_SEPARATOR,
94
+ STATUS_ROW2_INDENT,
62
95
  type StatusFacts,
63
96
  type StatusGroup,
64
97
  type StatusItemId,
@@ -79,6 +112,7 @@ import {
79
112
  import {
80
113
  lineSegment,
81
114
  markdownLines,
115
+ reasoningLines,
82
116
  styledLines,
83
117
  textLines,
84
118
  transcriptEntryLines,
@@ -103,6 +137,8 @@ export interface AppProps {
103
137
  skills: SkillsView
104
138
  /** `provider/model` selection serving this session (updated on /model). */
105
139
  model: string
140
+ /** Effective reasoning effort in force ('' when none), for the /model picker mark. */
141
+ effort?: string
106
142
  /** Working-directory basename the session serves. */
107
143
  cwd: string
108
144
  /** Absolute working directory used by session filters and references. */
@@ -113,8 +149,10 @@ export interface AppProps {
113
149
  sessionId: string
114
150
  /** Whether this session was resumed from persistence. */
115
151
  resumed: boolean
116
- /** Agent preset currently composing the session. */
152
+ /** Agent preset selected for the current or pending first session. */
117
153
  mode: string
154
+ /** Permission preset selected for the current or pending first session. */
155
+ permission: string
118
156
  /** Submit one line: slash commands to the registry, other text to the agent. */
119
157
  dispatch(text: string): void
120
158
  /** Submit steering: consumed at the running turn's next step boundary. */
@@ -127,10 +165,22 @@ export interface AppProps {
127
165
  loadModels(): Promise<ModelDirectory>
128
166
  /** Load @mention candidates for the typed query (files + sessions). */
129
167
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
130
- /** Apply one /model selection; returns the display label. */
131
- selectModel(row: ModelRow): string
168
+ /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
169
+ selectModel(row: ModelRow, effortId?: string): string
170
+ /** Load provider/settings/credential facts for the optional /model provider stage. */
171
+ loadModelProviders?(): Promise<ProviderSettingsDirectory>
172
+ /** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
173
+ subscribeModelProviders?(listener: () => void): () => void
174
+ /** Store or rotate one provider credential through the Harness credential service. */
175
+ saveModelProviderCredential?(target: ProviderTargetView, key: string): Promise<void>
176
+ /** Remove one writable provider credential without removing its settings profile. */
177
+ unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
178
+ /** Remove one user-owned provider profile and its page-managed credential. */
179
+ removeModelProvider?(target: ProviderTargetView): Promise<void>
132
180
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
133
181
  cyclePermission(): string
182
+ /** Select or inspect a permission preset without requiring a pre-existing session. */
183
+ setPermission(id: string): string
134
184
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
135
185
  exportTranscript(argument: string): Promise<void>
136
186
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
@@ -138,6 +188,8 @@ export interface AppProps {
138
188
  /** Preset/session/plugin kernel operations. */
139
189
  loadPresets(): Promise<readonly PresetRow[]>
140
190
  switchMode(id: string): Promise<string>
191
+ /** Load the switchable permission presets for the /permission panel. */
192
+ loadPermissions(): Promise<readonly PermissionRow[]>
141
193
  createSession(mode?: string): void
142
194
  loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
143
195
  loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
@@ -150,6 +202,8 @@ export interface AppProps {
150
202
  statusline: readonly string[]
151
203
  /** Persist a new statusline item set; the runner surfaces IO failures as notices. */
152
204
  saveStatusline(items: readonly string[]): void
205
+ /** Apply and persist one /theme selection; the runner owns the theme.json file. */
206
+ saveTheme?(name: ThemeName): void
153
207
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
154
208
  history: readonly string[]
155
209
  /** Persist one submitted prompt to the global history file. */
@@ -158,11 +212,6 @@ export interface AppProps {
158
212
  cancelQueued(messageId: string): void
159
213
  }
160
214
 
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
215
  /** Pad text with spaces to a visible-column target (menu name column). */
167
216
  function padColumns(text: string, width: number): string {
168
217
  const clipped = truncateColumns(singleLineText(text), width)
@@ -199,7 +248,7 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
199
248
  /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
200
249
  function Pulse(): ReactElement {
201
250
  const tick = useFrames(125)
202
- return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
251
+ return createElement(Text, { color: inkColor(getPalette().brandBright) }, pulseFrame(tick))
203
252
  }
204
253
 
205
254
  /**
@@ -210,7 +259,7 @@ function Pulse(): ReactElement {
210
259
  */
211
260
  function BusyChase(): ReactElement {
212
261
  const tick = useFrames(125)
213
- return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, busyChaseFrame(tick) + ' ')
262
+ return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
214
263
  }
215
264
 
216
265
  /** Blinking block caret appended to streaming text. */
@@ -262,30 +311,40 @@ function DeepDivingLine({ since }: { since: number }): ReactElement {
262
311
  * the freshest tokens stay visible while a long reply streams; the complete
263
312
  * text lands in the flushed scrollback once the turn assembles it.
264
313
  */
265
- function StreamTail({ text, dim, maxRows, prefix, children }: {
314
+ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children }: {
266
315
  text: string
267
316
  dim: boolean
268
317
  maxRows: number
269
318
  prefix?: string
319
+ continuationPrefix?: string
270
320
  children?: ReactElement
271
321
  }): ReactElement {
272
322
  const columns = useStdout().stdout?.columns ?? 80
273
323
  const safeRows = Math.max(1, maxRows)
274
324
  // App padding consumes two columns; the final extra column keeps a caret
275
- // from wrapping onto an unbudgeted row.
276
- const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
325
+ // from wrapping onto an unbudgeted row. Both prefixes participate because
326
+ // every physical row now repeats its hanging indent.
327
+ const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
328
+ const contentColumns = Math.max(10, columns - 3 - prefixColumns)
277
329
  const initial = displayTail(text, contentColumns, safeRows)
278
330
  // Reserve one row for the omission marker only when a marker is needed.
279
331
  const tail = initial.truncated && safeRows > 1
280
332
  ? displayTail(text, contentColumns, safeRows - 1)
281
333
  : initial
334
+ const rows = tail.text.split('\n')
282
335
  return createElement(
283
336
  Box,
284
337
  { flexDirection: 'column' },
285
338
  tail.truncated && safeRows > 1
286
- ? createElement(Text, { dimColor: true }, ' …')
339
+ ? createElement(Text, { color: inkColor(getPalette().dim) }, continuationPrefix, '…')
287
340
  : undefined,
288
- createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
341
+ ...rows.map((row, index) => createElement(
342
+ Text,
343
+ { key: index, dimColor: dim || undefined },
344
+ index === 0 ? prefix : continuationPrefix,
345
+ row,
346
+ index + 1 === rows.length ? children : undefined,
347
+ )),
289
348
  )
290
349
  }
291
350
 
@@ -298,11 +357,13 @@ function segmentProps(style: MdSegment['style']): {
298
357
  } {
299
358
  switch (style) {
300
359
  case 'accent':
301
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
360
+ return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
361
+ case 'accentBold':
362
+ return { color: inkColor(getPalette().brandBright), bold: true, italic: undefined, strikethrough: undefined }
302
363
  case 'code':
303
- return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
364
+ return { color: inkColor(getPalette().code), bold: undefined, italic: undefined, strikethrough: undefined }
304
365
  case 'dim':
305
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
366
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: undefined, strikethrough: undefined }
306
367
  case 'bold':
307
368
  return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
308
369
  case 'italic':
@@ -310,7 +371,7 @@ function segmentProps(style: MdSegment['style']): {
310
371
  case 'boldItalic':
311
372
  return { color: undefined, bold: true, italic: true, strikethrough: undefined }
312
373
  case 'strike':
313
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
374
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: undefined, strikethrough: true }
314
375
  default:
315
376
  return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
316
377
  }
@@ -326,15 +387,15 @@ function lineStyleProps(style: LineStyle): {
326
387
  } {
327
388
  switch (style) {
328
389
  case 'brand':
329
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
390
+ return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
330
391
  case 'success':
331
- return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
392
+ return { color: inkColor(getPalette().success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
332
393
  case 'error':
333
- return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
394
+ return { color: inkColor(getPalette().error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
334
395
  case 'warn':
335
- return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
396
+ return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
336
397
  case 'dimItalic':
337
- return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
398
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined }
338
399
  default:
339
400
  return { ...segmentProps(style), dimColor: undefined }
340
401
  }
@@ -387,6 +448,13 @@ function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }):
387
448
  )
388
449
  }
389
450
 
451
+ /** Expanded reasoning with the same two-column content edge as the reply. */
452
+ function ReasoningBody({ text }: { text: string }): ReactElement {
453
+ const columns = useStdout().stdout?.columns ?? 80
454
+ const lines = useMemo(() => reasoningLines(text, Math.max(10, columns - 2)), [text, columns])
455
+ return createElement(StyledRows, { lines })
456
+ }
457
+
390
458
  /**
391
459
  * One expanded tool-card body for the verbose transcript (Ctrl+O): the
392
460
  * presentation contract's structured cards — inline diffs, read windows,
@@ -407,7 +475,7 @@ function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
407
475
  Text,
408
476
  {
409
477
  key: at,
410
- color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
478
+ color: line.mark === '+' ? inkColor(getPalette().success) : line.mark === '-' ? inkColor(getPalette().error) : inkColor(getPalette().dim),
411
479
  wrap: 'truncate-end',
412
480
  },
413
481
  ` ${line.mark}${displayText(line.text)}`,
@@ -472,8 +540,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
472
540
  entry.reasoning === ''
473
541
  ? undefined
474
542
  : showReasoning
475
- ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
476
- : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
543
+ ? createElement(ReasoningBody, { text: entry.reasoning })
544
+ : createElement(Text, { color: inkColor(getPalette().dim) }, `✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
477
545
  createElement(MarkdownBody, { text: entry.text, indent: 2 }),
478
546
  )
479
547
  case 'tool': {
@@ -482,8 +550,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
482
550
  const mark = entry.state === 'running'
483
551
  ? createElement(Pulse)
484
552
  : entry.state === 'error'
485
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
486
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
553
+ ? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
554
+ : createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
487
555
  return createElement(
488
556
  Box,
489
557
  { flexDirection: 'column' },
@@ -492,14 +560,14 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
492
560
  { wrap: verbose ? 'truncate-end' : undefined },
493
561
  mark,
494
562
  ' ',
495
- brand(entry.name),
563
+ brand(displayText(entry.name)),
496
564
  entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
497
565
  ),
498
566
  entry.summary === ''
499
567
  ? undefined
500
568
  : createElement(
501
569
  Text,
502
- { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
570
+ { color: entry.state === 'error' ? inkColor(getPalette().error) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
503
571
  ` ⎿ ${displayText(entry.summary)}`,
504
572
  ),
505
573
  verbose && entry.detail !== undefined
@@ -511,8 +579,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
511
579
  const mark = entry.state === 'running'
512
580
  ? createElement(Pulse)
513
581
  : entry.state === 'error'
514
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
515
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
582
+ ? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
583
+ : createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
516
584
  return createElement(
517
585
  Box,
518
586
  { flexDirection: 'column' },
@@ -521,12 +589,12 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
521
589
  { wrap: verbose ? 'truncate-end' : undefined },
522
590
  mark,
523
591
  ' ',
524
- brand(`/${entry.name}`),
592
+ brand(displayText(`/${entry.name}`)),
525
593
  entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
526
594
  ),
527
595
  entry.summary === ''
528
596
  ? undefined
529
- : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
597
+ : createElement(Text, { color: inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
530
598
  )
531
599
  }
532
600
  case 'turn-marker':
@@ -546,7 +614,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
546
614
  // next attempt is underway.
547
615
  return createElement(
548
616
  Text,
549
- { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
617
+ { color: entry.state === 'running' ? inkColor(getPalette().warn) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
550
618
  ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
551
619
  )
552
620
  case 'files': {
@@ -572,39 +640,48 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
572
640
  }
573
641
 
574
642
  /**
575
- * The whale wordmark header in DeepSeek blue, hugging its content width.
576
- * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
577
- * short to show it whole (or mid-resize) the clipped pairs garble the
578
- * screen — below the height floor the header collapses to a single-line
579
- * wordmark that stays correct at any size.
643
+ * The whale header with a compact three-line copy lockup. The title, bilingual
644
+ * slogan, and key hint stay centered inside the existing eight content rows,
645
+ * preserving the Static header's ten physical rows. Short or narrow terminals
646
+ * keep a one-line form.
580
647
  */
581
648
  function Header({ resumed }: { resumed: boolean }): ReactElement {
582
- const rows = useStdout().stdout?.rows ?? 40
583
- const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
584
- if (rows < 20) {
649
+ const stdout = useStdout().stdout
650
+ const rows = stdout?.rows ?? 40
651
+ const columns = stdout?.columns ?? 80
652
+ const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`
653
+ const slogan = 'Into the Unknown 探索未至之境'
654
+ const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
655
+ const copyColumns = Math.max(visibleColumns(title), visibleColumns(slogan), visibleColumns(hint))
656
+ const compact = `${title} · ${hint}`
657
+ if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 8) {
585
658
  return createElement(
586
659
  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'),
589
- createElement(Text, { dimColor: true }, hint),
660
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1 },
661
+ createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, truncateColumns(compact, Math.max(1, columns - 5))),
590
662
  )
591
663
  }
592
664
  return createElement(
593
665
  Box,
594
- // alignSelf shrinks the border to the whale-plus-wordmark content instead
595
- // of stretching across the terminal and stranding empty space on the right
596
- // (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' },
666
+ // alignSelf shrinks the border to the whale-plus-copy content instead of
667
+ // stretching across the terminal and stranding empty space on the right.
668
+ { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
598
669
  createElement(
599
670
  Box,
600
671
  { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
601
- ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
672
+ ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(getPalette().brand) }, row)),
602
673
  ),
603
674
  createElement(
604
675
  Box,
605
- { flexDirection: 'column', justifyContent: 'center' },
606
- createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
607
- createElement(Text, { dimColor: true }, hint),
676
+ { flexDirection: 'column', width: copyColumns, justifyContent: 'center' },
677
+ createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, title),
678
+ createElement(
679
+ Text,
680
+ { color: inkColor(getPalette().code), wrap: 'truncate-end' },
681
+ createElement(Text, { bold: true }, 'Into the Unknown'),
682
+ ' 探索未至之境',
683
+ ),
684
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, hint),
608
685
  ),
609
686
  )
610
687
  }
@@ -626,10 +703,10 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
626
703
  { paddingX: 1 },
627
704
  createElement(
628
705
  Text,
629
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
706
+ { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
630
707
  `todos ${completed}/${todos.length}`,
631
708
  createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
632
- current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
709
+ current === undefined ? '' : createElement(Text, { color: inkColor(getPalette().brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
633
710
  ),
634
711
  )
635
712
  }
@@ -637,7 +714,7 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
637
714
  /**
638
715
  * Ink props for one status tone: the Codex status-line accent mapping over
639
716
  * 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
717
+ * degrees of blue (deep accent, primary figures, model identity, sky
641
718
  * paths and done states), with amber/red reserved for warnings and errors.
642
719
  */
643
720
  function statusToneProps(tone: StatusTone): {
@@ -647,28 +724,38 @@ function statusToneProps(tone: StatusTone): {
647
724
  } {
648
725
  switch (tone) {
649
726
  case 'model':
650
- return { color: inkColor(TUI_RGB.brandBright), bold: true, dimColor: undefined }
727
+ // Same tone as the working-directory segment: the model name reads as
728
+ // a path fact, not a brand accent.
729
+ return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
651
730
  case 'live':
652
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, dimColor: undefined }
731
+ return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
653
732
  case 'path':
654
- return { color: inkColor(TUI_RGB.code), bold: undefined, dimColor: undefined }
733
+ return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
655
734
  case 'branch':
656
- return { color: inkColor(TUI_RGB.text), bold: undefined, dimColor: undefined }
735
+ return { color: inkColor(getPalette().text), bold: undefined, dimColor: undefined }
657
736
  case 'value':
658
- return { color: inkColor(TUI_RGB.brand), bold: undefined, dimColor: undefined }
737
+ return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
659
738
  case 'label':
660
739
  case 'meta':
661
- return { color: undefined, bold: undefined, dimColor: true }
740
+ // Explicit RGB gray, not SGR dim: Ink's token stream inherits an
741
+ // unclosed `dim` into the next span (the model name after the busy dot
742
+ // rendered dim+bold and looked gray), and a concrete color closes
743
+ // cleanly on the style transition. Theme-aware via the palette.
744
+ return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
662
745
  case 'accent':
663
- return { color: inkColor(TUI_RGB.brandDeep), bold: undefined, dimColor: undefined }
746
+ return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
747
+ // Context-bar fill: one DeepSeek blue over the whole occupied run; the
748
+ // dotted free track reads through the dim label gray.
749
+ case 'ctxFill':
750
+ return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
664
751
  case 'success':
665
- return { color: inkColor(TUI_RGB.code), bold: true, dimColor: undefined }
752
+ return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
666
753
  case 'warn':
667
- return { color: inkColor(TUI_RGB.warn), bold: true, dimColor: undefined }
754
+ return { color: inkColor(getPalette().warn), bold: true, dimColor: undefined }
668
755
  case 'error':
669
- return { color: inkColor(TUI_RGB.error), bold: true, dimColor: undefined }
756
+ return { color: inkColor(getPalette().error), bold: true, dimColor: undefined }
670
757
  default:
671
- return { color: undefined, bold: undefined, dimColor: true }
758
+ return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
672
759
  }
673
760
  }
674
761
 
@@ -681,7 +768,28 @@ function statusToneProps(tone: StatusTone): {
681
768
  * content, so the footer degrades to a single row on narrow terminals. Both
682
769
  * layouts arrive pre-measured from the pure reducer, so Ink only paints;
683
770
  * truncation degrades groups, it never wraps a row.
771
+ *
772
+ * The DeepSeek easter egg: when the model label *switches* to an official
773
+ * DeepSeek route, the composer's INPUT ROW (not the frame) plays Codex's
774
+ * effort-ignition "Wave" — a blue crest sweeping the content row column by
775
+ * column, with the `· ✦ ✧` sparkles on the deepseek tier — and the prompt
776
+ * marker keeps the tier accent afterwards. The border stays a constant
777
+ * static dim; only the row's per-column background tints during the wave,
778
+ * so the row and column budget is untouched throughout.
684
779
  */
780
+
781
+ /** Theme anchors for the one-shot composer wave, read from the active palette
782
+ * so the wave stays coordinated in both themes. The flash tier runs the
783
+ * brand blues; the deepseek tier swaps in the code sky-blue for a brighter,
784
+ * richer mix. Codex's Wave bands carry no hue index (only hues[0] tints the
785
+ * row), so the accent the prompt keeps is always hues[0]. */
786
+ function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTriple, RgbTriple] {
787
+ const palette = getPalette()
788
+ return tier === 'flash'
789
+ ? [palette.brandBright, palette.brand, palette.brandMid]
790
+ : [palette.brandBright, palette.code, palette.brandMid]
791
+ }
792
+
685
793
  function StatusLine({ facts, stats, busy, columns, items }: {
686
794
  facts: StatusFacts
687
795
  stats: Parameters<typeof layoutStatusBar>[1]
@@ -690,11 +798,12 @@ function StatusLine({ facts, stats, busy, columns, items }: {
690
798
  items: readonly string[]
691
799
  }): ReactElement {
692
800
  const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
693
- const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
801
+
802
+ const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
694
803
  const leftParts: ReactElement[] = []
695
804
  row.left.forEach((group, groupIndex) => {
696
805
  if (groupIndex > 0) {
697
- leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, dimColor: true }, STATUS_GROUP_SEPARATOR))
806
+ leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, color: inkColor(getPalette().dim) }, STATUS_GROUP_SEPARATOR))
698
807
  }
699
808
  group.spans.forEach((span, spanIndex) => {
700
809
  leftParts.push(createElement(
@@ -707,7 +816,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
707
816
  const rightParts: ReactElement[] = []
708
817
  row.right.forEach((span, index) => {
709
818
  if (index > 0) {
710
- rightParts.push(createElement(Text, { key: key + 'rs' + index, dimColor: true }, STATUS_ITEM_SEPARATOR))
819
+ rightParts.push(createElement(Text, { key: key + 'rs' + index, color: inkColor(getPalette().dim) }, STATUS_ITEM_SEPARATOR))
711
820
  }
712
821
  rightParts.push(createElement(
713
822
  Text,
@@ -716,7 +825,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
716
825
  ))
717
826
  })
718
827
  if (row.hint) {
719
- rightParts.push(createElement(Text, { key: key + 'hint', dimColor: true }, STATUS_CYCLE_HINT))
828
+ rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, STATUS_CYCLE_HINT))
720
829
  }
721
830
  // Each row already fits the column budget; truncate-end stays as the
722
831
  // terminal-measurement backstop so a drifting cell count clips instead
@@ -724,9 +833,10 @@ function StatusLine({ facts, stats, busy, columns, items }: {
724
833
  return createElement(
725
834
  Box,
726
835
  // Match the prompt text inside the bordered composer: one border column
727
- // plus one padding column. Keeping these rows margin-free also makes
728
- // the composer and status a fixed bottom unit in every interface.
729
- { paddingLeft: 2, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
836
+ // plus one padding column. The secondary row adds the model-name indent
837
+ // (its budget already shrinks by the same amount) so its figures align
838
+ // under the model name rather than under the busy dot.
839
+ { paddingLeft: 2 + indent, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
730
840
  createElement(Text, { wrap: 'truncate-end' }, ...leftParts),
731
841
  rightParts.length > 0 ? createElement(Text, { wrap: 'truncate-end' }, ...rightParts) : undefined,
732
842
  )
@@ -736,7 +846,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
736
846
  Box,
737
847
  { flexDirection: 'column' },
738
848
  renderRow(layout.row1, 's1'),
739
- row2Present ? renderRow(layout.row2, 's2') : undefined,
849
+ row2Present ? renderRow(layout.row2, 's2', STATUS_ROW2_INDENT) : undefined,
740
850
  )
741
851
  }
742
852
 
@@ -751,10 +861,10 @@ function NoticeLine({ text, tone, columns }: {
751
861
  columns: number
752
862
  }): ReactElement {
753
863
  const color = tone === 'error'
754
- ? TUI_RGB.error
864
+ ? getPalette().error
755
865
  : tone === 'warning'
756
- ? TUI_RGB.warn
757
- : TUI_RGB.brandBright
866
+ ? getPalette().warn
867
+ : getPalette().brandBright
758
868
  const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•'
759
869
  return createElement(
760
870
  Box,
@@ -825,8 +935,8 @@ function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked:
825
935
  const { answered } = snapshot
826
936
  return createElement(
827
937
  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)),
938
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
939
+ 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
940
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
831
941
  createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
832
942
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1093,10 +1203,10 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1093
1203
  : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
1094
1204
  return createElement(
1095
1205
  Box,
1096
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
1206
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep) },
1097
1207
  createElement(
1098
1208
  Text,
1099
- { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
1209
+ { color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep), bold: true, wrap: 'truncate-end' },
1100
1210
  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
1211
  ),
1102
1212
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1107,10 +1217,11 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1107
1217
  }
1108
1218
 
1109
1219
  /** The /model panel: a scrolling list over the advisory model directory. */
1110
- function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1220
+ function ModelPanel({ directory, error, onSelect, onProviders, onRetry, onClose }: {
1111
1221
  directory: ModelDirectory | undefined
1112
1222
  error: string | undefined
1113
1223
  onSelect(row: ModelRow): void
1224
+ onProviders?(): void
1114
1225
  onRetry(): void
1115
1226
  onClose(): void
1116
1227
  }): ReactElement {
@@ -1136,6 +1247,10 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1136
1247
  onRetry()
1137
1248
  return
1138
1249
  }
1250
+ if (input === 'a' && onProviders !== undefined) {
1251
+ onProviders()
1252
+ return
1253
+ }
1139
1254
  if (rows.length === 0) return
1140
1255
  if (key.upArrow) {
1141
1256
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
@@ -1168,7 +1283,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1168
1283
 
1169
1284
  if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1170
1285
  if (viewport.compact) {
1171
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
1286
+ const providers = onProviders === undefined ? '' : ' · a providers'
1287
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model${providers} · r retry · esc/q close`, viewport.contentColumns))
1172
1288
  }
1173
1289
 
1174
1290
  const stateRows: ReactElement[] = directory === undefined && error === undefined
@@ -1176,7 +1292,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1176
1292
  : error !== undefined
1177
1293
  ? [createElement(
1178
1294
  Text,
1179
- { key: 'error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1295
+ { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1180
1296
  truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
1181
1297
  )]
1182
1298
  : [
@@ -1184,7 +1300,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1184
1300
  ? []
1185
1301
  : [createElement(
1186
1302
  Text,
1187
- { key: 'failures', color: inkColor(TUI_RGB.warn), wrap: 'truncate-end' },
1303
+ { key: 'failures', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1188
1304
  truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
1189
1305
  )]),
1190
1306
  ...(rows.length === 0
@@ -1194,15 +1310,16 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1194
1310
  // Measurement and rendering share the same physical-row budget: state
1195
1311
  // messages consume body rows before selectable entries, as in Codex's
1196
1312
  // list-selection views.
1197
- const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length)
1313
+ const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1314
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1198
1315
  const first = selectionWindow(cursor, rows.length, rowBudget)
1199
1316
  const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1200
1317
  return createElement(
1201
1318
  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)),
1319
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1320
+ 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
1321
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1205
- ...stateRows,
1322
+ ...visibleStateRows,
1206
1323
  ...visible.map((row) => {
1207
1324
  const index = rows.indexOf(row)
1208
1325
  const label = displayText(`${row.providerName} · ${row.modelName}`)
@@ -1210,14 +1327,310 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1210
1327
  Text,
1211
1328
  {
1212
1329
  key: `${row.provider}/${row.model}`,
1213
- color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1330
+ color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
1214
1331
  wrap: 'truncate-end',
1215
1332
  },
1216
1333
  truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
1217
1334
  )
1218
1335
  }),
1219
1336
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1220
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · r retry · esc/q close', viewport.contentColumns))),
1337
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · a providers'} · r retry · esc/q close`, viewport.contentColumns))),
1338
+ )
1339
+ }
1340
+
1341
+ /** Compact provider-state copy; only value-free credential facts cross this boundary. */
1342
+ function providerStateLabel(row: ProviderTargetView): string {
1343
+ const route = row.active ? 'active' : 'dormant'
1344
+ const credential = row.credential
1345
+ if (credential?.kind === 'error') return `${route} · key status unavailable`
1346
+ if (credential?.kind === 'facts') {
1347
+ if (!credential.configured) return `${route} · key missing`
1348
+ const source = credential.source === undefined ? 'configured' : singleLineText(credential.source)
1349
+ return `${route} · key ${source}${credential.writable ? '' : ' · read-only'}`
1350
+ }
1351
+ return `${route} · ${row.configured ? 'provider auth' : 'not configured'}`
1352
+ }
1353
+
1354
+ /** The provider-management stage reached from /model with `a`. */
1355
+ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRetry, onBack }: {
1356
+ directory: ProviderSettingsDirectory | undefined
1357
+ error: string | undefined
1358
+ onCredential(target: ProviderTargetView): void
1359
+ onUnset(target: ProviderTargetView): void
1360
+ onRemove(target: ProviderTargetView): void
1361
+ onRetry(): void
1362
+ onBack(): void
1363
+ }): ReactElement {
1364
+ const stdout = useStdout().stdout
1365
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1366
+ const rows = directory?.rows ?? []
1367
+ const [cursor, setCursor] = useState(0)
1368
+ const [actionError, setActionError] = useState<string | undefined>(undefined)
1369
+
1370
+ useEffect(() => {
1371
+ if (rows.length === 0) {
1372
+ if (cursor !== 0) setCursor(0)
1373
+ return
1374
+ }
1375
+ if (cursor >= rows.length) setCursor(rows.length - 1)
1376
+ }, [rows.length, cursor])
1377
+
1378
+ useStableInput((input, key) => {
1379
+ if (key.escape || input === 'q') {
1380
+ onBack()
1381
+ return
1382
+ }
1383
+ if (input === 'r') {
1384
+ setActionError(undefined)
1385
+ onRetry()
1386
+ return
1387
+ }
1388
+ if (rows.length === 0) return
1389
+ if (key.upArrow) {
1390
+ setActionError(undefined)
1391
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
1392
+ return
1393
+ }
1394
+ if (key.downArrow) {
1395
+ setActionError(undefined)
1396
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
1397
+ return
1398
+ }
1399
+ if (key.pageUp) {
1400
+ setActionError(undefined)
1401
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
1402
+ return
1403
+ }
1404
+ if (key.pageDown) {
1405
+ setActionError(undefined)
1406
+ setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1407
+ return
1408
+ }
1409
+ const target = rows[cursor]
1410
+ if (target === undefined) return
1411
+ if (input === 'd') {
1412
+ const facts = target.credential
1413
+ if (facts?.kind !== 'facts' || !facts.configured) {
1414
+ setActionError('this provider has no configured API key to remove')
1415
+ } else if (!facts.writable) {
1416
+ setActionError('this API key is supplied read-only by the environment')
1417
+ } else {
1418
+ onUnset(target)
1419
+ }
1420
+ return
1421
+ }
1422
+ if (input === 'x') {
1423
+ if (!target.removable) {
1424
+ setActionError('this provider profile is not removable')
1425
+ } else {
1426
+ onRemove(target)
1427
+ }
1428
+ return
1429
+ }
1430
+ if (key.return) {
1431
+ if (target.settingsNs.length === 0) {
1432
+ setActionError('this provider is not managed by Harness settings')
1433
+ } else if (target.credential?.kind === 'error') {
1434
+ setActionError('credential status is unavailable; retry before writing')
1435
+ } else if (target.credential?.kind === 'facts' && !target.credential.writable) {
1436
+ setActionError('this API key is supplied read-only by the environment')
1437
+ } else if (target.credentialRef === undefined && directory?.writable !== true) {
1438
+ setActionError('settings are read-only; this provider cannot be activated here')
1439
+ } else {
1440
+ onCredential(target)
1441
+ }
1442
+ }
1443
+ }, true)
1444
+
1445
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1446
+ if (viewport.compact) {
1447
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model providers · enter key · d remove key · esc back', viewport.contentColumns))
1448
+ }
1449
+ const stateRows: ReactElement[] = directory === undefined && error === undefined
1450
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' loading providers…')]
1451
+ : error !== undefined
1452
+ ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
1453
+ : [
1454
+ ...(actionError === undefined
1455
+ ? []
1456
+ : [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
1457
+ ...(directory?.failures ?? []).map((failure, index) => createElement(
1458
+ Text,
1459
+ { key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1460
+ truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1461
+ )),
1462
+ ...(rows.length === 0
1463
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
1464
+ : []),
1465
+ ]
1466
+ const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1467
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1468
+ const first = selectionWindow(cursor, rows.length, rowBudget)
1469
+ const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1470
+ return createElement(
1471
+ Box,
1472
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1473
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — providers${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1474
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1475
+ ...visibleStateRows,
1476
+ ...visible.map((row) => {
1477
+ const index = rows.indexOf(row)
1478
+ const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
1479
+ const label = `${identity} · ${providerStateLabel(row)}${row.removable ? ' · custom' : ''}`
1480
+ return createElement(
1481
+ Text,
1482
+ { key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
1483
+ truncateColumns(`${index === cursor ? '❯ ' : ' '}${displayText(label)}`, viewport.contentColumns),
1484
+ )
1485
+ }),
1486
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1487
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter add/update key · d remove key · x remove custom provider · r retry · esc back', viewport.contentColumns)),
1488
+ )
1489
+ }
1490
+
1491
+ /** Write-only masked API-key editor; the secret lives only in this mounted component. */
1492
+ function ProviderCredentialPanel({ target, save, done, back }: {
1493
+ target: ProviderTargetView
1494
+ save(target: ProviderTargetView, key: string): Promise<void>
1495
+ done(): void
1496
+ back(): void
1497
+ }): ReactElement {
1498
+ const stdout = useStdout().stdout
1499
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1500
+ const [draft, setDraft] = useState('')
1501
+ const [busy, setBusy] = useState(false)
1502
+ const [error, setError] = useState<string | undefined>(undefined)
1503
+
1504
+ const submit = (): void => {
1505
+ if (busy) return
1506
+ setBusy(true)
1507
+ setError(undefined)
1508
+ Promise.resolve().then(() => save(target, draft)).then(() => {
1509
+ setDraft('')
1510
+ done()
1511
+ }, (reason: unknown) => {
1512
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
1513
+ setBusy(false)
1514
+ })
1515
+ }
1516
+
1517
+ useStableInput((input, key) => {
1518
+ if (busy) return
1519
+ if (key.escape) {
1520
+ setDraft('')
1521
+ back()
1522
+ return
1523
+ }
1524
+ if (key.return) {
1525
+ submit()
1526
+ return
1527
+ }
1528
+ if (key.backspace || key.delete) {
1529
+ setError(undefined)
1530
+ setDraft(current => [...current].slice(0, -1).join(''))
1531
+ return
1532
+ }
1533
+ if (key.ctrl && input === 'u') {
1534
+ setError(undefined)
1535
+ setDraft('')
1536
+ return
1537
+ }
1538
+ if (key.ctrl || key.meta || input.length === 0) return
1539
+ const next = draft + input
1540
+ if (next.length > 4096) {
1541
+ setError('API key input is too long')
1542
+ return
1543
+ }
1544
+ setError(undefined)
1545
+ setDraft(next)
1546
+ }, true)
1547
+
1548
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1549
+ const keyBudget = Math.max(1, viewport.contentColumns - 4)
1550
+ const bullets = '•'.repeat(Math.min([...draft].length, keyBudget))
1551
+ if (viewport.compact) {
1552
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`API key ${bullets}${busy ? ' saving…' : ' ▏'} · esc back`, viewport.contentColumns))
1553
+ }
1554
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
1555
+ const source = target.credential?.kind === 'facts' && target.credential.configured
1556
+ ? `replaces ${singleLineText(target.credential.source ?? 'stored key')}`
1557
+ : 'new key'
1558
+ const providerRow = createElement(Text, { key: 'provider', wrap: 'truncate-end' }, truncateColumns(` provider ${displayText(identity)}`, viewport.contentColumns))
1559
+ const referenceRow = createElement(Text, { key: 'reference', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` reference ${displayText(target.credentialRef ?? target.suggestedRef)} · ${source}`, viewport.contentColumns))
1560
+ const keyRow = createElement(Text, { key: 'key', color: error === undefined ? inkColor(getPalette().brandBright) : inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` key ${bullets}${busy ? ' saving…' : ' ▏'}`, viewport.contentColumns))
1561
+ const errorRow = error === undefined
1562
+ ? undefined
1563
+ : createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
1564
+ const detailRows = errorRow === undefined ? [providerRow, referenceRow] : [providerRow, errorRow]
1565
+ const primaryRow = viewport.bodyRows === 1 && errorRow !== undefined ? errorRow : keyRow
1566
+ const detailBudget = Math.max(0, viewport.bodyRows - 1)
1567
+ const bodyRows = [
1568
+ ...(detailBudget === 0 ? [] : detailRows.slice(-detailBudget)),
1569
+ ...(viewport.bodyRows === 0 ? [] : [primaryRow]),
1570
+ ]
1571
+ return createElement(
1572
+ Box,
1573
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1574
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — add API key', viewport.contentColumns)),
1575
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1576
+ ...bodyRows,
1577
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1578
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('type or paste key · enter save · ctrl+u clear · esc back', viewport.contentColumns)),
1579
+ )
1580
+ }
1581
+
1582
+ /** Bounded destructive-action confirmation for credential or provider removal. */
1583
+ function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
1584
+ target: ProviderTargetView
1585
+ kind: 'credential' | 'provider'
1586
+ confirm(target: ProviderTargetView): Promise<void>
1587
+ done(): void
1588
+ back(): void
1589
+ }): ReactElement {
1590
+ const stdout = useStdout().stdout
1591
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1592
+ const [busy, setBusy] = useState(false)
1593
+ const [error, setError] = useState<string | undefined>(undefined)
1594
+ const run = (): void => {
1595
+ if (busy) return
1596
+ setBusy(true)
1597
+ setError(undefined)
1598
+ Promise.resolve().then(() => confirm(target)).then(done, (reason: unknown) => {
1599
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
1600
+ setBusy(false)
1601
+ })
1602
+ }
1603
+ useStableInput((input, key) => {
1604
+ if (busy) return
1605
+ if (key.escape || input === 'n') {
1606
+ back()
1607
+ return
1608
+ }
1609
+ if (input === 'y') run()
1610
+ }, true)
1611
+
1612
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1613
+ const action = kind === 'credential' ? 'remove API key' : 'remove provider'
1614
+ if (viewport.compact) {
1615
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${action} ${target.displayName}? · y confirm · n/esc back`, viewport.contentColumns))
1616
+ }
1617
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
1618
+ const identityRow = createElement(Text, { key: 'identity', wrap: 'truncate-end' }, truncateColumns(` ${displayText(identity)}`, viewport.contentColumns))
1619
+ const descriptionRow = createElement(Text, { key: 'description', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(kind === 'credential' ? ' the provider profile and selected model stay available' : ' the user settings profile and its managed key will be removed', viewport.contentColumns))
1620
+ const errorRow = error === undefined
1621
+ ? undefined
1622
+ : createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
1623
+ const bodyRows = errorRow === undefined
1624
+ ? [identityRow, descriptionRow].slice(0, viewport.bodyRows)
1625
+ : [identityRow, errorRow].slice(-viewport.bodyRows)
1626
+ return createElement(
1627
+ Box,
1628
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
1629
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — ${action}`, viewport.contentColumns)),
1630
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1631
+ ...bodyRows,
1632
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1633
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? 'working…' : 'y confirm · n/esc back', viewport.contentColumns)),
1221
1634
  )
1222
1635
  }
1223
1636
 
@@ -1258,16 +1671,19 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1258
1671
  ? []
1259
1672
  : [createElement(
1260
1673
  Text,
1261
- { key: 'commands-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1674
+ { key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1262
1675
  truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
1263
1676
  )]),
1264
1677
  createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
1265
1678
  createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1679
+ createElement(Box, { key: 'local-effort' }, row('/effort', 'adjust reasoning effort for the current model')),
1266
1680
  createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
1681
+ createElement(Box, { key: 'local-permission' }, row('/permission', 'inspect or select the permission preset (/permission [preset])')),
1267
1682
  createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1268
1683
  createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1269
1684
  createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
1270
1685
  createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
1686
+ createElement(Box, { key: 'local-theme' }, row('/theme', 'switch the color theme')),
1271
1687
  createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
1272
1688
  createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1273
1689
  createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
@@ -1288,7 +1704,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1288
1704
  ? []
1289
1705
  : [createElement(
1290
1706
  Text,
1291
- { key: 'skills-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1707
+ { key: 'skills-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1292
1708
  truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns),
1293
1709
  )]),
1294
1710
  ...skills.map(skill => createElement(
@@ -1326,8 +1742,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1326
1742
 
1327
1743
  return createElement(
1328
1744
  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)),
1745
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1746
+ 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
1747
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1332
1748
  ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1333
1749
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1358,6 +1774,59 @@ function editorWindow(value: string, cursor: number, columns: number): { before:
1358
1774
  return { before, caret, after }
1359
1775
  }
1360
1776
 
1777
+ /** The empty-composer placeholder text (shared by the static and wave paths). */
1778
+ const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
1779
+
1780
+ /** One physical cell of the wave-painted composer row: a char plus styles. */
1781
+ interface ComposerCell {
1782
+ char: string
1783
+ color?: string
1784
+ backgroundColor?: string
1785
+ bold?: boolean
1786
+ inverse?: boolean
1787
+ dim?: boolean
1788
+ }
1789
+
1790
+ /** Adjacent cells with identical styling merge into one styled Text span. */
1791
+ function sameCellStyle(a: ComposerCell, b: ComposerCell): boolean {
1792
+ return a.color === b.color
1793
+ && a.backgroundColor === b.backgroundColor
1794
+ && a.bold === b.bold
1795
+ && a.inverse === b.inverse
1796
+ && a.dim === b.dim
1797
+ }
1798
+
1799
+ /**
1800
+ * Render the wave row as one Text whose cells carry per-column
1801
+ * `backgroundColor` runs: the Codex Wave crest paints a smooth gradient
1802
+ * (one SGR run per sampled column) over the prompt, draft, cursor,
1803
+ * placeholder, and the trailing blank fill — the draft stays readable
1804
+ * because the tint blends at ≤ 0.55 toward the theme's blank-cell base.
1805
+ */
1806
+ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
1807
+ const spans: ReactElement[] = []
1808
+ let start = 0
1809
+ while (start < cells.length) {
1810
+ const cell = cells[start]!
1811
+ let end = start + 1
1812
+ while (end < cells.length && sameCellStyle(cells[end]!, cell)) end += 1
1813
+ spans.push(createElement(
1814
+ Text,
1815
+ {
1816
+ key: start,
1817
+ color: cell.color,
1818
+ backgroundColor: cell.backgroundColor,
1819
+ bold: cell.bold,
1820
+ inverse: cell.inverse,
1821
+ dimColor: cell.dim,
1822
+ },
1823
+ cells.slice(start, end).map(c => c.char).join(''),
1824
+ ))
1825
+ start = end
1826
+ }
1827
+ return spans
1828
+ }
1829
+
1361
1830
  /**
1362
1831
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
1363
1832
  * with independent history selection and content scrolling. The complete
@@ -1474,11 +1943,11 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
1474
1943
  width: viewport.outerColumns,
1475
1944
  paddingX: 1,
1476
1945
  borderStyle: 'round',
1477
- borderColor: inkColor(TUI_RGB.brand),
1946
+ borderColor: inkColor(getPalette().brand),
1478
1947
  },
1479
1948
  createElement(
1480
1949
  Text,
1481
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1950
+ { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
1482
1951
  truncateColumns(title, viewport.contentColumns),
1483
1952
  ),
1484
1953
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -1526,9 +1995,14 @@ interface CompletionCandidate {
1526
1995
  * Resolve completion candidates for the current input: TUI-local commands,
1527
1996
  * the live registry descriptors, and user-invocable skills, filtered by the
1528
1997
  * typed prefix. Command names win collisions (the dispatch tries the
1529
- * registry first and only then falls through to the skill gesture).
1998
+ * registry first and only then falls through to the skill gesture), and a
1999
+ * later duplicate name never renders twice.
2000
+ *
2001
+ * A bare `/` returns the FULL merged list — Codex's command popup shows every
2002
+ * command inside a scroll window on an empty filter, and the menu's own
2003
+ * selection window bounds the visible rows, so no slice cap is needed.
1530
2004
  */
1531
- function completionCandidates(
2005
+ export function completionCandidates(
1532
2006
  value: string,
1533
2007
  descriptors: readonly CommandDescriptor[],
1534
2008
  skills: readonly SkillRow[],
@@ -1538,20 +2012,23 @@ function completionCandidates(
1538
2012
  const local: CompletionCandidate[] = [
1539
2013
  { label: '/help', description: 'show commands', origin: 'command' },
1540
2014
  { label: '/model', description: 'switch the model', origin: 'command' },
2015
+ { label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
1541
2016
  { label: '/mode', description: 'select the agent preset', origin: 'command' },
2017
+ { label: '/permission', description: 'inspect or select the permission preset', origin: 'command' },
1542
2018
  { label: '/new', description: 'start a fresh session', origin: 'command' },
1543
2019
  { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
1544
2020
  { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
1545
2021
  { label: '/statusline', description: 'customize the status line', origin: 'command' },
2022
+ { label: '/theme', description: 'switch the color theme', origin: 'command' },
1546
2023
  { label: '/history', description: 'search and recall past prompts', origin: 'command' },
1547
2024
  { label: '/clear', description: 'clear the screen', origin: 'command' },
1548
2025
  { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1549
2026
  { label: '/title', description: 'rename this session', origin: 'command' },
1550
2027
  { label: '/quit', description: 'exit', origin: 'command' },
1551
2028
  ]
1552
- // Local commands shadow registry names (e.g. the plugin-registered
1553
- // /permission is served by the registry itself, never duplicated here),
1554
- // so collisions cannot render two rows with the same key.
2029
+ // Local commands shadow registry names (e.g. the TUI-local /permission works
2030
+ // before any session exists, while the registry child needs one), so
2031
+ // collisions cannot render two rows with the same key.
1555
2032
  const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1556
2033
  const registry = descriptors
1557
2034
  .filter(descriptor => !localNames.has(descriptor.name))
@@ -1568,12 +2045,19 @@ function completionCandidates(
1568
2045
  description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
1569
2046
  origin: 'skill',
1570
2047
  }))
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)
2048
+ // One row per name, first occurrence wins: local before registry before
2049
+ // skills, which is exactly the shadowing precedence above (defensive
2050
+ // against duplicate registry names across scopes).
2051
+ const seen = new Set<string>()
2052
+ const all: CompletionCandidate[] = []
2053
+ for (const candidate of [...local, ...registry, ...skillRows]) {
2054
+ const name = candidate.label.slice(1)
2055
+ if (seen.has(name)) continue
2056
+ seen.add(name)
2057
+ all.push(candidate)
2058
+ }
2059
+ if (prefix === '') return all
2060
+ return all.filter(candidate => candidate.label.slice(1).startsWith(prefix))
1577
2061
  }
1578
2062
 
1579
2063
  /**
@@ -1607,6 +2091,7 @@ function CompletionMenu({ active, mention, index, rows }: {
1607
2091
  const selected = rows.length === 0 ? 0 : index % rows.length
1608
2092
  const first = selectionWindow(selected, rows.length, limit)
1609
2093
  const visible = rows.slice(first, first + limit)
2094
+ const hidden = rows.length - visible.length
1610
2095
  return createElement(
1611
2096
  Box,
1612
2097
  { flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
@@ -1618,13 +2103,17 @@ function CompletionMenu({ active, mention, index, rows }: {
1618
2103
  Text,
1619
2104
  {
1620
2105
  key: candidate.label,
1621
- color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
2106
+ color: absolute === selected ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
1622
2107
  wrap: 'truncate-end',
1623
2108
  },
1624
2109
  `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1625
2110
  )
1626
2111
  })),
1627
- showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
2112
+ // Scroll affordance: with the full merged catalog (commands + registry +
2113
+ // skills) the six-row window rarely shows the tail — count and hint keep
2114
+ // the rest discoverable without inflating the menu budget.
2115
+ hidden > 0 ? createElement(Text, { key: 'more', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, dim(` … +${hidden} more`)) : undefined,
2116
+ 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
2117
  )
1629
2118
  }
1630
2119
 
@@ -1634,7 +2123,7 @@ function CompletionMenu({ active, mention, index, rows }: {
1634
2123
  * While a modal (approval / question / model panel) owns the keys, the
1635
2124
  * box passes every key through untouched.
1636
2125
  */
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 }: {
2126
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, 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, waveTier, waveStyle }: {
1638
2127
  active: boolean
1639
2128
  frozen: boolean
1640
2129
  busy: boolean
@@ -1645,11 +2134,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1645
2134
  interrupt(): boolean
1646
2135
  quit(): void
1647
2136
  openModel(): void
2137
+ openEffort(): void
1648
2138
  openHelp(): void
1649
2139
  openMode(): void
2140
+ openPermission(): void
1650
2141
  openResume(): void
1651
2142
  openPlugin(query?: string): void
1652
2143
  openStatusline(): void
2144
+ openTheme(): void
1653
2145
  openHistory(): void
1654
2146
  createSession(mode?: string): void
1655
2147
  cancelSessionSwitch(): boolean
@@ -1678,6 +2170,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1678
2170
  historyFill: { text: string; index: number } | undefined
1679
2171
  /** Marks the accepted entry consumed (called after the fill is applied). */
1680
2172
  historyConsumed(): void
2173
+ /** DeepSeek easter-egg wave tier of the applied official DeepSeek model
2174
+ * (null otherwise): drives the persistent prompt glyph/accent and the
2175
+ * sparkle tier. */
2176
+ waveTier: DeepseekWaveTier | null
2177
+ /** The ignition style running, if any: Wave / Aurora / Pulse. */
2178
+ waveStyle: DeepseekWaveStyle | null
1681
2179
  }): ReactElement {
1682
2180
  const columns = useStdout().stdout?.columns ?? 80
1683
2181
  const [value, setValue] = useState('')
@@ -1915,6 +2413,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1915
2413
  openModel()
1916
2414
  return
1917
2415
  }
2416
+ if (text === '/effort' || text.startsWith('/effort ')) {
2417
+ openEffort()
2418
+ return
2419
+ }
2420
+ if (text === '/permission') {
2421
+ openPermission()
2422
+ return
2423
+ }
2424
+ if (text.startsWith('/permission ')) {
2425
+ dispatch(text)
2426
+ return
2427
+ }
1918
2428
  if (text === '/mode' || text.startsWith('/mode ')) {
1919
2429
  const mode = text.slice(5).trim()
1920
2430
  if (mode === '') openMode()
@@ -1943,6 +2453,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1943
2453
  openStatusline()
1944
2454
  return
1945
2455
  }
2456
+ if (text === '/theme') {
2457
+ openTheme()
2458
+ return
2459
+ }
1946
2460
  if (text === '/history') {
1947
2461
  openHistory()
1948
2462
  return
@@ -2075,69 +2589,407 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2075
2589
  }
2076
2590
  })
2077
2591
 
2592
+ // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
2593
+ // the interval re-renders only the composer row at 30fps, never the whole
2594
+ // tree. App drives the tier/style pair on a model switch; this local effect
2595
+ // starts the sweep whenever that pair changes (App picks a NEW random style
2596
+ // for every replay — including effort changes on the same route — so the
2597
+ // pair always differs when a new wave should run) and stops it when the
2598
+ // model leaves the official DeepSeek route (tier becomes null).
2599
+ const [waveTick, setWaveTick] = useState<number | null>(null)
2600
+ const wavePrevious = useRef<{ tier: DeepseekWaveTier | null; style: DeepseekWaveStyle | null }>({ tier: null, style: null })
2601
+ useEffect(() => {
2602
+ const previous = wavePrevious.current
2603
+ wavePrevious.current = { tier: waveTier, style: waveStyle }
2604
+ if (waveTier === null) {
2605
+ setWaveTick(null)
2606
+ return
2607
+ }
2608
+ if (previous.tier !== waveTier || previous.style !== waveStyle) {
2609
+ setWaveTick(0)
2610
+ }
2611
+ }, [waveTier, waveStyle])
2612
+ const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
2613
+ && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
2614
+ useEffect(() => {
2615
+ if (!waveActive) return
2616
+ const id = setInterval(() => {
2617
+ setWaveTick(current => (current === null ? 0 : current + 1))
2618
+ }, DEEPSEEK_WAVE_TICK_MS)
2619
+ return () => {
2620
+ clearInterval(id)
2621
+ }
2622
+ }, [waveActive])
2623
+ useEffect(() => {
2624
+ if (waveTick !== null && waveTier !== null && waveStyle !== null
2625
+ && waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
2626
+ }, [waveTick, waveTier, waveStyle])
2627
+
2078
2628
  // Every exclusive panel keeps the composer as a stable visual anchor, but
2079
2629
  // freezes it to one row: no menu, multiline wrap, or animation.
2630
+ const tierActive = waveTier !== null
2631
+ const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
2632
+ const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
2633
+ const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
2080
2634
  if (frozen) {
2081
2635
  const frozen = value === ''
2082
2636
  ? 'type a message'
2083
2637
  : verboseLine(value, Math.max(1, columns - 6))
2084
2638
  return createElement(
2085
2639
  Box,
2086
- { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
2640
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
2087
2641
  createElement(
2088
2642
  Text,
2089
2643
  { wrap: 'truncate-end' },
2090
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
2644
+ createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
2091
2645
  frozen,
2092
2646
  ),
2093
2647
  )
2094
2648
  }
2095
2649
 
2650
+ // The bordered frame: static dim at rest; while the wave runs the border
2651
+ // breathes with the sweep (dim blends toward the tier accent and back), so
2652
+ // the frame glows up while the crest crosses the row.
2653
+ const frame = (row: ReactElement, borderRgb?: RgbTriple): ReactElement => createElement(
2654
+ Box,
2655
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(borderRgb ?? getPalette().dim), paddingX: 1 },
2656
+ row,
2657
+ )
2658
+ const menu = createElement(CompletionMenu, {
2659
+ active: menuActive,
2660
+ mention: mentionActive,
2661
+ index: completionIndex,
2662
+ rows: menuRows,
2663
+ })
2664
+
2665
+ // Static row (idle, busy, or after the wave): prompt + editor window. The
2666
+ // prompt marker keeps the tier accent while an official DeepSeek model is
2667
+ // applied, restoring the static brand ❯ on any other route.
2096
2668
  const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
2669
+ const staticRow = createElement(
2670
+ Text,
2671
+ { wrap: 'truncate-end' },
2672
+ busy
2673
+ ? createElement(BusyChase)
2674
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `),
2675
+ value === '' ? undefined : editor.before,
2676
+ createElement(CursorBlock, { char: editor.caret }),
2677
+ value === '' && !busy
2678
+ ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
2679
+ : editor.after,
2680
+ )
2681
+
2682
+ // Wave row: the input row assembled column by column, each cell carrying
2683
+ // the sampled wave `backgroundColor` (null outside the crest → transparent),
2684
+ // so the crest sweeps the FULL content row — prompt, draft, cursor,
2685
+ // placeholder, and the trailing blank fill. The deepseek tier drops the
2686
+ // `· ✦ ✧` sparkles into the rightmost blank cell from 900ms on.
2687
+ const waveRow = (): ReactElement => {
2688
+ const contentWidth = Math.max(1, columns - 5)
2689
+ const waveEditor = editorWindow(value, cursor, Math.max(1, contentWidth - 2))
2690
+ const hues = deepseekWaveHues(waveTier!)
2691
+ const style = waveStyle!
2692
+ const base = getTheme() === 'light' ? WAVE_BASE_LIGHT : WAVE_BASE_DARK
2693
+ const waveBg = (column: number): string | undefined => {
2694
+ const rgb = deepseekWaveColumnBg(waveTick!, column, contentWidth, waveTier!, style, hues, base)
2695
+ return rgb === null ? undefined : inkColor(rgb)
2696
+ }
2697
+ const cells: ComposerCell[] = []
2698
+ cells.push({ char: promptGlyph, color: promptColor, bold: true, backgroundColor: waveBg(0) })
2699
+ cells.push({ char: ' ', color: promptColor, backgroundColor: waveBg(1) })
2700
+ for (const char of waveEditor.before) {
2701
+ cells.push({ char, backgroundColor: waveBg(cells.length) })
2702
+ }
2703
+ cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(cells.length) })
2704
+ if (value === '' && !busy) {
2705
+ for (let at = 0; at < COMPOSER_PLACEHOLDER.length; at += 1) {
2706
+ cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(cells.length) })
2707
+ }
2708
+ } else {
2709
+ for (const char of waveEditor.after) {
2710
+ cells.push({ char, backgroundColor: waveBg(cells.length) })
2711
+ }
2712
+ }
2713
+ while (cells.length < contentWidth) {
2714
+ cells.push({ char: ' ', backgroundColor: waveBg(cells.length) })
2715
+ }
2716
+ // The brand wordmark rides the wave's middle: `deepseek` in the tier's
2717
+ // cycled hues, placed in the row's mid-section and only over blank or
2718
+ // placeholder cells — real draft text is never covered.
2719
+ if (deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
2720
+ const word = 'deepseek'
2721
+ const start = Math.max(2, Math.floor((contentWidth - word.length) / 2))
2722
+ let clear = true
2723
+ for (let at = 0; at < word.length; at += 1) {
2724
+ const cell = cells[start + at]
2725
+ if (cell === undefined || (cell.char !== ' ' && cell.dim !== true)) { clear = false; break }
2726
+ }
2727
+ if (clear) {
2728
+ for (let at = 0; at < word.length; at += 1) {
2729
+ const cell = cells[start + at]!
2730
+ cell.char = word[at]!
2731
+ cell.color = inkColor(deepseekWaveWordHue(at, hues))
2732
+ cell.bold = true
2733
+ cell.dim = false
2734
+ }
2735
+ }
2736
+ }
2737
+ // The tail sparkles belong to the Wave style's deepseek tier only
2738
+ // (Codex paints spark_frame on Wave+Ultra).
2739
+ if (waveTier === 'deepseek' && style === 'wave') {
2740
+ const spark = deepseekWaveSpark(waveTick!)
2741
+ if (spark !== null) {
2742
+ const last = cells[cells.length - 1]
2743
+ if (last !== undefined && last.char === ' ') {
2744
+ last.char = spark
2745
+ last.color = promptColor
2746
+ last.bold = true
2747
+ last.dim = false
2748
+ }
2749
+ }
2750
+ }
2751
+ const borderRgb = deepseekWaveBorderColor(waveTick!, waveTier!, style, hues, getPalette().dim)
2752
+ return frame(createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)), borderRgb)
2753
+ }
2097
2754
 
2098
2755
  return createElement(
2099
2756
  Box,
2100
2757
  { 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
- ),
2758
+ menu,
2759
+ waveTick !== null && waveTier !== null && !busy ? waveRow() : frame(staticRow),
2129
2760
  )
2130
2761
  }
2131
2762
 
2763
+ /** One cached settled row: the row Box plus its roomy-prompt spacers. */
2764
+ interface SettledRowRecord {
2765
+ /** The row Box element (keyed by the entry's settled index). */
2766
+ box: ReactElement
2767
+ /** The roomy-prompt spacer BEFORE the row, or undefined. */
2768
+ before: ReactElement | undefined
2769
+ /** The roomy-prompt spacer AFTER the row, or undefined. */
2770
+ after: ReactElement | undefined
2771
+ /** Whether the row's text depends on the reasoning toggle (Ctrl+R). */
2772
+ reasonSensitive: boolean
2773
+ /** The toggle state the row was built with. */
2774
+ showReasoning: boolean
2775
+ }
2776
+
2777
+ /** The incremental settled-history cache (see `computeSettledRows`). */
2778
+ interface SettledRowsCache {
2779
+ /** The exact settled entries the cache covers (`view.entries[0..entries.length)`). */
2780
+ entries: TranscriptEntry[]
2781
+ /** Records keyed by entry identity; mutated in place so the append path
2782
+ * never copies the whole map. */
2783
+ records: Map<TranscriptEntry, SettledRowRecord>
2784
+ /** The header element (depends only on `resumed`). */
2785
+ header: ReactElement
2786
+ /** The `resumed` the header was built with. */
2787
+ resumed: boolean
2788
+ /** The toggle state the rows were built with. */
2789
+ showReasoning: boolean
2790
+ /** The refreshEpoch the rows were built for; a bump forces a full rebuild. */
2791
+ epoch: number
2792
+ /** The flat row list (header + per-entry before/box/after). */
2793
+ flat: ReactElement[]
2794
+ }
2795
+
2796
+ /** One step of `computeSettledRows`. */
2797
+ interface SettledRowsResult {
2798
+ cache: SettledRowsCache
2799
+ /** How many rows had to be BUILT by this step (0 = pure reuse). */
2800
+ built: number
2801
+ }
2802
+
2803
+ /** Build one settled row (row Box plus its roomy-prompt spacers). */
2804
+ function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean): SettledRowRecord {
2805
+ const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
2806
+ const roomyPrompt = entry.kind === 'user' && !entry.notice
2807
+ return {
2808
+ box: createElement(Box, { key: index, paddingX: 1 }, row),
2809
+ before: roomyPrompt
2810
+ ? createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
2811
+ : undefined,
2812
+ after: roomyPrompt
2813
+ ? createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
2814
+ : undefined,
2815
+ reasonSensitive: entry.kind === 'assistant' && entry.reasoning !== '',
2816
+ showReasoning,
2817
+ }
2818
+ }
2819
+
2820
+ /**
2821
+ * The settled `<Static>` row set as a PURE incremental state machine (App
2822
+ * drives it from the memo; tests drive it directly and read `built`).
2823
+ *
2824
+ * The settled prefix is permanently final: the projection only APPENDS below
2825
+ * the flush boundary, removes pending rows at or beyond it, and replaces
2826
+ * running tool/retry/command rows there too. So extending the cache never
2827
+ * rescans the old prefix — a grown boundary builds ONLY the newly settled
2828
+ * suffix and reuses every cached element, letting React bail out of unchanged
2829
+ * rows and keeping long histories out of the per-durable-event path (no O(N)
2830
+ * rebuild of rows, Map, or MarkdownBody parses). `records` is mutated in place
2831
+ * on the append/toggle paths to stay O(delta).
2832
+ *
2833
+ * Full rebuilds run only on the rare, deliberate paths: no cache yet, a
2834
+ * source-backed replay (`epoch` bump: resize / Ctrl+L / Ctrl+R remounts
2835
+ * `<Static>` and must re-flush the CURRENT rows), a `resumed` change, or a shrink
2836
+ * (`store.reset`). A reasoning toggle rebuilds only the rows whose text
2837
+ * depends on it, preserving the other rows' element identity.
2838
+ */
2839
+ export function computeSettledRows(
2840
+ previous: SettledRowsCache | undefined,
2841
+ entries: readonly TranscriptEntry[],
2842
+ settled: number,
2843
+ showReasoning: boolean,
2844
+ resumed: boolean,
2845
+ epoch: number,
2846
+ ): SettledRowsResult {
2847
+ if (previous === undefined || previous.epoch !== epoch || previous.resumed !== resumed
2848
+ || settled < previous.entries.length) {
2849
+ // Full rebuild from the current settled prefix.
2850
+ const records = new Map<TranscriptEntry, SettledRowRecord>()
2851
+ const flat: ReactElement[] = [createElement(Header, { key: 'header', resumed })]
2852
+ for (let index = 0; index < settled; index++) {
2853
+ const entry = entries[index]
2854
+ const record = buildSettledRow(entry, index, showReasoning)
2855
+ records.set(entry, record)
2856
+ if (record.before !== undefined) flat.push(record.before)
2857
+ flat.push(record.box)
2858
+ if (record.after !== undefined) flat.push(record.after)
2859
+ }
2860
+ return {
2861
+ cache: { entries: entries.slice(0, settled), records, header: flat[0]!, resumed, showReasoning, epoch, flat },
2862
+ built: settled,
2863
+ }
2864
+ }
2865
+ if (previous.showReasoning !== showReasoning) {
2866
+ // Reasoning toggle: only rows whose text depends on it rebuild; spacers
2867
+ // and the other rows keep their element identity.
2868
+ const records = previous.records
2869
+ const flat: ReactElement[] = [previous.header]
2870
+ let built = 0
2871
+ for (let index = 0; index < previous.entries.length; index++) {
2872
+ const entry = previous.entries[index]
2873
+ const record = records.get(entry)!
2874
+ const current = record.reasonSensitive
2875
+ ? {
2876
+ ...record,
2877
+ box: createElement(Box, { key: index, paddingX: 1 }, createElement(EntryLine, { entry, showReasoning, verbose: false })),
2878
+ showReasoning,
2879
+ }
2880
+ : record
2881
+ if (current !== record) {
2882
+ records.set(entry, current)
2883
+ built += 1
2884
+ }
2885
+ if (current.before !== undefined) flat.push(current.before)
2886
+ flat.push(current.box)
2887
+ if (current.after !== undefined) flat.push(current.after)
2888
+ }
2889
+ return { cache: { ...previous, records, showReasoning, flat }, built }
2890
+ }
2891
+ if (settled === previous.entries.length) {
2892
+ // Nothing below the boundary changed (a pending retirement above it, a
2893
+ // tool/result at the boundary): keep the SAME flat identity so the
2894
+ // memoized <Static> subtree does not re-render at all.
2895
+ return { cache: previous, built: 0 }
2896
+ }
2897
+ // The boundary grew: build ONLY the newly settled suffix.
2898
+ const records = previous.records
2899
+ const suffix: TranscriptEntry[] = []
2900
+ const added: ReactElement[] = []
2901
+ for (let index = previous.entries.length; index < settled; index++) {
2902
+ const entry = entries[index]
2903
+ const record = buildSettledRow(entry, index, showReasoning)
2904
+ records.set(entry, record)
2905
+ suffix.push(entry)
2906
+ if (record.before !== undefined) added.push(record.before)
2907
+ added.push(record.box)
2908
+ if (record.after !== undefined) added.push(record.after)
2909
+ }
2910
+ return {
2911
+ cache: {
2912
+ entries: previous.entries.concat(suffix),
2913
+ records,
2914
+ header: previous.header,
2915
+ resumed: previous.resumed,
2916
+ showReasoning,
2917
+ epoch: previous.epoch,
2918
+ flat: previous.flat.concat(added),
2919
+ },
2920
+ built: settled - previous.entries.length,
2921
+ }
2922
+ }
2923
+
2132
2924
  /** The whole terminal app; state arrives via the store, output via Ink. */
2133
2925
  export function App(props: AppProps): ReactElement {
2134
2926
  const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
2135
- const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
2136
- const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
2927
+ // getSnapshot must be a STABLE reference (the React contract): an inline
2928
+ // arrow here re-subscribes the store hook on every render and cascades
2929
+ // force-updates — during a fast reasoning stream that chain crossed React's
2930
+ // nested-passive-update limit and flooded "Maximum update depth exceeded"
2931
+ // warnings. The view objects are process-stable, so one callback per view
2932
+ // identity is enough.
2933
+ // getSnapshot should be a stable reference (the React contract): an inline
2934
+ // arrow re-subscribes the store hook on every render and forces the uETS
2935
+ // consistency check to re-run per commit. The view objects are
2936
+ // process-stable, so one callback per view identity is enough.
2937
+ const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands])
2938
+ const readSkills = useCallback(() => props.skills.rows, [props.skills])
2939
+ const descriptors = useSyncExternalStore(props.commands.subscribe, readDescriptors)
2940
+ const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
2137
2941
  const [modelLabel, setModelLabel] = useState(props.model)
2138
2942
  const [modelOpen, setModelOpen] = useState(false)
2943
+ /** Nested /model stages; only one owns terminal input at a time. */
2944
+ const [providerOpen, setProviderOpen] = useState(false)
2945
+ const [providerAction, setProviderAction] = useState<{
2946
+ kind: 'credential' | 'unset' | 'remove'
2947
+ target: ProviderTargetView
2948
+ } | undefined>(undefined)
2949
+ /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
2950
+ const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
2951
+ /** Effective reasoning effort, shown in the /model picker and switch notice. */
2952
+ const [effortLabel, setEffortLabel] = useState<string | undefined>(props.effort)
2953
+ /** DeepSeek easter egg: switching INTO an official DeepSeek route plays
2954
+ * one of Codex's three ignition styles (Wave / Aurora / Pulse, picked at
2955
+ * random without repeating) across the composer's padded band (33ms tick,
2956
+ * per-style durations), then the band returns to static while the prompt
2957
+ * marker keeps the tier accent. The trigger follows the applied model
2958
+ * label (what the status bar actually shows), never the initial paint,
2959
+ * and the tier is derived from the label and cached at the switch. The
2960
+ * 33ms tick itself lives inside Input, so the sweep re-renders only the
2961
+ * composer row, not the whole tree, at 30fps; App owns the rarely-changing
2962
+ * tier/style and Input starts the sweep whenever that pair changes. */
2963
+ const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
2964
+ const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
2965
+ const previousModel = useRef<string | undefined>(undefined)
2966
+ const previousEffort = useRef<string | undefined>(props.effort)
2967
+ const previousStyle = useRef<DeepseekWaveStyle | undefined>(undefined)
2968
+ useEffect(() => {
2969
+ const previous = previousModel.current
2970
+ previousModel.current = modelLabel
2971
+ // The wave replays when the applied model changes OR its effort level
2972
+ // changes on the same official DeepSeek route (Codex replays the
2973
+ // ignition on effort changes too).
2974
+ const effortChanged = previousEffort.current !== effortLabel
2975
+ previousEffort.current = effortLabel
2976
+ const modelChanged = previous !== undefined && previous !== modelLabel
2977
+ if (!isOfficialDeepSeekLabel(modelLabel)) {
2978
+ setWaveTier(null)
2979
+ setWaveStyle(null)
2980
+ return
2981
+ }
2982
+ if (modelChanged || effortChanged) {
2983
+ setWaveTier(deepseekWaveTier(modelLabel))
2984
+ const nextStyle = deepseekWaveStyleRandom(previousStyle.current)
2985
+ previousStyle.current = nextStyle
2986
+ setWaveStyle(nextStyle)
2987
+ }
2988
+ }, [modelLabel, effortLabel])
2139
2989
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
2140
2990
  const [modelError, setModelError] = useState<string | undefined>(undefined)
2991
+ const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
2992
+ const [providerError, setProviderError] = useState<string | undefined>(undefined)
2141
2993
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
2142
2994
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
2143
2995
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
@@ -2164,17 +3016,42 @@ export function App(props: AppProps): ReactElement {
2164
3016
  cancelled = true
2165
3017
  }
2166
3018
  }, [modelOpen, modelLoadEpoch, props.loadModels])
3019
+ useEffect(() => {
3020
+ if (!modelOpen || props.loadModelProviders === undefined) return
3021
+ let cancelled = false
3022
+ setProviderDirectory(undefined)
3023
+ setProviderError(undefined)
3024
+ Promise.resolve().then(() => props.loadModelProviders!()).then((loaded) => {
3025
+ if (!cancelled) setProviderDirectory(loaded)
3026
+ }, (error: unknown) => {
3027
+ if (!cancelled) setProviderError(error instanceof Error ? error.message : String(error))
3028
+ })
3029
+ return () => {
3030
+ cancelled = true
3031
+ }
3032
+ }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
3033
+ useEffect(() => {
3034
+ const subscribe = props.subscribeModelProviders
3035
+ if (!modelOpen || subscribe === undefined) return
3036
+ try {
3037
+ return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
3038
+ } catch (error: unknown) {
3039
+ setProviderError(error instanceof Error ? error.message : String(error))
3040
+ }
3041
+ }, [modelOpen, props.subscribeModelProviders])
2167
3042
 
2168
3043
  const busy = view.busy
2169
3044
  const [showReasoning, setShowReasoning] = useState(false)
2170
3045
  const [verboseOpen, setVerboseOpen] = useState(false)
2171
3046
  const [helpOpen, setHelpOpen] = useState(false)
2172
3047
  const [modeOpen, setModeOpen] = useState(false)
3048
+ const [permissionOpen, setPermissionOpen] = useState(false)
2173
3049
  const [resumeOpen, setResumeOpen] = useState(false)
2174
3050
  const [pluginOpen, setPluginOpen] = useState(false)
2175
3051
  const [pluginQuery, setPluginQuery] = useState('')
2176
3052
  const [statuslineOpen, setStatuslineOpen] = useState(false)
2177
3053
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
3054
+ const [themeOpen, setThemeOpen] = useState(false)
2178
3055
  const [historyOpen, setHistoryOpen] = useState(false)
2179
3056
  /** The /history panel's accepted entry: text plus its recall-space index. */
2180
3057
  const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
@@ -2191,62 +3068,79 @@ export function App(props: AppProps): ReactElement {
2191
3068
  const historyConsumed = useCallback((): void => {
2192
3069
  setHistoryFill(undefined)
2193
3070
  }, [])
2194
- /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). */
2195
- const queuedRows = useMemo(
2196
- () => view.entries.filter((entry): entry is Extract<TranscriptEntry, { kind: 'pending' }> => entry.kind === 'pending'),
2197
- [view.entries],
2198
- )
3071
+ /** The append-only flush boundary (see `settledEntryCount`): entries below
3072
+ * this index are final and ride the `<Static>` scrollback; everything at or
3073
+ * beyond stays in the live tree. Pending inbox rows always live at
3074
+ * index >= settled, so the queued-inbox scan below only walks the mutable
3075
+ * tail instead of the whole history. */
3076
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
3077
+ /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). The
3078
+ * projection only appends and removes pending rows at index >= settled, so
3079
+ * a bounded tail scan replaces an unconditional O(history) filter on every
3080
+ * event. */
3081
+ const queuedRows = useMemo(() => {
3082
+ const rows: Array<Extract<TranscriptEntry, { kind: 'pending' }>> = []
3083
+ for (let index = settled; index < view.entries.length; index++) {
3084
+ const entry = view.entries[index]
3085
+ if (entry.kind === 'pending') rows.push(entry)
3086
+ }
3087
+ return rows
3088
+ }, [view.entries, settled])
2199
3089
  const [refreshEpoch, setRefreshEpoch] = useState(0)
2200
3090
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
2201
3091
  const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
2202
3092
  const approvalPending = approvalSnapshot.pending !== undefined
2203
3093
  const questionPending = questionSnapshot.pending !== undefined
2204
3094
  // 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
3095
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2206
3096
 
2207
3097
  // Human questions outrank local inspectors. Close the lower modal instead
2208
3098
  // of leaving an approval/question visible but keyboard-locked behind it.
2209
3099
  useEffect(() => {
2210
3100
  if (!approvalPending && !questionPending) return
2211
3101
  setModelOpen(false)
3102
+ setProviderOpen(false)
3103
+ setProviderAction(undefined)
3104
+ setEffortFor(undefined)
2212
3105
  setHelpOpen(false)
2213
3106
  setModeOpen(false)
3107
+ setPermissionOpen(false)
2214
3108
  setResumeOpen(false)
2215
3109
  setPluginOpen(false)
2216
3110
  setStatuslineOpen(false)
3111
+ setThemeOpen(false)
2217
3112
  setHistoryOpen(false)
2218
3113
  setVerboseOpen(false)
2219
3114
  }, [approvalPending, questionPending])
2220
3115
 
2221
3116
  // Append-only transcript: everything up to the first still-mutable entry
2222
- // (a running tool/retry) flushes through Ink's `<Static>` into native
3117
+ // (a running tool/retry/command) flushes through Ink's `<Static>` into native
2223
3118
  // scrollback and is normally never rewritten — the Claude-Code stability
2224
- // contract
2225
- // that lets arbitrarily long conversations scroll instead of freezing when
2226
- // the live tree exceeds the terminal height. The dynamic region below stays
2227
- // small: the streaming tail, modals, composer, and its status footer.
2228
- // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
2229
- // keeps long settled histories out of the per-token render path.
2230
- const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
2231
- // Claude-Code spacing: one blank row before each user prompt (except the
2232
- // first) separates replies from the next turn. Settled rows flush once with
2233
- // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
2234
- // Ctrl+O browses the frozen history through a bounded selected-entry view.
3119
+ // contract that lets arbitrarily long conversations scroll instead of
3120
+ // freezing when the live tree exceeds the terminal height. The dynamic
3121
+ // region below stays small: the streaming tail, modals, composer, and its
3122
+ // status footer. `assistant/chunk` preserves `entries` identity.
3123
+ //
3124
+ // `computeSettledRows` extends the cached row set incrementally: the
3125
+ // settled prefix is permanently final, so a grown boundary builds ONLY the
3126
+ // newly settled suffix and reuses every cached element long histories
3127
+ // stop re-creating rows (and re-parsing MarkdownBody) on every durable
3128
+ // event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L /
3129
+ // Ctrl+R remounts `<Static>`) rebuilds the CURRENT row set from index 0,
3130
+ // so the replay stays complete and never ghosts a pending/running tail.
3131
+ const settledRowsCache = useRef<SettledRowsCache | undefined>(undefined)
2235
3132
  const settledRows = useMemo(() => {
2236
- const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
2237
- view.entries.slice(0, settled).forEach((entry, index) => {
2238
- const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
2239
- const roomyPrompt = entry.kind === 'user' && !entry.notice
2240
- if (roomyPrompt) {
2241
- rows.push(createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
2242
- }
2243
- rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
2244
- if (roomyPrompt) {
2245
- rows.push(createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
2246
- }
2247
- })
2248
- return rows
2249
- }, [view.entries, settled, showReasoning, props.resumed])
3133
+ const result = computeSettledRows(
3134
+ settledRowsCache.current,
3135
+ view.entries,
3136
+ settled,
3137
+ showReasoning,
3138
+ props.resumed,
3139
+ refreshEpoch,
3140
+ )
3141
+ settledRowsCache.current = result.cache
3142
+ return result.cache.flat
3143
+ }, [view.entries, settled, showReasoning, props.resumed, refreshEpoch])
2250
3144
 
2251
3145
  // Hook order is unconditional. Its dimensions drive every live-region
2252
3146
  // budget before any dynamic rows are constructed.
@@ -2319,9 +3213,9 @@ export function App(props: AppProps): ReactElement {
2319
3213
  ? Math.max(1, Math.floor(streamRows / 3))
2320
3214
  : 1
2321
3215
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2322
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
3216
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2323
3217
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2324
- const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || historyOpen || inspectorVisible || approvalPending || questionPending
3218
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
2325
3219
  const closeInspector = useCallback((): void => {
2326
3220
  setVerboseOpen(false)
2327
3221
  }, [])
@@ -2330,6 +3224,133 @@ export function App(props: AppProps): ReactElement {
2330
3224
  setRefreshEpoch(epoch => epoch + 1)
2331
3225
  }
2332
3226
 
3227
+ /** Apply one /model pick: record the selection, close the panel, report via notice. */
3228
+ const applyModel = (row: ModelRow, effortId: string | undefined): void => {
3229
+ try {
3230
+ const label = props.selectModel(row, effortId)
3231
+ setModelLabel(label)
3232
+ setEffortLabel(effortId)
3233
+ notify(`model → next step uses ${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`)
3234
+ setModelOpen(false)
3235
+ setProviderOpen(false)
3236
+ setProviderAction(undefined)
3237
+ setEffortFor(undefined)
3238
+ } catch (error: unknown) {
3239
+ notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
3240
+ }
3241
+ }
3242
+
3243
+ const reloadModelSurfaces = (): void => {
3244
+ setModelLoadEpoch(epoch => epoch + 1)
3245
+ }
3246
+ const closeModelSurface = (): void => {
3247
+ setModelOpen(false)
3248
+ setProviderOpen(false)
3249
+ setProviderAction(undefined)
3250
+ setEffortFor(undefined)
3251
+ }
3252
+ let modelSurface: ReactElement | undefined
3253
+ if (modelOpen && !approvalPending && !questionPending) {
3254
+ if (providerAction?.kind === 'credential' && props.saveModelProviderCredential !== undefined) {
3255
+ modelSurface = createElement(ProviderCredentialPanel, {
3256
+ target: providerAction.target,
3257
+ save: props.saveModelProviderCredential,
3258
+ done: () => {
3259
+ const target = providerAction.target
3260
+ setProviderAction(undefined)
3261
+ setProviderOpen(false)
3262
+ reloadModelSurfaces()
3263
+ notify(`API key saved for ${target.displayName}; select a model`)
3264
+ },
3265
+ back: () => setProviderAction(undefined),
3266
+ })
3267
+ } else if (providerAction?.kind === 'unset' && props.unsetModelProviderCredential !== undefined) {
3268
+ modelSurface = createElement(ProviderConfirmPanel, {
3269
+ target: providerAction.target,
3270
+ kind: 'credential',
3271
+ confirm: props.unsetModelProviderCredential,
3272
+ done: () => {
3273
+ const target = providerAction.target
3274
+ setProviderAction(undefined)
3275
+ setProviderOpen(true)
3276
+ reloadModelSurfaces()
3277
+ notify(`API key removed for ${target.displayName}`)
3278
+ },
3279
+ back: () => setProviderAction(undefined),
3280
+ })
3281
+ } else if (providerAction?.kind === 'remove' && props.removeModelProvider !== undefined) {
3282
+ modelSurface = createElement(ProviderConfirmPanel, {
3283
+ target: providerAction.target,
3284
+ kind: 'provider',
3285
+ confirm: props.removeModelProvider,
3286
+ done: () => {
3287
+ const target = providerAction.target
3288
+ setProviderAction(undefined)
3289
+ setProviderOpen(true)
3290
+ reloadModelSurfaces()
3291
+ notify(`provider removed: ${target.displayName}`)
3292
+ },
3293
+ back: () => setProviderAction(undefined),
3294
+ })
3295
+ } else if (providerOpen) {
3296
+ modelSurface = createElement(ProviderPanel, {
3297
+ directory: providerDirectory,
3298
+ error: providerError,
3299
+ onCredential: (target: ProviderTargetView) => {
3300
+ if (props.saveModelProviderCredential === undefined) {
3301
+ notify('API key storage is unavailable in this profile', 'warning')
3302
+ return
3303
+ }
3304
+ setProviderAction({ kind: 'credential', target })
3305
+ },
3306
+ onUnset: (target: ProviderTargetView) => {
3307
+ if (props.unsetModelProviderCredential === undefined) {
3308
+ notify('API key removal is unavailable in this profile', 'warning')
3309
+ return
3310
+ }
3311
+ setProviderAction({ kind: 'unset', target })
3312
+ },
3313
+ onRemove: (target: ProviderTargetView) => {
3314
+ if (props.removeModelProvider === undefined) {
3315
+ notify('provider removal is unavailable in this profile', 'warning')
3316
+ return
3317
+ }
3318
+ setProviderAction({ kind: 'remove', target })
3319
+ },
3320
+ onRetry: reloadModelSurfaces,
3321
+ onBack: () => setProviderOpen(false),
3322
+ })
3323
+ } else if (effortFor !== undefined) {
3324
+ modelSurface = createElement(EffortPanel, {
3325
+ row: effortFor,
3326
+ current: effortLabel,
3327
+ select: (effortId: string) => applyModel(effortFor, effortId),
3328
+ back: () => setEffortFor(undefined),
3329
+ })
3330
+ } else {
3331
+ modelSurface = createElement(ModelPanel, {
3332
+ directory,
3333
+ error: modelError,
3334
+ onSelect: (row: ModelRow) => {
3335
+ // A model advertising several levels opens the effort stage first;
3336
+ // one advertised level is its only option, while no capability uses
3337
+ // the model default exactly as before.
3338
+ if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
3339
+ setEffortFor(row)
3340
+ return
3341
+ }
3342
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
3343
+ applyModel(row, effortId)
3344
+ },
3345
+ ...(props.loadModelProviders === undefined || props.saveModelProviderCredential === undefined
3346
+ ? {}
3347
+ : { onProviders: () => setProviderOpen(true) }),
3348
+ onRetry: reloadModelSurfaces,
3349
+ onClose: closeModelSurface,
3350
+ })
3351
+ }
3352
+ }
3353
+
2333
3354
  return createElement(
2334
3355
  Box,
2335
3356
  { flexDirection: 'column' },
@@ -2347,7 +3368,8 @@ export function App(props: AppProps): ReactElement {
2347
3368
  view.streamingReasoning !== '' && reasoningRows > 0
2348
3369
  ? createElement(StreamTail, {
2349
3370
  text: showReasoning ? view.streamingReasoning : 'Thinking…',
2350
- prefix: ' ✻ ',
3371
+ prefix: '✻ ',
3372
+ continuationPrefix: ' ',
2351
3373
  dim: true,
2352
3374
  maxRows: reasoningRows,
2353
3375
  })
@@ -2367,27 +3389,7 @@ export function App(props: AppProps): ReactElement {
2367
3389
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
2368
3390
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2369
3391
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
2370
- 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}`)
2378
- 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
- })
2390
- : undefined,
3392
+ modelSurface,
2391
3393
  helpOpen && !approvalPending && !questionPending
2392
3394
  ? createElement(HelpPanel, {
2393
3395
  descriptors,
@@ -2418,6 +3420,22 @@ export function App(props: AppProps): ReactElement {
2418
3420
  close: () => setModeOpen(false),
2419
3421
  })
2420
3422
  : undefined,
3423
+ permissionOpen && !approvalPending && !questionPending
3424
+ ? createElement(PermissionPanel, {
3425
+ current: props.permission,
3426
+ load: props.loadPermissions,
3427
+ select: (id: string) => {
3428
+ try {
3429
+ const selected = props.setPermission(id)
3430
+ notify(`permission → ${selected}`)
3431
+ setPermissionOpen(false)
3432
+ } catch (reason: unknown) {
3433
+ notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3434
+ }
3435
+ },
3436
+ close: () => setPermissionOpen(false),
3437
+ })
3438
+ : undefined,
2421
3439
  resumeOpen && !approvalPending && !questionPending
2422
3440
  ? createElement(ResumePanel, {
2423
3441
  currentCwd: props.workspaceRoot,
@@ -2440,6 +3458,21 @@ export function App(props: AppProps): ReactElement {
2440
3458
  close: () => setStatuslineOpen(false),
2441
3459
  })
2442
3460
  : undefined,
3461
+ themeOpen && !approvalPending && !questionPending
3462
+ ? createElement(ThemePanel, {
3463
+ current: getTheme(),
3464
+ select: (name: ThemeName) => {
3465
+ // Apply immediately (module-level palette), persist through the
3466
+ // runner, then close: the close re-render paints with the new
3467
+ // palette. `auto` stores as requested; detection is a later step.
3468
+ setTheme(name)
3469
+ props.saveTheme?.(name)
3470
+ notify(`theme → ${name}`)
3471
+ setThemeOpen(false)
3472
+ },
3473
+ close: () => setThemeOpen(false),
3474
+ })
3475
+ : undefined,
2443
3476
  historyOpen && !approvalPending && !questionPending
2444
3477
  ? createElement(HistoryPanel, {
2445
3478
  entries: recallSpace,
@@ -2476,15 +3509,57 @@ export function App(props: AppProps): ReactElement {
2476
3509
  openModel: () => {
2477
3510
  setDirectory(undefined)
2478
3511
  setModelError(undefined)
3512
+ setProviderDirectory(undefined)
3513
+ setProviderError(undefined)
3514
+ setProviderOpen(false)
3515
+ setProviderAction(undefined)
3516
+ setEffortFor(undefined)
2479
3517
  setModelOpen(true)
2480
3518
  },
3519
+ openEffort: () => {
3520
+ // /effort adjusts the CURRENT model's reasoning: resolve it from
3521
+ // the live catalog, then open the same effort stage the /model
3522
+ // picker would. Match on the applied label (what the status bar
3523
+ // shows) — `props.model` may still carry the deployment default
3524
+ // until the next request header lands. The model-id fallback
3525
+ // prefers a reasoning-capable row (several routes may serve the
3526
+ // same id), and a capability-lookup failure reads as "retry",
3527
+ // never as "the model has no efforts" — the adapter advertises
3528
+ // levels for every deepseek model, so "no efforts" is almost
3529
+ // always a failed resolveModelInfo, not a fact.
3530
+ void props.loadModels().then((loaded) => {
3531
+ const [provider, model] = modelLabel.split('/')
3532
+ const row = loaded.rows.find(candidate => candidate.provider === provider && candidate.model === model)
3533
+ ?? loaded.rows.find(candidate => candidate.model === model && candidate.reasoning !== undefined)
3534
+ ?? loaded.rows.find(candidate => candidate.model === model)
3535
+ if (row === undefined) {
3536
+ notify('current model is not in the catalog', 'warning')
3537
+ return
3538
+ }
3539
+ const rowTag = `${row.provider}/${row.model}`
3540
+ if (loaded.reasoningFailures?.includes(rowTag) === true) {
3541
+ notify('reasoning levels temporarily unavailable (capability lookup failed) — try again', 'warning')
3542
+ return
3543
+ }
3544
+ if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
3545
+ notify('current model does not expose reasoning efforts', 'warning')
3546
+ return
3547
+ }
3548
+ setEffortFor(row)
3549
+ setModelOpen(true)
3550
+ }, (error: unknown) => {
3551
+ notify(`model lookup failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
3552
+ })
3553
+ },
2481
3554
  openHelp: () => {
2482
3555
  setHelpOpen(true)
2483
3556
  },
2484
3557
  openMode: () => setModeOpen(true),
3558
+ openPermission: () => setPermissionOpen(true),
2485
3559
  openResume: () => setResumeOpen(true),
2486
3560
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2487
3561
  openStatusline: () => setStatuslineOpen(true),
3562
+ openTheme: () => setThemeOpen(true),
2488
3563
  openHistory: () => setHistoryOpen(true),
2489
3564
  createSession: props.createSession,
2490
3565
  cancelSessionSwitch: props.cancelSessionSwitch,
@@ -2519,6 +3594,8 @@ export function App(props: AppProps): ReactElement {
2519
3594
  cancelQueued: props.cancelQueued,
2520
3595
  historyFill,
2521
3596
  historyConsumed,
3597
+ waveTier,
3598
+ waveStyle,
2522
3599
  }),
2523
3600
  createElement(StatusLine, {
2524
3601
  facts: {
@@ -2529,7 +3606,7 @@ export function App(props: AppProps): ReactElement {
2529
3606
  sessionId: props.sessionId,
2530
3607
  title: view.title,
2531
3608
  plan: view.plan,
2532
- permission: view.permission,
3609
+ permission: view.permission !== '' ? view.permission : props.permission,
2533
3610
  sandbox: view.sandbox,
2534
3611
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
2535
3612
  },