dsh-code 0.7.0 → 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 (44) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +2685 -622
  4. package/lib/types/app.d.ts +77 -1
  5. package/lib/types/history.d.ts +15 -4
  6. package/lib/types/index.d.ts +48 -0
  7. package/lib/types/kernel-panels.d.ts +7 -0
  8. package/lib/types/permissions.d.ts +37 -0
  9. package/lib/types/presets.d.ts +2 -0
  10. package/lib/types/provider-settings.d.ts +144 -0
  11. package/lib/types/questions.d.ts +2 -0
  12. package/lib/types/render/animations.d.ts +8 -6
  13. package/lib/types/render/lines.d.ts +6 -0
  14. package/lib/types/render/markdown.d.ts +3 -3
  15. package/lib/types/render/projection.d.ts +95 -3
  16. package/lib/types/render/status.d.ts +26 -36
  17. package/lib/types/render/text.d.ts +14 -7
  18. package/lib/types/render/tool-detail.d.ts +3 -1
  19. package/lib/types/render/tool-preview.d.ts +4 -1
  20. package/lib/types/session-directory.d.ts +15 -0
  21. package/lib/types/store.d.ts +13 -2
  22. package/lib/types/version.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/src/app.ts +847 -150
  25. package/src/approval.ts +11 -2
  26. package/src/history.ts +20 -5
  27. package/src/index.ts +402 -159
  28. package/src/kernel-panels.ts +45 -8
  29. package/src/permissions.ts +85 -0
  30. package/src/presets.ts +12 -0
  31. package/src/provider-settings.ts +520 -0
  32. package/src/questions.ts +15 -5
  33. package/src/render/animations.ts +32 -18
  34. package/src/render/lines.ts +21 -6
  35. package/src/render/markdown.ts +302 -4
  36. package/src/render/projection.ts +665 -10
  37. package/src/render/status.ts +68 -162
  38. package/src/render/text.ts +28 -9
  39. package/src/render/tool-detail.ts +81 -40
  40. package/src/render/tool-preview.ts +18 -2
  41. package/src/session-directory.ts +44 -5
  42. package/src/skills.ts +8 -4
  43. package/src/store.ts +26 -8
  44. package/src/version.ts +16 -0
package/src/app.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  } from './theme.ts'
36
36
  import { ThemePanel } from './theme-panel.ts'
37
37
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
38
+ import { DSH_CODE_VERSION } from './version.ts'
38
39
  import type { TranscriptStore } from './store.ts'
39
40
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
40
41
  import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
