dsh-code 0.7.0 → 0.9.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 (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. 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,14 @@ 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 type { SubagentFeedView, SubagentRow } from './subagents.ts'
70
+ import { AgentsPanel, EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, SubagentPanel } from './kernel-panels.ts'
68
71
  import type { PresetRow } from './presets.ts'
72
+ import type { PermissionRow } from './permissions.ts'
69
73
  import type { PluginRow } from './plugin-inventory.ts'
70
74
  import {
71
75
  recallEntries,
@@ -88,6 +92,7 @@ import {
88
92
  STATUS_CYCLE_HINT,
89
93
  STATUS_GROUP_SEPARATOR,
90
94
  STATUS_ITEM_SEPARATOR,
95
+ STATUS_ROW2_INDENT,
91
96
  type StatusFacts,
92
97
  type StatusGroup,
93
98
  type StatusItemId,
@@ -108,6 +113,7 @@ import {
108
113
  import {
109
114
  lineSegment,
110
115
  markdownLines,
116
+ reasoningLines,
111
117
  styledLines,
112
118
  textLines,
113
119
  transcriptEntryLines,
@@ -126,6 +132,8 @@ export interface AppProps {
126
132
  approval: ApprovalStore
127
133
  /** ask_user_question store fed by the single UI provider. */
128
134
  questions: QuestionStore
135
+ /** Live subagent activity feed (child sessions of the current root). */
136
+ subagents: SubagentFeedView
129
137
  /** Live slash-command descriptor list (completion candidates). */
130
138
  commands: CommandsView
131
139
  /** Live user-invocable skill catalog (completion candidates). */
@@ -144,8 +152,10 @@ export interface AppProps {
144
152
  sessionId: string
145
153
  /** Whether this session was resumed from persistence. */
146
154
  resumed: boolean
147
- /** Agent preset currently composing the session. */
155
+ /** Agent preset selected for the current or pending first session. */
148
156
  mode: string
157
+ /** Permission preset selected for the current or pending first session. */
158
+ permission: string
149
159
  /** Submit one line: slash commands to the registry, other text to the agent. */
150
160
  dispatch(text: string): void
151
161
  /** Submit steering: consumed at the running turn's next step boundary. */
@@ -160,8 +170,28 @@ export interface AppProps {
160
170
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
161
171
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
162
172
  selectModel(row: ModelRow, effortId?: string): string
173
+ /** The /subagent override label, '' when delegated agents follow the current model. */
174
+ subagentModel: string
175
+ /** Apply one /subagent model pick; returns the override label. */
176
+ setSubagentModel(row: ModelRow, effortId?: string): string
177
+ /** Drop the /subagent override (delegated agents follow the current model). */
178
+ clearSubagentModel(): void
179
+ /** Delete one session subtree; resolves with the outcome line. */
180
+ deleteSession(id: string): Promise<string>
181
+ /** Load provider/settings/credential facts for the optional /model provider stage. */
182
+ loadModelProviders?(): Promise<ProviderSettingsDirectory>
183
+ /** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
184
+ subscribeModelProviders?(listener: () => void): () => void
185
+ /** Store or rotate one provider credential through the Harness credential service. */
186
+ saveModelProviderCredential?(target: ProviderTargetView, key: string): Promise<void>
187
+ /** Remove one writable provider credential without removing its settings profile. */
188
+ unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
189
+ /** Remove one user-owned provider profile and its page-managed credential. */
190
+ removeModelProvider?(target: ProviderTargetView): Promise<void>
163
191
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
164
192
  cyclePermission(): string
193
+ /** Select or inspect a permission preset without requiring a pre-existing session. */
194
+ setPermission(id: string): string
165
195
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
166
196
  exportTranscript(argument: string): Promise<void>
167
197
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
@@ -169,9 +199,13 @@ export interface AppProps {
169
199
  /** Preset/session/plugin kernel operations. */
170
200
  loadPresets(): Promise<readonly PresetRow[]>
171
201
  switchMode(id: string): Promise<string>
202
+ /** Load the switchable permission presets for the /permission panel. */
203
+ loadPermissions(): Promise<readonly PermissionRow[]>
172
204
  createSession(mode?: string): void
173
205
  loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
174
206
  loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
207
+ /** Load this session's subagent conversations (children by lineage). */
208
+ loadSubagents(): Promise<readonly SessionRow[]>
175
209
  switchSession(row: SessionRow): void
176
210
  cancelSessionSwitch(): boolean
177
211
  loadPlugins(): readonly PluginRow[]
@@ -290,30 +324,40 @@ function DeepDivingLine({ since }: { since: number }): ReactElement {
290
324
  * the freshest tokens stay visible while a long reply streams; the complete
291
325
  * text lands in the flushed scrollback once the turn assembles it.
292
326
  */
293
- function StreamTail({ text, dim, maxRows, prefix, children }: {
327
+ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children }: {
294
328
  text: string
295
329
  dim: boolean
296
330
  maxRows: number
297
331
  prefix?: string
332
+ continuationPrefix?: string
298
333
  children?: ReactElement
299
334
  }): ReactElement {
300
335
  const columns = useStdout().stdout?.columns ?? 80
301
336
  const safeRows = Math.max(1, maxRows)
302
337
  // 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 ?? ''))
338
+ // from wrapping onto an unbudgeted row. Both prefixes participate because
339
+ // every physical row now repeats its hanging indent.
340
+ const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
341
+ const contentColumns = Math.max(10, columns - 3 - prefixColumns)
305
342
  const initial = displayTail(text, contentColumns, safeRows)
306
343
  // Reserve one row for the omission marker only when a marker is needed.
307
344
  const tail = initial.truncated && safeRows > 1
308
345
  ? displayTail(text, contentColumns, safeRows - 1)
309
346
  : initial
347
+ const rows = tail.text.split('\n')
310
348
  return createElement(
311
349
  Box,
312
350
  { flexDirection: 'column' },
313
351
  tail.truncated && safeRows > 1
314
- ? createElement(Text, { dimColor: true }, ' …')
352
+ ? createElement(Text, { color: inkColor(getPalette().dim) }, continuationPrefix, '…')
315
353
  : undefined,
316
- createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
354
+ ...rows.map((row, index) => createElement(
355
+ Text,
356
+ { key: index, dimColor: dim || undefined },
357
+ index === 0 ? prefix : continuationPrefix,
358
+ row,
359
+ index + 1 === rows.length ? children : undefined,
360
+ )),
317
361
  )
318
362
  }
319
363
 
@@ -327,6 +371,8 @@ function segmentProps(style: MdSegment['style']): {
327
371
  switch (style) {
328
372
  case 'accent':
329
373
  return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
374
+ case 'accentBold':
375
+ return { color: inkColor(getPalette().brandBright), bold: true, italic: undefined, strikethrough: undefined }
330
376
  case 'code':
331
377
  return { color: inkColor(getPalette().code), bold: undefined, italic: undefined, strikethrough: undefined }
332
378
  case 'dim':
@@ -362,7 +408,7 @@ function lineStyleProps(style: LineStyle): {
362
408
  case 'warn':
363
409
  return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
364
410
  case 'dimItalic':
365
- return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
411
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined }
366
412
  default:
367
413
  return { ...segmentProps(style), dimColor: undefined }
368
414
  }
@@ -415,6 +461,13 @@ function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }):
415
461
  )
416
462
  }
417
463
 
464
+ /** Expanded reasoning with the same two-column content edge as the reply. */
465
+ function ReasoningBody({ text }: { text: string }): ReactElement {
466
+ const columns = useStdout().stdout?.columns ?? 80
467
+ const lines = useMemo(() => reasoningLines(text, Math.max(10, columns - 2)), [text, columns])
468
+ return createElement(StyledRows, { lines })
469
+ }
470
+
418
471
  /**
419
472
  * One expanded tool-card body for the verbose transcript (Ctrl+O): the
420
473
  * presentation contract's structured cards — inline diffs, read windows,
@@ -500,8 +553,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
500
553
  entry.reasoning === ''
501
554
  ? undefined
502
555
  : 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)`),
556
+ ? createElement(ReasoningBody, { text: entry.reasoning })
557
+ : createElement(Text, { color: inkColor(getPalette().dim) }, `✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
505
558
  createElement(MarkdownBody, { text: entry.text, indent: 2 }),
506
559
  )
507
560
  case 'tool': {
@@ -520,7 +573,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
520
573
  { wrap: verbose ? 'truncate-end' : undefined },
521
574
  mark,
522
575
  ' ',
523
- brand(entry.name),
576
+ brand(displayText(entry.name)),
524
577
  entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
525
578
  ),
526
579
  entry.summary === ''
@@ -549,7 +602,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
549
602
  { wrap: verbose ? 'truncate-end' : undefined },
550
603
  mark,
551
604
  ' ',
552
- brand(`/${entry.name}`),
605
+ brand(displayText(`/${entry.name}`)),
553
606
  entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
554
607
  ),
555
608
  entry.summary === ''
@@ -600,28 +653,31 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
600
653
  }
601
654
 
602
655
  /**
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.
656
+ * The whale header with a compact three-line copy lockup. The title, bilingual
657
+ * slogan, and key hint stay centered inside the existing eight content rows,
658
+ * preserving the Static header's ten physical rows. Short or narrow terminals
659
+ * keep a one-line form.
608
660
  */
609
661
  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) {
662
+ const stdout = useStdout().stdout
663
+ const rows = stdout?.rows ?? 40
664
+ const columns = stdout?.columns ?? 80
665
+ const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`
666
+ const slogan = 'Into the Unknown 探索未至之境'
667
+ const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
668
+ const copyColumns = Math.max(visibleColumns(title), visibleColumns(slogan), visibleColumns(hint))
669
+ const compact = `${title} · ${hint}`
670
+ if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 8) {
613
671
  return createElement(
614
672
  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),
673
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1 },
674
+ createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, truncateColumns(compact, Math.max(1, columns - 5))),
618
675
  )
619
676
  }
620
677
  return createElement(
621
678
  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).
679
+ // alignSelf shrinks the border to the whale-plus-copy content instead of
680
+ // stretching across the terminal and stranding empty space on the right.
625
681
  { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
626
682
  createElement(
627
683
  Box,
@@ -630,9 +686,15 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
630
686
  ),
631
687
  createElement(
632
688
  Box,
633
- { flexDirection: 'column', justifyContent: 'center' },
634
- createElement(Text, { color: inkColor(getPalette().brandBright), bold: true }, 'DeepSeek Harness'),
635
- createElement(Text, { dimColor: true }, hint),
689
+ { flexDirection: 'column', width: copyColumns, justifyContent: 'center' },
690
+ createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, title),
691
+ createElement(
692
+ Text,
693
+ { color: inkColor(getPalette().code), wrap: 'truncate-end' },
694
+ createElement(Text, { bold: true }, 'Into the Unknown'),
695
+ ' 探索未至之境',
696
+ ),
697
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, hint),
636
698
  ),
637
699
  )
638
700
  }
@@ -642,6 +704,30 @@ function todoMark(status: TodoItem['status']): string {
642
704
  return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
643
705
  }
644
706
 
707
+ /**
708
+ * One-row live subagent summary (the Codex agent status feed, compressed to
709
+ * the transcript's budget): running count, total, and the most recently
710
+ * active child's current activity. One line, never more — the full view is
711
+ * the /agents panel.
712
+ */
713
+ function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement | undefined {
714
+ if (rows.length === 0) return undefined
715
+ const running = rows.filter(row => row.state !== 'done').length
716
+ const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
717
+ const mark = newest.state === 'done' ? '✓' : newest.state === 'idle' ? '⏸' : '●'
718
+ return createElement(
719
+ Box,
720
+ { paddingX: 1 },
721
+ createElement(
722
+ Text,
723
+ { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
724
+ `agents ${running} live`,
725
+ createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${rows.length} total · /agents`),
726
+ createElement(Text, { color: inkColor(getPalette().text) }, ` · ${mark} ${newest.label} ${newest.activity}`),
727
+ ),
728
+ )
729
+ }
730
+
645
731
  /** One-row todo summary: task count cannot grow the live Ink tree. */
646
732
  function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
647
733
  if (todos.length === 0) return undefined
@@ -695,19 +781,10 @@ function statusToneProps(tone: StatusTone): {
695
781
  return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
696
782
  case 'accent':
697
783
  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':
784
+ // Context-bar fill: one DeepSeek blue over the whole occupied run; the
785
+ // dotted free track reads through the dim label gray.
786
+ case 'ctxFill':
704
787
  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
788
  case 'success':
712
789
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
713
790
  case 'warn':
@@ -759,7 +836,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
759
836
  }): ReactElement {
760
837
  const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
761
838
 
762
- const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
839
+ const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
763
840
  const leftParts: ReactElement[] = []
764
841
  row.left.forEach((group, groupIndex) => {
765
842
  if (groupIndex > 0) {
@@ -793,9 +870,10 @@ function StatusLine({ facts, stats, busy, columns, items }: {
793
870
  return createElement(
794
871
  Box,
795
872
  // 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 },
873
+ // plus one padding column. The secondary row adds the model-name indent
874
+ // (its budget already shrinks by the same amount) so its figures align
875
+ // under the model name rather than under the busy dot.
876
+ { paddingLeft: 2 + indent, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
799
877
  createElement(Text, { wrap: 'truncate-end' }, ...leftParts),
800
878
  rightParts.length > 0 ? createElement(Text, { wrap: 'truncate-end' }, ...rightParts) : undefined,
801
879
  )
@@ -805,7 +883,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
805
883
  Box,
806
884
  { flexDirection: 'column' },
807
885
  renderRow(layout.row1, 's1'),
808
- row2Present ? renderRow(layout.row2, 's2') : undefined,
886
+ row2Present ? renderRow(layout.row2, 's2', STATUS_ROW2_INDENT) : undefined,
809
887
  )
810
888
  }
811
889
 
@@ -836,72 +914,139 @@ function NoticeLine({ text, tone, columns }: {
836
914
  )
837
915
  }
838
916
 
839
- /** The y/n approval bar rendered while an approval ask is pending. */
840
- function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
917
+ /** One selectable approval decision (Codex approval-overlay wording). */
918
+ interface ApprovalOption {
919
+ readonly key: 'allow' | 'reject-note' | 'reject'
920
+ readonly label: string
921
+ readonly hotkey: string
922
+ }
923
+
924
+ /** The fixed decision list; answers stay in the binary answerer vocabulary. */
925
+ const APPROVAL_OPTIONS: readonly ApprovalOption[] = [
926
+ { key: 'allow', label: 'Yes, proceed', hotkey: 'y' },
927
+ { key: 'reject-note', label: 'No, and tell it what to do differently', hotkey: 'n' },
928
+ { key: 'reject', label: 'No, continue without running it', hotkey: 'd' },
929
+ ]
930
+
931
+ /**
932
+ * The approval dialog (Codex ApprovalOverlay contract): a bold question
933
+ * header, the bounded command body with an explicit overflow marker, a
934
+ * numbered option list with a `›` cursor, single-key shortcuts, and digits
935
+ * for direct selection. Askers queue FIFO — the count rides the header.
936
+ * The upstream answerer vocabulary stays binary (`allowed-once` /
937
+ * `rejected`): "tell it what to do differently" rejects and hands the
938
+ * composer back with a hint notice, exactly Codex's decline-then-type flow.
939
+ */
940
+ function ApprovalBar({ snapshot, locked, notify }: {
941
+ snapshot: ApprovalSnapshot
942
+ locked: boolean
943
+ notify(text: string, tone?: NoticeTone): void
944
+ }): ReactElement | undefined {
841
945
  const stdout = useStdout().stdout
842
946
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
843
- const [scroll, setScroll] = useState(0)
947
+ const [cursor, setCursor] = useState(0)
844
948
  const pending = snapshot.pending
845
- const active = !locked && snapshot.pending !== undefined && !snapshot.answered
846
- const content = useMemo<readonly StyledLine[]>(() => pending === undefined
949
+ const active = !locked && pending !== undefined && !snapshot.answered
950
+ const body = useMemo<readonly StyledLine[]>(() => pending === undefined || pending.command === ''
847
951
  ? []
848
- : [
849
- ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
850
- ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
851
- ], [pending, viewport.contentColumns])
852
- const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
952
+ : textLines(pending.command, viewport.contentColumns, 'dim'), [pending, viewport.contentColumns])
853
953
 
854
954
  useEffect(() => {
855
- setScroll(0)
955
+ setCursor(0)
856
956
  }, [pending])
857
957
 
858
- useEffect(() => {
859
- if (visibleScroll !== scroll) setScroll(visibleScroll)
860
- }, [visibleScroll, scroll])
958
+ const decide = (option: ApprovalOption): void => {
959
+ const ask = snapshot.pending
960
+ if (ask === undefined || snapshot.answered) return
961
+ if (option.key === 'allow') {
962
+ ask.answer('allowed-once')
963
+ return
964
+ }
965
+ ask.answer('rejected')
966
+ if (option.key === 'reject-note') {
967
+ notify('rejected — type below what it should do differently (it steers the next step)', 'warning')
968
+ }
969
+ }
861
970
 
862
971
  useInput((input, key) => {
863
- if (snapshot.pending === undefined) return
972
+ const ask = snapshot.pending
973
+ if (ask === undefined || snapshot.answered) return
864
974
  if (key.upArrow) {
865
- setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
975
+ setCursor(current => (current + APPROVAL_OPTIONS.length - 1) % APPROVAL_OPTIONS.length)
866
976
  return
867
977
  }
868
978
  if (key.downArrow) {
869
- setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
979
+ setCursor(current => (current + 1) % APPROVAL_OPTIONS.length)
870
980
  return
871
981
  }
872
- if (key.pageUp) {
873
- setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
982
+ if (key.return) {
983
+ decide(APPROVAL_OPTIONS[cursor]!)
874
984
  return
875
985
  }
876
- if (key.pageDown) {
877
- setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
986
+ if (key.escape) {
987
+ decide(APPROVAL_OPTIONS[2]!)
878
988
  return
879
989
  }
880
- if (snapshot.answered) return
881
990
  if (input === 'y' || input === 'Y') {
882
- snapshot.pending.answer('allowed-once')
991
+ decide(APPROVAL_OPTIONS[0]!)
883
992
  return
884
993
  }
885
994
  if (input === 'n' || input === 'N') {
886
- snapshot.pending.answer('rejected')
995
+ decide(APPROVAL_OPTIONS[1]!)
996
+ return
997
+ }
998
+ if (input === 'd' || input === 'D') {
999
+ decide(APPROVAL_OPTIONS[2]!)
1000
+ return
1001
+ }
1002
+ if (/^[1-9]$/u.test(input)) {
1003
+ const index = Number(input) - 1
1004
+ if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index]!)
887
1005
  }
888
1006
  }, { isActive: active })
889
- if (snapshot.pending === undefined) return undefined
1007
+
1008
+ if (pending === undefined) return undefined
890
1009
  if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1010
+ const queuedSuffix = snapshot.queued > 0 ? ` · +${snapshot.queued} queued` : ''
891
1011
  if (viewport.compact) {
892
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
1012
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`approval${queuedSuffix} · enter/y allow · esc/n reject`, viewport.contentColumns))
893
1013
  }
894
- const { answered } = snapshot
1014
+ // Body budget: title + options + footer consume fixed rows; the command
1015
+ // preview shrinks with an explicit overflow marker (Codex's "[… N lines]").
1016
+ const reservedRows = 3 + APPROVAL_OPTIONS.length
1017
+ const bodyBudget = Math.max(1, viewport.bodyRows - reservedRows)
1018
+ const visibleBody = body.slice(0, bodyBudget)
1019
+ const overflow = body.length - visibleBody.length
895
1020
  return createElement(
896
1021
  Box,
897
1022
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
898
- createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
899
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
900
- createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
901
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
902
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
1023
+ createElement(
1024
+ Text,
1025
+ { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' },
1026
+ truncateColumns(`${pending.headline}${queuedSuffix}`, viewport.contentColumns),
1027
+ ),
1028
+ createElement(PanelGap, { visible: viewport.gapRows > 0 && body.length > 0 }),
1029
+ ...visibleBody.map((line, index) => createElement(StyledRows, { key: `body-${index}`, lines: [line] })),
1030
+ ...(overflow > 0
1031
+ ? [createElement(Text, { key: 'overflow', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(`… +${overflow} more lines · ctrl+o shows the full call in the transcript`, viewport.contentColumns))]
1032
+ : []),
1033
+ ...(body.length > 0 ? [createElement(PanelGap, { visible: viewport.gapRows > 0 })] : []),
1034
+ ...APPROVAL_OPTIONS.map((option, index) => {
1035
+ const selected = !snapshot.answered && index === cursor
1036
+ return createElement(
1037
+ Text,
1038
+ {
1039
+ key: option.key,
1040
+ color: selected ? inkColor(getPalette().brandBright) : inkColor(getPalette().text),
1041
+ bold: selected || undefined,
1042
+ wrap: 'truncate-end',
1043
+ },
1044
+ truncateColumns(`${selected ? '›' : ' '} ${index + 1}. ${option.label} (${option.hotkey})`, viewport.contentColumns),
1045
+ )
1046
+ }),
1047
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(snapshot.answered
903
1048
  ? 'submitted…'
904
- : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
1049
+ : '↑↓ choose · enter confirm · y/n/d quick · esc reject', viewport.contentColumns)),
905
1050
  )
906
1051
  }
907
1052
 
@@ -1176,10 +1321,13 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1176
1321
  }
1177
1322
 
1178
1323
  /** The /model panel: a scrolling list over the advisory model directory. */
1179
- function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1324
+ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry, onClose }: {
1180
1325
  directory: ModelDirectory | undefined
1181
1326
  error: string | undefined
1327
+ /** `provider/model` label of the applied model: the cursor lands on it once. */
1328
+ current?: string
1182
1329
  onSelect(row: ModelRow): void
1330
+ onProviders?(): void
1183
1331
  onRetry(): void
1184
1332
  onClose(): void
1185
1333
  }): ReactElement {
@@ -1187,14 +1335,27 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1187
1335
  const stdout = useStdout().stdout
1188
1336
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1189
1337
  const rows = directory?.rows ?? []
1338
+ const positioned = useRef(false)
1190
1339
 
1191
1340
  useEffect(() => {
1192
- if (rows.length === 0) {
1193
- if (cursor !== 0) setCursor(0)
1341
+ // Open ON the applied model (Codex resumes the previous pick): the first
1342
+ // non-empty directory positions the cursor once, never on later refreshes.
1343
+ if (positioned.current || rows.length === 0 || current === undefined) {
1344
+ if (rows.length === 0) {
1345
+ if (cursor !== 0) setCursor(0)
1346
+ return
1347
+ }
1348
+ if (cursor >= rows.length) setCursor(rows.length - 1)
1194
1349
  return
1195
1350
  }
1196
- if (cursor >= rows.length) setCursor(rows.length - 1)
1197
- }, [rows.length, cursor])
1351
+ const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
1352
+ if (index >= 0) {
1353
+ positioned.current = true
1354
+ setCursor(index)
1355
+ } else if (cursor >= rows.length) {
1356
+ setCursor(Math.max(0, rows.length - 1))
1357
+ }
1358
+ }, [rows, cursor, current])
1198
1359
 
1199
1360
  useInput((input, key) => {
1200
1361
  if (key.escape || input === 'q') {
@@ -1205,6 +1366,10 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1205
1366
  onRetry()
1206
1367
  return
1207
1368
  }
1369
+ if (input === 'a' && onProviders !== undefined) {
1370
+ onProviders()
1371
+ return
1372
+ }
1208
1373
  if (rows.length === 0) return
1209
1374
  if (key.upArrow) {
1210
1375
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
@@ -1237,7 +1402,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1237
1402
 
1238
1403
  if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1239
1404
  if (viewport.compact) {
1240
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
1405
+ const providers = onProviders === undefined ? '' : ' · a providers'
1406
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model${providers} · r retry · esc/q close`, viewport.contentColumns))
1241
1407
  }
1242
1408
 
1243
1409
  const stateRows: ReactElement[] = directory === undefined && error === undefined
@@ -1263,7 +1429,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1263
1429
  // Measurement and rendering share the same physical-row budget: state
1264
1430
  // messages consume body rows before selectable entries, as in Codex's
1265
1431
  // list-selection views.
1266
- const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length)
1432
+ const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1433
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1267
1434
  const first = selectionWindow(cursor, rows.length, rowBudget)
1268
1435
  const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1269
1436
  return createElement(
@@ -1271,7 +1438,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1271
1438
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1272
1439
  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
1440
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1274
- ...stateRows,
1441
+ ...visibleStateRows,
1275
1442
  ...visible.map((row) => {
1276
1443
  const index = rows.indexOf(row)
1277
1444
  const label = displayText(`${row.providerName} · ${row.modelName}`)
@@ -1286,7 +1453,303 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1286
1453
  )
1287
1454
  }),
1288
1455
  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))),
1456
+ 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))),
1457
+ )
1458
+ }
1459
+
1460
+ /** Compact provider-state copy; only value-free credential facts cross this boundary. */
1461
+ function providerStateLabel(row: ProviderTargetView): string {
1462
+ const route = row.active ? 'active' : 'dormant'
1463
+ const credential = row.credential
1464
+ if (credential?.kind === 'error') return `${route} · key status unavailable`
1465
+ if (credential?.kind === 'facts') {
1466
+ if (!credential.configured) return `${route} · key missing`
1467
+ const source = credential.source === undefined ? 'configured' : singleLineText(credential.source)
1468
+ return `${route} · key ${source}${credential.writable ? '' : ' · read-only'}`
1469
+ }
1470
+ return `${route} · ${row.configured ? 'provider auth' : 'not configured'}`
1471
+ }
1472
+
1473
+ /** The provider-management stage reached from /model with `a`. */
1474
+ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRetry, onBack }: {
1475
+ directory: ProviderSettingsDirectory | undefined
1476
+ error: string | undefined
1477
+ onCredential(target: ProviderTargetView): void
1478
+ onUnset(target: ProviderTargetView): void
1479
+ onRemove(target: ProviderTargetView): void
1480
+ onRetry(): void
1481
+ onBack(): void
1482
+ }): ReactElement {
1483
+ const stdout = useStdout().stdout
1484
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1485
+ const rows = directory?.rows ?? []
1486
+ const [cursor, setCursor] = useState(0)
1487
+ const [actionError, setActionError] = useState<string | undefined>(undefined)
1488
+
1489
+ useEffect(() => {
1490
+ if (rows.length === 0) {
1491
+ if (cursor !== 0) setCursor(0)
1492
+ return
1493
+ }
1494
+ if (cursor >= rows.length) setCursor(rows.length - 1)
1495
+ }, [rows.length, cursor])
1496
+
1497
+ useStableInput((input, key) => {
1498
+ if (key.escape || input === 'q') {
1499
+ onBack()
1500
+ return
1501
+ }
1502
+ if (input === 'r') {
1503
+ setActionError(undefined)
1504
+ onRetry()
1505
+ return
1506
+ }
1507
+ if (rows.length === 0) return
1508
+ if (key.upArrow) {
1509
+ setActionError(undefined)
1510
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
1511
+ return
1512
+ }
1513
+ if (key.downArrow) {
1514
+ setActionError(undefined)
1515
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
1516
+ return
1517
+ }
1518
+ if (key.pageUp) {
1519
+ setActionError(undefined)
1520
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
1521
+ return
1522
+ }
1523
+ if (key.pageDown) {
1524
+ setActionError(undefined)
1525
+ setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1526
+ return
1527
+ }
1528
+ const target = rows[cursor]
1529
+ if (target === undefined) return
1530
+ if (input === 'd') {
1531
+ const facts = target.credential
1532
+ if (facts?.kind !== 'facts' || !facts.configured) {
1533
+ setActionError('this provider has no configured API key to remove')
1534
+ } else if (!facts.writable) {
1535
+ setActionError('this API key is supplied read-only by the environment')
1536
+ } else {
1537
+ onUnset(target)
1538
+ }
1539
+ return
1540
+ }
1541
+ if (input === 'x') {
1542
+ if (!target.removable) {
1543
+ setActionError('this provider profile is not removable')
1544
+ } else {
1545
+ onRemove(target)
1546
+ }
1547
+ return
1548
+ }
1549
+ if (key.return) {
1550
+ if (target.settingsNs.length === 0) {
1551
+ setActionError('this provider is not managed by Harness settings')
1552
+ } else if (target.credential?.kind === 'error') {
1553
+ setActionError('credential status is unavailable; retry before writing')
1554
+ } else if (target.credential?.kind === 'facts' && !target.credential.writable) {
1555
+ setActionError('this API key is supplied read-only by the environment')
1556
+ } else if (target.credentialRef === undefined && directory?.writable !== true) {
1557
+ setActionError('settings are read-only; this provider cannot be activated here')
1558
+ } else {
1559
+ onCredential(target)
1560
+ }
1561
+ }
1562
+ }, true)
1563
+
1564
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1565
+ if (viewport.compact) {
1566
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model providers · enter key · d remove key · esc back', viewport.contentColumns))
1567
+ }
1568
+ const stateRows: ReactElement[] = directory === undefined && error === undefined
1569
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' loading providers…')]
1570
+ : error !== undefined
1571
+ ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
1572
+ : [
1573
+ ...(actionError === undefined
1574
+ ? []
1575
+ : [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
1576
+ ...(directory?.failures ?? []).map((failure, index) => createElement(
1577
+ Text,
1578
+ { key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1579
+ truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1580
+ )),
1581
+ ...(rows.length === 0
1582
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
1583
+ : []),
1584
+ ]
1585
+ const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1586
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1587
+ const first = selectionWindow(cursor, rows.length, rowBudget)
1588
+ const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1589
+ return createElement(
1590
+ Box,
1591
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1592
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — providers${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1593
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1594
+ ...visibleStateRows,
1595
+ ...visible.map((row) => {
1596
+ const index = rows.indexOf(row)
1597
+ const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
1598
+ const label = `${identity} · ${providerStateLabel(row)}${row.removable ? ' · custom' : ''}`
1599
+ return createElement(
1600
+ Text,
1601
+ { key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
1602
+ truncateColumns(`${index === cursor ? '❯ ' : ' '}${displayText(label)}`, viewport.contentColumns),
1603
+ )
1604
+ }),
1605
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1606
+ 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)),
1607
+ )
1608
+ }
1609
+
1610
+ /** Write-only masked API-key editor; the secret lives only in this mounted component. */
1611
+ function ProviderCredentialPanel({ target, save, done, back }: {
1612
+ target: ProviderTargetView
1613
+ save(target: ProviderTargetView, key: string): Promise<void>
1614
+ done(): void
1615
+ back(): void
1616
+ }): ReactElement {
1617
+ const stdout = useStdout().stdout
1618
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1619
+ const [draft, setDraft] = useState('')
1620
+ const [busy, setBusy] = useState(false)
1621
+ const [error, setError] = useState<string | undefined>(undefined)
1622
+
1623
+ const submit = (): void => {
1624
+ if (busy) return
1625
+ setBusy(true)
1626
+ setError(undefined)
1627
+ Promise.resolve().then(() => save(target, draft)).then(() => {
1628
+ setDraft('')
1629
+ done()
1630
+ }, (reason: unknown) => {
1631
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
1632
+ setBusy(false)
1633
+ })
1634
+ }
1635
+
1636
+ useStableInput((input, key) => {
1637
+ if (busy) return
1638
+ if (key.escape) {
1639
+ setDraft('')
1640
+ back()
1641
+ return
1642
+ }
1643
+ if (key.return) {
1644
+ submit()
1645
+ return
1646
+ }
1647
+ if (key.backspace || key.delete) {
1648
+ setError(undefined)
1649
+ setDraft(current => [...current].slice(0, -1).join(''))
1650
+ return
1651
+ }
1652
+ if (key.ctrl && input === 'u') {
1653
+ setError(undefined)
1654
+ setDraft('')
1655
+ return
1656
+ }
1657
+ if (key.ctrl || key.meta || input.length === 0) return
1658
+ const next = draft + input
1659
+ if (next.length > 4096) {
1660
+ setError('API key input is too long')
1661
+ return
1662
+ }
1663
+ setError(undefined)
1664
+ setDraft(next)
1665
+ }, true)
1666
+
1667
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1668
+ const keyBudget = Math.max(1, viewport.contentColumns - 4)
1669
+ const bullets = '•'.repeat(Math.min([...draft].length, keyBudget))
1670
+ if (viewport.compact) {
1671
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`API key ${bullets}${busy ? ' saving…' : ' ▏'} · esc back`, viewport.contentColumns))
1672
+ }
1673
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
1674
+ const source = target.credential?.kind === 'facts' && target.credential.configured
1675
+ ? `replaces ${singleLineText(target.credential.source ?? 'stored key')}`
1676
+ : 'new key'
1677
+ const providerRow = createElement(Text, { key: 'provider', wrap: 'truncate-end' }, truncateColumns(` provider ${displayText(identity)}`, viewport.contentColumns))
1678
+ const referenceRow = createElement(Text, { key: 'reference', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` reference ${displayText(target.credentialRef ?? target.suggestedRef)} · ${source}`, viewport.contentColumns))
1679
+ 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))
1680
+ const errorRow = error === undefined
1681
+ ? undefined
1682
+ : createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
1683
+ const detailRows = errorRow === undefined ? [providerRow, referenceRow] : [providerRow, errorRow]
1684
+ const primaryRow = viewport.bodyRows === 1 && errorRow !== undefined ? errorRow : keyRow
1685
+ const detailBudget = Math.max(0, viewport.bodyRows - 1)
1686
+ const bodyRows = [
1687
+ ...(detailBudget === 0 ? [] : detailRows.slice(-detailBudget)),
1688
+ ...(viewport.bodyRows === 0 ? [] : [primaryRow]),
1689
+ ]
1690
+ return createElement(
1691
+ Box,
1692
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1693
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — add API key', viewport.contentColumns)),
1694
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1695
+ ...bodyRows,
1696
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1697
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('type or paste key · enter save · ctrl+u clear · esc back', viewport.contentColumns)),
1698
+ )
1699
+ }
1700
+
1701
+ /** Bounded destructive-action confirmation for credential or provider removal. */
1702
+ function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
1703
+ target: ProviderTargetView
1704
+ kind: 'credential' | 'provider'
1705
+ confirm(target: ProviderTargetView): Promise<void>
1706
+ done(): void
1707
+ back(): void
1708
+ }): ReactElement {
1709
+ const stdout = useStdout().stdout
1710
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1711
+ const [busy, setBusy] = useState(false)
1712
+ const [error, setError] = useState<string | undefined>(undefined)
1713
+ const run = (): void => {
1714
+ if (busy) return
1715
+ setBusy(true)
1716
+ setError(undefined)
1717
+ Promise.resolve().then(() => confirm(target)).then(done, (reason: unknown) => {
1718
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
1719
+ setBusy(false)
1720
+ })
1721
+ }
1722
+ useStableInput((input, key) => {
1723
+ if (busy) return
1724
+ if (key.escape || input === 'n') {
1725
+ back()
1726
+ return
1727
+ }
1728
+ if (input === 'y') run()
1729
+ }, true)
1730
+
1731
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1732
+ const action = kind === 'credential' ? 'remove API key' : 'remove provider'
1733
+ if (viewport.compact) {
1734
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${action} ${target.displayName}? · y confirm · n/esc back`, viewport.contentColumns))
1735
+ }
1736
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
1737
+ const identityRow = createElement(Text, { key: 'identity', wrap: 'truncate-end' }, truncateColumns(` ${displayText(identity)}`, viewport.contentColumns))
1738
+ 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))
1739
+ const errorRow = error === undefined
1740
+ ? undefined
1741
+ : createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
1742
+ const bodyRows = errorRow === undefined
1743
+ ? [identityRow, descriptionRow].slice(0, viewport.bodyRows)
1744
+ : [identityRow, errorRow].slice(-viewport.bodyRows)
1745
+ return createElement(
1746
+ Box,
1747
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
1748
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — ${action}`, viewport.contentColumns)),
1749
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1750
+ ...bodyRows,
1751
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1752
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? 'working…' : 'y confirm · n/esc back', viewport.contentColumns)),
1290
1753
  )
1291
1754
  }
1292
1755
 
@@ -1334,12 +1797,16 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1334
1797
  createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1335
1798
  createElement(Box, { key: 'local-effort' }, row('/effort', 'adjust reasoning effort for the current model')),
1336
1799
  createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
1800
+ createElement(Box, { key: 'local-permission' }, row('/permission', 'inspect or select the permission preset (/permission [preset])')),
1337
1801
  createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1338
1802
  createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1339
1803
  createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
1340
1804
  createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
1341
1805
  createElement(Box, { key: 'local-theme' }, row('/theme', 'switch the color theme')),
1342
1806
  createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
1807
+ createElement(Box, { key: 'local-agents' }, row('/agents', 'inspect subagent sessions of this conversation')),
1808
+ createElement(Box, { key: 'local-subagent' }, row('/subagent', 'choose the model delegated subagents run on')),
1809
+ createElement(Box, { key: 'local-delete' }, row('/delete', 'delete a session and its subagent threads')),
1343
1810
  createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1344
1811
  createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
1345
1812
  createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
@@ -1669,20 +2136,24 @@ export function completionCandidates(
1669
2136
  { label: '/model', description: 'switch the model', origin: 'command' },
1670
2137
  { label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
1671
2138
  { label: '/mode', description: 'select the agent preset', origin: 'command' },
2139
+ { label: '/permission', description: 'inspect or select the permission preset', origin: 'command' },
1672
2140
  { label: '/new', description: 'start a fresh session', origin: 'command' },
1673
2141
  { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
1674
2142
  { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
1675
2143
  { label: '/statusline', description: 'customize the status line', origin: 'command' },
1676
2144
  { label: '/theme', description: 'switch the color theme', origin: 'command' },
1677
2145
  { label: '/history', description: 'search and recall past prompts', origin: 'command' },
2146
+ { label: '/agents', description: 'inspect subagent sessions of this conversation', origin: 'command' },
2147
+ { label: '/subagent', description: 'choose the model delegated subagents run on', origin: 'command' },
2148
+ { label: '/delete', description: 'delete a session and its subagent threads', origin: 'command' },
1678
2149
  { label: '/clear', description: 'clear the screen', origin: 'command' },
1679
2150
  { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1680
2151
  { label: '/title', description: 'rename this session', origin: 'command' },
1681
2152
  { label: '/quit', description: 'exit', origin: 'command' },
1682
2153
  ]
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.
2154
+ // Local commands shadow registry names (e.g. the TUI-local /permission works
2155
+ // before any session exists, while the registry child needs one), so
2156
+ // collisions cannot render two rows with the same key.
1686
2157
  const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1687
2158
  const registry = descriptors
1688
2159
  .filter(descriptor => !localNames.has(descriptor.name))
@@ -1777,7 +2248,7 @@ function CompletionMenu({ active, mention, index, rows }: {
1777
2248
  * While a modal (approval / question / model panel) owns the keys, the
1778
2249
  * box passes every key through untouched.
1779
2250
  */
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 }: {
2251
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, openAgents, openSubagent, openDelete, deleteConfirm, confirmDelete, cancelDelete, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }: {
1781
2252
  active: boolean
1782
2253
  frozen: boolean
1783
2254
  busy: boolean
@@ -1791,11 +2262,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1791
2262
  openEffort(): void
1792
2263
  openHelp(): void
1793
2264
  openMode(): void
2265
+ openPermission(): void
1794
2266
  openResume(): void
1795
2267
  openPlugin(query?: string): void
1796
2268
  openStatusline(): void
1797
2269
  openTheme(): void
1798
2270
  openHistory(): void
2271
+ /** Open the /agents panel (live subagent feed + transcript entry). */
2272
+ openAgents(): void
2273
+ /** Open the /subagent model panel. */
2274
+ openSubagent(): void
2275
+ /** Open the /resume picker in delete mode, optionally pre-armed on one id. */
2276
+ openDelete(id?: string): void
2277
+ /** The row id awaiting y/n in this box, when a deletion is pending. */
2278
+ deleteConfirm?: string
2279
+ /** Confirm the pending deletion (y in the box). */
2280
+ confirmDelete(): void
2281
+ /** Cancel the pending deletion (any other key in the box). */
2282
+ cancelDelete(): void
1799
2283
  createSession(mode?: string): void
1800
2284
  cancelSessionSwitch(): boolean
1801
2285
  notify(text: string, tone?: NoticeTone): void
@@ -1823,11 +2307,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1823
2307
  historyFill: { text: string; index: number } | undefined
1824
2308
  /** Marks the accepted entry consumed (called after the fill is applied). */
1825
2309
  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. */
2310
+ /** DeepSeek easter-egg wave tier of the applied official DeepSeek model
2311
+ * (null otherwise): drives the persistent prompt glyph/accent and the
2312
+ * sparkle tier. */
1831
2313
  waveTier: DeepseekWaveTier | null
1832
2314
  /** The ignition style running, if any: Wave / Aurora / Pulse. */
1833
2315
  waveStyle: DeepseekWaveStyle | null
@@ -1944,9 +2426,53 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
1944
2426
  }))
1945
2427
  : candidates
1946
2428
 
2429
+ /** Accept the highlighted completion-menu candidate into the draft. */
2430
+ const acceptMenuCandidate = (): void => {
2431
+ if (mentionActive && mentionToken !== undefined) {
2432
+ const row = mentionRows[completionIndex % mentionRows.length]
2433
+ if (row !== undefined) {
2434
+ // Session rows carry the canonical @[label](dsh-session:…) token;
2435
+ // file rows insert `@path` (directories keep their trailing slash).
2436
+ const insertion = row.label.startsWith('@')
2437
+ ? row.label
2438
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2439
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2440
+ setCursor(mentionToken.start + insertion.length)
2441
+ }
2442
+ } else if (pathActive) {
2443
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
2444
+ if (row !== undefined) {
2445
+ // Bare path completion replaces the typed token with the chosen
2446
+ // workspace path (directories keep their trailing slash).
2447
+ const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
2448
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
2449
+ setCursor(pathTokenStart + insertion.length)
2450
+ }
2451
+ } else {
2452
+ const candidate = candidates[completionIndex % candidates.length]
2453
+ if (candidate !== undefined) {
2454
+ setValue(`${candidate.label} `)
2455
+ setCursor(candidate.label.length + 1)
2456
+ }
2457
+ }
2458
+ setCompletionIndex(0)
2459
+ setDismissedMenuValue(undefined)
2460
+ }
2461
+
1947
2462
  useInput((input, key) => {
1948
2463
  // Modal ownership: approval/question/model dialogs consume all keys.
1949
2464
  if (!active) return
2465
+ // Deletion confirm owns the box: y proceeds, anything else cancels.
2466
+ // Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
2467
+ // as the box's own prompt, not an invisible panel keypress.
2468
+ if (deleteConfirm !== undefined) {
2469
+ if (input === 'y' || input === 'Y') {
2470
+ confirmDelete()
2471
+ } else {
2472
+ cancelDelete()
2473
+ }
2474
+ return
2475
+ }
1950
2476
  // Shift+Tab cycles the permission preset (Claude-Code convention).
1951
2477
  if (key.tab && key.shift) {
1952
2478
  try {
@@ -2018,6 +2544,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2018
2544
  setDismissedMenuValue(undefined)
2019
2545
  return
2020
2546
  }
2547
+ // Enter on an open completion menu accepts the highlighted candidate
2548
+ // (Codex list parity: Tab and Enter are both accept keys — many users
2549
+ // never discover Tab) — UNLESS the draft already spells one candidate
2550
+ // exactly, in which case Enter submits it (typing a full "/effort" and
2551
+ // pressing return must run the command, not re-accept its own text).
2552
+ if (menuActive) {
2553
+ const exactSlash = !mentionActive && !pathActive && candidates.some(candidate => candidate.label === value)
2554
+ if (!exactSlash) {
2555
+ acceptMenuCandidate()
2556
+ return
2557
+ }
2558
+ }
2021
2559
  const text = value.trim()
2022
2560
  setValue('')
2023
2561
  setCursor(0)
@@ -2072,6 +2610,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2072
2610
  openEffort()
2073
2611
  return
2074
2612
  }
2613
+ if (text === '/permission') {
2614
+ openPermission()
2615
+ return
2616
+ }
2617
+ if (text.startsWith('/permission ')) {
2618
+ dispatch(text)
2619
+ return
2620
+ }
2075
2621
  if (text === '/mode' || text.startsWith('/mode ')) {
2076
2622
  const mode = text.slice(5).trim()
2077
2623
  if (mode === '') openMode()
@@ -2108,6 +2654,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2108
2654
  openHistory()
2109
2655
  return
2110
2656
  }
2657
+ if (text === '/agents') {
2658
+ openAgents()
2659
+ return
2660
+ }
2661
+ if (text === '/subagent') {
2662
+ openSubagent()
2663
+ return
2664
+ }
2665
+ if (text === '/delete' || text.startsWith('/delete ')) {
2666
+ openDelete(text.slice(7).trim())
2667
+ return
2668
+ }
2111
2669
  if (busy && !text.startsWith('/')) {
2112
2670
  // A running turn is steered, not blocked: the inbox delivers this
2113
2671
  // text at the next step boundary (Esc/Ctrl+C still cancels outright).
@@ -2153,35 +2711,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2153
2711
  return
2154
2712
  }
2155
2713
  if (key.tab && menuActive) {
2156
- if (mentionActive && mentionToken !== undefined) {
2157
- const row = mentionRows[completionIndex % mentionRows.length]
2158
- if (row !== undefined) {
2159
- // Session rows carry the canonical @[label](dsh-session:…) token;
2160
- // file rows insert `@path` (directories keep their trailing slash).
2161
- const insertion = row.label.startsWith('@')
2162
- ? row.label
2163
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2164
- setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2165
- setCursor(mentionToken.start + insertion.length)
2166
- }
2167
- } else if (pathActive) {
2168
- const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
2169
- if (row !== undefined) {
2170
- // Bare path completion replaces the typed token with the chosen
2171
- // workspace path (directories keep their trailing slash).
2172
- const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
2173
- setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
2174
- setCursor(pathTokenStart + insertion.length)
2175
- }
2176
- } else {
2177
- const candidate = candidates[completionIndex % candidates.length]
2178
- if (candidate !== undefined) {
2179
- setValue(`${candidate.label} `)
2180
- setCursor(candidate.label.length + 1)
2181
- }
2182
- }
2183
- setCompletionIndex(0)
2184
- setDismissedMenuValue(undefined)
2714
+ acceptMenuCandidate()
2185
2715
  return
2186
2716
  }
2187
2717
  if (key.backspace || key.delete) {
@@ -2236,6 +2766,42 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2236
2766
  }
2237
2767
  })
2238
2768
 
2769
+ // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
2770
+ // the interval re-renders only the composer row at 30fps, never the whole
2771
+ // tree. App drives the tier/style pair on a model switch; this local effect
2772
+ // starts the sweep whenever that pair changes (App picks a NEW random style
2773
+ // for every replay — including effort changes on the same route — so the
2774
+ // pair always differs when a new wave should run) and stops it when the
2775
+ // model leaves the official DeepSeek route (tier becomes null).
2776
+ const [waveTick, setWaveTick] = useState<number | null>(null)
2777
+ const wavePrevious = useRef<{ tier: DeepseekWaveTier | null; style: DeepseekWaveStyle | null }>({ tier: null, style: null })
2778
+ useEffect(() => {
2779
+ const previous = wavePrevious.current
2780
+ wavePrevious.current = { tier: waveTier, style: waveStyle }
2781
+ if (waveTier === null) {
2782
+ setWaveTick(null)
2783
+ return
2784
+ }
2785
+ if (previous.tier !== waveTier || previous.style !== waveStyle) {
2786
+ setWaveTick(0)
2787
+ }
2788
+ }, [waveTier, waveStyle])
2789
+ const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
2790
+ && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
2791
+ useEffect(() => {
2792
+ if (!waveActive) return
2793
+ const id = setInterval(() => {
2794
+ setWaveTick(current => (current === null ? 0 : current + 1))
2795
+ }, DEEPSEEK_WAVE_TICK_MS)
2796
+ return () => {
2797
+ clearInterval(id)
2798
+ }
2799
+ }, [waveActive])
2800
+ useEffect(() => {
2801
+ if (waveTick !== null && waveTier !== null && waveStyle !== null
2802
+ && waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
2803
+ }, [waveTick, waveTier, waveStyle])
2804
+
2239
2805
  // Every exclusive panel keeps the composer as a stable visual anchor, but
2240
2806
  // freezes it to one row: no menu, multiline wrap, or animation.
2241
2807
  const tierActive = waveTier !== null
@@ -2243,6 +2809,20 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2243
2809
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
2244
2810
  const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
2245
2811
  if (frozen) {
2812
+ // A pending deletion turns the box into the confirm prompt: the y/n is
2813
+ // typed HERE, with a readable warn-styled hint instead of a dim footer.
2814
+ if (deleteConfirm !== undefined) {
2815
+ return createElement(
2816
+ Box,
2817
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().warn), paddingX: 1 },
2818
+ createElement(
2819
+ Text,
2820
+ { wrap: 'truncate-end' },
2821
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '❯ '),
2822
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true }, 'y delete · any other key cancels'),
2823
+ ),
2824
+ )
2825
+ }
2246
2826
  const frozen = value === ''
2247
2827
  ? 'type a message'
2248
2828
  : verboseLine(value, Math.max(1, columns - 6))
@@ -2371,13 +2951,192 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2371
2951
  )
2372
2952
  }
2373
2953
 
2954
+ /** One cached settled row: the row Box plus its roomy-prompt spacers. */
2955
+ interface SettledRowRecord {
2956
+ /** The row Box element (keyed by the entry's settled index). */
2957
+ box: ReactElement
2958
+ /** The roomy-prompt spacer BEFORE the row, or undefined. */
2959
+ before: ReactElement | undefined
2960
+ /** The roomy-prompt spacer AFTER the row, or undefined. */
2961
+ after: ReactElement | undefined
2962
+ /** Whether the row's text depends on the reasoning toggle (Ctrl+R). */
2963
+ reasonSensitive: boolean
2964
+ /** The toggle state the row was built with. */
2965
+ showReasoning: boolean
2966
+ }
2967
+
2968
+ /** The incremental settled-history cache (see `computeSettledRows`). */
2969
+ interface SettledRowsCache {
2970
+ /** The exact settled entries the cache covers (`view.entries[0..entries.length)`). */
2971
+ entries: TranscriptEntry[]
2972
+ /** Records keyed by entry identity; mutated in place so the append path
2973
+ * never copies the whole map. */
2974
+ records: Map<TranscriptEntry, SettledRowRecord>
2975
+ /** The header element (depends only on `resumed`). */
2976
+ header: ReactElement
2977
+ /** The `resumed` the header was built with. */
2978
+ resumed: boolean
2979
+ /** The toggle state the rows were built with. */
2980
+ showReasoning: boolean
2981
+ /** The refreshEpoch the rows were built for; a bump forces a full rebuild. */
2982
+ epoch: number
2983
+ /** The flat row list (header + per-entry before/box/after). */
2984
+ flat: ReactElement[]
2985
+ }
2986
+
2987
+ /** One step of `computeSettledRows`. */
2988
+ interface SettledRowsResult {
2989
+ cache: SettledRowsCache
2990
+ /** How many rows had to be BUILT by this step (0 = pure reuse). */
2991
+ built: number
2992
+ }
2993
+
2994
+ /** Build one settled row (row Box plus its roomy-prompt spacers). */
2995
+ function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean): SettledRowRecord {
2996
+ const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
2997
+ const roomyPrompt = entry.kind === 'user' && !entry.notice
2998
+ return {
2999
+ box: createElement(Box, { key: index, paddingX: 1 }, row),
3000
+ before: roomyPrompt
3001
+ ? createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
3002
+ : undefined,
3003
+ after: roomyPrompt
3004
+ ? createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
3005
+ : undefined,
3006
+ reasonSensitive: entry.kind === 'assistant' && entry.reasoning !== '',
3007
+ showReasoning,
3008
+ }
3009
+ }
3010
+
3011
+ /**
3012
+ * The settled `<Static>` row set as a PURE incremental state machine (App
3013
+ * drives it from the memo; tests drive it directly and read `built`).
3014
+ *
3015
+ * The settled prefix is permanently final: the projection only APPENDS below
3016
+ * the flush boundary, removes pending rows at or beyond it, and replaces
3017
+ * running tool/retry/command rows there too. So extending the cache never
3018
+ * rescans the old prefix — a grown boundary builds ONLY the newly settled
3019
+ * suffix and reuses every cached element, letting React bail out of unchanged
3020
+ * rows and keeping long histories out of the per-durable-event path (no O(N)
3021
+ * rebuild of rows, Map, or MarkdownBody parses). `records` is mutated in place
3022
+ * on the append/toggle paths to stay O(delta).
3023
+ *
3024
+ * Full rebuilds run only on the rare, deliberate paths: no cache yet, a
3025
+ * source-backed replay (`epoch` bump: resize / Ctrl+L / Ctrl+R remounts
3026
+ * `<Static>` and must re-flush the CURRENT rows), a `resumed` change, or a shrink
3027
+ * (`store.reset`). A reasoning toggle rebuilds only the rows whose text
3028
+ * depends on it, preserving the other rows' element identity.
3029
+ */
3030
+ export function computeSettledRows(
3031
+ previous: SettledRowsCache | undefined,
3032
+ entries: readonly TranscriptEntry[],
3033
+ settled: number,
3034
+ showReasoning: boolean,
3035
+ resumed: boolean,
3036
+ epoch: number,
3037
+ ): SettledRowsResult {
3038
+ if (previous === undefined || previous.epoch !== epoch || previous.resumed !== resumed
3039
+ || settled < previous.entries.length) {
3040
+ // Full rebuild from the current settled prefix.
3041
+ const records = new Map<TranscriptEntry, SettledRowRecord>()
3042
+ const flat: ReactElement[] = [createElement(Header, { key: 'header', resumed })]
3043
+ for (let index = 0; index < settled; index++) {
3044
+ const entry = entries[index]
3045
+ const record = buildSettledRow(entry, index, showReasoning)
3046
+ records.set(entry, record)
3047
+ if (record.before !== undefined) flat.push(record.before)
3048
+ flat.push(record.box)
3049
+ if (record.after !== undefined) flat.push(record.after)
3050
+ }
3051
+ return {
3052
+ cache: { entries: entries.slice(0, settled), records, header: flat[0]!, resumed, showReasoning, epoch, flat },
3053
+ built: settled,
3054
+ }
3055
+ }
3056
+ if (previous.showReasoning !== showReasoning) {
3057
+ // Reasoning toggle: only rows whose text depends on it rebuild; spacers
3058
+ // and the other rows keep their element identity.
3059
+ const records = previous.records
3060
+ const flat: ReactElement[] = [previous.header]
3061
+ let built = 0
3062
+ for (let index = 0; index < previous.entries.length; index++) {
3063
+ const entry = previous.entries[index]
3064
+ const record = records.get(entry)!
3065
+ const current = record.reasonSensitive
3066
+ ? {
3067
+ ...record,
3068
+ box: createElement(Box, { key: index, paddingX: 1 }, createElement(EntryLine, { entry, showReasoning, verbose: false })),
3069
+ showReasoning,
3070
+ }
3071
+ : record
3072
+ if (current !== record) {
3073
+ records.set(entry, current)
3074
+ built += 1
3075
+ }
3076
+ if (current.before !== undefined) flat.push(current.before)
3077
+ flat.push(current.box)
3078
+ if (current.after !== undefined) flat.push(current.after)
3079
+ }
3080
+ return { cache: { ...previous, records, showReasoning, flat }, built }
3081
+ }
3082
+ if (settled === previous.entries.length) {
3083
+ // Nothing below the boundary changed (a pending retirement above it, a
3084
+ // tool/result at the boundary): keep the SAME flat identity so the
3085
+ // memoized <Static> subtree does not re-render at all.
3086
+ return { cache: previous, built: 0 }
3087
+ }
3088
+ // The boundary grew: build ONLY the newly settled suffix.
3089
+ const records = previous.records
3090
+ const suffix: TranscriptEntry[] = []
3091
+ const added: ReactElement[] = []
3092
+ for (let index = previous.entries.length; index < settled; index++) {
3093
+ const entry = entries[index]
3094
+ const record = buildSettledRow(entry, index, showReasoning)
3095
+ records.set(entry, record)
3096
+ suffix.push(entry)
3097
+ if (record.before !== undefined) added.push(record.before)
3098
+ added.push(record.box)
3099
+ if (record.after !== undefined) added.push(record.after)
3100
+ }
3101
+ return {
3102
+ cache: {
3103
+ entries: previous.entries.concat(suffix),
3104
+ records,
3105
+ header: previous.header,
3106
+ resumed: previous.resumed,
3107
+ showReasoning,
3108
+ epoch: previous.epoch,
3109
+ flat: previous.flat.concat(added),
3110
+ },
3111
+ built: settled - previous.entries.length,
3112
+ }
3113
+ }
3114
+
2374
3115
  /** The whole terminal app; state arrives via the store, output via Ink. */
2375
3116
  export function App(props: AppProps): ReactElement {
2376
3117
  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)
3118
+ // getSnapshot must be a STABLE reference (the React contract): an inline
3119
+ // arrow here re-subscribes the store hook on every render and cascades
3120
+ // force-updates — during a fast reasoning stream that chain crossed React's
3121
+ // nested-passive-update limit and flooded "Maximum update depth exceeded"
3122
+ // warnings. The view objects are process-stable, so one callback per view
3123
+ // identity is enough.
3124
+ // getSnapshot should be a stable reference (the React contract): an inline
3125
+ // arrow re-subscribes the store hook on every render and forces the uETS
3126
+ // consistency check to re-run per commit. The view objects are
3127
+ // process-stable, so one callback per view identity is enough.
3128
+ const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands])
3129
+ const readSkills = useCallback(() => props.skills.rows, [props.skills])
3130
+ const descriptors = useSyncExternalStore(props.commands.subscribe, readDescriptors)
3131
+ const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
2379
3132
  const [modelLabel, setModelLabel] = useState(props.model)
2380
3133
  const [modelOpen, setModelOpen] = useState(false)
3134
+ /** Nested /model stages; only one owns terminal input at a time. */
3135
+ const [providerOpen, setProviderOpen] = useState(false)
3136
+ const [providerAction, setProviderAction] = useState<{
3137
+ kind: 'credential' | 'unset' | 'remove'
3138
+ target: ProviderTargetView
3139
+ } | undefined>(undefined)
2381
3140
  /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
2382
3141
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
2383
3142
  /** Effective reasoning effort, shown in the /model picker and switch notice. */
@@ -2388,8 +3147,10 @@ export function App(props: AppProps): ReactElement {
2388
3147
  * per-style durations), then the band returns to static while the prompt
2389
3148
  * marker keeps the tier accent. The trigger follows the applied model
2390
3149
  * 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)
3150
+ * and the tier is derived from the label and cached at the switch. The
3151
+ * 33ms tick itself lives inside Input, so the sweep re-renders only the
3152
+ * composer row, not the whole tree, at 30fps; App owns the rarely-changing
3153
+ * tier/style and Input starts the sweep whenever that pair changes. */
2393
3154
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
2394
3155
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
2395
3156
  const previousModel = useRef<string | undefined>(undefined)
@@ -2407,7 +3168,6 @@ export function App(props: AppProps): ReactElement {
2407
3168
  if (!isOfficialDeepSeekLabel(modelLabel)) {
2408
3169
  setWaveTier(null)
2409
3170
  setWaveStyle(null)
2410
- setWaveTick(null)
2411
3171
  return
2412
3172
  }
2413
3173
  if (modelChanged || effortChanged) {
@@ -2415,26 +3175,12 @@ export function App(props: AppProps): ReactElement {
2415
3175
  const nextStyle = deepseekWaveStyleRandom(previousStyle.current)
2416
3176
  previousStyle.current = nextStyle
2417
3177
  setWaveStyle(nextStyle)
2418
- setWaveTick(0)
2419
3178
  }
2420
3179
  }, [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
3180
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
2437
3181
  const [modelError, setModelError] = useState<string | undefined>(undefined)
3182
+ const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
3183
+ const [providerError, setProviderError] = useState<string | undefined>(undefined)
2438
3184
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
2439
3185
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
2440
3186
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
@@ -2461,12 +3207,36 @@ export function App(props: AppProps): ReactElement {
2461
3207
  cancelled = true
2462
3208
  }
2463
3209
  }, [modelOpen, modelLoadEpoch, props.loadModels])
3210
+ useEffect(() => {
3211
+ if (!modelOpen || props.loadModelProviders === undefined) return
3212
+ let cancelled = false
3213
+ setProviderDirectory(undefined)
3214
+ setProviderError(undefined)
3215
+ Promise.resolve().then(() => props.loadModelProviders!()).then((loaded) => {
3216
+ if (!cancelled) setProviderDirectory(loaded)
3217
+ }, (error: unknown) => {
3218
+ if (!cancelled) setProviderError(error instanceof Error ? error.message : String(error))
3219
+ })
3220
+ return () => {
3221
+ cancelled = true
3222
+ }
3223
+ }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
3224
+ useEffect(() => {
3225
+ const subscribe = props.subscribeModelProviders
3226
+ if (!modelOpen || subscribe === undefined) return
3227
+ try {
3228
+ return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
3229
+ } catch (error: unknown) {
3230
+ setProviderError(error instanceof Error ? error.message : String(error))
3231
+ }
3232
+ }, [modelOpen, props.subscribeModelProviders])
2464
3233
 
2465
3234
  const busy = view.busy
2466
3235
  const [showReasoning, setShowReasoning] = useState(false)
2467
3236
  const [verboseOpen, setVerboseOpen] = useState(false)
2468
3237
  const [helpOpen, setHelpOpen] = useState(false)
2469
3238
  const [modeOpen, setModeOpen] = useState(false)
3239
+ const [permissionOpen, setPermissionOpen] = useState(false)
2470
3240
  const [resumeOpen, setResumeOpen] = useState(false)
2471
3241
  const [pluginOpen, setPluginOpen] = useState(false)
2472
3242
  const [pluginQuery, setPluginQuery] = useState('')
@@ -2474,6 +3244,34 @@ export function App(props: AppProps): ReactElement {
2474
3244
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
2475
3245
  const [themeOpen, setThemeOpen] = useState(false)
2476
3246
  const [historyOpen, setHistoryOpen] = useState(false)
3247
+ const [agentsOpen, setAgentsOpen] = useState(false)
3248
+ const [subagentOpen, setSubagentOpen] = useState(false)
3249
+ /** /delete state: delete-mode hint plus an optional pre-armed row id. */
3250
+ const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
3251
+ /** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
3252
+ * composer takes the keys, the resume panel yields until it settles. */
3253
+ const [deleteConfirmId, setDeleteConfirmId] = useState<string | undefined>(undefined)
3254
+ /** Bumped after a deletion so the /resume listing reloads immediately. */
3255
+ const [deleteReloadToken, setDeleteReloadToken] = useState(0)
3256
+ const requestDelete = useCallback((row: SessionRow): void => {
3257
+ setDeleteConfirmId(row.id)
3258
+ }, [])
3259
+ const cancelDelete = useCallback((): void => {
3260
+ setDeleteConfirmId(undefined)
3261
+ }, [])
3262
+ const confirmDelete = useCallback((): void => {
3263
+ const id = deleteConfirmId
3264
+ if (id === undefined) return
3265
+ setDeleteConfirmId(undefined)
3266
+ void props.deleteSession(id).then(outcome => {
3267
+ notify(outcome)
3268
+ // Keep the picker open and reload: a successful deletion must vanish
3269
+ // from the list immediately, not look like a no-op.
3270
+ setDeleteReloadToken(token => token + 1)
3271
+ }, (reason: unknown) => {
3272
+ notify(`delete failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3273
+ })
3274
+ }, [deleteConfirmId, props.deleteSession, notify])
2477
3275
  /** The /history panel's accepted entry: text plus its recall-space index. */
