dsh-code 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +12 -3
- package/README.md +12 -3
- package/lib/index.mjs +1079 -204
- 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/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 +1 -1
- package/src/app.ts +359 -75
- 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/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
|