@@ -61,11 +62,13 @@ import {
61
62
  import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
62
63
  import type { CommandsView } from './commands.ts'
63
64
  import type { ModelDirectory, ModelRow } from './models.ts'
65
+ import type { ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
64
66
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
65
67
  import type { SkillsView, SkillRow } from './skills.ts'
66
68
  import type { MentionCandidate } from './mentions.ts'
67
- import { EffortPanel, ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
69
+ import { EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
68
70
  import type { PresetRow } from './presets.ts'
71
+ import type { PermissionRow } from './permissions.ts'
69
72
  import type { PluginRow } from './plugin-inventory.ts'
70
73
  import {
71
74
  recallEntries,
@@ -88,6 +91,7 @@ import {
88
91
  STATUS_CYCLE_HINT,
89
92
  STATUS_GROUP_SEPARATOR,
90
93
  STATUS_ITEM_SEPARATOR,
94
+ STATUS_ROW2_INDENT,
91
95
  type StatusFacts,
92
96
  type StatusGroup,
93
97
  type StatusItemId,
@@ -108,6 +112,7 @@ import {
108
112
  import {
109
113
  lineSegment,
110
114
  markdownLines,
115
+ reasoningLines,
111
116
  styledLines,
112
117
  textLines,
113
118
  transcriptEntryLines,
@@ -144,8 +149,10 @@ export interface AppProps {
144
149
  sessionId: string
145
150
  /** Whether this session was resumed from persistence. */
146
151
  resumed: boolean
147
- /** Agent preset currently composing the session. */
152
+ /** Agent preset selected for the current or pending first session. */
148
153
  mode: string
154
+ /** Permission preset selected for the current or pending first session. */
155
+ permission: string
149
156
  /** Submit one line: slash commands to the registry, other text to the agent. */
150
157
  dispatch(text: string): void
151
158
  /** Submit steering: consumed at the running turn's next step boundary. */
@@ -160,8 +167,20 @@ export interface AppProps {
160
167
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
161
168
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
162
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>
163
180
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
164
181
  cyclePermission(): string
182
+ /** Select or inspect a permission preset without requiring a pre-existing session. */
183
+ setPermission(id: string): string
165
184
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
166
185
  exportTranscript(argument: string): Promise<void>
167
186
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
@@ -169,6 +188,8 @@ export interface AppProps {
169
188
  /** Preset/session/plugin kernel operations. */
170
189
  loadPresets(): Promise<readonly PresetRow[]>
171
190
  switchMode(id: string): Promise<string>
191
+ /** Load the switchable permission presets for the /permission panel. */
192
+ loadPermissions(): Promise<readonly PermissionRow[]>
172
193
  createSession(mode?: string): void
173
194
  loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
174
195
  loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
@@ -290,30 +311,40 @@ function DeepDivingLine({ since }: { since: number }): ReactElement {
290
311
  * the freshest tokens stay visible while a long reply streams; the complete
291
312
  * text lands in the flushed scrollback once the turn assembles it.
292
313
  */
293
- function StreamTail({ text, dim, maxRows, prefix, children }: {
314
+ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children }: {
294
315
  text: string
295
316
  dim: boolean
296
317
  maxRows: number
297
318
  prefix?: string
319
+ continuationPrefix?: string
298
320
  children?: ReactElement
299
321
  }): ReactElement {
300
322
  const columns = useStdout().stdout?.columns ?? 80
301
323
  const safeRows = Math.max(1, maxRows)
302
324
  // App padding consumes two columns; the final extra column keeps a caret
303
- // from wrapping onto an unbudgeted row.
304
- 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)
305
329
  const initial = displayTail(text, contentColumns, safeRows)
306
330
  // Reserve one row for the omission marker only when a marker is needed.
307
331
  const tail = initial.truncated && safeRows > 1
308
332
  ? displayTail(text, contentColumns, safeRows - 1)
309
333
  : initial
334
+ const rows = tail.text.split('\n')
310
335
  return createElement(
311
336
  Box,
312
337
  { flexDirection: 'column' },
313
338
  tail.truncated && safeRows > 1
314
- ? createElement(Text, { dimColor: true }, ' …')
339
+ ? createElement(Text, { color: inkColor(getPalette().dim) }, continuationPrefix, '…')
315
340
  : undefined,
316
- 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
+ )),
317
348
  )
318
349
  }
319
350
 
@@ -327,6 +358,8 @@ function segmentProps(style: MdSegment['style']): {
327
358
  switch (style) {
328
359
  case 'accent':
329
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 }
330
363
  case 'code':
331
364
  return { color: inkColor(getPalette().code), bold: undefined, italic: undefined, strikethrough: undefined }
332
365
  case 'dim':
@@ -362,7 +395,7 @@ function lineStyleProps(style: LineStyle): {
362
395
  case 'warn':
363
396
  return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
364
397
  case 'dimItalic':
365
- 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 }
366
399
  default:
367
400
  return { ...segmentProps(style), dimColor: undefined }
368
401
  }
@@ -415,6 +448,13 @@ function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }):
415
448
  )
416
449
  }
417
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
+
418
458
  /**
419
459
  * One expanded tool-card body for the verbose transcript (Ctrl+O): the
420
460
  * presentation contract's structured cards — inline diffs, read windows,
@@ -500,8 +540,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
500
540
  entry.reasoning === ''
501
541
  ? undefined
502
542
  : showReasoning
503
- ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
504
- : 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)`),
505
545
  createElement(MarkdownBody, { text: entry.text, indent: 2 }),
506
546
  )
507
547
  case 'tool': {
@@ -520,7 +560,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
520
560
  { wrap: verbose ? 'truncate-end' : undefined },
521
561
  mark,
522
562
  ' ',
523
- brand(entry.name),
563
+ brand(displayText(entry.name)),
524
564
  entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
525
565
  ),
526
566
  entry.summary === ''
@@ -549,7 +589,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
549
589
  { wrap: verbose ? 'truncate-end' : undefined },
550
590
  mark,
551
591
  ' ',
552
- brand(`/${entry.name}`),
592
+ brand(displayText(`/${entry.name}`)),
553
593
  entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
554
594
  ),
555
595
  entry.summary === ''
@@ -600,28 +640,31 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
600
640
  }
601
641
 
602
642
  /**
603
- * The whale wordmark header in DeepSeek blue, hugging its content width.
604
- * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
605
- * short to show it whole (or mid-resize) the clipped pairs garble the
606
- * screen — below the height floor the header collapses to a single-line
607
- * 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.
608
647
  */
609
648
  function Header({ resumed }: { resumed: boolean }): ReactElement {
610
- const rows = useStdout().stdout?.rows ?? 40
611
- const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
612
- 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) {
613
658
  return createElement(
614
659
  Box,
615
- { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
616
- createElement(Text, { color: inkColor(getPalette().brandBright), bold: true }, 'DeepSeek Harness'),
617
- 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))),
618
662
  )
619
663
  }
620
664
  return createElement(
621
665
  Box,
622
- // alignSelf shrinks the border to the whale-plus-wordmark content instead
623
- // of stretching across the terminal and stranding empty space on the right
624
- // (the compact-banner treatment the Claude Code welcome uses).
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.
625
668
  { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
626
669
  createElement(
627
670
  Box,
@@ -630,9 +673,15 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
630
673
  ),
631
674
  createElement(
632
675
  Box,
633
- { flexDirection: 'column', justifyContent: 'center' },
634
- createElement(Text, { color: inkColor(getPalette().brandBright), bold: true }, 'DeepSeek Harness'),
635
- 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),
636
685
  ),
637
686
  )
638
687
  }
@@ -695,19 +744,10 @@ function statusToneProps(tone: StatusTone): {
695
744
  return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
696
745
  case 'accent':
697
746
  return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
698
- // Context-bar segment shades, dark light across the DeepSeek blues:
699
- // system/prompt/assistant/thinking/tools. The tools segment borrows the
700
- // code sky-blue as the fifth shade (no new theme token).
701
- case 'ctxSystem':
702
- return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
703
- case 'ctxPrompt':
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':
704
750
  return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
705
- case 'ctxAssistant':
706
- return { color: inkColor(getPalette().brandMid), bold: undefined, dimColor: undefined }
707
- case 'ctxThinking':
708
- return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
709
- case 'ctxTools':
710
- return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
711
751
  case 'success':
712
752
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
713
753
  case 'warn':
@@ -759,7 +799,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
759
799
  }): ReactElement {
760
800
  const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
761
801
 
762
- const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
802
+ const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
763
803
  const leftParts: ReactElement[] = []
764
804
  row.left.forEach((group, groupIndex) => {
765
805
  if (groupIndex > 0) {
@@ -793,9 +833,10 @@ function StatusLine({ facts, stats, busy, columns, items }: {
793
833
  return createElement(
794
834
  Box,
795
835
  // Match the prompt text inside the bordered composer: one border column
796
- // plus one padding column. Keeping these rows margin-free also makes
797
- // the composer and status a fixed bottom unit in every interface.
798
- { 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 },
799
840
  createElement(Text, { wrap: 'truncate-end' }, ...leftParts),
800
841
  rightParts.length > 0 ? createElement(Text, { wrap: 'truncate-end' }, ...rightParts) : undefined,
801
842
  )
@@ -805,7 +846,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
805
846
  Box,
806
847
  { flexDirection: 'column' },
807
848
  renderRow(layout.row1, 's1'),
808
- row2Present ? renderRow(layout.row2, 's2') : undefined,
849
+ row2Present ? renderRow(layout.row2, 's2', STATUS_ROW2_INDENT) : undefined,
809
850
  )
810
851
  }
811
852
 
@@ -1176,10 +1217,11 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1176
1217
  }
1177
1218
 
1178
1219
  /** The /model panel: a scrolling list over the advisory model directory. */
1179
- function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1220
+ function ModelPanel({ directory, error, onSelect, onProviders, onRetry, onClose }: {
1180
1221
  directory: ModelDirectory | undefined
1181
1222
  error: string | undefined
1182
1223
  onSelect(row: ModelRow): void
1224
+ onProviders?(): void
1183
1225
  onRetry(): void
1184
1226
  onClose(): void
1185
1227
  }): ReactElement {
@@ -1205,6 +1247,10 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1205
1247
  onRetry()
1206
1248
  return
1207
1249
  }
1250
+ if (input === 'a' && onProviders !== undefined) {
1251
+ onProviders()
1252
+ return
1253
+ }
1208
1254
  if (rows.length === 0) return
1209
1255
  if (key.upArrow) {
1210
1256
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
@@ -1237,7 +1283,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1237
1283
 
1238
1284
  if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1239
1285
  if (viewport.compact) {
1240
- 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))
1241
1288
  }
1242
1289
 
1243
1290
  const stateRows: ReactElement[] = directory === undefined && error === undefined
@@ -1263,7 +1310,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1263
1310
  // Measurement and rendering share the same physical-row budget: state
1264
1311
  // messages consume body rows before selectable entries, as in Codex's
1265
1312
  // list-selection views.
1266
- 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)
1267
1315
  const first = selectionWindow(cursor, rows.length, rowBudget)
1268
1316
  const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1269
1317
  return createElement(
@@ -1271,7 +1319,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1271
1319
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1272
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)),
1273
1321
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1274
- ...stateRows,
1322
+ ...visibleStateRows,
1275
1323
  ...visible.map((row) => {
1276
1324
  const index = rows.indexOf(row)
1277
1325
  const label = displayText(`${row.providerName} · ${row.modelName}`)
@@ -1286,7 +1334,303 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1286
1334
  )
1287
1335
  }),
