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.
- package/README.en.md +30 -7
- package/README.md +30 -7
- package/lib/index.mjs +3791 -853
- package/lib/types/app.d.ts +90 -1
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +65 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +97 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +14 -1
- package/lib/types/session-directory.d.ts +61 -2
- package/lib/types/store.d.ts +13 -2
- package/lib/types/subagents.d.ts +60 -0
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +1200 -219
- package/src/approval.ts +161 -126
- package/src/history.ts +20 -5
- package/src/index.ts +577 -167
- package/src/kernel-panels.ts +354 -37
- package/src/models.ts +26 -0
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +236 -218
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +670 -11
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +77 -34
- package/src/session-directory.ts +171 -10
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/subagents.ts +165 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
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, {
|
|
352
|
+
? createElement(Text, { color: inkColor(getPalette().dim) }, continuationPrefix, '…')
|
|
315
353
|
: undefined,
|
|
316
|
-
|
|
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:
|
|
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(
|
|
504
|
-
: createElement(Text, {
|
|
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
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
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
|
|
611
|
-
const
|
|
612
|
-
|
|
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
|
-
{
|
|
616
|
-
createElement(Text, { color: inkColor(getPalette().brandBright), bold: true },
|
|
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-
|
|
623
|
-
//
|
|
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 },
|
|
635
|
-
createElement(
|
|
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
|
|
699
|
-
//
|
|
700
|
-
|
|
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.
|
|
797
|
-
//
|
|
798
|
-
|
|
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
|
-
/**
|
|
840
|
-
|
|
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 [
|
|
947
|
+
const [cursor, setCursor] = useState(0)
|
|
844
948
|
const pending = snapshot.pending
|
|
845
|
-
const active = !locked &&
|
|
846
|
-
const
|
|
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
|
-
|
|
955
|
+
setCursor(0)
|
|
856
956
|
}, [pending])
|
|
857
957
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
972
|
+
const ask = snapshot.pending
|
|
973
|
+
if (ask === undefined || snapshot.answered) return
|
|
864
974
|
if (key.upArrow) {
|
|
865
|
-
|
|
975
|
+
setCursor(current => (current + APPROVAL_OPTIONS.length - 1) % APPROVAL_OPTIONS.length)
|
|
866
976
|
return
|
|
867
977
|
}
|
|
868
978
|
if (key.downArrow) {
|
|
869
|
-
|
|
979
|
+
setCursor(current => (current + 1) % APPROVAL_OPTIONS.length)
|
|
870
980
|
return
|
|
871
981
|
}
|
|
872
|
-
if (key.
|
|
873
|
-
|
|
982
|
+
if (key.return) {
|
|
983
|
+
decide(APPROVAL_OPTIONS[cursor]!)
|
|
874
984
|
return
|
|
875
985
|
}
|
|
876
|
-
if (key.
|
|
877
|
-
|
|
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
|
-
|
|
991
|
+
decide(APPROVAL_OPTIONS[0]!)
|
|
883
992
|
return
|
|
884
993
|
}
|
|
885
994
|
if (input === 'n' || input === 'N') {
|
|
886
|
-
|
|
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
|
-
|
|
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(
|
|
1012
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`approval${queuedSuffix} · enter/y allow · esc/n reject`, viewport.contentColumns))
|
|
893
1013
|
}
|
|
894
|
-
|
|
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(
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
: '
|
|
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
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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(
|
|
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
|
|
1684
|
-
//
|
|
1685
|
-
//
|
|
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,
|
|
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
|
|
1827
|
-
*
|
|
1828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
2530
|
-
|
|
2531
|
-
//
|
|
2532
|
-
//
|
|
2533
|
-
//
|
|
2534
|
-
// Ctrl+
|
|
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
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|