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.
@@ -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(`search: ${props.query === '' ? 'type to filter' : props.query}`), viewport.contentColumns)),
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
- 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: '' })
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
- 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()
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') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
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: `/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`,
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: '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',
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, with the effective effort and the model default marked.
459
- * A model WITHOUT an adapter-declared default leads with a "Default"
460
- * (provider-default) row the web effort pane's first entry so the user
461
- * can clear a picked level back to provider behavior instead of being forced
462
- * to choose an advertised one. Enter applies one level; Esc returns to the
463
- * 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.
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 [cursor, setCursor] = useState(0)
476
- const efforts = row.reasoning?.efforts ?? []
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 = hasDefaultRow
481
- ? [{ id: '', name: 'Default' }, ...efforts]
482
- : efforts
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 (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
+ }
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: rows.map(effort => ({
510
- key: effort.id,
511
- text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
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