1288
1336
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1289
- 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)),
1290
1634
  )
1291
1635
  }
1292
1636
 
@@ -1334,6 +1678,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1334
1678
  createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1335
1679
  createElement(Box, { key: 'local-effort' }, row('/effort', 'adjust reasoning effort for the current model')),
1336
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])')),
1337
1682
  createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1338
1683
  createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1339
1684
  createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
@@ -1669,6 +2014,7 @@ export function completionCandidates(
1669
2014
  { label: '/model', description: 'switch the model', origin: 'command' },
1670
2015
  { label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
1671
2016
  { label: '/mode', description: 'select the agent preset', origin: 'command' },
2017
+ { label: '/permission', description: 'inspect or select the permission preset', origin: 'command' },
1672
2018
  { label: '/new', description: 'start a fresh session', origin: 'command' },
1673
2019
  { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
1674
2020
  { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
@@ -1680,9 +2026,9 @@ export function completionCandidates(
1680
2026
  { label: '/title', description: 'rename this session', origin: 'command' },
1681
2027
  { label: '/quit', description: 'exit', origin: 'command' },
1682
2028
  ]
1683
- // Local commands shadow registry names (e.g. the plugin-registered
1684
- // /permission is served by the registry itself, never duplicated here),
1685
- // 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.
1686
2032
  const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1687
2033
  const registry = descriptors
1688
2034
  .filter(descriptor => !localNames.has(descriptor.name))
@@ -1777,7 +2123,7 @@ function CompletionMenu({ active, mention, index, rows }: {
1777
2123
  * While a modal (approval / question / model panel) owns the keys, the
1778
2124
  * box passes every key through untouched.
1779
2125
  */
1780
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTick, waveTier, waveStyle }: {
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 }: {
1781
2127
  active: boolean
1782
2128
  frozen: boolean
1783
2129
  busy: boolean
@@ -1791,6 +2137,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1791
2137
  openEffort(): void
1792
2138
  openHelp(): void
1793
2139
  openMode(): void
2140
+ openPermission(): void
1794
2141
  openResume(): void
1795
2142
  openPlugin(query?: string): void
1796
2143
  openStatusline(): void
@@ -1823,11 +2170,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1823
2170
  historyFill: { text: string; index: number } | undefined
1824
2171
  /** Marks the accepted entry consumed (called after the fill is applied). */
1825
2172
  historyConsumed(): void
1826
- /** DeepSeek easter-egg wave frame (null when static): the composer's input
1827
- * row rides Codex's Wave sweep (33ms tick, 1.0s flash / 1.3s deepseek). */
1828
- waveTick: number | null
1829
- /** The wave tier of the applied official DeepSeek model (null otherwise):
1830
- * drives the persistent prompt glyph/accent and the sparkle tier. */
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. */
1831
2176
  waveTier: DeepseekWaveTier | null
1832
2177
  /** The ignition style running, if any: Wave / Aurora / Pulse. */
1833
2178
  waveStyle: DeepseekWaveStyle | null
@@ -2072,6 +2417,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2072
2417
  openEffort()
2073
2418
  return
2074
2419
  }
2420
+ if (text === '/permission') {
2421
+ openPermission()
2422
+ return
2423
+ }
2424
+ if (text.startsWith('/permission ')) {
2425
+ dispatch(text)
2426
+ return
2427
+ }
2075
2428
  if (text === '/mode' || text.startsWith('/mode ')) {
2076
2429
  const mode = text.slice(5).trim()
2077
2430
  if (mode === '') openMode()
@@ -2236,6 +2589,42 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2236
2589
  }
2237
2590
  })
2238
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
+
2239
2628
  // Every exclusive panel keeps the composer as a stable visual anchor, but
2240
2629
  // freezes it to one row: no menu, multiline wrap, or animation.
2241
2630
  const tierActive = waveTier !== null
@@ -2371,13 +2760,192 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2371
2760
  )
2372
2761
  }
2373
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
+
2374
2924
  /** The whole terminal app; state arrives via the store, output via Ink. */
2375
2925
  export function App(props: AppProps): ReactElement {
2376
2926
  const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
2377
- const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
2378
- 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)
2379
2941
  const [modelLabel, setModelLabel] = useState(props.model)
2380
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)
2381
2949
  /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