2478
3276
  const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
2479
3277
  /** Submissions recorded in this process (Codex local history; persistent file stays in the runner). */
@@ -2489,64 +3287,88 @@ export function App(props: AppProps): ReactElement {
2489
3287
  const historyConsumed = useCallback((): void => {
2490
3288
  setHistoryFill(undefined)
2491
3289
  }, [])
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
- )
3290
+ /** The append-only flush boundary (see `settledEntryCount`): entries below
3291
+ * this index are final and ride the `<Static>` scrollback; everything at or
3292
+ * beyond stays in the live tree. Pending inbox rows always live at
3293
+ * index >= settled, so the queued-inbox scan below only walks the mutable
3294
+ * tail instead of the whole history. */
3295
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
3296
+ /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). The
3297
+ * projection only appends and removes pending rows at index >= settled, so
3298
+ * a bounded tail scan replaces an unconditional O(history) filter on every
3299
+ * event. */
3300
+ const queuedRows = useMemo(() => {
3301
+ const rows: Array<Extract<TranscriptEntry, { kind: 'pending' }>> = []
3302
+ for (let index = settled; index < view.entries.length; index++) {
3303
+ const entry = view.entries[index]
3304
+ if (entry.kind === 'pending') rows.push(entry)
3305
+ }
3306
+ return rows
3307
+ }, [view.entries, settled])
2497
3308
  const [refreshEpoch, setRefreshEpoch] = useState(0)
