dsh-code 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +20 -6
- package/README.md +20 -6
- package/lib/index.mjs +2685 -622
- package/lib/types/app.d.ts +77 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +7 -0
- 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 +95 -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 +4 -1
- package/lib/types/session-directory.d.ts +15 -0
- package/lib/types/store.d.ts +13 -2
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +847 -150
- package/src/approval.ts +11 -2
- package/src/history.ts +20 -5
- package/src/index.ts +402 -159
- package/src/kernel-panels.ts +45 -8
- 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 +21 -6
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +665 -10
- 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 +18 -2
- package/src/session-directory.ts +44 -5
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/version.ts +16 -0
package/src/app.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './theme.ts'
|
|
36
36
|
import { ThemePanel } from './theme-panel.ts'
|
|
37
37
|
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
38
|
+
import { DSH_CODE_VERSION } from './version.ts'
|
|
38
39
|
import type { TranscriptStore } from './store.ts'
|
|
39
40
|
import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
40
41
|
import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
@@ -61,11 +62,13 @@ import {
|
|
|
61
62
|
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
62
63
|
import type { CommandsView } from './commands.ts'
|
|
63
64
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
65
|
+
import type { ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
|
|
64
66
|
import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
65
67
|
import type { SkillsView, SkillRow } from './skills.ts'
|
|
66
68
|
import type { MentionCandidate } from './mentions.ts'
|
|
67
|
-
import { EffortPanel, ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
|
|
69
|
+
import { EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
|
|
68
70
|
import type { PresetRow } from './presets.ts'
|
|
71
|
+
import type { PermissionRow } from './permissions.ts'
|
|
69
72
|
import type { PluginRow } from './plugin-inventory.ts'
|
|
70
73
|
import {
|
|
71
74
|
recallEntries,
|
|
@@ -88,6 +91,7 @@ import {
|
|
|
88
91
|
STATUS_CYCLE_HINT,
|
|
89
92
|
STATUS_GROUP_SEPARATOR,
|
|
90
93
|
STATUS_ITEM_SEPARATOR,
|
|
94
|
+
STATUS_ROW2_INDENT,
|
|
91
95
|
type StatusFacts,
|
|
92
96
|
type StatusGroup,
|
|
93
97
|
type StatusItemId,
|
|
@@ -108,6 +112,7 @@ import {
|
|
|
108
112
|
import {
|
|
109
113
|
lineSegment,
|
|
110
114
|
markdownLines,
|
|
115
|
+
reasoningLines,
|
|
111
116
|
styledLines,
|
|
112
117
|
textLines,
|
|
113
118
|
transcriptEntryLines,
|
|
@@ -144,8 +149,10 @@ export interface AppProps {
|
|
|
144
149
|
sessionId: string
|
|
145
150
|
/** Whether this session was resumed from persistence. */
|
|
146
151
|
resumed: boolean
|
|
147
|
-
/** Agent preset
|
|
152
|
+
/** Agent preset selected for the current or pending first session. */
|
|
148
153
|
mode: string
|
|
154
|
+
/** Permission preset selected for the current or pending first session. */
|
|
155
|
+
permission: string
|
|
149
156
|
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
150
157
|
dispatch(text: string): void
|
|
151
158
|
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
@@ -160,8 +167,20 @@ export interface AppProps {
|
|
|
160
167
|
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
161
168
|
/** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
|
|
162
169
|
selectModel(row: ModelRow, effortId?: string): string
|
|
170
|
+
/** Load provider/settings/credential facts for the optional /model provider stage. */
|
|
171
|
+
loadModelProviders?(): Promise<ProviderSettingsDirectory>
|
|
172
|
+
/** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
|
|
173
|
+
subscribeModelProviders?(listener: () => void): () => void
|
|
174
|
+
/** Store or rotate one provider credential through the Harness credential service. */
|
|
175
|
+
saveModelProviderCredential?(target: ProviderTargetView, key: string): Promise<void>
|
|
176
|
+
/** Remove one writable provider credential without removing its settings profile. */
|
|
177
|
+
unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
|
|
178
|
+
/** Remove one user-owned provider profile and its page-managed credential. */
|
|
179
|
+
removeModelProvider?(target: ProviderTargetView): Promise<void>
|
|
163
180
|
/** Cycle to the next permission preset (Shift+Tab); returns the new label. */
|
|
164
181
|
cyclePermission(): string
|
|
182
|
+
/** Select or inspect a permission preset without requiring a pre-existing session. */
|
|
183
|
+
setPermission(id: string): string
|
|
165
184
|
/** Export the transcript to a markdown file (/export [path]); reports via notices. */
|
|
166
185
|
exportTranscript(argument: string): Promise<void>
|
|
167
186
|
/** Rename the session (/title <text>); returns the outcome line for the notice. */
|
|
@@ -169,6 +188,8 @@ export interface AppProps {
|
|
|
169
188
|
/** Preset/session/plugin kernel operations. */
|
|
170
189
|
loadPresets(): Promise<readonly PresetRow[]>
|
|
171
190
|
switchMode(id: string): Promise<string>
|
|
191
|
+
/** Load the switchable permission presets for the /permission panel. */
|
|
192
|
+
loadPermissions(): Promise<readonly PermissionRow[]>
|
|
172
193
|
createSession(mode?: string): void
|
|
173
194
|
loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
174
195
|
loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
@@ -290,30 +311,40 @@ function DeepDivingLine({ since }: { since: number }): ReactElement {
|
|
|
290
311
|
* the freshest tokens stay visible while a long reply streams; the complete
|
|
291
312
|
* text lands in the flushed scrollback once the turn assembles it.
|
|
292
313
|
*/
|
|
293
|
-
function StreamTail({ text, dim, maxRows, prefix, children }: {
|
|
314
|
+
function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children }: {
|
|
294
315
|
text: string
|
|
295
316
|
dim: boolean
|
|
296
317
|
maxRows: number
|
|
297
318
|
prefix?: string
|
|
319
|
+
continuationPrefix?: string
|
|
298
320
|
children?: ReactElement
|
|
299
321
|
}): ReactElement {
|
|
300
322
|
const columns = useStdout().stdout?.columns ?? 80
|
|
301
323
|
const safeRows = Math.max(1, maxRows)
|
|
302
324
|
// App padding consumes two columns; the final extra column keeps a caret
|
|
303
|
-
// from wrapping onto an unbudgeted row.
|
|
304
|
-
|
|
325
|
+
// from wrapping onto an unbudgeted row. Both prefixes participate because
|
|
326
|
+
// every physical row now repeats its hanging indent.
|
|
327
|
+
const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
|
|
328
|
+
const contentColumns = Math.max(10, columns - 3 - prefixColumns)
|
|
305
329
|
const initial = displayTail(text, contentColumns, safeRows)
|
|
306
330
|
// Reserve one row for the omission marker only when a marker is needed.
|
|
307
331
|
const tail = initial.truncated && safeRows > 1
|
|
308
332
|
? displayTail(text, contentColumns, safeRows - 1)
|
|
309
333
|
: initial
|
|
334
|
+
const rows = tail.text.split('\n')
|
|
310
335
|
return createElement(
|
|
311
336
|
Box,
|
|
312
337
|
{ flexDirection: 'column' },
|
|
313
338
|
tail.truncated && safeRows > 1
|
|
314
|
-
? createElement(Text, {
|
|
339
|
+
? createElement(Text, { color: inkColor(getPalette().dim) }, continuationPrefix, '…')
|
|
315
340
|
: undefined,
|
|
316
|
-
|
|
341
|
+
...rows.map((row, index) => createElement(
|
|
342
|
+
Text,
|
|
343
|
+
{ key: index, dimColor: dim || undefined },
|
|
344
|
+
index === 0 ? prefix : continuationPrefix,
|
|
345
|
+
row,
|
|
346
|
+
index + 1 === rows.length ? children : undefined,
|
|
347
|
+
)),
|
|
317
348
|
)
|
|
318
349
|
}
|
|
319
350
|
|
|
@@ -327,6 +358,8 @@ function segmentProps(style: MdSegment['style']): {
|
|
|
327
358
|
switch (style) {
|
|
328
359
|
case 'accent':
|
|
329
360
|
return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
361
|
+
case 'accentBold':
|
|
362
|
+
return { color: inkColor(getPalette().brandBright), bold: true, italic: undefined, strikethrough: undefined }
|
|
330
363
|
case 'code':
|
|
331
364
|
return { color: inkColor(getPalette().code), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
332
365
|
case 'dim':
|
|
@@ -362,7 +395,7 @@ function lineStyleProps(style: LineStyle): {
|
|
|
362
395
|
case 'warn':
|
|
363
396
|
return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
|
|
364
397
|
case 'dimItalic':
|
|
365
|
-
return { color:
|
|
398
|
+
return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined }
|
|
366
399
|
default:
|
|
367
400
|
return { ...segmentProps(style), dimColor: undefined }
|
|
368
401
|
}
|
|
@@ -415,6 +448,13 @@ function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }):
|
|
|
415
448
|
)
|
|
416
449
|
}
|
|
417
450
|
|
|
451
|
+
/** Expanded reasoning with the same two-column content edge as the reply. */
|
|
452
|
+
function ReasoningBody({ text }: { text: string }): ReactElement {
|
|
453
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
454
|
+
const lines = useMemo(() => reasoningLines(text, Math.max(10, columns - 2)), [text, columns])
|
|
455
|
+
return createElement(StyledRows, { lines })
|
|
456
|
+
}
|
|
457
|
+
|
|
418
458
|
/**
|
|
419
459
|
* One expanded tool-card body for the verbose transcript (Ctrl+O): the
|
|
420
460
|
* presentation contract's structured cards — inline diffs, read windows,
|
|
@@ -500,8 +540,8 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
|
|
|
500
540
|
entry.reasoning === ''
|
|
501
541
|
? undefined
|
|
502
542
|
: showReasoning
|
|
503
|
-
? createElement(
|
|
504
|
-
: createElement(Text, {
|
|
543
|
+
? createElement(ReasoningBody, { text: entry.reasoning })
|
|
544
|
+
: createElement(Text, { color: inkColor(getPalette().dim) }, `✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
|
|
505
545
|
createElement(MarkdownBody, { text: entry.text, indent: 2 }),
|
|
506
546
|
)
|
|
507
547
|
case 'tool': {
|
|
@@ -520,7 +560,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
|
|
|
520
560
|
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
521
561
|
mark,
|
|
522
562
|
' ',
|
|
523
|
-
brand(entry.name),
|
|
563
|
+
brand(displayText(entry.name)),
|
|
524
564
|
entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
|
|
525
565
|
),
|
|
526
566
|
entry.summary === ''
|
|
@@ -549,7 +589,7 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
|
|
|
549
589
|
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
550
590
|
mark,
|
|
551
591
|
' ',
|
|
552
|
-
brand(`/${entry.name}`),
|
|
592
|
+
brand(displayText(`/${entry.name}`)),
|
|
553
593
|
entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
|
|
554
594
|
),
|
|
555
595
|
entry.summary === ''
|
|
@@ -600,28 +640,31 @@ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry;
|
|
|
600
640
|
}
|
|
601
641
|
|
|
602
642
|
/**
|
|
603
|
-
* The whale
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* wordmark that stays correct at any size.
|
|
643
|
+
* The whale header with a compact three-line copy lockup. The title, bilingual
|
|
644
|
+
* slogan, and key hint stay centered inside the existing eight content rows,
|
|
645
|
+
* preserving the Static header's ten physical rows. Short or narrow terminals
|
|
646
|
+
* keep a one-line form.
|
|
608
647
|
*/
|
|
609
648
|
function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
|
|
649
|
+
const stdout = useStdout().stdout
|
|
650
|
+
const rows = stdout?.rows ?? 40
|
|
651
|
+
const columns = stdout?.columns ?? 80
|
|
652
|
+
const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`
|
|
653
|
+
const slogan = 'Into the Unknown 探索未至之境'
|
|
654
|
+
const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
|
|
655
|
+
const copyColumns = Math.max(visibleColumns(title), visibleColumns(slogan), visibleColumns(hint))
|
|
656
|
+
const compact = `${title} · ${hint}`
|
|
657
|
+
if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 8) {
|
|
613
658
|
return createElement(
|
|
614
659
|
Box,
|
|
615
|
-
{
|
|
616
|
-
createElement(Text, { color: inkColor(getPalette().brandBright), bold: true },
|
|
617
|
-
createElement(Text, { dimColor: true }, hint),
|
|
660
|
+
{ width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1 },
|
|
661
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, truncateColumns(compact, Math.max(1, columns - 5))),
|
|
618
662
|
)
|
|
619
663
|
}
|
|
620
664
|
return createElement(
|
|
621
665
|
Box,
|
|
622
|
-
// alignSelf shrinks the border to the whale-plus-
|
|
623
|
-
//
|
|
624
|
-
// (the compact-banner treatment the Claude Code welcome uses).
|
|
666
|
+
// alignSelf shrinks the border to the whale-plus-copy content instead of
|
|
667
|
+
// stretching across the terminal and stranding empty space on the right.
|
|
625
668
|
{ flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
|
|
626
669
|
createElement(
|
|
627
670
|
Box,
|
|
@@ -630,9 +673,15 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
|
630
673
|
),
|
|
631
674
|
createElement(
|
|
632
675
|
Box,
|
|
633
|
-
{ flexDirection: 'column', justifyContent: 'center' },
|
|
634
|
-
createElement(Text, { color: inkColor(getPalette().brandBright), bold: true },
|
|
635
|
-
createElement(
|
|
676
|
+
{ flexDirection: 'column', width: copyColumns, justifyContent: 'center' },
|
|
677
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, title),
|
|
678
|
+
createElement(
|
|
679
|
+
Text,
|
|
680
|
+
{ color: inkColor(getPalette().code), wrap: 'truncate-end' },
|
|
681
|
+
createElement(Text, { bold: true }, 'Into the Unknown'),
|
|
682
|
+
' 探索未至之境',
|
|
683
|
+
),
|
|
684
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, hint),
|
|
636
685
|
),
|
|
637
686
|
)
|
|
638
687
|
}
|
|
@@ -695,19 +744,10 @@ function statusToneProps(tone: StatusTone): {
|
|
|
695
744
|
return { color: inkColor(getPalette().dim), bold: undefined, dimColor: undefined }
|
|
696
745
|
case 'accent':
|
|
697
746
|
return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
|
|
698
|
-
// Context-bar
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
case 'ctxSystem':
|
|
702
|
-
return { color: inkColor(getPalette().brandDeep), bold: undefined, dimColor: undefined }
|
|
703
|
-
case 'ctxPrompt':
|
|
747
|
+
// Context-bar fill: one DeepSeek blue over the whole occupied run; the
|
|
748
|
+
// dotted free track reads through the dim label gray.
|
|
749
|
+
case 'ctxFill':
|
|
704
750
|
return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
|
|
705
|
-
case 'ctxAssistant':
|
|
706
|
-
return { color: inkColor(getPalette().brandMid), bold: undefined, dimColor: undefined }
|
|
707
|
-
case 'ctxThinking':
|
|
708
|
-
return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
|
|
709
|
-
case 'ctxTools':
|
|
710
|
-
return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
|
|
711
751
|
case 'success':
|
|
712
752
|
return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
|
|
713
753
|
case 'warn':
|
|
@@ -759,7 +799,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
|
|
|
759
799
|
}): ReactElement {
|
|
760
800
|
const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
|
|
761
801
|
|
|
762
|
-
const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
|
|
802
|
+
const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
|
|
763
803
|
const leftParts: ReactElement[] = []
|
|
764
804
|
row.left.forEach((group, groupIndex) => {
|
|
765
805
|
if (groupIndex > 0) {
|
|
@@ -793,9 +833,10 @@ function StatusLine({ facts, stats, busy, columns, items }: {
|
|
|
793
833
|
return createElement(
|
|
794
834
|
Box,
|
|
795
835
|
// Match the prompt text inside the bordered composer: one border column
|
|
796
|
-
// plus one padding column.
|
|
797
|
-
//
|
|
798
|
-
|
|
836
|
+
// plus one padding column. The secondary row adds the model-name indent
|
|
837
|
+
// (its budget already shrinks by the same amount) so its figures align
|
|
838
|
+
// under the model name rather than under the busy dot.
|
|
839
|
+
{ paddingLeft: 2 + indent, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
|
|
799
840
|
createElement(Text, { wrap: 'truncate-end' }, ...leftParts),
|
|
800
841
|
rightParts.length > 0 ? createElement(Text, { wrap: 'truncate-end' }, ...rightParts) : undefined,
|
|
801
842
|
)
|
|
@@ -805,7 +846,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
|
|
|
805
846
|
Box,
|
|
806
847
|
{ flexDirection: 'column' },
|
|
807
848
|
renderRow(layout.row1, 's1'),
|
|
808
|
-
row2Present ? renderRow(layout.row2, 's2') : undefined,
|
|
849
|
+
row2Present ? renderRow(layout.row2, 's2', STATUS_ROW2_INDENT) : undefined,
|
|
809
850
|
)
|
|
810
851
|
}
|
|
811
852
|
|
|
@@ -1176,10 +1217,11 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
|
|
|
1176
1217
|
}
|
|
1177
1218
|
|
|
1178
1219
|
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
1179
|
-
function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
1220
|
+
function ModelPanel({ directory, error, onSelect, onProviders, onRetry, onClose }: {
|
|
1180
1221
|
directory: ModelDirectory | undefined
|
|
1181
1222
|
error: string | undefined
|
|
1182
1223
|
onSelect(row: ModelRow): void
|
|
1224
|
+
onProviders?(): void
|
|
1183
1225
|
onRetry(): void
|
|
1184
1226
|
onClose(): void
|
|
1185
1227
|
}): ReactElement {
|
|
@@ -1205,6 +1247,10 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
|
1205
1247
|
onRetry()
|
|
1206
1248
|
return
|
|
1207
1249
|
}
|
|
1250
|
+
if (input === 'a' && onProviders !== undefined) {
|
|
1251
|
+
onProviders()
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1208
1254
|
if (rows.length === 0) return
|
|
1209
1255
|
if (key.upArrow) {
|
|
1210
1256
|
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
@@ -1237,7 +1283,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
|
1237
1283
|
|
|
1238
1284
|
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1239
1285
|
if (viewport.compact) {
|
|
1240
|
-
|
|
1286
|
+
const providers = onProviders === undefined ? '' : ' · a providers'
|
|
1287
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model${providers} · r retry · esc/q close`, viewport.contentColumns))
|
|
1241
1288
|
}
|
|
1242
1289
|
|
|
1243
1290
|
const stateRows: ReactElement[] = directory === undefined && error === undefined
|
|
@@ -1263,7 +1310,8 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
|
1263
1310
|
// Measurement and rendering share the same physical-row budget: state
|
|
1264
1311
|
// messages consume body rows before selectable entries, as in Codex's
|
|
1265
1312
|
// list-selection views.
|
|
1266
|
-
const
|
|
1313
|
+
const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
|
|
1314
|
+
const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
|
|
1267
1315
|
const first = selectionWindow(cursor, rows.length, rowBudget)
|
|
1268
1316
|
const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
|
|
1269
1317
|
return createElement(
|
|
@@ -1271,7 +1319,7 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
|
1271
1319
|
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
1272
1320
|
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
|
|
1273
1321
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1274
|
-
...
|
|
1322
|
+
...visibleStateRows,
|
|
1275
1323
|
...visible.map((row) => {
|
|
1276
1324
|
const index = rows.indexOf(row)
|
|
1277
1325
|
const label = displayText(`${row.providerName} · ${row.modelName}`)
|
|
@@ -1286,7 +1334,303 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
|
1286
1334
|
)
|
|
1287
1335
|
}),
|
|
1288
1336
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1289
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(
|
|
1337
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · a providers'} · r retry · esc/q close`, viewport.contentColumns))),
|
|
1338
|
+
)
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** Compact provider-state copy; only value-free credential facts cross this boundary. */
|
|
1342
|
+
function providerStateLabel(row: ProviderTargetView): string {
|
|
1343
|
+
const route = row.active ? 'active' : 'dormant'
|
|
1344
|
+
const credential = row.credential
|
|
1345
|
+
if (credential?.kind === 'error') return `${route} · key status unavailable`
|
|
1346
|
+
if (credential?.kind === 'facts') {
|
|
1347
|
+
if (!credential.configured) return `${route} · key missing`
|
|
1348
|
+
const source = credential.source === undefined ? 'configured' : singleLineText(credential.source)
|
|
1349
|
+
return `${route} · key ${source}${credential.writable ? '' : ' · read-only'}`
|
|
1350
|
+
}
|
|
1351
|
+
return `${route} · ${row.configured ? 'provider auth' : 'not configured'}`
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** The provider-management stage reached from /model with `a`. */
|
|
1355
|
+
function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRetry, onBack }: {
|
|
1356
|
+
directory: ProviderSettingsDirectory | undefined
|
|
1357
|
+
error: string | undefined
|
|
1358
|
+
onCredential(target: ProviderTargetView): void
|
|
1359
|
+
onUnset(target: ProviderTargetView): void
|
|
1360
|
+
onRemove(target: ProviderTargetView): void
|
|
1361
|
+
onRetry(): void
|
|
1362
|
+
onBack(): void
|
|
1363
|
+
}): ReactElement {
|
|
1364
|
+
const stdout = useStdout().stdout
|
|
1365
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1366
|
+
const rows = directory?.rows ?? []
|
|
1367
|
+
const [cursor, setCursor] = useState(0)
|
|
1368
|
+
const [actionError, setActionError] = useState<string | undefined>(undefined)
|
|
1369
|
+
|
|
1370
|
+
useEffect(() => {
|
|
1371
|
+
if (rows.length === 0) {
|
|
1372
|
+
if (cursor !== 0) setCursor(0)
|
|
1373
|
+
return
|
|
1374
|
+
}
|
|
1375
|
+
if (cursor >= rows.length) setCursor(rows.length - 1)
|
|
1376
|
+
}, [rows.length, cursor])
|
|
1377
|
+
|
|
1378
|
+
useStableInput((input, key) => {
|
|
1379
|
+
if (key.escape || input === 'q') {
|
|
1380
|
+
onBack()
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1383
|
+
if (input === 'r') {
|
|
1384
|
+
setActionError(undefined)
|
|
1385
|
+
onRetry()
|
|
1386
|
+
return
|
|
1387
|
+
}
|
|
1388
|
+
if (rows.length === 0) return
|
|
1389
|
+
if (key.upArrow) {
|
|
1390
|
+
setActionError(undefined)
|
|
1391
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
if (key.downArrow) {
|
|
1395
|
+
setActionError(undefined)
|
|
1396
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
|
|
1397
|
+
return
|
|
1398
|
+
}
|
|
1399
|
+
if (key.pageUp) {
|
|
1400
|
+
setActionError(undefined)
|
|
1401
|
+
setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
if (key.pageDown) {
|
|
1405
|
+
setActionError(undefined)
|
|
1406
|
+
setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
|
|
1407
|
+
return
|
|
1408
|
+
}
|
|
1409
|
+
const target = rows[cursor]
|
|
1410
|
+
if (target === undefined) return
|
|
1411
|
+
if (input === 'd') {
|
|
1412
|
+
const facts = target.credential
|
|
1413
|
+
if (facts?.kind !== 'facts' || !facts.configured) {
|
|
1414
|
+
setActionError('this provider has no configured API key to remove')
|
|
1415
|
+
} else if (!facts.writable) {
|
|
1416
|
+
setActionError('this API key is supplied read-only by the environment')
|
|
1417
|
+
} else {
|
|
1418
|
+
onUnset(target)
|
|
1419
|
+
}
|
|
1420
|
+
return
|
|
1421
|
+
}
|
|
1422
|
+
if (input === 'x') {
|
|
1423
|
+
if (!target.removable) {
|
|
1424
|
+
setActionError('this provider profile is not removable')
|
|
1425
|
+
} else {
|
|
1426
|
+
onRemove(target)
|
|
1427
|
+
}
|
|
1428
|
+
return
|
|
1429
|
+
}
|
|
1430
|
+
if (key.return) {
|
|
1431
|
+
if (target.settingsNs.length === 0) {
|
|
1432
|
+
setActionError('this provider is not managed by Harness settings')
|
|
1433
|
+
} else if (target.credential?.kind === 'error') {
|
|
1434
|
+
setActionError('credential status is unavailable; retry before writing')
|
|
1435
|
+
} else if (target.credential?.kind === 'facts' && !target.credential.writable) {
|
|
1436
|
+
setActionError('this API key is supplied read-only by the environment')
|
|
1437
|
+
} else if (target.credentialRef === undefined && directory?.writable !== true) {
|
|
1438
|
+
setActionError('settings are read-only; this provider cannot be activated here')
|
|
1439
|
+
} else {
|
|
1440
|
+
onCredential(target)
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}, true)
|
|
1444
|
+
|
|
1445
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1446
|
+
if (viewport.compact) {
|
|
1447
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model providers · enter key · d remove key · esc back', viewport.contentColumns))
|
|
1448
|
+
}
|
|
1449
|
+
const stateRows: ReactElement[] = directory === undefined && error === undefined
|
|
1450
|
+
? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' loading providers…')]
|
|
1451
|
+
: error !== undefined
|
|
1452
|
+
? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
|
|
1453
|
+
: [
|
|
1454
|
+
...(actionError === undefined
|
|
1455
|
+
? []
|
|
1456
|
+
: [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
|
|
1457
|
+
...(directory?.failures ?? []).map((failure, index) => createElement(
|
|
1458
|
+
Text,
|
|
1459
|
+
{ key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
1460
|
+
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1461
|
+
)),
|
|
1462
|
+
...(rows.length === 0
|
|
1463
|
+
? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
|
|
1464
|
+
: []),
|
|
1465
|
+
]
|
|
1466
|
+
const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
|
|
1467
|
+
const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
|
|
1468
|
+
const first = selectionWindow(cursor, rows.length, rowBudget)
|
|
1469
|
+
const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
|
|
1470
|
+
return createElement(
|
|
1471
|
+
Box,
|
|
1472
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
1473
|
+
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — providers${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
|
|
1474
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1475
|
+
...visibleStateRows,
|
|
1476
|
+
...visible.map((row) => {
|
|
1477
|
+
const index = rows.indexOf(row)
|
|
1478
|
+
const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
|
|
1479
|
+
const label = `${identity} · ${providerStateLabel(row)}${row.removable ? ' · custom' : ''}`
|
|
1480
|
+
return createElement(
|
|
1481
|
+
Text,
|
|
1482
|
+
{ key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
|
|
1483
|
+
truncateColumns(`${index === cursor ? '❯ ' : ' '}${displayText(label)}`, viewport.contentColumns),
|
|
1484
|
+
)
|
|
1485
|
+
}),
|
|
1486
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1487
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter add/update key · d remove key · x remove custom provider · r retry · esc back', viewport.contentColumns)),
|
|
1488
|
+
)
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/** Write-only masked API-key editor; the secret lives only in this mounted component. */
|
|
1492
|
+
function ProviderCredentialPanel({ target, save, done, back }: {
|
|
1493
|
+
target: ProviderTargetView
|
|
1494
|
+
save(target: ProviderTargetView, key: string): Promise<void>
|
|
1495
|
+
done(): void
|
|
1496
|
+
back(): void
|
|
1497
|
+
}): ReactElement {
|
|
1498
|
+
const stdout = useStdout().stdout
|
|
1499
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1500
|
+
const [draft, setDraft] = useState('')
|
|
1501
|
+
const [busy, setBusy] = useState(false)
|
|
1502
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
1503
|
+
|
|
1504
|
+
const submit = (): void => {
|
|
1505
|
+
if (busy) return
|
|
1506
|
+
setBusy(true)
|
|
1507
|
+
setError(undefined)
|
|
1508
|
+
Promise.resolve().then(() => save(target, draft)).then(() => {
|
|
1509
|
+
setDraft('')
|
|
1510
|
+
done()
|
|
1511
|
+
}, (reason: unknown) => {
|
|
1512
|
+
setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
|
|
1513
|
+
setBusy(false)
|
|
1514
|
+
})
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
useStableInput((input, key) => {
|
|
1518
|
+
if (busy) return
|
|
1519
|
+
if (key.escape) {
|
|
1520
|
+
setDraft('')
|
|
1521
|
+
back()
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
if (key.return) {
|
|
1525
|
+
submit()
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
if (key.backspace || key.delete) {
|
|
1529
|
+
setError(undefined)
|
|
1530
|
+
setDraft(current => [...current].slice(0, -1).join(''))
|
|
1531
|
+
return
|
|
1532
|
+
}
|
|
1533
|
+
if (key.ctrl && input === 'u') {
|
|
1534
|
+
setError(undefined)
|
|
1535
|
+
setDraft('')
|
|
1536
|
+
return
|
|
1537
|
+
}
|
|
1538
|
+
if (key.ctrl || key.meta || input.length === 0) return
|
|
1539
|
+
const next = draft + input
|
|
1540
|
+
if (next.length > 4096) {
|
|
1541
|
+
setError('API key input is too long')
|
|
1542
|
+
return
|
|
1543
|
+
}
|
|
1544
|
+
setError(undefined)
|
|
1545
|
+
setDraft(next)
|
|
1546
|
+
}, true)
|
|
1547
|
+
|
|
1548
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1549
|
+
const keyBudget = Math.max(1, viewport.contentColumns - 4)
|
|
1550
|
+
const bullets = '•'.repeat(Math.min([...draft].length, keyBudget))
|
|
1551
|
+
if (viewport.compact) {
|
|
1552
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`API key ${bullets}${busy ? ' saving…' : ' ▏'} · esc back`, viewport.contentColumns))
|
|
1553
|
+
}
|
|
1554
|
+
const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
|
|
1555
|
+
const source = target.credential?.kind === 'facts' && target.credential.configured
|
|
1556
|
+
? `replaces ${singleLineText(target.credential.source ?? 'stored key')}`
|
|
1557
|
+
: 'new key'
|
|
1558
|
+
const providerRow = createElement(Text, { key: 'provider', wrap: 'truncate-end' }, truncateColumns(` provider ${displayText(identity)}`, viewport.contentColumns))
|
|
1559
|
+
const referenceRow = createElement(Text, { key: 'reference', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` reference ${displayText(target.credentialRef ?? target.suggestedRef)} · ${source}`, viewport.contentColumns))
|
|
1560
|
+
const keyRow = createElement(Text, { key: 'key', color: error === undefined ? inkColor(getPalette().brandBright) : inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` key ${bullets}${busy ? ' saving…' : ' ▏'}`, viewport.contentColumns))
|
|
1561
|
+
const errorRow = error === undefined
|
|
1562
|
+
? undefined
|
|
1563
|
+
: createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
|
|
1564
|
+
const detailRows = errorRow === undefined ? [providerRow, referenceRow] : [providerRow, errorRow]
|
|
1565
|
+
const primaryRow = viewport.bodyRows === 1 && errorRow !== undefined ? errorRow : keyRow
|
|
1566
|
+
const detailBudget = Math.max(0, viewport.bodyRows - 1)
|
|
1567
|
+
const bodyRows = [
|
|
1568
|
+
...(detailBudget === 0 ? [] : detailRows.slice(-detailBudget)),
|
|
1569
|
+
...(viewport.bodyRows === 0 ? [] : [primaryRow]),
|
|
1570
|
+
]
|
|
1571
|
+
return createElement(
|
|
1572
|
+
Box,
|
|
1573
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
1574
|
+
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — add API key', viewport.contentColumns)),
|
|
1575
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1576
|
+
...bodyRows,
|
|
1577
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1578
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('type or paste key · enter save · ctrl+u clear · esc back', viewport.contentColumns)),
|
|
1579
|
+
)
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** Bounded destructive-action confirmation for credential or provider removal. */
|
|
1583
|
+
function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
|
|
1584
|
+
target: ProviderTargetView
|
|
1585
|
+
kind: 'credential' | 'provider'
|
|
1586
|
+
confirm(target: ProviderTargetView): Promise<void>
|
|
1587
|
+
done(): void
|
|
1588
|
+
back(): void
|
|
1589
|
+
}): ReactElement {
|
|
1590
|
+
const stdout = useStdout().stdout
|
|
1591
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1592
|
+
const [busy, setBusy] = useState(false)
|
|
1593
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
1594
|
+
const run = (): void => {
|
|
1595
|
+
if (busy) return
|
|
1596
|
+
setBusy(true)
|
|
1597
|
+
setError(undefined)
|
|
1598
|
+
Promise.resolve().then(() => confirm(target)).then(done, (reason: unknown) => {
|
|
1599
|
+
setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
|
|
1600
|
+
setBusy(false)
|
|
1601
|
+
})
|
|
1602
|
+
}
|
|
1603
|
+
useStableInput((input, key) => {
|
|
1604
|
+
if (busy) return
|
|
1605
|
+
if (key.escape || input === 'n') {
|
|
1606
|
+
back()
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
if (input === 'y') run()
|
|
1610
|
+
}, true)
|
|
1611
|
+
|
|
1612
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1613
|
+
const action = kind === 'credential' ? 'remove API key' : 'remove provider'
|
|
1614
|
+
if (viewport.compact) {
|
|
1615
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${action} ${target.displayName}? · y confirm · n/esc back`, viewport.contentColumns))
|
|
1616
|
+
}
|
|
1617
|
+
const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
|
|
1618
|
+
const identityRow = createElement(Text, { key: 'identity', wrap: 'truncate-end' }, truncateColumns(` ${displayText(identity)}`, viewport.contentColumns))
|
|
1619
|
+
const descriptionRow = createElement(Text, { key: 'description', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(kind === 'credential' ? ' the provider profile and selected model stay available' : ' the user settings profile and its managed key will be removed', viewport.contentColumns))
|
|
1620
|
+
const errorRow = error === undefined
|
|
1621
|
+
? undefined
|
|
1622
|
+
: createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
|
|
1623
|
+
const bodyRows = errorRow === undefined
|
|
1624
|
+
? [identityRow, descriptionRow].slice(0, viewport.bodyRows)
|
|
1625
|
+
: [identityRow, errorRow].slice(-viewport.bodyRows)
|
|
1626
|
+
return createElement(
|
|
1627
|
+
Box,
|
|
1628
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
|
|
1629
|
+
createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — ${action}`, viewport.contentColumns)),
|
|
1630
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1631
|
+
...bodyRows,
|
|
1632
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1633
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? 'working…' : 'y confirm · n/esc back', viewport.contentColumns)),
|
|
1290
1634
|
)
|
|
1291
1635
|
}
|
|
1292
1636
|
|
|
@@ -1334,6 +1678,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
|
1334
1678
|
createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
|
|
1335
1679
|
createElement(Box, { key: 'local-effort' }, row('/effort', 'adjust reasoning effort for the current model')),
|
|
1336
1680
|
createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
|
|
1681
|
+
createElement(Box, { key: 'local-permission' }, row('/permission', 'inspect or select the permission preset (/permission [preset])')),
|
|
1337
1682
|
createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
|
|
1338
1683
|
createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
|
|
1339
1684
|
createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
|
|
@@ -1669,6 +2014,7 @@ export function completionCandidates(
|
|
|
1669
2014
|
{ label: '/model', description: 'switch the model', origin: 'command' },
|
|
1670
2015
|
{ label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
|
|
1671
2016
|
{ label: '/mode', description: 'select the agent preset', origin: 'command' },
|
|
2017
|
+
{ label: '/permission', description: 'inspect or select the permission preset', origin: 'command' },
|
|
1672
2018
|
{ label: '/new', description: 'start a fresh session', origin: 'command' },
|
|
1673
2019
|
{ label: '/resume', description: 'browse or switch sessions', origin: 'command' },
|
|
1674
2020
|
{ label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
|
|
@@ -1680,9 +2026,9 @@ export function completionCandidates(
|
|
|
1680
2026
|
{ label: '/title', description: 'rename this session', origin: 'command' },
|
|
1681
2027
|
{ label: '/quit', description: 'exit', origin: 'command' },
|
|
1682
2028
|
]
|
|
1683
|
-
// Local commands shadow registry names (e.g. the
|
|
1684
|
-
//
|
|
1685
|
-
//
|
|
2029
|
+
// Local commands shadow registry names (e.g. the TUI-local /permission works
|
|
2030
|
+
// before any session exists, while the registry child needs one), so
|
|
2031
|
+
// collisions cannot render two rows with the same key.
|
|
1686
2032
|
const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
|
|
1687
2033
|
const registry = descriptors
|
|
1688
2034
|
.filter(descriptor => !localNames.has(descriptor.name))
|
|
@@ -1777,7 +2123,7 @@ function CompletionMenu({ active, mention, index, rows }: {
|
|
|
1777
2123
|
* While a modal (approval / question / model panel) owns the keys, the
|
|
1778
2124
|
* box passes every key through untouched.
|
|
1779
2125
|
*/
|
|
1780
|
-
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed,
|
|
2126
|
+
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }: {
|
|
1781
2127
|
active: boolean
|
|
1782
2128
|
frozen: boolean
|
|
1783
2129
|
busy: boolean
|
|
@@ -1791,6 +2137,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1791
2137
|
openEffort(): void
|
|
1792
2138
|
openHelp(): void
|
|
1793
2139
|
openMode(): void
|
|
2140
|
+
openPermission(): void
|
|
1794
2141
|
openResume(): void
|
|
1795
2142
|
openPlugin(query?: string): void
|
|
1796
2143
|
openStatusline(): void
|
|
@@ -1823,11 +2170,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1823
2170
|
historyFill: { text: string; index: number } | undefined
|
|
1824
2171
|
/** Marks the accepted entry consumed (called after the fill is applied). */
|
|
1825
2172
|
historyConsumed(): void
|
|
1826
|
-
/** DeepSeek easter-egg wave
|
|
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. */
|
|
2173
|
+
/** DeepSeek easter-egg wave tier of the applied official DeepSeek model
|
|
2174
|
+
* (null otherwise): drives the persistent prompt glyph/accent and the
|
|
2175
|
+
* sparkle tier. */
|
|
1831
2176
|
waveTier: DeepseekWaveTier | null
|
|
1832
2177
|
/** The ignition style running, if any: Wave / Aurora / Pulse. */
|
|
1833
2178
|
waveStyle: DeepseekWaveStyle | null
|
|
@@ -2072,6 +2417,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2072
2417
|
openEffort()
|
|
2073
2418
|
return
|
|
2074
2419
|
}
|
|
2420
|
+
if (text === '/permission') {
|
|
2421
|
+
openPermission()
|
|
2422
|
+
return
|
|
2423
|
+
}
|
|
2424
|
+
if (text.startsWith('/permission ')) {
|
|
2425
|
+
dispatch(text)
|
|
2426
|
+
return
|
|
2427
|
+
}
|
|
2075
2428
|
if (text === '/mode' || text.startsWith('/mode ')) {
|
|
2076
2429
|
const mode = text.slice(5).trim()
|
|
2077
2430
|
if (mode === '') openMode()
|
|
@@ -2236,6 +2589,42 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2236
2589
|
}
|
|
2237
2590
|
})
|
|
2238
2591
|
|
|
2592
|
+
// The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
|
|
2593
|
+
// the interval re-renders only the composer row at 30fps, never the whole
|
|
2594
|
+
// tree. App drives the tier/style pair on a model switch; this local effect
|
|
2595
|
+
// starts the sweep whenever that pair changes (App picks a NEW random style
|
|
2596
|
+
// for every replay — including effort changes on the same route — so the
|
|
2597
|
+
// pair always differs when a new wave should run) and stops it when the
|
|
2598
|
+
// model leaves the official DeepSeek route (tier becomes null).
|
|
2599
|
+
const [waveTick, setWaveTick] = useState<number | null>(null)
|
|
2600
|
+
const wavePrevious = useRef<{ tier: DeepseekWaveTier | null; style: DeepseekWaveStyle | null }>({ tier: null, style: null })
|
|
2601
|
+
useEffect(() => {
|
|
2602
|
+
const previous = wavePrevious.current
|
|
2603
|
+
wavePrevious.current = { tier: waveTier, style: waveStyle }
|
|
2604
|
+
if (waveTier === null) {
|
|
2605
|
+
setWaveTick(null)
|
|
2606
|
+
return
|
|
2607
|
+
}
|
|
2608
|
+
if (previous.tier !== waveTier || previous.style !== waveStyle) {
|
|
2609
|
+
setWaveTick(0)
|
|
2610
|
+
}
|
|
2611
|
+
}, [waveTier, waveStyle])
|
|
2612
|
+
const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
|
|
2613
|
+
&& waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
|
|
2614
|
+
useEffect(() => {
|
|
2615
|
+
if (!waveActive) return
|
|
2616
|
+
const id = setInterval(() => {
|
|
2617
|
+
setWaveTick(current => (current === null ? 0 : current + 1))
|
|
2618
|
+
}, DEEPSEEK_WAVE_TICK_MS)
|
|
2619
|
+
return () => {
|
|
2620
|
+
clearInterval(id)
|
|
2621
|
+
}
|
|
2622
|
+
}, [waveActive])
|
|
2623
|
+
useEffect(() => {
|
|
2624
|
+
if (waveTick !== null && waveTier !== null && waveStyle !== null
|
|
2625
|
+
&& waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
|
|
2626
|
+
}, [waveTick, waveTier, waveStyle])
|
|
2627
|
+
|
|
2239
2628
|
// Every exclusive panel keeps the composer as a stable visual anchor, but
|
|
2240
2629
|
// freezes it to one row: no menu, multiline wrap, or animation.
|
|
2241
2630
|
const tierActive = waveTier !== null
|
|
@@ -2371,13 +2760,192 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2371
2760
|
)
|
|
2372
2761
|
}
|
|
2373
2762
|
|
|
2763
|
+
/** One cached settled row: the row Box plus its roomy-prompt spacers. */
|
|
2764
|
+
interface SettledRowRecord {
|
|
2765
|
+
/** The row Box element (keyed by the entry's settled index). */
|
|
2766
|
+
box: ReactElement
|
|
2767
|
+
/** The roomy-prompt spacer BEFORE the row, or undefined. */
|
|
2768
|
+
before: ReactElement | undefined
|
|
2769
|
+
/** The roomy-prompt spacer AFTER the row, or undefined. */
|
|
2770
|
+
after: ReactElement | undefined
|
|
2771
|
+
/** Whether the row's text depends on the reasoning toggle (Ctrl+R). */
|
|
2772
|
+
reasonSensitive: boolean
|
|
2773
|
+
/** The toggle state the row was built with. */
|
|
2774
|
+
showReasoning: boolean
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/** The incremental settled-history cache (see `computeSettledRows`). */
|
|
2778
|
+
interface SettledRowsCache {
|
|
2779
|
+
/** The exact settled entries the cache covers (`view.entries[0..entries.length)`). */
|
|
2780
|
+
entries: TranscriptEntry[]
|
|
2781
|
+
/** Records keyed by entry identity; mutated in place so the append path
|
|
2782
|
+
* never copies the whole map. */
|
|
2783
|
+
records: Map<TranscriptEntry, SettledRowRecord>
|
|
2784
|
+
/** The header element (depends only on `resumed`). */
|
|
2785
|
+
header: ReactElement
|
|
2786
|
+
/** The `resumed` the header was built with. */
|
|
2787
|
+
resumed: boolean
|
|
2788
|
+
/** The toggle state the rows were built with. */
|
|
2789
|
+
showReasoning: boolean
|
|
2790
|
+
/** The refreshEpoch the rows were built for; a bump forces a full rebuild. */
|
|
2791
|
+
epoch: number
|
|
2792
|
+
/** The flat row list (header + per-entry before/box/after). */
|
|
2793
|
+
flat: ReactElement[]
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
/** One step of `computeSettledRows`. */
|
|
2797
|
+
interface SettledRowsResult {
|
|
2798
|
+
cache: SettledRowsCache
|
|
2799
|
+
/** How many rows had to be BUILT by this step (0 = pure reuse). */
|
|
2800
|
+
built: number
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/** Build one settled row (row Box plus its roomy-prompt spacers). */
|
|
2804
|
+
function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean): SettledRowRecord {
|
|
2805
|
+
const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
|
|
2806
|
+
const roomyPrompt = entry.kind === 'user' && !entry.notice
|
|
2807
|
+
return {
|
|
2808
|
+
box: createElement(Box, { key: index, paddingX: 1 }, row),
|
|
2809
|
+
before: roomyPrompt
|
|
2810
|
+
? createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
|
|
2811
|
+
: undefined,
|
|
2812
|
+
after: roomyPrompt
|
|
2813
|
+
? createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
|
|
2814
|
+
: undefined,
|
|
2815
|
+
reasonSensitive: entry.kind === 'assistant' && entry.reasoning !== '',
|
|
2816
|
+
showReasoning,
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/**
|
|
2821
|
+
* The settled `<Static>` row set as a PURE incremental state machine (App
|
|
2822
|
+
* drives it from the memo; tests drive it directly and read `built`).
|
|
2823
|
+
*
|
|
2824
|
+
* The settled prefix is permanently final: the projection only APPENDS below
|
|
2825
|
+
* the flush boundary, removes pending rows at or beyond it, and replaces
|
|
2826
|
+
* running tool/retry/command rows there too. So extending the cache never
|
|
2827
|
+
* rescans the old prefix — a grown boundary builds ONLY the newly settled
|
|
2828
|
+
* suffix and reuses every cached element, letting React bail out of unchanged
|
|
2829
|
+
* rows and keeping long histories out of the per-durable-event path (no O(N)
|
|
2830
|
+
* rebuild of rows, Map, or MarkdownBody parses). `records` is mutated in place
|
|
2831
|
+
* on the append/toggle paths to stay O(delta).
|
|
2832
|
+
*
|
|
2833
|
+
* Full rebuilds run only on the rare, deliberate paths: no cache yet, a
|
|
2834
|
+
* source-backed replay (`epoch` bump: resize / Ctrl+L / Ctrl+R remounts
|
|
2835
|
+
* `<Static>` and must re-flush the CURRENT rows), a `resumed` change, or a shrink
|
|
2836
|
+
* (`store.reset`). A reasoning toggle rebuilds only the rows whose text
|
|
2837
|
+
* depends on it, preserving the other rows' element identity.
|
|
2838
|
+
*/
|
|
2839
|
+
export function computeSettledRows(
|
|
2840
|
+
previous: SettledRowsCache | undefined,
|
|
2841
|
+
entries: readonly TranscriptEntry[],
|
|
2842
|
+
settled: number,
|
|
2843
|
+
showReasoning: boolean,
|
|
2844
|
+
resumed: boolean,
|
|
2845
|
+
epoch: number,
|
|
2846
|
+
): SettledRowsResult {
|
|
2847
|
+
if (previous === undefined || previous.epoch !== epoch || previous.resumed !== resumed
|
|
2848
|
+
|| settled < previous.entries.length) {
|
|
2849
|
+
// Full rebuild from the current settled prefix.
|
|
2850
|
+
const records = new Map<TranscriptEntry, SettledRowRecord>()
|
|
2851
|
+
const flat: ReactElement[] = [createElement(Header, { key: 'header', resumed })]
|
|
2852
|
+
for (let index = 0; index < settled; index++) {
|
|
2853
|
+
const entry = entries[index]
|
|
2854
|
+
const record = buildSettledRow(entry, index, showReasoning)
|
|
2855
|
+
records.set(entry, record)
|
|
2856
|
+
if (record.before !== undefined) flat.push(record.before)
|
|
2857
|
+
flat.push(record.box)
|
|
2858
|
+
if (record.after !== undefined) flat.push(record.after)
|
|
2859
|
+
}
|
|
2860
|
+
return {
|
|
2861
|
+
cache: { entries: entries.slice(0, settled), records, header: flat[0]!, resumed, showReasoning, epoch, flat },
|
|
2862
|
+
built: settled,
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
if (previous.showReasoning !== showReasoning) {
|
|
2866
|
+
// Reasoning toggle: only rows whose text depends on it rebuild; spacers
|
|
2867
|
+
// and the other rows keep their element identity.
|
|
2868
|
+
const records = previous.records
|
|
2869
|
+
const flat: ReactElement[] = [previous.header]
|
|
2870
|
+
let built = 0
|
|
2871
|
+
for (let index = 0; index < previous.entries.length; index++) {
|
|
2872
|
+
const entry = previous.entries[index]
|
|
2873
|
+
const record = records.get(entry)!
|
|
2874
|
+
const current = record.reasonSensitive
|
|
2875
|
+
? {
|
|
2876
|
+
...record,
|
|
2877
|
+
box: createElement(Box, { key: index, paddingX: 1 }, createElement(EntryLine, { entry, showReasoning, verbose: false })),
|
|
2878
|
+
showReasoning,
|
|
2879
|
+
}
|
|
2880
|
+
: record
|
|
2881
|
+
if (current !== record) {
|
|
2882
|
+
records.set(entry, current)
|
|
2883
|
+
built += 1
|
|
2884
|
+
}
|
|
2885
|
+
if (current.before !== undefined) flat.push(current.before)
|
|
2886
|
+
flat.push(current.box)
|
|
2887
|
+
if (current.after !== undefined) flat.push(current.after)
|
|
2888
|
+
}
|
|
2889
|
+
return { cache: { ...previous, records, showReasoning, flat }, built }
|
|
2890
|
+
}
|
|
2891
|
+
if (settled === previous.entries.length) {
|
|
2892
|
+
// Nothing below the boundary changed (a pending retirement above it, a
|
|
2893
|
+
// tool/result at the boundary): keep the SAME flat identity so the
|
|
2894
|
+
// memoized <Static> subtree does not re-render at all.
|
|
2895
|
+
return { cache: previous, built: 0 }
|
|
2896
|
+
}
|
|
2897
|
+
// The boundary grew: build ONLY the newly settled suffix.
|
|
2898
|
+
const records = previous.records
|
|
2899
|
+
const suffix: TranscriptEntry[] = []
|
|
2900
|
+
const added: ReactElement[] = []
|
|
2901
|
+
for (let index = previous.entries.length; index < settled; index++) {
|
|
2902
|
+
const entry = entries[index]
|
|
2903
|
+
const record = buildSettledRow(entry, index, showReasoning)
|
|
2904
|
+
records.set(entry, record)
|
|
2905
|
+
suffix.push(entry)
|
|
2906
|
+
if (record.before !== undefined) added.push(record.before)
|
|
2907
|
+
added.push(record.box)
|
|
2908
|
+
if (record.after !== undefined) added.push(record.after)
|
|
2909
|
+
}
|
|
2910
|
+
return {
|
|
2911
|
+
cache: {
|
|
2912
|
+
entries: previous.entries.concat(suffix),
|
|
2913
|
+
records,
|
|
2914
|
+
header: previous.header,
|
|
2915
|
+
resumed: previous.resumed,
|
|
2916
|
+
showReasoning,
|
|
2917
|
+
epoch: previous.epoch,
|
|
2918
|
+
flat: previous.flat.concat(added),
|
|
2919
|
+
},
|
|
2920
|
+
built: settled - previous.entries.length,
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2374
2924
|
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
2375
2925
|
export function App(props: AppProps): ReactElement {
|
|
2376
2926
|
const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
|
|
2377
|
-
|
|
2378
|
-
|
|
2927
|
+
// getSnapshot must be a STABLE reference (the React contract): an inline
|
|
2928
|
+
// arrow here re-subscribes the store hook on every render and cascades
|
|
2929
|
+
// force-updates — during a fast reasoning stream that chain crossed React's
|
|
2930
|
+
// nested-passive-update limit and flooded "Maximum update depth exceeded"
|
|
2931
|
+
// warnings. The view objects are process-stable, so one callback per view
|
|
2932
|
+
// identity is enough.
|
|
2933
|
+
// getSnapshot should be a stable reference (the React contract): an inline
|
|
2934
|
+
// arrow re-subscribes the store hook on every render and forces the uETS
|
|
2935
|
+
// consistency check to re-run per commit. The view objects are
|
|
2936
|
+
// process-stable, so one callback per view identity is enough.
|
|
2937
|
+
const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands])
|
|
2938
|
+
const readSkills = useCallback(() => props.skills.rows, [props.skills])
|
|
2939
|
+
const descriptors = useSyncExternalStore(props.commands.subscribe, readDescriptors)
|
|
2940
|
+
const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
|
|
2379
2941
|
const [modelLabel, setModelLabel] = useState(props.model)
|
|
2380
2942
|
const [modelOpen, setModelOpen] = useState(false)
|
|
2943
|
+
/** Nested /model stages; only one owns terminal input at a time. */
|
|
2944
|
+
const [providerOpen, setProviderOpen] = useState(false)
|
|
2945
|
+
const [providerAction, setProviderAction] = useState<{
|
|
2946
|
+
kind: 'credential' | 'unset' | 'remove'
|
|
2947
|
+
target: ProviderTargetView
|
|
2948
|
+
} | undefined>(undefined)
|
|
2381
2949
|
/** The model row whose effort levels the /model stage lists; undefined shows the model list. */
|
|
2382
2950
|
const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
|
|
2383
2951
|
/** Effective reasoning effort, shown in the /model picker and switch notice. */
|
|
@@ -2388,8 +2956,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
2388
2956
|
* per-style durations), then the band returns to static while the prompt
|
|
2389
2957
|
* marker keeps the tier accent. The trigger follows the applied model
|
|
2390
2958
|
* label (what the status bar actually shows), never the initial paint,
|
|
2391
|
-
* and the tier is derived from the label and cached at the switch.
|
|
2392
|
-
|
|
2959
|
+
* and the tier is derived from the label and cached at the switch. The
|
|
2960
|
+
* 33ms tick itself lives inside Input, so the sweep re-renders only the
|
|
2961
|
+
* composer row, not the whole tree, at 30fps; App owns the rarely-changing
|
|
2962
|
+
* tier/style and Input starts the sweep whenever that pair changes. */
|
|
2393
2963
|
const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
|
|
2394
2964
|
const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
|
|
2395
2965
|
const previousModel = useRef<string | undefined>(undefined)
|
|
@@ -2407,7 +2977,6 @@ export function App(props: AppProps): ReactElement {
|
|
|
2407
2977
|
if (!isOfficialDeepSeekLabel(modelLabel)) {
|
|
2408
2978
|
setWaveTier(null)
|
|
2409
2979
|
setWaveStyle(null)
|
|
2410
|
-
setWaveTick(null)
|
|
2411
2980
|
return
|
|
2412
2981
|
}
|
|
2413
2982
|
if (modelChanged || effortChanged) {
|
|
@@ -2415,26 +2984,12 @@ export function App(props: AppProps): ReactElement {
|
|
|
2415
2984
|
const nextStyle = deepseekWaveStyleRandom(previousStyle.current)
|
|
2416
2985
|
previousStyle.current = nextStyle
|
|
2417
2986
|
setWaveStyle(nextStyle)
|
|
2418
|
-
setWaveTick(0)
|
|
2419
2987
|
}
|
|
2420
2988
|
}, [modelLabel, effortLabel])
|
|
2421
|
-
const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
|
|
2422
|
-
&& waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
|
|
2423
|
-
useEffect(() => {
|
|
2424
|
-
if (!waveActive) return
|
|
2425
|
-
const id = setInterval(() => {
|
|
2426
|
-
setWaveTick(current => (current === null ? 0 : current + 1))
|
|
2427
|
-
}, DEEPSEEK_WAVE_TICK_MS)
|
|
2428
|
-
return () => {
|
|
2429
|
-
clearInterval(id)
|
|
2430
|
-
}
|
|
2431
|
-
}, [waveActive])
|
|
2432
|
-
useEffect(() => {
|
|
2433
|
-
if (waveTick !== null && waveTier !== null && waveStyle !== null
|
|
2434
|
-
&& waveTick * DEEPSEEK_WAVE_TICK_MS >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null)
|
|
2435
|
-
}, [waveTick, waveTier, waveStyle])
|
|
2436
2989
|
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
2437
2990
|
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
2991
|
+
const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
|
|
2992
|
+
const [providerError, setProviderError] = useState<string | undefined>(undefined)
|
|
2438
2993
|
const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
|
|
2439
2994
|
const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
|
|
2440
2995
|
const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
|
|
@@ -2461,12 +3016,36 @@ export function App(props: AppProps): ReactElement {
|
|
|
2461
3016
|
cancelled = true
|
|
2462
3017
|
}
|
|
2463
3018
|
}, [modelOpen, modelLoadEpoch, props.loadModels])
|
|
3019
|
+
useEffect(() => {
|
|
3020
|
+
if (!modelOpen || props.loadModelProviders === undefined) return
|
|
3021
|
+
let cancelled = false
|
|
3022
|
+
setProviderDirectory(undefined)
|
|
3023
|
+
setProviderError(undefined)
|
|
3024
|
+
Promise.resolve().then(() => props.loadModelProviders!()).then((loaded) => {
|
|
3025
|
+
if (!cancelled) setProviderDirectory(loaded)
|
|
3026
|
+
}, (error: unknown) => {
|
|
3027
|
+
if (!cancelled) setProviderError(error instanceof Error ? error.message : String(error))
|
|
3028
|
+
})
|
|
3029
|
+
return () => {
|
|
3030
|
+
cancelled = true
|
|
3031
|
+
}
|
|
3032
|
+
}, [modelOpen, modelLoadEpoch, props.loadModelProviders])
|
|
3033
|
+
useEffect(() => {
|
|
3034
|
+
const subscribe = props.subscribeModelProviders
|
|
3035
|
+
if (!modelOpen || subscribe === undefined) return
|
|
3036
|
+
try {
|
|
3037
|
+
return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
|
|
3038
|
+
} catch (error: unknown) {
|
|
3039
|
+
setProviderError(error instanceof Error ? error.message : String(error))
|
|
3040
|
+
}
|
|
3041
|
+
}, [modelOpen, props.subscribeModelProviders])
|
|
2464
3042
|
|
|
2465
3043
|
const busy = view.busy
|
|
2466
3044
|
const [showReasoning, setShowReasoning] = useState(false)
|
|
2467
3045
|
const [verboseOpen, setVerboseOpen] = useState(false)
|
|
2468
3046
|
const [helpOpen, setHelpOpen] = useState(false)
|
|
2469
3047
|
const [modeOpen, setModeOpen] = useState(false)
|
|
3048
|
+
const [permissionOpen, setPermissionOpen] = useState(false)
|
|
2470
3049
|
const [resumeOpen, setResumeOpen] = useState(false)
|
|
2471
3050
|
const [pluginOpen, setPluginOpen] = useState(false)
|
|
2472
3051
|
const [pluginQuery, setPluginQuery] = useState('')
|
|
@@ -2489,27 +3068,43 @@ export function App(props: AppProps): ReactElement {
|
|
|
2489
3068
|
const historyConsumed = useCallback((): void => {
|
|
2490
3069
|
setHistoryFill(undefined)
|
|
2491
3070
|
}, [])
|
|
2492
|
-
/**
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
3071
|
+
/** The append-only flush boundary (see `settledEntryCount`): entries below
|
|
3072
|
+
* this index are final and ride the `<Static>` scrollback; everything at or
|
|
3073
|
+
* beyond stays in the live tree. Pending inbox rows always live at
|
|
3074
|
+
* index >= settled, so the queued-inbox scan below only walks the mutable
|
|
3075
|
+
* tail instead of the whole history. */
|
|
3076
|
+
const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
|
|
3077
|
+
/** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). The
|
|
3078
|
+
* projection only appends and removes pending rows at index >= settled, so
|
|
3079
|
+
* a bounded tail scan replaces an unconditional O(history) filter on every
|
|
3080
|
+
* event. */
|
|
3081
|
+
const queuedRows = useMemo(() => {
|
|
3082
|
+
const rows: Array<Extract<TranscriptEntry, { kind: 'pending' }>> = []
|
|
3083
|
+
for (let index = settled; index < view.entries.length; index++) {
|
|
3084
|
+
const entry = view.entries[index]
|
|
3085
|
+
if (entry.kind === 'pending') rows.push(entry)
|
|
3086
|
+
}
|
|
3087
|
+
return rows
|
|
3088
|
+
}, [view.entries, settled])
|
|
2497
3089
|
const [refreshEpoch, setRefreshEpoch] = useState(0)
|
|
2498
3090
|
const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
|
|
2499
3091
|
const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
|
|
2500
3092
|
const approvalPending = approvalSnapshot.pending !== undefined
|
|
2501
3093
|
const questionPending = questionSnapshot.pending !== undefined
|
|
2502
3094
|
// While any modal owns the keys, the prompt box passes everything through.
|
|
2503
|
-
const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
|
|
3095
|
+
const inputActive = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
|
|
2504
3096
|
|
|
2505
3097
|
// Human questions outrank local inspectors. Close the lower modal instead
|
|
2506
3098
|
// of leaving an approval/question visible but keyboard-locked behind it.
|
|
2507
3099
|
useEffect(() => {
|
|
2508
3100
|
if (!approvalPending && !questionPending) return
|
|
2509
3101
|
setModelOpen(false)
|
|
3102
|
+
setProviderOpen(false)
|
|
3103
|
+
setProviderAction(undefined)
|
|
2510
3104
|
setEffortFor(undefined)
|
|
2511
3105
|
setHelpOpen(false)
|
|
2512
3106
|
setModeOpen(false)
|
|
3107
|
+
setPermissionOpen(false)
|
|
2513
3108
|
setResumeOpen(false)
|
|
2514
3109
|
setPluginOpen(false)
|
|
2515
3110
|
setStatuslineOpen(false)
|
|
@@ -2519,34 +3114,33 @@ export function App(props: AppProps): ReactElement {
|
|
|
2519
3114
|
}, [approvalPending, questionPending])
|
|
2520
3115
|
|
|
2521
3116
|
// Append-only transcript: everything up to the first still-mutable entry
|
|
2522
|
-
// (a running tool/retry) flushes through Ink's `<Static>` into native
|
|
3117
|
+
// (a running tool/retry/command) flushes through Ink's `<Static>` into native
|
|
2523
3118
|
// scrollback and is normally never rewritten — the Claude-Code stability
|
|
2524
|
-
// contract
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
2530
|
-
|
|
2531
|
-
//
|
|
2532
|
-
//
|
|
2533
|
-
//
|
|
2534
|
-
// Ctrl+
|
|
3119
|
+
// contract that lets arbitrarily long conversations scroll instead of
|
|
3120
|
+
// freezing when the live tree exceeds the terminal height. The dynamic
|
|
3121
|
+
// region below stays small: the streaming tail, modals, composer, and its
|
|
3122
|
+
// status footer. `assistant/chunk` preserves `entries` identity.
|
|
3123
|
+
//
|
|
3124
|
+
// `computeSettledRows` extends the cached row set incrementally: the
|
|
3125
|
+
// settled prefix is permanently final, so a grown boundary builds ONLY the
|
|
3126
|
+
// newly settled suffix and reuses every cached element — long histories
|
|
3127
|
+
// stop re-creating rows (and re-parsing MarkdownBody) on every durable
|
|
3128
|
+
// event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L /
|
|
3129
|
+
// Ctrl+R remounts `<Static>`) rebuilds the CURRENT row set from index 0,
|
|
3130
|
+
// so the replay stays complete and never ghosts a pending/running tail.
|
|
3131
|
+
const settledRowsCache = useRef<SettledRowsCache | undefined>(undefined)
|
|
2535
3132
|
const settledRows = useMemo(() => {
|
|
2536
|
-
const
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
})
|
|
2548
|
-
return rows
|
|
2549
|
-
}, [view.entries, settled, showReasoning, props.resumed])
|
|
3133
|
+
const result = computeSettledRows(
|
|
3134
|
+
settledRowsCache.current,
|
|
3135
|
+
view.entries,
|
|
3136
|
+
settled,
|
|
3137
|
+
showReasoning,
|
|
3138
|
+
props.resumed,
|
|
3139
|
+
refreshEpoch,
|
|
3140
|
+
)
|
|
3141
|
+
settledRowsCache.current = result.cache
|
|
3142
|
+
return result.cache.flat
|
|
3143
|
+
}, [view.entries, settled, showReasoning, props.resumed, refreshEpoch])
|
|
2550
3144
|
|
|
2551
3145
|
// Hook order is unconditional. Its dimensions drive every live-region
|
|
2552
3146
|
// budget before any dynamic rows are constructed.
|
|
@@ -2619,9 +3213,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
2619
3213
|
? Math.max(1, Math.floor(streamRows / 3))
|
|
2620
3214
|
: 1
|
|
2621
3215
|
const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
|
|
2622
|
-
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
|
|
3216
|
+
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
|
|
2623
3217
|
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
2624
|
-
const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
|
|
3218
|
+
const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
|
|
2625
3219
|
const closeInspector = useCallback((): void => {
|
|
2626
3220
|
setVerboseOpen(false)
|
|
2627
3221
|
}, [])
|
|
@@ -2638,12 +3232,125 @@ export function App(props: AppProps): ReactElement {
|
|
|
2638
3232
|
setEffortLabel(effortId)
|
|
2639
3233
|
notify(`model → next step uses ${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`)
|
|
2640
3234
|
setModelOpen(false)
|
|
3235
|
+
setProviderOpen(false)
|
|
3236
|
+
setProviderAction(undefined)
|
|
2641
3237
|
setEffortFor(undefined)
|
|
2642
3238
|
} catch (error: unknown) {
|
|
2643
3239
|
notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
2644
3240
|
}
|
|
2645
3241
|
}
|
|
2646
3242
|
|
|
3243
|
+
const reloadModelSurfaces = (): void => {
|
|
3244
|
+
setModelLoadEpoch(epoch => epoch + 1)
|
|
3245
|
+
}
|
|
3246
|
+
const closeModelSurface = (): void => {
|
|
3247
|
+
setModelOpen(false)
|
|
3248
|
+
setProviderOpen(false)
|
|
3249
|
+
setProviderAction(undefined)
|
|
3250
|
+
setEffortFor(undefined)
|
|
3251
|
+
}
|
|
3252
|
+
let modelSurface: ReactElement | undefined
|
|
3253
|
+
if (modelOpen && !approvalPending && !questionPending) {
|
|
3254
|
+
if (providerAction?.kind === 'credential' && props.saveModelProviderCredential !== undefined) {
|
|
3255
|
+
modelSurface = createElement(ProviderCredentialPanel, {
|
|
3256
|
+
target: providerAction.target,
|
|
3257
|
+
save: props.saveModelProviderCredential,
|
|
3258
|
+
done: () => {
|
|
3259
|
+
const target = providerAction.target
|
|
3260
|
+
setProviderAction(undefined)
|
|
3261
|
+
setProviderOpen(false)
|
|
3262
|
+
reloadModelSurfaces()
|
|
3263
|
+
notify(`API key saved for ${target.displayName}; select a model`)
|
|
3264
|
+
},
|
|
3265
|
+
back: () => setProviderAction(undefined),
|
|
3266
|
+
})
|
|
3267
|
+
} else if (providerAction?.kind === 'unset' && props.unsetModelProviderCredential !== undefined) {
|
|
3268
|
+
modelSurface = createElement(ProviderConfirmPanel, {
|
|
3269
|
+
target: providerAction.target,
|
|
3270
|
+
kind: 'credential',
|
|
3271
|
+
confirm: props.unsetModelProviderCredential,
|
|
3272
|
+
done: () => {
|
|
3273
|
+
const target = providerAction.target
|
|
3274
|
+
setProviderAction(undefined)
|
|
3275
|
+
setProviderOpen(true)
|
|
3276
|
+
reloadModelSurfaces()
|
|
3277
|
+
notify(`API key removed for ${target.displayName}`)
|
|
3278
|
+
},
|
|
3279
|
+
back: () => setProviderAction(undefined),
|
|
3280
|
+
})
|
|
3281
|
+
} else if (providerAction?.kind === 'remove' && props.removeModelProvider !== undefined) {
|
|
3282
|
+
modelSurface = createElement(ProviderConfirmPanel, {
|
|
3283
|
+
target: providerAction.target,
|
|
3284
|
+
kind: 'provider',
|
|
3285
|
+
confirm: props.removeModelProvider,
|
|
3286
|
+
done: () => {
|
|
3287
|
+
const target = providerAction.target
|
|
3288
|
+
setProviderAction(undefined)
|
|
3289
|
+
setProviderOpen(true)
|
|
3290
|
+
reloadModelSurfaces()
|
|
3291
|
+
notify(`provider removed: ${target.displayName}`)
|
|
3292
|
+
},
|
|
3293
|
+
back: () => setProviderAction(undefined),
|
|
3294
|
+
})
|
|
3295
|
+
} else if (providerOpen) {
|
|
3296
|
+
modelSurface = createElement(ProviderPanel, {
|
|
3297
|
+
directory: providerDirectory,
|
|
3298
|
+
error: providerError,
|
|
3299
|
+
onCredential: (target: ProviderTargetView) => {
|
|
3300
|
+
if (props.saveModelProviderCredential === undefined) {
|
|
3301
|
+
notify('API key storage is unavailable in this profile', 'warning')
|
|
3302
|
+
return
|
|
3303
|
+
}
|
|
3304
|
+
setProviderAction({ kind: 'credential', target })
|
|
3305
|
+
},
|
|
3306
|
+
onUnset: (target: ProviderTargetView) => {
|
|
3307
|
+
if (props.unsetModelProviderCredential === undefined) {
|
|
3308
|
+
notify('API key removal is unavailable in this profile', 'warning')
|
|
3309
|
+
return
|
|
3310
|
+
}
|
|
3311
|
+
setProviderAction({ kind: 'unset', target })
|
|
3312
|
+
},
|
|
3313
|
+
onRemove: (target: ProviderTargetView) => {
|
|
3314
|
+
if (props.removeModelProvider === undefined) {
|
|
3315
|
+
notify('provider removal is unavailable in this profile', 'warning')
|
|
3316
|
+
return
|
|
3317
|
+
}
|
|
3318
|
+
setProviderAction({ kind: 'remove', target })
|
|
3319
|
+
},
|
|
3320
|
+
onRetry: reloadModelSurfaces,
|
|
3321
|
+
onBack: () => setProviderOpen(false),
|
|
3322
|
+
})
|
|
3323
|
+
} else if (effortFor !== undefined) {
|
|
3324
|
+
modelSurface = createElement(EffortPanel, {
|
|
3325
|
+
row: effortFor,
|
|
3326
|
+
current: effortLabel,
|
|
3327
|
+
select: (effortId: string) => applyModel(effortFor, effortId),
|
|
3328
|
+
back: () => setEffortFor(undefined),
|
|
3329
|
+
})
|
|
3330
|
+
} else {
|
|
3331
|
+
modelSurface = createElement(ModelPanel, {
|
|
3332
|
+
directory,
|
|
3333
|
+
error: modelError,
|
|
3334
|
+
onSelect: (row: ModelRow) => {
|
|
3335
|
+
// A model advertising several levels opens the effort stage first;
|
|
3336
|
+
// one advertised level is its only option, while no capability uses
|
|
3337
|
+
// the model default exactly as before.
|
|
3338
|
+
if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
|
|
3339
|
+
setEffortFor(row)
|
|
3340
|
+
return
|
|
3341
|
+
}
|
|
3342
|
+
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
|
|
3343
|
+
applyModel(row, effortId)
|
|
3344
|
+
},
|
|
3345
|
+
...(props.loadModelProviders === undefined || props.saveModelProviderCredential === undefined
|
|
3346
|
+
? {}
|
|
3347
|
+
: { onProviders: () => setProviderOpen(true) }),
|
|
3348
|
+
onRetry: reloadModelSurfaces,
|
|
3349
|
+
onClose: closeModelSurface,
|
|
3350
|
+
})
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
|
|
2647
3354
|
return createElement(
|
|
2648
3355
|
Box,
|
|
2649
3356
|
{ flexDirection: 'column' },
|
|
@@ -2661,7 +3368,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
2661
3368
|
view.streamingReasoning !== '' && reasoningRows > 0
|
|
2662
3369
|
? createElement(StreamTail, {
|
|
2663
3370
|
text: showReasoning ? view.streamingReasoning : 'Thinking…',
|
|
2664
|
-
prefix: '
|
|
3371
|
+
prefix: '✻ ',
|
|
3372
|
+
continuationPrefix: ' ',
|
|
2665
3373
|
dim: true,
|
|
2666
3374
|
maxRows: reasoningRows,
|
|
2667
3375
|
})
|
|
@@ -2681,38 +3389,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
2681
3389
|
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
2682
3390
|
createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
|
|
2683
3391
|
createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
|
|
2684
|
-
|
|
2685
|
-
? effortFor === undefined
|
|
2686
|
-
? createElement(ModelPanel, {
|
|
2687
|
-
directory,
|
|
2688
|
-
error: modelError,
|
|
2689
|
-
onSelect: (row: ModelRow) => {
|
|
2690
|
-
// A model advertising several levels opens the effort stage first;
|
|
2691
|
-
// one advertised level is its only option (Codex's
|
|
2692
|
-
// single-supported-effort shortcut), and no capability applies
|
|
2693
|
-
// the model default exactly as before.
|
|
2694
|
-
if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
|
|
2695
|
-
setEffortFor(row)
|
|
2696
|
-
return
|
|
2697
|
-
}
|
|
2698
|
-
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
|
|
2699
|
-
applyModel(row, effortId)
|
|
2700
|
-
},
|
|
2701
|
-
onRetry: () => {
|
|
2702
|
-
setModelLoadEpoch(epoch => epoch + 1)
|
|
2703
|
-
},
|
|
2704
|
-
onClose: () => {
|
|
2705
|
-
setModelOpen(false)
|
|
2706
|
-
setEffortFor(undefined)
|
|
2707
|
-
},
|
|
2708
|
-
})
|
|
2709
|
-
: createElement(EffortPanel, {
|
|
2710
|
-
row: effortFor,
|
|
2711
|
-
current: effortLabel,
|
|
2712
|
-
select: (effortId: string) => applyModel(effortFor, effortId),
|
|
2713
|
-
back: () => setEffortFor(undefined),
|
|
2714
|
-
})
|
|
2715
|
-
: undefined,
|
|
3392
|
+
modelSurface,
|
|
2716
3393
|
helpOpen && !approvalPending && !questionPending
|
|
2717
3394
|
? createElement(HelpPanel, {
|
|
2718
3395
|
descriptors,
|
|
@@ -2743,6 +3420,22 @@ export function App(props: AppProps): ReactElement {
|
|
|
2743
3420
|
close: () => setModeOpen(false),
|
|
2744
3421
|
})
|
|
2745
3422
|
: undefined,
|
|
3423
|
+
permissionOpen && !approvalPending && !questionPending
|
|
3424
|
+
? createElement(PermissionPanel, {
|
|
3425
|
+
current: props.permission,
|
|
3426
|
+
load: props.loadPermissions,
|
|
3427
|
+
select: (id: string) => {
|
|
3428
|
+
try {
|
|
3429
|
+
const selected = props.setPermission(id)
|
|
3430
|
+
notify(`permission → ${selected}`)
|
|
3431
|
+
setPermissionOpen(false)
|
|
3432
|
+
} catch (reason: unknown) {
|
|
3433
|
+
notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3434
|
+
}
|
|
3435
|
+
},
|
|
3436
|
+
close: () => setPermissionOpen(false),
|
|
3437
|
+
})
|
|
3438
|
+
: undefined,
|
|
2746
3439
|
resumeOpen && !approvalPending && !questionPending
|
|
2747
3440
|
? createElement(ResumePanel, {
|
|
2748
3441
|
currentCwd: props.workspaceRoot,
|
|
@@ -2816,6 +3509,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
2816
3509
|
openModel: () => {
|
|
2817
3510
|
setDirectory(undefined)
|
|
2818
3511
|
setModelError(undefined)
|
|
3512
|
+
setProviderDirectory(undefined)
|
|
3513
|
+
setProviderError(undefined)
|
|
3514
|
+
setProviderOpen(false)
|
|
3515
|
+
setProviderAction(undefined)
|
|
2819
3516
|
setEffortFor(undefined)
|
|
2820
3517
|
setModelOpen(true)
|
|
2821
3518
|
},
|
|
@@ -2858,6 +3555,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
2858
3555
|
setHelpOpen(true)
|
|
2859
3556
|
},
|
|
2860
3557
|
openMode: () => setModeOpen(true),
|
|
3558
|
+
openPermission: () => setPermissionOpen(true),
|
|
2861
3559
|
openResume: () => setResumeOpen(true),
|
|
2862
3560
|
openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
|
|
2863
3561
|
openStatusline: () => setStatuslineOpen(true),
|
|
@@ -2896,7 +3594,6 @@ export function App(props: AppProps): ReactElement {
|
|
|
2896
3594
|
cancelQueued: props.cancelQueued,
|
|
2897
3595
|
historyFill,
|
|
2898
3596
|
historyConsumed,
|
|
2899
|
-
waveTick,
|
|
2900
3597
|
waveTier,
|
|
2901
3598
|
waveStyle,
|
|
2902
3599
|
}),
|
|
@@ -2909,7 +3606,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
2909
3606
|
sessionId: props.sessionId,
|
|
2910
3607
|
title: view.title,
|
|
2911
3608
|
plan: view.plan,
|
|
2912
|
-
permission: view.permission,
|
|
3609
|
+
permission: view.permission !== '' ? view.permission : props.permission,
|
|
2913
3610
|
sandbox: view.sandbox,
|
|
2914
3611
|
goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
|
|
2915
3612
|
},
|