dsh-code 0.8.0 → 0.9.1
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 +19 -3
- package/README.md +19 -3
- package/lib/index.mjs +1175 -227
- package/lib/types/app.d.ts +13 -0
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/kernel-panels.d.ts +58 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/render/animations.d.ts +14 -2
- package/lib/types/render/projection.d.ts +2 -0
- package/lib/types/render/tool-preview.d.ts +10 -0
- package/lib/types/session-directory.d.ts +46 -2
- package/lib/types/subagents.d.ts +60 -0
- package/package.json +25 -1
- package/src/app.ts +400 -103
- package/src/approval.ts +161 -135
- package/src/index.ts +175 -8
- package/src/kernel-panels.ts +310 -30
- package/src/models.ts +26 -0
- package/src/render/animations.ts +49 -5
- package/src/render/lines.ts +236 -233
- package/src/render/projection.ts +5 -1
- package/src/render/tool-preview.ts +77 -50
- package/src/session-directory.ts +128 -6
- package/src/subagents.ts +165 -0
package/src/kernel-panels.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
4
4
|
import { Box, Text, useInput, useStdout } from 'ink'
|
|
5
|
-
import type { ModelRow } from './models.ts'
|
|
5
|
+
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
6
|
+
import type { SubagentRow } from './subagents.ts'
|
|
6
7
|
import type { PermissionRow } from './permissions.ts'
|
|
7
8
|
import type { PresetRow } from './presets.ts'
|
|
8
9
|
import type { PluginRow } from './plugin-inventory.ts'
|
|
9
10
|
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
11
|
+
import { formatRelativeTime } from './session-directory.ts'
|
|
10
12
|
import { panelViewport, revealRow } from './render/inspector.ts'
|
|
11
13
|
import { textLines } from './render/lines.ts'
|
|
12
14
|
import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
|
|
@@ -20,9 +22,26 @@ interface ListFrameProps {
|
|
|
20
22
|
readonly loading: boolean
|
|
21
23
|
readonly error?: string
|
|
22
24
|
readonly query: string
|
|
25
|
+
/** Ctrl+F-gated search focus for this panel: typing edits the query only
|
|
26
|
+
* while true. `undefined` keeps the plain "type to filter" prompt (the
|
|
27
|
+
* panel filters by typing directly). */
|
|
28
|
+
readonly searching?: boolean
|
|
23
29
|
readonly footer: string
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/** True for the Ctrl+F search-focus toggle. */
|
|
33
|
+
function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
|
|
34
|
+
return key.ctrl === true && input === 'f'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
|
|
38
|
+
* toggle lives in the footer), direct-typing panels keep the plain prompt. */
|
|
39
|
+
function searchLine(searching: boolean | undefined, query: string): string {
|
|
40
|
+
if (searching === true) return `search: ${query === '' ? 'type to filter · esc stops' : query}`
|
|
41
|
+
if (searching === false) return query === '' ? '' : `search: ${query}`
|
|
42
|
+
return `search: ${query === '' ? 'type to filter' : query}`
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
function ListFrame(props: ListFrameProps): ReactElement {
|
|
27
46
|
const stdout = useStdout().stdout
|
|
28
47
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
@@ -44,7 +63,7 @@ function ListFrame(props: ListFrameProps): ReactElement {
|
|
|
44
63
|
Box,
|
|
45
64
|
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
46
65
|
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
|
|
47
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(
|
|
66
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
|
|
48
67
|
...visible.map((row, index) => {
|
|
49
68
|
const absolute = offset + index
|
|
50
69
|
const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
|
|
@@ -163,14 +182,25 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
|
|
|
163
182
|
})
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
|
|
185
|
+
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
|
|
167
186
|
currentCwd: string
|
|
168
187
|
load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
169
188
|
readTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
170
189
|
select(row: SessionRow): void
|
|
190
|
+
/** Arm the composer-based delete confirm for one row (App owns the keys). */
|
|
191
|
+
requestDelete?(row: SessionRow): void
|
|
192
|
+
/** The row id awaiting y/n in the composer, when any (App-owned). */
|
|
193
|
+
deleteConfirmId?: string
|
|
194
|
+
/** Bump to reload the listing (e.g. after a deletion). */
|
|
195
|
+
reloadToken?: number
|
|
196
|
+
/** Opened via /delete: hint-first delete mode. */
|
|
197
|
+
deleteMode?: boolean
|
|
171
198
|
close(): void
|
|
172
199
|
}): ReactElement {
|
|
173
|
-
|
|
200
|
+
// Codex resume-picker default: the CURRENT directory's root sessions; the
|
|
201
|
+
// cwd filter widens to all only on request (the old default leaked every
|
|
202
|
+
// directory's sessions into what read as a current-directory view).
|
|
203
|
+
const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' })
|
|
174
204
|
const [focus, setFocus] = useState(0)
|
|
175
205
|
const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
|
|
176
206
|
const [rows, setRows] = useState<readonly SessionRow[]>([])
|
|
@@ -179,6 +209,10 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
179
209
|
const [error, setError] = useState<string>()
|
|
180
210
|
const [expanded, setExpanded] = useState<string>()
|
|
181
211
|
const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
|
|
212
|
+
/** Ctrl+F-gated search: typing filters only while searching (codex). */
|
|
213
|
+
const [searching, setSearching] = useState(false)
|
|
214
|
+
/** Reference clock pinned per row render, so relative times never drift mid-list. */
|
|
215
|
+
const now = useMemo(() => Date.now(), [rows, options])
|
|
182
216
|
const transcriptLoad = useRef<AbortController>()
|
|
183
217
|
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
184
218
|
useEffect(() => {
|
|
@@ -190,7 +224,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
190
224
|
if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
|
|
191
225
|
})
|
|
192
226
|
return () => controller.abort()
|
|
193
|
-
}, [options])
|
|
227
|
+
}, [options, reloadToken])
|
|
194
228
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
195
229
|
const cycle = (): void => {
|
|
196
230
|
if (focus === 3) {
|
|
@@ -204,7 +238,21 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
204
238
|
})
|
|
205
239
|
}
|
|
206
240
|
useInput((input, key) => {
|
|
207
|
-
|
|
241
|
+
// While a deletion awaits y/n, the COMPOSER owns every key (App routes
|
|
242
|
+
// them); the panel yields so y/n cannot be handled twice.
|
|
243
|
+
if (deleteConfirmId !== undefined) return
|
|
244
|
+
if (key.escape) {
|
|
245
|
+
if (searching) { setSearching(false); return }
|
|
246
|
+
return close()
|
|
247
|
+
}
|
|
248
|
+
if (isSearchToggle(input, key)) { setSearching(current => !current); return }
|
|
249
|
+
if (searching) {
|
|
250
|
+
if (key.return) { setSearching(false); return }
|
|
251
|
+
const next = editQuery(options.query, input, key)
|
|
252
|
+
if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
if (input === 'q') return close()
|
|
208
256
|
if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
|
|
209
257
|
if (key.leftArrow) return cycle()
|
|
210
258
|
if (key.rightArrow) return cycle()
|
|
@@ -214,7 +262,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
214
262
|
if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
|
|
215
263
|
if (input === 'g') return setCursor(0)
|
|
216
264
|
if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
|
|
217
|
-
if (input === 'd'
|
|
265
|
+
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
|
|
218
266
|
if (input === 'e' && rows[cursor] !== undefined) {
|
|
219
267
|
return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
|
|
220
268
|
}
|
|
@@ -231,8 +279,6 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
231
279
|
return
|
|
232
280
|
}
|
|
233
281
|
if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
|
|
234
|
-
const next = editQuery(options.query, input, key)
|
|
235
|
-
if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
|
|
236
282
|
}, { isActive: transcript === undefined })
|
|
237
283
|
if (transcript !== undefined) {
|
|
238
284
|
return createElement(DocumentPanel, {
|
|
@@ -242,15 +288,18 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
242
288
|
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
|
|
243
289
|
})
|
|
244
290
|
}
|
|
291
|
+
const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
|
|
245
292
|
const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
|
|
246
293
|
return createElement(ListFrame, {
|
|
247
|
-
title:
|
|
294
|
+
title: deleteConfirmId === undefined
|
|
295
|
+
? `/resume${deleteMode ? ' — delete mode' : ''}${searching ? ' — searching' : ''} · ${toolbar}`
|
|
296
|
+
: `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
|
|
248
297
|
rows: rows.map(row => ({
|
|
249
298
|
key: row.id,
|
|
250
299
|
disabled: !row.resumable,
|
|
251
|
-
text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
|
|
252
|
-
})), cursor, loading, error, query: options.query,
|
|
253
|
-
footer: '
|
|
300
|
+
text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${formatRelativeTime(row.updatedAt ?? row.createdAt, now)} · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
|
|
301
|
+
})), cursor, loading, error, query: options.query, searching,
|
|
302
|
+
footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
|
|
254
303
|
})
|
|
255
304
|
}
|
|
256
305
|
|
|
@@ -455,12 +504,15 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
455
504
|
/**
|
|
456
505
|
* The `/model` reasoning-effort stage (the Codex model → reasoning popup
|
|
457
506
|
* contract): one bounded list over the selected model's adapter-advertised
|
|
458
|
-
* effort levels
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* model
|
|
507
|
+
* effort levels — in the adapter's own display order, ids verbatim (the
|
|
508
|
+
* kernel treats them as opaque and rejects anything else) — with the
|
|
509
|
+
* effective effort and the model default marked. A model WITHOUT an
|
|
510
|
+
* adapter-declared default leads with a "Default" (provider-default) row —
|
|
511
|
+
* the web effort pane's first entry — so the user can clear a picked level
|
|
512
|
+
* back to provider behavior. A model advertising no levels opens the same
|
|
513
|
+
* stage with an explicit empty state (the web pane's "no levels" copy)
|
|
514
|
+
* instead of a bare failure notice. Enter applies one level; Esc returns to
|
|
515
|
+
* the model list without applying.
|
|
464
516
|
*/
|
|
465
517
|
export function EffortPanel({ row, current, select, back }: {
|
|
466
518
|
/** The model row whose advertised levels this stage lists. */
|
|
@@ -472,16 +524,23 @@ export function EffortPanel({ row, current, select, back }: {
|
|
|
472
524
|
/** Return to the model list without applying. */
|
|
473
525
|
back(): void
|
|
474
526
|
}): ReactElement {
|
|
475
|
-
const
|
|
476
|
-
const
|
|
527
|
+
const advertised = row.reasoning?.efforts ?? []
|
|
528
|
+
const empty = row.reasoning === undefined || advertised.length === 0
|
|
477
529
|
// The provider-default row only exists when the adapter declares no default
|
|
478
530
|
// effort: with one, the default is an advertised level already in the list.
|
|
479
531
|
const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined
|
|
480
|
-
const rows =
|
|
481
|
-
? [{ id: '', name: '
|
|
482
|
-
:
|
|
532
|
+
const rows = empty
|
|
533
|
+
? [{ id: '', name: '' }]
|
|
534
|
+
: hasDefaultRow
|
|
535
|
+
? [{ id: '', name: 'Default' }, ...advertised]
|
|
536
|
+
: advertised
|
|
483
537
|
// An absent or cleared effort is the Default row's current state.
|
|
484
538
|
const effective = current === undefined || current === '' ? '' : current
|
|
539
|
+
// The list opens ON the effective level (or the model's default row), so a
|
|
540
|
+
// quick re-pick never restarts the cursor from the top.
|
|
541
|
+
const wanted = effective === '' ? row.reasoning?.defaultEffort ?? '' : effective
|
|
542
|
+
const initialCursor = Math.max(0, rows.findIndex(effort => effort.id === wanted))
|
|
543
|
+
const [cursor, setCursor] = useState(initialCursor)
|
|
485
544
|
useEffect(() => {
|
|
486
545
|
if (rows.length === 0) {
|
|
487
546
|
if (cursor !== 0) setCursor(0)
|
|
@@ -491,7 +550,15 @@ export function EffortPanel({ row, current, select, back }: {
|
|
|
491
550
|
}, [rows.length, cursor])
|
|
492
551
|
useInput((input, key) => {
|
|
493
552
|
if (key.escape || input === 'q') return back()
|
|
494
|
-
if (
|
|
553
|
+
if (empty) return
|
|
554
|
+
if (input === 'g') {
|
|
555
|
+
setCursor(0)
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
if (input === 'G') {
|
|
559
|
+
setCursor(rows.length - 1)
|
|
560
|
+
return
|
|
561
|
+
}
|
|
495
562
|
if (key.upArrow) {
|
|
496
563
|
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
497
564
|
return
|
|
@@ -506,13 +573,226 @@ export function EffortPanel({ row, current, select, back }: {
|
|
|
506
573
|
})
|
|
507
574
|
return createElement(ListFrame, {
|
|
508
575
|
title: `/model — effort for ${row.providerName} · ${row.modelName}`,
|
|
509
|
-
rows:
|
|
510
|
-
key: effort
|
|
511
|
-
|
|
512
|
-
|
|
576
|
+
rows: empty
|
|
577
|
+
? [{ key: 'empty', disabled: true, text: 'this model advertises no reasoning effort levels — the provider default applies' }]
|
|
578
|
+
: rows.map(effort => ({
|
|
579
|
+
key: effort.id,
|
|
580
|
+
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
|
|
581
|
+
})),
|
|
513
582
|
cursor,
|
|
514
583
|
loading: false,
|
|
515
584
|
query: '',
|
|
516
|
-
footer: '↑↓ choose · enter apply · esc/q back',
|
|
585
|
+
footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** One merged /agents row: live feed state or a persisted child session. */
|
|
590
|
+
interface AgentsEntry {
|
|
591
|
+
readonly id: string
|
|
592
|
+
readonly label: string
|
|
593
|
+
readonly activity: string
|
|
594
|
+
readonly running: boolean
|
|
595
|
+
readonly done: boolean
|
|
596
|
+
readonly live: boolean
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The /agents panel (the Codex agent-picker contract, read-only): this
|
|
601
|
+
* conversation's subagent conversations — live rows from the activity feed
|
|
602
|
+
* first, persisted children the feed has not seen this process after — with
|
|
603
|
+
* Enter/t opening the child's full transcript in the shared read-only
|
|
604
|
+
* document view (the same projection the exporter uses).
|
|
605
|
+
*/
|
|
606
|
+
export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
607
|
+
/** Live feed rows (child sessions observed this process). */
|
|
608
|
+
live: readonly SubagentRow[]
|
|
609
|
+
/** Load this session's persisted child sessions by lineage. */
|
|
610
|
+
load(): Promise<readonly SessionRow[]>
|
|
611
|
+
/** Read one child session's full transcript as markdown. */
|
|
612
|
+
readTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
613
|
+
close(): void
|
|
614
|
+
}): ReactElement {
|
|
615
|
+
const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
|
|
616
|
+
const [error, setError] = useState<string>()
|
|
617
|
+
const [loading, setLoading] = useState(true)
|
|
618
|
+
const [cursor, setCursor] = useState(0)
|
|
619
|
+
const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
|
|
620
|
+
const transcriptLoad = useRef<AbortController>()
|
|
621
|
+
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
622
|
+
const refresh = (): void => {
|
|
623
|
+
setLoading(true)
|
|
624
|
+
setError(undefined)
|
|
625
|
+
Promise.resolve().then(load).then(value => {
|
|
626
|
+
setDirRows(value)
|
|
627
|
+
setLoading(false)
|
|
628
|
+
}, reason => {
|
|
629
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
630
|
+
setLoading(false)
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
useEffect(refresh, [])
|
|
634
|
+
// Live feed rows first (they carry the running state), then persisted
|
|
635
|
+
// children only the directory knows — settled subagents from earlier turns.
|
|
636
|
+
const rows = useMemo<readonly AgentsEntry[]>(() => {
|
|
637
|
+
const seen = new Set(live.map(row => row.id))
|
|
638
|
+
const feedRows: AgentsEntry[] = live.map(row => ({
|
|
639
|
+
id: row.id,
|
|
640
|
+
label: row.label,
|
|
641
|
+
activity: row.activity,
|
|
642
|
+
running: row.state === 'running',
|
|
643
|
+
done: row.state === 'done',
|
|
644
|
+
live: true,
|
|
645
|
+
}))
|
|
646
|
+
const persisted: AgentsEntry[] = (dirRows ?? [])
|
|
647
|
+
.filter(row => !seen.has(row.id))
|
|
648
|
+
.map(row => ({
|
|
649
|
+
id: row.id,
|
|
650
|
+
label: row.title ?? row.id.slice(-12),
|
|
651
|
+
activity: row.workspace,
|
|
652
|
+
running: false,
|
|
653
|
+
done: !row.live,
|
|
654
|
+
live: row.live,
|
|
655
|
+
}))
|
|
656
|
+
return [...feedRows, ...persisted]
|
|
657
|
+
}, [live, dirRows])
|
|
658
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
659
|
+
const openTranscript = (): void => {
|
|
660
|
+
const row = rows[cursor]
|
|
661
|
+
if (row === undefined) return
|
|
662
|
+
transcriptLoad.current?.abort()
|
|
663
|
+
setTranscript({ id: row.id })
|
|
664
|
+
const controller = new AbortController()
|
|
665
|
+
transcriptLoad.current = controller
|
|
666
|
+
Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
|
|
667
|
+
text => {
|
|
668
|
+
if (!controller.signal.aborted) setTranscript({ id: row.id, text })
|
|
669
|
+
},
|
|
670
|
+
reason => {
|
|
671
|
+
if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) })
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
useInput((input, key) => {
|
|
676
|
+
if (key.escape || input === 'q') return close()
|
|
677
|
+
if (input === 'r') return refresh()
|
|
678
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
|
|
679
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
|
|
680
|
+
if ((key.return || input === 't') && rows[cursor] !== undefined) return openTranscript()
|
|
681
|
+
}, { isActive: transcript === undefined })
|
|
682
|
+
if (transcript !== undefined) {
|
|
683
|
+
return createElement(DocumentPanel, {
|
|
684
|
+
title: `subagent · ${transcript.id.slice(-12)}`,
|
|
685
|
+
text: transcript.text,
|
|
686
|
+
error: transcript.error,
|
|
687
|
+
close: () => {
|
|
688
|
+
transcriptLoad.current?.abort()
|
|
689
|
+
setTranscript(undefined)
|
|
690
|
+
},
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
return createElement(ListFrame, {
|
|
694
|
+
title: `/agents · ${live.length} live · ${rows.length} total`,
|
|
695
|
+
rows: rows.map(row => ({
|
|
696
|
+
key: row.id,
|
|
697
|
+
text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
|
|
698
|
+
})),
|
|
699
|
+
cursor,
|
|
700
|
+
loading,
|
|
701
|
+
...error === undefined ? {} : { error },
|
|
702
|
+
query: '',
|
|
703
|
+
footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The /subagent model panel: which model configuration delegated subagents
|
|
709
|
+
* run on. The kernel seeds child agents from the parent's CREATE-TIME
|
|
710
|
+
* AgentOptions, so a mid-session /model switch would otherwise leave them on
|
|
711
|
+
* the launch-time route; the TUI mirrors the selection onto subagent-origin
|
|
712
|
+
* requests (or an explicit override picked here) via an agent/request
|
|
713
|
+
* listener. The leading "inherit" row restores follow-the-current-model
|
|
714
|
+
* behavior; picking a model with several advertised efforts opens the same
|
|
715
|
+
* effort stage /model uses. Effort overrides are not offered separately —
|
|
716
|
+
* the kernel's AgentOptions has no effort channel for children, so the level
|
|
717
|
+
* rides the selected model exactly as /model applies it.
|
|
718
|
+
*/
|
|
719
|
+
export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
720
|
+
/** Display label of the override in force, '' when following the current model. */
|
|
721
|
+
current: string
|
|
722
|
+
load(): Promise<ModelDirectory>
|
|
723
|
+
/** Apply one model (with an advertised effort, when picked) as the override. */
|
|
724
|
+
pick(row: ModelRow, effortId?: string): void
|
|
725
|
+
/** Drop the override: subagents follow the current model again. */
|
|
726
|
+
inherit(): void
|
|
727
|
+
close(): void
|
|
728
|
+
}): ReactElement {
|
|
729
|
+
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
730
|
+
const [error, setError] = useState<string>()
|
|
731
|
+
const [loading, setLoading] = useState(true)
|
|
732
|
+
const [cursor, setCursor] = useState(0)
|
|
733
|
+
const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
|
|
734
|
+
const refresh = (): void => {
|
|
735
|
+
setLoading(true)
|
|
736
|
+
setError(undefined)
|
|
737
|
+
Promise.resolve().then(load).then(value => {
|
|
738
|
+
setDirectory(value)
|
|
739
|
+
setLoading(false)
|
|
740
|
+
}, reason => {
|
|
741
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
742
|
+
setLoading(false)
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
useEffect(refresh, [])
|
|
746
|
+
const rows = useMemo(() => directory?.rows ?? [], [directory])
|
|
747
|
+
// The list opens on the override's own row (index 0 is the inherit row).
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
if (current === '' || rows.length === 0) return
|
|
750
|
+
const index = rows.findIndex(row => current.startsWith(`${row.provider}/${row.model}`))
|
|
751
|
+
if (index >= 0) setCursor(index + 1)
|
|
752
|
+
}, [rows, current])
|
|
753
|
+
useEffect(() => setCursor(value => Math.min(value, rows.length)), [rows.length])
|
|
754
|
+
// Hooks stay unconditional: the effort stage below swaps the rendered
|
|
755
|
+
// subtree but must never skip the input hook (an early return here would
|
|
756
|
+
// change the hook count when the stage opens and closes).
|
|
757
|
+
useInput((input, key) => {
|
|
758
|
+
if (effortFor !== undefined) return
|
|
759
|
+
if (key.escape || input === 'q') return close()
|
|
760
|
+
if (input === 'r' && !loading) return refresh()
|
|
761
|
+
if (key.upArrow) return setCursor(value => (value + rows.length) % (rows.length + 1))
|
|
762
|
+
if (key.downArrow) return setCursor(value => (value + 1) % (rows.length + 1))
|
|
763
|
+
if (key.return) {
|
|
764
|
+
if (cursor === 0) return inherit()
|
|
765
|
+
const row = rows[cursor - 1]
|
|
766
|
+
if (row === undefined) return
|
|
767
|
+
if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
|
|
768
|
+
setEffortFor(row)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
|
|
772
|
+
pick(row, effortId)
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
if (effortFor !== undefined) {
|
|
776
|
+
return createElement(EffortPanel, {
|
|
777
|
+
row: effortFor,
|
|
778
|
+
current: current === '' ? undefined : current.split('@')[1],
|
|
779
|
+
select: effortId => pick(effortFor, effortId),
|
|
780
|
+
back: () => setEffortFor(undefined),
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
return createElement(ListFrame, {
|
|
784
|
+
title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
|
|
785
|
+
rows: [
|
|
786
|
+
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
|
|
787
|
+
...rows.map(row => ({
|
|
788
|
+
key: `${row.provider}/${row.model}`,
|
|
789
|
+
text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
|
|
790
|
+
})),
|
|
791
|
+
],
|
|
792
|
+
cursor,
|
|
793
|
+
loading,
|
|
794
|
+
...error === undefined ? {} : { error },
|
|
795
|
+
query: '',
|
|
796
|
+
footer: '↑↓ choose · enter apply · r refresh · esc close',
|
|
517
797
|
})
|
|
518
798
|
}
|
package/src/models.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
12
12
|
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
|
|
13
13
|
import {
|
|
14
14
|
ReasoningEffortId,
|
|
15
|
+
type LlmCallConfig,
|
|
15
16
|
type LlmModelInfo,
|
|
16
17
|
type LlmModelReasoningInfo,
|
|
17
18
|
type LlmResolvedModelInfo,
|
|
@@ -138,6 +139,31 @@ export function modelSelectionLabel(selection: ModelSelection): string {
|
|
|
138
139
|
: `${selection.provider}/${selection.model}@${selection.reasoningEffort}`
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Apply one model selection onto a resolved request config — the exact
|
|
144
|
+
* semantics of the kernel's `installModelSelection` request listener,
|
|
145
|
+
* extracted so the TUI can mirror it for subagent-origin requests: children
|
|
146
|
+
* spawned by the subagent tool inherit the parent's CREATE-TIME AgentOptions,
|
|
147
|
+
* which a mid-session /model switch never touches, so delegated work would
|
|
148
|
+
* otherwise keep running on the launch-time route. An absent effort strips
|
|
149
|
+
* any inherited effort (restoring the selected model's provider default),
|
|
150
|
+
* matching the kernel listener field-for-field.
|
|
151
|
+
* @param resolved - the config the inner chain produced.
|
|
152
|
+
* @param selection - the selection to enforce.
|
|
153
|
+
* @returns the overridden config.
|
|
154
|
+
*/
|
|
155
|
+
export function applyModelSelectionToConfig(resolved: LlmCallConfig, selection: ModelSelection): LlmCallConfig {
|
|
156
|
+
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved
|
|
157
|
+
return {
|
|
158
|
+
...withoutInheritedEffort,
|
|
159
|
+
provider: selection.provider,
|
|
160
|
+
model: selection.model,
|
|
161
|
+
...selection.reasoningEffort === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { reasoningEffort: selection.reasoningEffort },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
141
167
|
/**
|
|
142
168
|
* Load the selectable model directory from the live `ctx.llm` registry.
|
|
143
169
|
* Providers are listed synchronously; each provider's models are discovered
|
package/src/render/animations.ts
CHANGED
|
@@ -59,11 +59,15 @@ export function caretVisible(tick: number): boolean {
|
|
|
59
59
|
export const DEEPSEEK_WAVE_TICK_MS = 33
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* The
|
|
62
|
+
* The DeepSeek wave tiers. The concept maps Codex's reasoning tiers to
|
|
63
63
|
* model ids: `flash` runs the Max parameters, `deepseek` (pro models) runs
|
|
64
64
|
* the Ultra parameters (dual band + tail sparkles on the Wave style).
|
|
65
|
+
* `unknown` is the "Into the Unknown" variant: it reuses the deepseek tier's
|
|
66
|
+
* exact parameters (dual band, durations, sparkles) for NON-DeepSeek models
|
|
67
|
+
* running a reasoning effort above high — the wordmark renders differently
|
|
68
|
+
* but the motion is identical.
|
|
65
69
|
*/
|
|
66
|
-
export type DeepseekWaveTier = 'flash' | 'deepseek'
|
|
70
|
+
export type DeepseekWaveTier = 'flash' | 'deepseek' | 'unknown'
|
|
67
71
|
|
|
68
72
|
/**
|
|
69
73
|
* The three ignition styles — Codex `IgnitionStyle`: a traveling crest
|
|
@@ -100,18 +104,22 @@ export const DEEPSEEK_WAVE_BANDS: Readonly<Record<DeepseekWaveStyle, Readonly<Re
|
|
|
100
104
|
flash: [[0.10, 0.75, 1.0]],
|
|
101
105
|
// Wave-Ultra: two offset bands for a richer crest.
|
|
102
106
|
deepseek: [[0.10, 0.70, 1.0], [0.35, 0.55, 1.0]],
|
|
107
|
+
// Into the Unknown reuses the Ultra parameters verbatim.
|
|
108
|
+
unknown: [[0.10, 0.70, 1.0], [0.35, 0.55, 1.0]],
|
|
103
109
|
},
|
|
104
110
|
aurora: {
|
|
105
111
|
// Aurora-Max: two drifting bands (hues 0 and 1).
|
|
106
112
|
flash: [[0.35, 0.15, 0.0], [-0.50, 0.60, 1.0]],
|
|
107
113
|
// Aurora-Ultra: a third band adds hue 2.
|
|
108
114
|
deepseek: [[0.35, 0.15, 0.0], [-0.50, 0.60, 1.0], [0.75, 0.35, 2.0]],
|
|
115
|
+
unknown: [[0.35, 0.15, 0.0], [-0.50, 0.60, 1.0], [0.75, 0.35, 2.0]],
|
|
109
116
|
},
|
|
110
117
|
pulse: {
|
|
111
118
|
// Pulse-Max: one expanding ring.
|
|
112
119
|
flash: [[0.10, 0.60, 1.0]],
|
|
113
120
|
// Pulse-Ultra: two rings (inner weaker, outer stronger).
|
|
114
121
|
deepseek: [[0.10, 0.55, 0.8], [0.45, 0.55, 1.1]],
|
|
122
|
+
unknown: [[0.10, 0.55, 0.8], [0.45, 0.55, 1.1]],
|
|
115
123
|
},
|
|
116
124
|
}
|
|
117
125
|
|
|
@@ -120,10 +128,12 @@ export const DEEPSEEK_WAVE_DURATION_EXTENSION_MS = 200
|
|
|
120
128
|
|
|
121
129
|
/** Original Codex duration used as the animation's sampling timeline. */
|
|
122
130
|
function deepseekWaveBaseDuration(tier: DeepseekWaveTier, style: DeepseekWaveStyle): number {
|
|
131
|
+
// The unknown tier reuses the deepseek (pro) durations exactly.
|
|
132
|
+
const pro = tier === 'deepseek' || tier === 'unknown'
|
|
123
133
|
switch (style) {
|
|
124
|
-
case 'aurora': return
|
|
125
|
-
case 'pulse': return
|
|
126
|
-
case 'wave': return
|
|
134
|
+
case 'aurora': return pro ? 1600 : 1300
|
|
135
|
+
case 'pulse': return pro ? 1250 : 900
|
|
136
|
+
case 'wave': return pro ? 1300 : 1000
|
|
127
137
|
}
|
|
128
138
|
}
|
|
129
139
|
|
|
@@ -404,3 +414,37 @@ export function isOfficialDeepSeekLabel(label: string): boolean {
|
|
|
404
414
|
const model = slash < 0 ? '' : label.slice(slash + 1)
|
|
405
415
|
return provider.toLowerCase().includes('deepseek') || model.toLowerCase().includes('deepseek')
|
|
406
416
|
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Known reasoning-effort ranks in ascending order. Effort ids are opaque
|
|
420
|
+
* adapter-owned strings, so the rank table covers the conventional names
|
|
421
|
+
* (off → low → medium → high → xhigh → max/ultra); an unrecognized id
|
|
422
|
+
* ranks as unknown (0), which never triggers the high-effort wave.
|
|
423
|
+
*/
|
|
424
|
+
const EFFORT_RANK: Readonly<Record<string, number>> = {
|
|
425
|
+
off: 0,
|
|
426
|
+
none: 0,
|
|
427
|
+
low: 1,
|
|
428
|
+
medium: 2,
|
|
429
|
+
med: 2,
|
|
430
|
+
high: 3,
|
|
431
|
+
xhigh: 4,
|
|
432
|
+
'x-high': 4,
|
|
433
|
+
'very-high': 4,
|
|
434
|
+
max: 5,
|
|
435
|
+
maximum: 5,
|
|
436
|
+
ultra: 5,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* True when an effective reasoning effort is STRICTLY above `high` — the
|
|
441
|
+
* trigger gate for the "Into the Unknown" wave on non-DeepSeek routes.
|
|
442
|
+
* Absent efforts and unrecognized ids never qualify.
|
|
443
|
+
* @param effort - the effective reasoning-effort id ('' or undefined when none).
|
|
444
|
+
* @returns whether the effort ranks above high.
|
|
445
|
+
*/
|
|
446
|
+
export function effortAboveHigh(effort: string | undefined): boolean {
|
|
447
|
+
if (effort === undefined || effort === '') return false
|
|
448
|
+
const rank = EFFORT_RANK[effort.trim().toLowerCase()]
|
|
449
|
+
return rank !== undefined && rank > 3
|
|
450
|
+
}
|