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