2498
3309
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
2499
3310
  const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
3311
+ const agentRows = useSyncExternalStore(props.subagents.subscribe, props.subagents.getSnapshot)
2500
3312
  const approvalPending = approvalSnapshot.pending !== undefined
2501
3313
  const questionPending = questionSnapshot.pending !== undefined
2502
3314
  // 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
3315
+ // While a deletion waits for y/n, the composer takes the keys (the resume
3316
+ // panel yields): the confirm is typed IN the input box, not as an invisible
3317
+ // panel keypress.
3318
+ const inputActive = deleteConfirmId !== undefined
3319
+ ? !approvalPending && !questionPending
3320
+ : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
2504
3321
 
2505
3322
  // Human questions outrank local inspectors. Close the lower modal instead
2506
3323
  // of leaving an approval/question visible but keyboard-locked behind it.
2507
3324
  useEffect(() => {
2508
3325
  if (!approvalPending && !questionPending) return
2509
3326
  setModelOpen(false)
3327
+ setProviderOpen(false)
3328
+ setProviderAction(undefined)
2510
3329
  setEffortFor(undefined)
2511
3330
  setHelpOpen(false)
2512
3331
  setModeOpen(false)
3332
+ setPermissionOpen(false)
2513
3333
  setResumeOpen(false)
2514
3334
  setPluginOpen(false)
2515
3335
  setStatuslineOpen(false)
2516
3336
  setThemeOpen(false)
2517
3337
  setHistoryOpen(false)
3338
+ setAgentsOpen(false)
3339
+ setSubagentOpen(false)
3340
+ setDeleteConfirmId(undefined)
2518
3341
  setVerboseOpen(false)
2519
3342
  }, [approvalPending, questionPending])