2382
2950
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
2383
2951
  /** Effective reasoning effort, shown in the /model picker and switch notice. */
@@ -2388,8 +2956,10 @@ export function App(props: AppProps): ReactElement {
2388
2956
  * per-style durations), then the band returns to static while the prompt
2389
2957
  * marker keeps the tier accent. The trigger follows the applied model
2390
2958
  * label (what the status bar actually shows), never the initial paint,
2391
- * and the tier is derived from the label and cached at the switch. */
2392
- const [waveTick, setWaveTick] = useState<number | null>(null)
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. */
2393
2963
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
2394
2964
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
2395
2965
  const previousModel = useRef<string | undefined>(undefined)
@@ -2407,7 +2977,6 @@ export function App(props: AppProps): ReactElement {
2407
2977
  if (!isOfficialDeepSeekLabel(modelLabel)) {
2408
2978
  setWaveTier(null)
2409
2979
  setWaveStyle(null)
2410
- setWaveTick(null)
2411
2980
  return
2412
2981
  }
2413
2982
  if (modelChanged || effortChanged) {
@@ -2415,26 +2984,12 @@ export function App(props: AppProps): ReactElement {
2415
2984
  const nextStyle = deepseekWaveStyleRandom(previousStyle.current)
2416
2985
  previousStyle.current = nextStyle
2417
2986
  setWaveStyle(nextStyle)
2418
- setWaveTick(0)
2419
2987
  }
2420
2988
  }, [modelLabel, effortLabel])
