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.
Files changed (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. package/src/version.ts +16 -0
@@ -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 { displayText, singleLineText, truncateColumns } from './render/text.ts'
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`, viewport.contentColumns))
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(`search: ${props.query === '' ? 'type to filter' : props.query}`, viewport.contentColumns)),
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
- const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'all', sort: 'newest', currentCwd, query: '' })
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
- if (key.escape || input === 'q') return close()
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') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
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: `/resume · ${toolbar}`,
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: 'type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume',
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 ? '› ' : ' ') + displayText(entry), viewport.contentColumns),
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, with the effective effort and the model default marked.
422
- * A model WITHOUT an adapter-declared default leads with a "Default"
423
- * (provider-default) row the web effort pane's first entry so the user
424
- * can clear a picked level back to provider behavior instead of being forced
425
- * to choose an advertised one. Enter applies one level; Esc returns to the
426
- * model list without applying.
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 [cursor, setCursor] = useState(0)
439
- const efforts = row.reasoning?.efforts ?? []
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 = hasDefaultRow
444
- ? [{ id: '', name: 'Default' }, ...efforts]
445
- : efforts
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 (rows.length === 0) return
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: rows.map(effort => ({
473
- key: effort.id,
474
- text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
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