2520
3343
 
2521
3344
  // Append-only transcript: everything up to the first still-mutable entry
2522
- // (a running tool/retry) flushes through Ink's `<Static>` into native
3345
+ // (a running tool/retry/command) flushes through Ink's `<Static>` into native
2523
3346
  // 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.
3347
+ // contract that lets arbitrarily long conversations scroll instead of
3348
+ // freezing when the live tree exceeds the terminal height. The dynamic
3349
+ // region below stays small: the streaming tail, modals, composer, and its
3350
+ // status footer. `assistant/chunk` preserves `entries` identity.
3351
+ //
3352
+ // `computeSettledRows` extends the cached row set incrementally: the
3353
+ // settled prefix is permanently final, so a grown boundary builds ONLY the
3354
+ // newly settled suffix and reuses every cached element long histories
3355
+ // stop re-creating rows (and re-parsing MarkdownBody) on every durable
3356
+ // event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L /
3357
+ // Ctrl+R remounts `<Static>`) rebuilds the CURRENT row set from index 0,
3358
+ // so the replay stays complete and never ghosts a pending/running tail.
3359
+ const settledRowsCache = useRef<SettledRowsCache | undefined>(undefined)
2535
3360
  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])
3361
+ const result = computeSettledRows(
3362
+ settledRowsCache.current,
3363
+ view.entries,
3364
+ settled,
3365
+ showReasoning,
3366
+ props.resumed,
3367
+ refreshEpoch,
3368
+ )
3369
+ settledRowsCache.current = result.cache
3370
+ return result.cache.flat
3371
+ }, [view.entries, settled, showReasoning, props.resumed, refreshEpoch])
2550
3372
 