2421
- const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
2422
- && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
2423
- useEffect(() => {
2424
- if (!waveActive) return
2425
- const id = setInterval(() => {
2426
- setWaveTick(current => (current === null ? 0 : current + 1))
2427
- }, DEEPSEEK_WAVE_TICK_MS)
2428
- return () => {
2429
- clearInterval(id)
2430
- }
2431
- }, [waveActive])
2432
- useEffect(() => {
2433
- if (waveTick !== null && waveTier !== null && waveStyle !== null
2434
- && waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
2435
- }, [waveTick, waveTier, waveStyle])
2436
2989
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
2437
2990
  const [modelError, setModelError] = useState<string | undefined>(undefined)
2991
+ const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
2992
+ const [providerError, setProviderError] = useState<string | undefined>(undefined)
2438
2993
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
2439
2994
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
2440
2995
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
@@ -2461,12 +3016,36 @@ export function App(props: AppProps): ReactElement {
2461
3016
  cancelled = true
2462
3017
  }
2463
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])
2464
3042
 
2465
3043
  const busy = view.busy
2466
3044
  const [showReasoning, setShowReasoning] = useState(false)
2467
3045
  const [verboseOpen, setVerboseOpen] = useState(false)
2468
3046
  const [helpOpen, setHelpOpen] = useState(false)
2469
3047
  const [modeOpen, setModeOpen] = useState(false)
