dsh-code 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +30 -7
- package/README.md +30 -7
- package/lib/index.mjs +3791 -853
- package/lib/types/app.d.ts +90 -1
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +65 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +97 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +14 -1
- package/lib/types/session-directory.d.ts +61 -2
- package/lib/types/store.d.ts +13 -2
- package/lib/types/subagents.d.ts +60 -0
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +1200 -219
- package/src/approval.ts +161 -126
- package/src/history.ts +20 -5
- package/src/index.ts +577 -167
- package/src/kernel-panels.ts +354 -37
- package/src/models.ts +26 -0
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +236 -218
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +670 -11
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +77 -34
- package/src/session-directory.ts +171 -10
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/subagents.ts +165 -0
- package/src/version.ts +16 -0
package/src/kernel-panels.ts
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
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'
|
|
7
|
+
import type { PermissionRow } from './permissions.ts'
|
|
6
8
|
import type { PresetRow } from './presets.ts'
|
|
7
9
|
import type { PluginRow } from './plugin-inventory.ts'
|
|
8
10
|
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
11
|
+
import { formatRelativeTime } from './session-directory.ts'
|
|
9
12
|
import { panelViewport, revealRow } from './render/inspector.ts'
|
|
10
13
|
import { textLines } from './render/lines.ts'
|
|
11
14
|
import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
|
|
12
|
-
import {
|
|
15
|
+
import { singleLineText, truncateColumns } from './render/text.ts'
|
|
13
16
|
import { getPalette, inkColor } from './theme.ts'
|
|
14
17
|
|
|
15
18
|
interface ListFrameProps {
|
|
@@ -19,15 +22,32 @@ interface ListFrameProps {
|
|
|
19
22
|
readonly loading: boolean
|
|
20
23
|
readonly error?: string
|
|
21
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
|
|
22
29
|
readonly footer: string
|
|
23
30
|
}
|
|
24
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
|
+
|
|
25
45
|
function ListFrame(props: ListFrameProps): ReactElement {
|
|
26
46
|
const stdout = useStdout().stdout
|
|
27
47
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
28
48
|
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
29
49
|
if (viewport.compact) {
|
|
30
|
-
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${props.title} · esc close
|
|
50
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns))
|
|
31
51
|
}
|
|
32
52
|
const stateRows = props.loading
|
|
33
53
|
? [{ key: 'loading', text: ' loading…' }]
|
|
@@ -42,8 +62,8 @@ function ListFrame(props: ListFrameProps): ReactElement {
|
|
|
42
62
|
return createElement(
|
|
43
63
|
Box,
|
|
44
64
|
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
45
|
-
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(props.title, viewport.contentColumns)),
|
|
46
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
65
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
|
|
66
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
|
|
47
67
|
...visible.map((row, index) => {
|
|
48
68
|
const absolute = offset + index
|
|
49
69
|
const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
|
|
@@ -52,9 +72,9 @@ function ListFrame(props: ListFrameProps): ReactElement {
|
|
|
52
72
|
color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : undefined,
|
|
53
73
|
dimColor: row.disabled,
|
|
54
74
|
wrap: 'truncate-end',
|
|
55
|
-
}, truncateColumns(`${selected ? '› ' : ' '}${row.text}`, viewport.contentColumns))
|
|
75
|
+
}, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(row.text)}`, viewport.contentColumns))
|
|
56
76
|
}),
|
|
57
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(props.footer, viewport.contentColumns)),
|
|
77
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)),
|
|
58
78
|
)
|
|
59
79
|
}
|
|
60
80
|
|
|
@@ -100,6 +120,42 @@ export function ModePanel({ current, load, select, close }: {
|
|
|
100
120
|
})
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
export function PermissionPanel({ current, load, select, close }: {
|
|
124
|
+
current: string
|
|
125
|
+
load(): Promise<readonly PermissionRow[]>
|
|
126
|
+
select(id: string): void
|
|
127
|
+
close(): void
|
|
128
|
+
}): ReactElement {
|
|
129
|
+
const [rows, setRows] = useState<readonly PermissionRow[]>([])
|
|
130
|
+
const [query, setQuery] = useState('')
|
|
131
|
+
const [cursor, setCursor] = useState(0)
|
|
132
|
+
const [loading, setLoading] = useState(true)
|
|
133
|
+
const [error, setError] = useState<string>()
|
|
134
|
+
const refresh = (): void => {
|
|
135
|
+
setLoading(true); setError(undefined)
|
|
136
|
+
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
|
|
137
|
+
setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
useEffect(refresh, [])
|
|
141
|
+
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
|
|
142
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
|
|
143
|
+
useInput((input, key) => {
|
|
144
|
+
if (key.escape || input === 'q') return close()
|
|
145
|
+
if (input === 'r' && query === '') return refresh()
|
|
146
|
+
if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
|
|
147
|
+
if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
|
|
148
|
+
if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
|
|
149
|
+
const next = editQuery(query, input, key)
|
|
150
|
+
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
151
|
+
})
|
|
152
|
+
return createElement(ListFrame, {
|
|
153
|
+
title: `/permission · current ${current}`,
|
|
154
|
+
rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
|
|
155
|
+
cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
|
|
104
160
|
const [epoch, setEpoch] = useState(0)
|
|
105
161
|
const [query, setQuery] = useState(initialQuery)
|
|
@@ -126,14 +182,25 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
|
|
|
126
182
|
})
|
|
127
183
|
}
|
|
128
184
|
|
|
129
|
-
export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
|
|
185
|
+
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
|
|
130
186
|
currentCwd: string
|
|
131
187
|
load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
132
188
|
readTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
133
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
|
|
134
198
|
close(): void
|
|
135
199
|
}): ReactElement {
|
|
136
|
-
|
|
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: '' })
|
|
137
204
|
const [focus, setFocus] = useState(0)
|
|
138
205
|
const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
|
|
139
206
|
const [rows, setRows] = useState<readonly SessionRow[]>([])
|
|
@@ -142,6 +209,10 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
142
209
|
const [error, setError] = useState<string>()
|
|
143
210
|
const [expanded, setExpanded] = useState<string>()
|
|
144
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])
|
|
145
216
|
const transcriptLoad = useRef<AbortController>()
|
|
146
217
|
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
147
218
|
useEffect(() => {
|
|
@@ -153,7 +224,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
153
224
|
if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
|
|
154
225
|
})
|
|
155
226
|
return () => controller.abort()
|
|
156
|
-
}, [options])
|
|
227
|
+
}, [options, reloadToken])
|
|
157
228
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
158
229
|
const cycle = (): void => {
|
|
159
230
|
if (focus === 3) {
|
|
@@ -167,7 +238,21 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
167
238
|
})
|
|
168
239
|
}
|
|
169
240
|
useInput((input, key) => {
|
|
170
|
-
|
|
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()
|
|
171
256
|
if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
|
|
172
257
|
if (key.leftArrow) return cycle()
|
|
173
258
|
if (key.rightArrow) return cycle()
|
|
@@ -177,7 +262,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
177
262
|
if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
|
|
178
263
|
if (input === 'g') return setCursor(0)
|
|
179
264
|
if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
|
|
180
|
-
if (input === 'd'
|
|
265
|
+
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
|
|
181
266
|
if (input === 'e' && rows[cursor] !== undefined) {
|
|
182
267
|
return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
|
|
183
268
|
}
|
|
@@ -194,8 +279,6 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
194
279
|
return
|
|
195
280
|
}
|
|
196
281
|
if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
|
|
197
|
-
const next = editQuery(options.query, input, key)
|
|
198
|
-
if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
|
|
199
282
|
}, { isActive: transcript === undefined })
|
|
200
283
|
if (transcript !== undefined) {
|
|
201
284
|
return createElement(DocumentPanel, {
|
|
@@ -205,15 +288,18 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, close }:
|
|
|
205
288
|
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
|
|
206
289
|
})
|
|
207
290
|
}
|
|
291
|
+
const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
|
|
208
292
|
const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
|
|
209
293
|
return createElement(ListFrame, {
|
|
210
|
-
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`,
|
|
211
297
|
rows: rows.map(row => ({
|
|
212
298
|
key: row.id,
|
|
213
299
|
disabled: !row.resumable,
|
|
214
|
-
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}`}` : ''}`,
|
|
215
|
-
})), cursor, loading, error, query: options.query,
|
|
216
|
-
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',
|
|
217
303
|
})
|
|
218
304
|
}
|
|
219
305
|
|
|
@@ -245,7 +331,7 @@ function DocumentPanel({ title, text, error, close }: {
|
|
|
245
331
|
return createElement(
|
|
246
332
|
Box,
|
|
247
333
|
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
248
|
-
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)),
|
|
334
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
|
|
249
335
|
...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
|
|
250
336
|
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)),
|
|
251
337
|
)
|
|
@@ -319,7 +405,7 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
319
405
|
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
320
406
|
wrap: 'truncate-end',
|
|
321
407
|
},
|
|
322
|
-
truncateColumns((selected ? '› ' : ' ') +
|
|
408
|
+
truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
|
|
323
409
|
)
|
|
324
410
|
})),
|
|
325
411
|
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
|
|
@@ -418,12 +504,15 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
418
504
|
/**
|
|
419
505
|
* The `/model` reasoning-effort stage (the Codex model → reasoning popup
|
|
420
506
|
* contract): one bounded list over the selected model's adapter-advertised
|
|
421
|
-
* effort levels
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
* 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.
|
|
427
516
|
*/
|
|
428
517
|
export function EffortPanel({ row, current, select, back }: {
|
|
429
518
|
/** The model row whose advertised levels this stage lists. */
|
|
@@ -435,16 +524,23 @@ export function EffortPanel({ row, current, select, back }: {
|
|
|
435
524
|
/** Return to the model list without applying. */
|
|
436
525
|
back(): void
|
|
437
526
|
}): ReactElement {
|
|
438
|
-
const
|
|
439
|
-
const
|
|
527
|
+
const advertised = row.reasoning?.efforts ?? []
|
|
528
|
+
const empty = row.reasoning === undefined || advertised.length === 0
|
|
440
529
|
// The provider-default row only exists when the adapter declares no default
|
|
441
530
|
// effort: with one, the default is an advertised level already in the list.
|
|
442
531
|
const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined
|
|
443
|
-
const rows =
|
|
444
|
-
? [{ id: '', name: '
|
|
445
|
-
:
|
|
532
|
+
const rows = empty
|
|
533
|
+
? [{ id: '', name: '' }]
|
|
534
|
+
: hasDefaultRow
|
|
535
|
+
? [{ id: '', name: 'Default' }, ...advertised]
|
|
536
|
+
: advertised
|
|
446
537
|
// An absent or cleared effort is the Default row's current state.
|
|
447
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)
|
|
448
544
|
useEffect(() => {
|
|
449
545
|
if (rows.length === 0) {
|
|
450
546
|
if (cursor !== 0) setCursor(0)
|
|
@@ -454,7 +550,15 @@ export function EffortPanel({ row, current, select, back }: {
|
|
|
454
550
|
}, [rows.length, cursor])
|
|
455
551
|
useInput((input, key) => {
|
|
456
552
|
if (key.escape || input === 'q') return back()
|
|
457
|
-
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
|
+
}
|
|
458
562
|
if (key.upArrow) {
|
|
459
563
|
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
460
564
|
return
|
|
@@ -469,13 +573,226 @@ export function EffortPanel({ row, current, select, back }: {
|
|
|
469
573
|
})
|
|
470
574
|
return createElement(ListFrame, {
|
|
471
575
|
title: `/model — effort for ${row.providerName} · ${row.modelName}`,
|
|
472
|
-
rows:
|
|
473
|
-
key: effort
|
|
474
|
-
|
|
475
|
-
|
|
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
|
+
})),
|
|
476
582
|
cursor,
|
|
477
583
|
loading: false,
|
|
478
584
|
query: '',
|
|
479
|
-
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',
|
|
480
797
|
})
|
|
481
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
|