2551
3373
  // Hook order is unconditional. Its dimensions drive every live-region
2552
3374
  // budget before any dynamic rows are constructed.
@@ -2619,9 +3441,9 @@ export function App(props: AppProps): ReactElement {
2619
3441
  ? Math.max(1, Math.floor(streamRows / 3))
2620
3442
  : 1
2621
3443
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2622
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
3444
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
2623
3445
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2624
- const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
3446
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || inspectorVisible || approvalPending || questionPending
2625
3447
  const closeInspector = useCallback((): void => {
2626
3448
  setVerboseOpen(false)
2627
3449
  }, [])
@@ -2638,12 +3460,129 @@ export function App(props: AppProps): ReactElement {
2638
3460
  setEffortLabel(effortId)
2639
3461
  notify(`model → next step uses ${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`)
2640
3462
  setModelOpen(false)
3463
+ setProviderOpen(false)
3464
+ setProviderAction(undefined)
2641
3465
  setEffortFor(undefined)
2642
3466
  } catch (error: unknown) {
2643
3467
  notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2644
3468
  }
2645
3469
  }
2646
3470
 
3471
+ const reloadModelSurfaces = (): void => {
3472
+ setModelLoadEpoch(epoch => epoch + 1)
3473
+ }
3474
+ const closeModelSurface = (): void => {
3475
+ setModelOpen(false)
3476
+ setProviderOpen(false)
3477
+ setProviderAction(undefined)
3478
+ setEffortFor(undefined)
3479
+ }
3480
+ let modelSurface: ReactElement | undefined
3481
+ if (modelOpen && !approvalPending && !questionPending) {
3482
+ if (providerAction?.kind === 'credential' && props.saveModelProviderCredential !== undefined) {
3483
+ modelSurface = createElement(ProviderCredentialPanel, {
3484
+ target: providerAction.target,
3485
+ save: props.saveModelProviderCredential,
3486
+ done: () => {
3487
+ const target = providerAction.target
3488
+ setProviderAction(undefined)
3489
+ setProviderOpen(false)
3490
+ reloadModelSurfaces()
3491
+ notify(`API key saved for ${target.displayName}; select a model`)
3492
+ },
3493
+ back: () => setProviderAction(undefined),
3494
+ })
3495
+ } else if (providerAction?.kind === 'unset' && props.unsetModelProviderCredential !== undefined) {
3496
+ modelSurface = createElement(ProviderConfirmPanel, {
3497
+ target: providerAction.target,
3498
+ kind: 'credential',
3499
+ confirm: props.unsetModelProviderCredential,
3500
+ done: () => {
3501
+ const target = providerAction.target
3502
+ setProviderAction(undefined)
3503
+ setProviderOpen(true)
3504
+ reloadModelSurfaces()
3505
+ notify(`API key removed for ${target.displayName}`)
3506
+ },
3507
+ back: () => setProviderAction(undefined),
3508
+ })
3509
+ } else if (providerAction?.kind === 'remove' && props.removeModelProvider !== undefined) {
3510
+ modelSurface = createElement(ProviderConfirmPanel, {
3511
+ target: providerAction.target,
3512
+ kind: 'provider',
3513
+ confirm: props.removeModelProvider,
3514
+ done: () => {
3515
+ const target = providerAction.target
3516
+ setProviderAction(undefined)
3517
+ setProviderOpen(true)
3518
+ reloadModelSurfaces()
3519
+ notify(`provider removed: ${target.displayName}`)
3520
+ },
3521
+ back: () => setProviderAction(undefined),
3522
+ })
3523
+ } else if (providerOpen) {
3524
+ modelSurface = createElement(ProviderPanel, {
3525
+ directory: providerDirectory,
3526
+ error: providerError,
3527
+ onCredential: (target: ProviderTargetView) => {
3528
+ if (props.saveModelProviderCredential === undefined) {
3529
+ notify('API key storage is unavailable in this profile', 'warning')
3530
+ return
3531
+ }
3532
+ setProviderAction({ kind: 'credential', target })
3533
+ },
3534
+ onUnset: (target: ProviderTargetView) => {
3535
+ if (props.unsetModelProviderCredential === undefined) {
3536
+ notify('API key removal is unavailable in this profile', 'warning')
3537
+ return
3538
+ }
3539
+ setProviderAction({ kind: 'unset', target })
3540
+ },
3541
+ onRemove: (target: ProviderTargetView) => {
3542
+ if (props.removeModelProvider === undefined) {
3543
+ notify('provider removal is unavailable in this profile', 'warning')
3544
+ return
3545
+ }
3546
+ setProviderAction({ kind: 'remove', target })
3547
+ },
3548
+ onRetry: reloadModelSurfaces,
3549
+ onBack: () => setProviderOpen(false),
3550
+ })
3551
+ } else if (effortFor !== undefined) {
3552
+ modelSurface = createElement(EffortPanel, {
3553
+ // Keyed per row: switching models remounts the stage so its cursor
3554
+ // initializes on the new model's effective effort.
3555
+ key: `${effortFor.provider}/${effortFor.model}`,
3556
+ row: effortFor,
3557
+ current: effortLabel,
3558
+ select: (effortId: string) => applyModel(effortFor, effortId),
3559
+ back: () => setEffortFor(undefined),
3560
+ })
3561
+ } else {
3562
+ modelSurface = createElement(ModelPanel, {
3563
+ directory,
3564
+ error: modelError,
3565
+ current: modelLabel,
3566
+ onSelect: (row: ModelRow) => {
3567
+ // A model advertising several levels opens the effort stage first;
3568
+ // one advertised level is its only option, while no capability uses
3569
+ // the model default exactly as before.
3570
+ if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
3571
+ setEffortFor(row)
3572
+ return
3573
+ }
3574
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
3575
+ applyModel(row, effortId)
3576
+ },
3577
+ ...(props.loadModelProviders === undefined || props.saveModelProviderCredential === undefined
3578
+ ? {}
3579
+ : { onProviders: () => setProviderOpen(true) }),
3580
+ onRetry: reloadModelSurfaces,
3581
+ onClose: closeModelSurface,
3582
+ })
3583
+ }
3584
+ }
3585
+
2647
3586
  return createElement(
2648
3587
  Box,
2649
3588
  { flexDirection: 'column' },
@@ -2661,7 +3600,8 @@ export function App(props: AppProps): ReactElement {
2661
3600
  view.streamingReasoning !== '' && reasoningRows > 0
2662
3601
  ? createElement(StreamTail, {
2663
3602
  text: showReasoning ? view.streamingReasoning : 'Thinking…',
2664
- prefix: ' ✻ ',
3603
+ prefix: '✻ ',
3604
+ continuationPrefix: ' ',
2665
3605
  dim: true,
2666
3606
  maxRows: reasoningRows,
2667
3607
  })
@@ -2679,40 +3619,10 @@ export function App(props: AppProps): ReactElement {
2679
3619
  )
2680
3620
  : undefined,
2681
3621
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
3622
+ transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
2682
3623
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2683
- 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,
3624
+ createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify }),
3625
+ modelSurface,
2716
3626
  helpOpen && !approvalPending && !questionPending