3048
+ const [permissionOpen, setPermissionOpen] = useState(false)
2470
3049
  const [resumeOpen, setResumeOpen] = useState(false)
2471
3050
  const [pluginOpen, setPluginOpen] = useState(false)
2472
3051
  const [pluginQuery, setPluginQuery] = useState('')
@@ -2489,27 +3068,43 @@ export function App(props: AppProps): ReactElement {
2489
3068
  const historyConsumed = useCallback((): void => {
2490
3069
  setHistoryFill(undefined)
2491
3070
  }, [])
2492
- /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). */
2493
- const queuedRows = useMemo(
2494
- () => view.entries.filter((entry): entry is Extract<TranscriptEntry, { kind: 'pending' }> => entry.kind === 'pending'),
2495
- [view.entries],
2496
- )
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])
2497
3089
  const [refreshEpoch, setRefreshEpoch] = useState(0)
2498
3090
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
2499
3091
  const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
2500
3092
  const approvalPending = approvalSnapshot.pending !== undefined
2501
3093
  const questionPending = questionSnapshot.pending !== undefined
2502
3094
  // While any modal owns the keys, the prompt box passes everything through.
2503
- const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
3095
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2504
3096
 
2505
3097
  // Human questions outrank local inspectors. Close the lower modal instead
2506
3098
  // of leaving an approval/question visible but keyboard-locked behind it.
2507
3099
  useEffect(() => {
2508
3100
  if (!approvalPending && !questionPending) return
2509
3101
  setModelOpen(false)
3102
+ setProviderOpen(false)
3103
+ setProviderAction(undefined)
2510
3104
  setEffortFor(undefined)
2511
3105
  setHelpOpen(false)
2512
3106
  setModeOpen(false)
3107
+ setPermissionOpen(false)
2513
3108
  setResumeOpen(false)
2514
3109
  setPluginOpen(false)
2515
3110
  setStatuslineOpen(false)
@@ -2519,34 +3114,33 @@ export function App(props: AppProps): ReactElement {
2519
3114
  }, [approvalPending, questionPending])
2520
3115
 
2521
3116
  // Append-only transcript: everything up to the first still-mutable entry
2522
- // (a running tool/retry) flushes through Ink's `<Static>` into native
3117
+ // (a running tool/retry/command) flushes through Ink's `<Static>` into native
2523
3118
  // scrollback and is normally never rewritten — the Claude-Code stability
2524
- // contract
2525
- // that lets arbitrarily long conversations scroll instead of freezing when
2526
- // the live tree exceeds the terminal height. The dynamic region below stays
2527
- // small: the streaming tail, modals, composer, and its status footer.
2528
- // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
2529
- // keeps long settled histories out of the per-token render path.
2530
- const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
2531
- // Claude-Code spacing: one blank row before each user prompt (except the
2532
- // first) separates replies from the next turn. Settled rows flush once with
2533
- // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
2534
- // 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)
2535
3132
  const settledRows = useMemo(() => {
2536
- const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
2537
- view.entries.slice(0, settled).forEach((entry, index) => {
2538
- const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
2539
- const roomyPrompt = entry.kind === 'user' && !entry.notice
2540
- if (roomyPrompt) {
2541
- rows.push(createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
2542
- }
2543
- rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
2544
- if (roomyPrompt) {
2545
- rows.push(createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
2546
- }
2547
- })
2548
- return rows
2549
- }, [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])
2550
3144
 
2551
3145
  // Hook order is unconditional. Its dimensions drive every live-region
2552
3146
  // budget before any dynamic rows are constructed.
@@ -2619,9 +3213,9 @@ export function App(props: AppProps): ReactElement {
2619
3213
  ? Math.max(1, Math.floor(streamRows / 3))
2620
3214
  : 1
2621
3215
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2622
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
3216
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2623
3217
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2624
- const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
3218
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
2625
3219
  const closeInspector = useCallback((): void => {
2626
3220
  setVerboseOpen(false)
2627
3221
  }, [])
@@ -2638,12 +3232,125 @@ export function App(props: AppProps): ReactElement {
2638
3232
  setEffortLabel(effortId)
2639
3233
  notify(`model → next step uses ${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`)
2640
3234
  setModelOpen(false)
3235
+ setProviderOpen(false)
3236
+ setProviderAction(undefined)
2641
3237
  setEffortFor(undefined)
2642
3238
  } catch (error: unknown) {
2643
3239
  notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2644
3240
  }
2645
3241
  }
2646
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
+
2647
3354
  return createElement(
2648
3355
  Box,
2649
3356
  { flexDirection: 'column' },
@@ -2661,7 +3368,8 @@ export function App(props: AppProps): ReactElement {
2661
3368
  view.streamingReasoning !== '' && reasoningRows > 0
2662
3369
  ? createElement(StreamTail, {
2663
3370
  text: showReasoning ? view.streamingReasoning : 'Thinking…',
2664
- prefix: ' ✻ ',
3371
+ prefix: '✻ ',
3372
+ continuationPrefix: ' ',
2665
3373
  dim: true,
2666
3374
  maxRows: reasoningRows,
2667
3375
  })
@@ -2681,38 +3389,7 @@ export function App(props: AppProps): ReactElement {
2681
3389
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
2682
3390
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2683
3391
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
2684
- modelOpen && !approvalPending && !questionPending
2685
- ? effortFor === undefined
2686
- ? createElement(ModelPanel, {
2687
- directory,
2688
- error: modelError,
2689
- onSelect: (row: ModelRow) => {
2690
- // A model advertising several levels opens the effort stage first;
2691
- // one advertised level is its only option (Codex's
2692
- // single-supported-effort shortcut), and no capability applies
2693
- // the model default exactly as before.
2694
- if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
2695
- setEffortFor(row)
2696
- return
2697
- }
2698
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
2699
- applyModel(row, effortId)
2700
- },
2701
- onRetry: () => {
2702
- setModelLoadEpoch(epoch => epoch + 1)
2703
- },
2704
- onClose: () => {
2705
- setModelOpen(false)
2706
- setEffortFor(undefined)
2707
- },
2708
- })
2709
- : createElement(EffortPanel, {
2710
- row: effortFor,
2711
- current: effortLabel,
2712
- select: (effortId: string) => applyModel(effortFor, effortId),
2713
- back: () => setEffortFor(undefined),
2714
- })
2715
- : undefined,
3392
+ modelSurface,
2716
3393
  helpOpen && !approvalPending && !questionPending
2717
3394
  ? createElement(HelpPanel, {
2718
3395
  descriptors,
@@ -2743,6 +3420,22 @@ export function App(props: AppProps): ReactElement {
2743
3420
  close: () => setModeOpen(false),
2744
3421
  })
2745
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,
2746
3439
  resumeOpen && !approvalPending && !questionPending
2747
3440
  ? createElement(ResumePanel, {
2748
3441
  currentCwd: props.workspaceRoot,
@@ -2816,6 +3509,10 @@ export function App(props: AppProps): ReactElement {
2816
3509
  openModel: () => {
2817
3510
  setDirectory(undefined)
2818
3511
  setModelError(undefined)
3512
+ setProviderDirectory(undefined)
3513
+ setProviderError(undefined)
3514
+ setProviderOpen(false)
3515
+ setProviderAction(undefined)
2819
3516
  setEffortFor(undefined)
2820
3517
  setModelOpen(true)
2821
3518
  },
@@ -2858,6 +3555,7 @@ export function App(props: AppProps): ReactElement {
2858
3555
  setHelpOpen(true)
2859
3556
  },
2860
3557
  openMode: () => setModeOpen(true),
3558
+ openPermission: () => setPermissionOpen(true),
2861
3559
  openResume: () => setResumeOpen(true),
2862
3560
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2863
3561
  openStatusline: () => setStatuslineOpen(true),
@@ -2896,7 +3594,6 @@ export function App(props: AppProps): ReactElement {
2896
3594
  cancelQueued: props.cancelQueued,
2897
3595
  historyFill,
2898
3596
  historyConsumed,
2899
- waveTick,
2900
3597
  waveTier,
2901
3598
  waveStyle,
2902
3599
  }),
@@ -2909,7 +3606,7 @@ export function App(props: AppProps): ReactElement {
2909
3606
  sessionId: props.sessionId,
2910
3607
  title: view.title,
2911
3608
  plan: view.plan,
2912
- permission: view.permission,
3609
+ permission: view.permission !== '' ? view.permission : props.permission,
2913
3610
  sandbox: view.sandbox,
2914
3611
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
2915
3612
  },