2717
3627
  ? createElement(HelpPanel, {
2718
3628
  descriptors,
@@ -2743,11 +3653,31 @@ export function App(props: AppProps): ReactElement {
2743
3653
  close: () => setModeOpen(false),
2744
3654
  })
2745
3655
  : undefined,
3656
+ permissionOpen && !approvalPending && !questionPending
3657
+ ? createElement(PermissionPanel, {
3658
+ current: props.permission,
3659
+ load: props.loadPermissions,
3660
+ select: (id: string) => {
3661
+ try {
3662
+ const selected = props.setPermission(id)
3663
+ notify(`permission → ${selected}`)
3664
+ setPermissionOpen(false)
3665
+ } catch (reason: unknown) {
3666
+ notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3667
+ }
3668
+ },
3669
+ close: () => setPermissionOpen(false),
3670
+ })
3671
+ : undefined,
2746
3672
  resumeOpen && !approvalPending && !questionPending
2747
3673
  ? createElement(ResumePanel, {
2748
3674
  currentCwd: props.workspaceRoot,
2749
3675
  load: props.loadSessions,
2750
3676
  readTranscript: props.loadSessionTranscript,
3677
+ requestDelete,
3678
+ deleteConfirmId,
3679
+ reloadToken: deleteReloadToken,
3680
+ deleteMode: resumeDelete.mode,
2751
3681
  select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
2752
3682
  close: () => setResumeOpen(false),
2753
3683
  })
@@ -2790,6 +3720,37 @@ export function App(props: AppProps): ReactElement {
2790
3720
  close: () => setHistoryOpen(false),
2791
3721
  })
2792
3722
  : undefined,
3723
+ agentsOpen && !approvalPending && !questionPending
3724
+ ? createElement(AgentsPanel, {
3725
+ live: agentRows,
3726
+ load: props.loadSubagents,
3727
+ readTranscript: props.loadSessionTranscript,
3728
+ close: () => setAgentsOpen(false),
3729
+ })
3730
+ : undefined,
3731
+ subagentOpen && !approvalPending && !questionPending
3732
+ ? createElement(SubagentPanel, {
3733
+ current: props.subagentModel,
3734
+ load: props.loadModels,
3735
+ pick: (row: ModelRow, effortId?: string) => {
3736
+ try {
3737
+ // The runner's label already carries the effort suffix
3738
+ // (`provider/model@effort`), so no second append here.
3739
+ const label = props.setSubagentModel(row, effortId)
3740
+ notify(`subagents → ${label}`)
3741
+ setSubagentOpen(false)
3742
+ } catch (reason: unknown) {
3743
+ notify(`subagent model change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3744
+ }
3745
+ },
3746
+ inherit: () => {
3747
+ props.clearSubagentModel()
3748
+ notify('subagents → inherit current model')
3749
+ setSubagentOpen(false)
3750
+ },
3751
+ close: () => setSubagentOpen(false),
3752
+ })
3753
+ : undefined,
2793
3754
  notice === undefined
2794
3755
  ? undefined
2795
3756
  : createElement(NoticeLine, {
@@ -2816,6 +3777,10 @@ export function App(props: AppProps): ReactElement {
2816
3777
  openModel: () => {
2817
3778
  setDirectory(undefined)
2818
3779
  setModelError(undefined)
3780
+ setProviderDirectory(undefined)
3781
+ setProviderError(undefined)
3782
+ setProviderOpen(false)
3783
+ setProviderAction(undefined)
2819
3784
  setEffortFor(undefined)
2820
3785
  setModelOpen(true)
2821
3786
  },
@@ -2845,7 +3810,12 @@ export function App(props: AppProps): ReactElement {
2845
3810
  return
2846
3811
  }
2847
3812
  if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
2848
- notify('current model does not expose reasoning efforts', 'warning')
3813
+ // A model that advertises no levels still opens the stage: the
3814
+ // panel itself carries the empty state (the web effort pane's
3815
+ // "no levels" copy), instead of a bare notice that reads like
3816
+ // a failure.
3817
+ setEffortFor(row)
3818
+ setModelOpen(true)
2849
3819
  return
2850
3820
  }
2851
3821
  setEffortFor(row)
@@ -2858,11 +3828,23 @@ export function App(props: AppProps): ReactElement {
2858
3828
  setHelpOpen(true)
2859
3829
  },
2860
3830
  openMode: () => setModeOpen(true),
2861
- openResume: () => setResumeOpen(true),
3831
+ openPermission: () => setPermissionOpen(true),
3832
+ openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
2862
3833
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2863
3834
  openStatusline: () => setStatuslineOpen(true),
2864
3835
  openTheme: () => setThemeOpen(true),
2865
3836
  openHistory: () => setHistoryOpen(true),
3837
+ openAgents: () => setAgentsOpen(true),
3838
+ openSubagent: () => setSubagentOpen(true),
3839
+ openDelete: (id?: string) => {
3840
+ const armed = id === undefined || id === '' ? undefined : id
3841
+ setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
3842
+ setDeleteConfirmId(armed)
3843
+ setResumeOpen(true)
3844
+ },
3845
+ deleteConfirm: deleteConfirmId,
3846
+ confirmDelete,
3847
+ cancelDelete,
2866
3848
  createSession: props.createSession,
2867
3849
  cancelSessionSwitch: props.cancelSessionSwitch,
2868
3850
  notify,
@@ -2896,7 +3878,6 @@ export function App(props: AppProps): ReactElement {
2896
3878
  cancelQueued: props.cancelQueued,
2897
3879
  historyFill,
2898
3880
  historyConsumed,
2899
- waveTick,
2900
3881
  waveTier,
2901
3882
  waveStyle,
2902
3883
  }),
@@ -2909,7 +3890,7 @@ export function App(props: AppProps): ReactElement {
2909
3890
  sessionId: props.sessionId,
2910
3891
  title: view.title,
2911
3892
  plan: view.plan,
2912
- permission: view.permission,
3893
+ permission: view.permission !== '' ? view.permission : props.permission,
2913
3894
  sandbox: view.sandbox,
2914
3895
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
2915
3896
  },