dsh-code 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -66,7 +66,8 @@ import type { ProviderSettingsDirectory, ProviderTargetView } from './provider-s
66
66
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
67
67
  import type { SkillsView, SkillRow } from './skills.ts'
68
68
  import type { MentionCandidate } from './mentions.ts'
69
- import { EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
69
+ import type { SubagentFeedView, SubagentRow } from './subagents.ts'
70
+ import { AgentsPanel, EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, SubagentPanel } from './kernel-panels.ts'
70
71
  import type { PresetRow } from './presets.ts'
71
72
  import type { PermissionRow } from './permissions.ts'
72
73
  import type { PluginRow } from './plugin-inventory.ts'
@@ -131,6 +132,8 @@ export interface AppProps {
131
132
  approval: ApprovalStore
132
133
  /** ask_user_question store fed by the single UI provider. */
133
134
  questions: QuestionStore
135
+ /** Live subagent activity feed (child sessions of the current root). */
136
+ subagents: SubagentFeedView
134
137
  /** Live slash-command descriptor list (completion candidates). */
135
138
  commands: CommandsView
136
139
  /** Live user-invocable skill catalog (completion candidates). */
@@ -167,6 +170,14 @@ export interface AppProps {
167
170
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
168
171
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
169
172
  selectModel(row: ModelRow, effortId?: string): string
173
+ /** The /subagent override label, '' when delegated agents follow the current model. */
174
+ subagentModel: string
175
+ /** Apply one /subagent model pick; returns the override label. */
176
+ setSubagentModel(row: ModelRow, effortId?: string): string
177
+ /** Drop the /subagent override (delegated agents follow the current model). */
178
+ clearSubagentModel(): void
179
+ /** Delete one session subtree; resolves with the outcome line. */
180
+ deleteSession(id: string): Promise<string>
170
181
  /** Load provider/settings/credential facts for the optional /model provider stage. */
171
182
  loadModelProviders?(): Promise<ProviderSettingsDirectory>
172
183
  /** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
@@ -193,6 +204,8 @@ export interface AppProps {
193
204
  createSession(mode?: string): void
194
205
  loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
195
206
  loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
207
+ /** Load this session's subagent conversations (children by lineage). */
208
+ loadSubagents(): Promise<readonly SessionRow[]>
196
209
  switchSession(row: SessionRow): void
197
210
  cancelSessionSwitch(): boolean
198
211
  loadPlugins(): readonly PluginRow[]
@@ -691,6 +704,30 @@ function todoMark(status: TodoItem['status']): string {
691
704
  return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
692
705
  }
693
706
 
707
+ /**
708
+ * One-row live subagent summary (the Codex agent status feed, compressed to
709
+ * the transcript's budget): running count, total, and the most recently
710
+ * active child's current activity. One line, never more — the full view is
711
+ * the /agents panel.
712
+ */
713
+ function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement | undefined {
714
+ if (rows.length === 0) return undefined
715
+ const running = rows.filter(row => row.state !== 'done').length
716
+ const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
717
+ const mark = newest.state === 'done' ? '✓' : newest.state === 'idle' ? '⏸' : '●'
718
+ return createElement(
719
+ Box,
720
+ { paddingX: 1 },
721
+ createElement(
722
+ Text,
723
+ { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
724
+ `agents ${running} live`,
725
+ createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${rows.length} total · /agents`),
726
+ createElement(Text, { color: inkColor(getPalette().text) }, ` · ${mark} ${newest.label} ${newest.activity}`),
727
+ ),
728
+ )
729
+ }
730
+
694
731
  /** One-row todo summary: task count cannot grow the live Ink tree. */
695
732
  function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
696
733
  if (todos.length === 0) return undefined
@@ -877,72 +914,139 @@ function NoticeLine({ text, tone, columns }: {
877
914
  )
878
915
  }
879
916
 
880
- /** The y/n approval bar rendered while an approval ask is pending. */
881
- function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
917
+ /** One selectable approval decision (Codex approval-overlay wording). */
918
+ interface ApprovalOption {
919
+ readonly key: 'allow' | 'reject-note' | 'reject'
920
+ readonly label: string
921
+ readonly hotkey: string
922
+ }
923
+
924
+ /** The fixed decision list; answers stay in the binary answerer vocabulary. */
925
+ const APPROVAL_OPTIONS: readonly ApprovalOption[] = [
926
+ { key: 'allow', label: 'Yes, proceed', hotkey: 'y' },
927
+ { key: 'reject-note', label: 'No, and tell it what to do differently', hotkey: 'n' },
928
+ { key: 'reject', label: 'No, continue without running it', hotkey: 'd' },
929
+ ]
930
+
931
+ /**
932
+ * The approval dialog (Codex ApprovalOverlay contract): a bold question
933
+ * header, the bounded command body with an explicit overflow marker, a
934
+ * numbered option list with a `›` cursor, single-key shortcuts, and digits
935
+ * for direct selection. Askers queue FIFO — the count rides the header.
936
+ * The upstream answerer vocabulary stays binary (`allowed-once` /
937
+ * `rejected`): "tell it what to do differently" rejects and hands the
938
+ * composer back with a hint notice, exactly Codex's decline-then-type flow.
939
+ */
940
+ function ApprovalBar({ snapshot, locked, notify }: {
941
+ snapshot: ApprovalSnapshot
942
+ locked: boolean
943
+ notify(text: string, tone?: NoticeTone): void
944
+ }): ReactElement | undefined {
882
945
  const stdout = useStdout().stdout
883
946
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
884
- const [scroll, setScroll] = useState(0)
947
+ const [cursor, setCursor] = useState(0)
885
948
  const pending = snapshot.pending
886
- const active = !locked && snapshot.pending !== undefined && !snapshot.answered
887
- const content = useMemo<readonly StyledLine[]>(() => pending === undefined
949
+ const active = !locked && pending !== undefined && !snapshot.answered
950
+ const body = useMemo<readonly StyledLine[]>(() => pending === undefined || pending.command === ''
888
951
  ? []
889
- : [
890
- ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
891
- ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
892
- ], [pending, viewport.contentColumns])
893
- const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
952
+ : textLines(pending.command, viewport.contentColumns, 'dim'), [pending, viewport.contentColumns])
894
953
 
895
954
  useEffect(() => {
896
- setScroll(0)
955
+ setCursor(0)
897
956
  }, [pending])
898
957
 
899
- useEffect(() => {
900
- if (visibleScroll !== scroll) setScroll(visibleScroll)
901
- }, [visibleScroll, scroll])
958
+ const decide = (option: ApprovalOption): void => {
959
+ const ask = snapshot.pending
960
+ if (ask === undefined || snapshot.answered) return
961
+ if (option.key === 'allow') {
962
+ ask.answer('allowed-once')
963
+ return
964
+ }
965
+ ask.answer('rejected')
966
+ if (option.key === 'reject-note') {
967
+ notify('rejected — type below what it should do differently (it steers the next step)', 'warning')
968
+ }
969
+ }
902
970
 
903
971
  useInput((input, key) => {
904
- if (snapshot.pending === undefined) return
972
+ const ask = snapshot.pending
973
+ if (ask === undefined || snapshot.answered) return
905
974
  if (key.upArrow) {
906
- setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
975
+ setCursor(current => (current + APPROVAL_OPTIONS.length - 1) % APPROVAL_OPTIONS.length)
907
976
  return
908
977
  }
909
978
  if (key.downArrow) {
910
- setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
979
+ setCursor(current => (current + 1) % APPROVAL_OPTIONS.length)
911
980
  return
912
981
  }
913
- if (key.pageUp) {
914
- setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
982
+ if (key.return) {
983
+ decide(APPROVAL_OPTIONS[cursor]!)
915
984
  return
916
985
  }
917
- if (key.pageDown) {
918
- setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
986
+ if (key.escape) {
987
+ decide(APPROVAL_OPTIONS[2]!)
919
988
  return
920
989
  }
921
- if (snapshot.answered) return
922
990
  if (input === 'y' || input === 'Y') {
923
- snapshot.pending.answer('allowed-once')
991
+ decide(APPROVAL_OPTIONS[0]!)
924
992
  return
925
993
  }
926
994
  if (input === 'n' || input === 'N') {
927
- snapshot.pending.answer('rejected')
995
+ decide(APPROVAL_OPTIONS[1]!)
996
+ return
997
+ }
998
+ if (input === 'd' || input === 'D') {
999
+ decide(APPROVAL_OPTIONS[2]!)
1000
+ return
1001
+ }
1002
+ if (/^[1-9]$/u.test(input)) {
1003
+ const index = Number(input) - 1
1004
+ if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index]!)
928
1005
  }
929
1006
  }, { isActive: active })
930
- if (snapshot.pending === undefined) return undefined
1007
+
1008
+ if (pending === undefined) return undefined
931
1009
  if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1010
+ const queuedSuffix = snapshot.queued > 0 ? ` · +${snapshot.queued} queued` : ''
932
1011
  if (viewport.compact) {
933
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
1012
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`approval${queuedSuffix} · enter/y allow · esc/n reject`, viewport.contentColumns))
934
1013
  }
935
- const { answered } = snapshot
1014
+ // Body budget: title + options + footer consume fixed rows; the command
1015
+ // preview shrinks with an explicit overflow marker (Codex's "[… N lines]").
1016
+ const reservedRows = 3 + APPROVAL_OPTIONS.length
1017
+ const bodyBudget = Math.max(1, viewport.bodyRows - reservedRows)
1018
+ const visibleBody = body.slice(0, bodyBudget)
1019
+ const overflow = body.length - visibleBody.length
936
1020
  return createElement(
937
1021
  Box,
938
1022
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
939
- createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
940
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
941
- createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
942
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
943
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
1023
+ createElement(
1024
+ Text,
1025
+ { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' },
1026
+ truncateColumns(`${pending.headline}${queuedSuffix}`, viewport.contentColumns),
1027
+ ),
1028
+ createElement(PanelGap, { visible: viewport.gapRows > 0 && body.length > 0 }),
1029
+ ...visibleBody.map((line, index) => createElement(StyledRows, { key: `body-${index}`, lines: [line] })),
1030
+ ...(overflow > 0
1031
+ ? [createElement(Text, { key: 'overflow', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(`… +${overflow} more lines · ctrl+o shows the full call in the transcript`, viewport.contentColumns))]
1032
+ : []),
1033
+ ...(body.length > 0 ? [createElement(PanelGap, { visible: viewport.gapRows > 0 })] : []),
1034
+ ...APPROVAL_OPTIONS.map((option, index) => {
1035
+ const selected = !snapshot.answered && index === cursor
1036
+ return createElement(
1037
+ Text,
1038
+ {
1039
+ key: option.key,
1040
+ color: selected ? inkColor(getPalette().brandBright) : inkColor(getPalette().text),
1041
+ bold: selected || undefined,
1042
+ wrap: 'truncate-end',
1043
+ },
1044
+ truncateColumns(`${selected ? '›' : ' '} ${index + 1}. ${option.label} (${option.hotkey})`, viewport.contentColumns),
1045
+ )
1046
+ }),
1047
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(snapshot.answered
944
1048
  ? 'submitted…'
945
- : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
1049
+ : '↑↓ choose · enter confirm · y/n/d quick · esc reject', viewport.contentColumns)),
946
1050
  )
947
1051
  }
948
1052
 
@@ -1217,9 +1321,11 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1217
1321
  }
1218
1322
 
1219
1323
  /** The /model panel: a scrolling list over the advisory model directory. */
1220
- function ModelPanel({ directory, error, onSelect, onProviders, onRetry, onClose }: {
1324
+ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry, onClose }: {
1221
1325
  directory: ModelDirectory | undefined
1222
1326
  error: string | undefined
1327
+ /** `provider/model` label of the applied model: the cursor lands on it once. */
1328
+ current?: string
1223
1329
  onSelect(row: ModelRow): void
1224
1330
  onProviders?(): void
1225
1331
  onRetry(): void
@@ -1229,14 +1335,27 @@ function ModelPanel({ directory, error, onSelect, onProviders, onRetry, onClose
1229
1335
  const stdout = useStdout().stdout
1230
1336
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1231
1337
  const rows = directory?.rows ?? []
1338
+ const positioned = useRef(false)
1232
1339
 
1233
1340
  useEffect(() => {
1234
- if (rows.length === 0) {
1235
- if (cursor !== 0) setCursor(0)
1341
+ // Open ON the applied model (Codex resumes the previous pick): the first
1342
+ // non-empty directory positions the cursor once, never on later refreshes.
1343
+ if (positioned.current || rows.length === 0 || current === undefined) {
1344
+ if (rows.length === 0) {
1345
+ if (cursor !== 0) setCursor(0)
1346
+ return
1347
+ }
1348
+ if (cursor >= rows.length) setCursor(rows.length - 1)
1236
1349
  return
1237
1350
  }
1238
- if (cursor >= rows.length) setCursor(rows.length - 1)
1239
- }, [rows.length, cursor])
1351
+ const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
1352
+ if (index >= 0) {
1353
+ positioned.current = true
1354
+ setCursor(index)
1355
+ } else if (cursor >= rows.length) {
1356
+ setCursor(Math.max(0, rows.length - 1))
1357
+ }
1358
+ }, [rows, cursor, current])
1240
1359
 
1241
1360
  useInput((input, key) => {
1242
1361
  if (key.escape || input === 'q') {
@@ -1685,6 +1804,9 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1685
1804
  createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
1686
1805
  createElement(Box, { key: 'local-theme' }, row('/theme', 'switch the color theme')),
1687
1806
  createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
1807
+ createElement(Box, { key: 'local-agents' }, row('/agents', 'inspect subagent sessions of this conversation')),
1808
+ createElement(Box, { key: 'local-subagent' }, row('/subagent', 'choose the model delegated subagents run on')),
1809
+ createElement(Box, { key: 'local-delete' }, row('/delete', 'delete a session and its subagent threads')),
1688
1810
  createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1689
1811
  createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
1690
1812
  createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
@@ -2021,6 +2143,9 @@ export function completionCandidates(
2021
2143
  { label: '/statusline', description: 'customize the status line', origin: 'command' },
2022
2144
  { label: '/theme', description: 'switch the color theme', origin: 'command' },
2023
2145
  { label: '/history', description: 'search and recall past prompts', origin: 'command' },
2146
+ { label: '/agents', description: 'inspect subagent sessions of this conversation', origin: 'command' },
2147
+ { label: '/subagent', description: 'choose the model delegated subagents run on', origin: 'command' },
2148
+ { label: '/delete', description: 'delete a session and its subagent threads', origin: 'command' },
2024
2149
  { label: '/clear', description: 'clear the screen', origin: 'command' },
2025
2150
  { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
2026
2151
  { label: '/title', description: 'rename this session', origin: 'command' },
@@ -2123,7 +2248,7 @@ function CompletionMenu({ active, mention, index, rows }: {
2123
2248
  * While a modal (approval / question / model panel) owns the keys, the
2124
2249
  * box passes every key through untouched.
2125
2250
  */
2126
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }: {
2251
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, openAgents, openSubagent, openDelete, deleteConfirm, confirmDelete, cancelDelete, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }: {
2127
2252
  active: boolean
2128
2253
  frozen: boolean
2129
2254
  busy: boolean
@@ -2143,6 +2268,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2143
2268
  openStatusline(): void
2144
2269
  openTheme(): void
2145
2270
  openHistory(): void
2271
+ /** Open the /agents panel (live subagent feed + transcript entry). */
2272
+ openAgents(): void
2273
+ /** Open the /subagent model panel. */
2274
+ openSubagent(): void
2275
+ /** Open the /resume picker in delete mode, optionally pre-armed on one id. */
2276
+ openDelete(id?: string): void
2277
+ /** The row id awaiting y/n in this box, when a deletion is pending. */
2278
+ deleteConfirm?: string
2279
+ /** Confirm the pending deletion (y in the box). */
2280
+ confirmDelete(): void
2281
+ /** Cancel the pending deletion (any other key in the box). */
2282
+ cancelDelete(): void
2146
2283
  createSession(mode?: string): void
2147
2284
  cancelSessionSwitch(): boolean
2148
2285
  notify(text: string, tone?: NoticeTone): void
@@ -2289,9 +2426,53 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2289
2426
  }))
2290
2427
  : candidates
2291
2428
 
2429
+ /** Accept the highlighted completion-menu candidate into the draft. */
2430
+ const acceptMenuCandidate = (): void => {
2431
+ if (mentionActive && mentionToken !== undefined) {
2432
+ const row = mentionRows[completionIndex % mentionRows.length]
2433
+ if (row !== undefined) {
2434
+ // Session rows carry the canonical @[label](dsh-session:…) token;
2435
+ // file rows insert `@path` (directories keep their trailing slash).
2436
+ const insertion = row.label.startsWith('@')
2437
+ ? row.label
2438
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2439
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2440
+ setCursor(mentionToken.start + insertion.length)
2441
+ }
2442
+ } else if (pathActive) {
2443
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
2444
+ if (row !== undefined) {
2445
+ // Bare path completion replaces the typed token with the chosen
2446
+ // workspace path (directories keep their trailing slash).
2447
+ const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
2448
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
2449
+ setCursor(pathTokenStart + insertion.length)
2450
+ }
2451
+ } else {
2452
+ const candidate = candidates[completionIndex % candidates.length]
2453
+ if (candidate !== undefined) {
2454
+ setValue(`${candidate.label} `)
2455
+ setCursor(candidate.label.length + 1)
2456
+ }
2457
+ }
2458
+ setCompletionIndex(0)
2459
+ setDismissedMenuValue(undefined)
2460
+ }
2461
+
2292
2462
  useInput((input, key) => {
2293
2463
  // Modal ownership: approval/question/model dialogs consume all keys.
2294
2464
  if (!active) return
2465
+ // Deletion confirm owns the box: y proceeds, anything else cancels.
2466
+ // Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
2467
+ // as the box's own prompt, not an invisible panel keypress.
2468
+ if (deleteConfirm !== undefined) {
2469
+ if (input === 'y' || input === 'Y') {
2470
+ confirmDelete()
2471
+ } else {
2472
+ cancelDelete()
2473
+ }
2474
+ return
2475
+ }
2295
2476
  // Shift+Tab cycles the permission preset (Claude-Code convention).
2296
2477
  if (key.tab && key.shift) {
2297
2478
  try {
@@ -2363,6 +2544,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2363
2544
  setDismissedMenuValue(undefined)
2364
2545
  return
2365
2546
  }
2547
+ // Enter on an open completion menu accepts the highlighted candidate
2548
+ // (Codex list parity: Tab and Enter are both accept keys — many users
2549
+ // never discover Tab) — UNLESS the draft already spells one candidate
2550
+ // exactly, in which case Enter submits it (typing a full "/effort" and
2551
+ // pressing return must run the command, not re-accept its own text).
2552
+ if (menuActive) {
2553
+ const exactSlash = !mentionActive && !pathActive && candidates.some(candidate => candidate.label === value)
2554
+ if (!exactSlash) {
2555
+ acceptMenuCandidate()
2556
+ return
2557
+ }
2558
+ }
2366
2559
  const text = value.trim()
2367
2560
  setValue('')
2368
2561
  setCursor(0)
@@ -2461,6 +2654,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2461
2654
  openHistory()
2462
2655
  return
2463
2656
  }
2657
+ if (text === '/agents') {
2658
+ openAgents()
2659
+ return
2660
+ }
2661
+ if (text === '/subagent') {
2662
+ openSubagent()
2663
+ return
2664
+ }
2665
+ if (text === '/delete' || text.startsWith('/delete ')) {
2666
+ openDelete(text.slice(7).trim())
2667
+ return
2668
+ }
2464
2669
  if (busy && !text.startsWith('/')) {
2465
2670
  // A running turn is steered, not blocked: the inbox delivers this
2466
2671
  // text at the next step boundary (Esc/Ctrl+C still cancels outright).
@@ -2506,35 +2711,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2506
2711
  return
2507
2712
  }
2508
2713
  if (key.tab && menuActive) {
2509
- if (mentionActive && mentionToken !== undefined) {
2510
- const row = mentionRows[completionIndex % mentionRows.length]
2511
- if (row !== undefined) {
2512
- // Session rows carry the canonical @[label](dsh-session:…) token;
2513
- // file rows insert `@path` (directories keep their trailing slash).
2514
- const insertion = row.label.startsWith('@')
2515
- ? row.label
2516
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2517
- setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2518
- setCursor(mentionToken.start + insertion.length)
2519
- }
2520
- } else if (pathActive) {
2521
- const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
2522
- if (row !== undefined) {
2523
- // Bare path completion replaces the typed token with the chosen
2524
- // workspace path (directories keep their trailing slash).
2525
- const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
2526
- setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
2527
- setCursor(pathTokenStart + insertion.length)
2528
- }
2529
- } else {
2530
- const candidate = candidates[completionIndex % candidates.length]
2531
- if (candidate !== undefined) {
2532
- setValue(`${candidate.label} `)
2533
- setCursor(candidate.label.length + 1)
2534
- }
2535
- }
2536
- setCompletionIndex(0)
2537
- setDismissedMenuValue(undefined)
2714
+ acceptMenuCandidate()
2538
2715
  return
2539
2716
  }
2540
2717
  if (key.backspace || key.delete) {
@@ -2632,6 +2809,20 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2632
2809
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
2633
2810
  const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
2634
2811
  if (frozen) {
2812
+ // A pending deletion turns the box into the confirm prompt: the y/n is
2813
+ // typed HERE, with a readable warn-styled hint instead of a dim footer.
2814
+ if (deleteConfirm !== undefined) {
2815
+ return createElement(
2816
+ Box,
2817
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().warn), paddingX: 1 },
2818
+ createElement(
2819
+ Text,
2820
+ { wrap: 'truncate-end' },
2821
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '❯ '),
2822
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true }, 'y delete · any other key cancels'),
2823
+ ),
2824
+ )
2825
+ }
2635
2826
  const frozen = value === ''
2636
2827
  ? 'type a message'
2637
2828
  : verboseLine(value, Math.max(1, columns - 6))
@@ -3053,6 +3244,34 @@ export function App(props: AppProps): ReactElement {
3053
3244
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
3054
3245
  const [themeOpen, setThemeOpen] = useState(false)
3055
3246
  const [historyOpen, setHistoryOpen] = useState(false)
3247
+ const [agentsOpen, setAgentsOpen] = useState(false)
3248
+ const [subagentOpen, setSubagentOpen] = useState(false)
3249
+ /** /delete state: delete-mode hint plus an optional pre-armed row id. */
3250
+ const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
3251
+ /** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
3252
+ * composer takes the keys, the resume panel yields until it settles. */
3253
+ const [deleteConfirmId, setDeleteConfirmId] = useState<string | undefined>(undefined)
3254
+ /** Bumped after a deletion so the /resume listing reloads immediately. */
3255
+ const [deleteReloadToken, setDeleteReloadToken] = useState(0)
3256
+ const requestDelete = useCallback((row: SessionRow): void => {
3257
+ setDeleteConfirmId(row.id)
3258
+ }, [])
3259
+ const cancelDelete = useCallback((): void => {
3260
+ setDeleteConfirmId(undefined)
3261
+ }, [])
3262
+ const confirmDelete = useCallback((): void => {
3263
+ const id = deleteConfirmId
3264
+ if (id === undefined) return
3265
+ setDeleteConfirmId(undefined)
3266
+ void props.deleteSession(id).then(outcome => {
3267
+ notify(outcome)
3268
+ // Keep the picker open and reload: a successful deletion must vanish
3269
+ // from the list immediately, not look like a no-op.
3270
+ setDeleteReloadToken(token => token + 1)
3271
+ }, (reason: unknown) => {
3272
+ notify(`delete failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3273
+ })
3274
+ }, [deleteConfirmId, props.deleteSession, notify])
3056
3275
  /** The /history panel's accepted entry: text plus its recall-space index. */
3057
3276
  const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
3058
3277
  /** Submissions recorded in this process (Codex local history; persistent file stays in the runner). */
@@ -3089,10 +3308,16 @@ export function App(props: AppProps): ReactElement {
3089
3308
  const [refreshEpoch, setRefreshEpoch] = useState(0)
3090
3309
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
3091
3310
  const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
3311
+ const agentRows = useSyncExternalStore(props.subagents.subscribe, props.subagents.getSnapshot)
3092
3312
  const approvalPending = approvalSnapshot.pending !== undefined
3093
3313
  const questionPending = questionSnapshot.pending !== undefined
3094
3314
  // While any modal owns the keys, the prompt box passes everything through.
3095
- const inputActive = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
3315
+ // While a deletion waits for y/n, the composer takes the keys (the resume
3316
+ // panel yields): the confirm is typed IN the input box, not as an invisible
3317
+ // panel keypress.
3318
+ const inputActive = deleteConfirmId !== undefined
3319
+ ? !approvalPending && !questionPending
3320
+ : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
3096
3321
 
3097
3322
  // Human questions outrank local inspectors. Close the lower modal instead
3098
3323
  // of leaving an approval/question visible but keyboard-locked behind it.
@@ -3110,6 +3335,9 @@ export function App(props: AppProps): ReactElement {
3110
3335
  setStatuslineOpen(false)
3111
3336
  setThemeOpen(false)
3112
3337
  setHistoryOpen(false)
3338
+ setAgentsOpen(false)
3339
+ setSubagentOpen(false)
3340
+ setDeleteConfirmId(undefined)
3113
3341
  setVerboseOpen(false)
3114
3342
  }, [approvalPending, questionPending])
3115
3343
 
@@ -3213,9 +3441,9 @@ export function App(props: AppProps): ReactElement {
3213
3441
  ? Math.max(1, Math.floor(streamRows / 3))
3214
3442
  : 1
3215
3443
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
3216
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
3444
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
3217
3445
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
3218
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || inspectorVisible || approvalPending || questionPending
3446
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || inspectorVisible || approvalPending || questionPending
3219
3447
  const closeInspector = useCallback((): void => {
3220
3448
  setVerboseOpen(false)
3221
3449
  }, [])
@@ -3322,6 +3550,9 @@ export function App(props: AppProps): ReactElement {
3322
3550
  })
3323
3551
  } else if (effortFor !== undefined) {
3324
3552
  modelSurface = createElement(EffortPanel, {
3553
+ // Keyed per row: switching models remounts the stage so its cursor
3554
+ // initializes on the new model's effective effort.
3555
+ key: `${effortFor.provider}/${effortFor.model}`,
3325
3556
  row: effortFor,
3326
3557
  current: effortLabel,
3327
3558
  select: (effortId: string) => applyModel(effortFor, effortId),
@@ -3331,6 +3562,7 @@ export function App(props: AppProps): ReactElement {
3331
3562
  modelSurface = createElement(ModelPanel, {
3332
3563
  directory,
3333
3564
  error: modelError,
3565
+ current: modelLabel,
3334
3566
  onSelect: (row: ModelRow) => {
3335
3567
  // A model advertising several levels opens the effort stage first;
3336
3568
  // one advertised level is its only option, while no capability uses
@@ -3387,8 +3619,9 @@ export function App(props: AppProps): ReactElement {
3387
3619
  )
3388
3620
  : undefined,
3389
3621
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
3622
+ transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
3390
3623
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
3391
- createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
3624
+ createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify }),
3392
3625
  modelSurface,
3393
3626
  helpOpen && !approvalPending && !questionPending
3394
3627
  ? createElement(HelpPanel, {
@@ -3441,6 +3674,10 @@ export function App(props: AppProps): ReactElement {
3441
3674
  currentCwd: props.workspaceRoot,
3442
3675
  load: props.loadSessions,
3443
3676
  readTranscript: props.loadSessionTranscript,
3677
+ requestDelete,
3678
+ deleteConfirmId,
3679
+ reloadToken: deleteReloadToken,
3680
+ deleteMode: resumeDelete.mode,
3444
3681
  select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
3445
3682
  close: () => setResumeOpen(false),
3446
3683
  })
@@ -3483,6 +3720,37 @@ export function App(props: AppProps): ReactElement {
3483
3720
  close: () => setHistoryOpen(false),
3484
3721
  })
3485
3722
  : undefined,
3723
+ agentsOpen && !approvalPending && !questionPending
3724
+ ? createElement(AgentsPanel, {
3725
+ live: agentRows,
3726
+ load: props.loadSubagents,
3727
+ readTranscript: props.loadSessionTranscript,
3728
+ close: () => setAgentsOpen(false),
3729
+ })
3730
+ : undefined,
3731
+ subagentOpen && !approvalPending && !questionPending
3732
+ ? createElement(SubagentPanel, {
3733
+ current: props.subagentModel,
3734
+ load: props.loadModels,
3735
+ pick: (row: ModelRow, effortId?: string) => {
3736
+ try {
3737
+ // The runner's label already carries the effort suffix
3738
+ // (`provider/model@effort`), so no second append here.
3739
+ const label = props.setSubagentModel(row, effortId)
3740
+ notify(`subagents → ${label}`)
3741
+ setSubagentOpen(false)
3742
+ } catch (reason: unknown) {
3743
+ notify(`subagent model change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3744
+ }
3745
+ },
3746
+ inherit: () => {
3747
+ props.clearSubagentModel()
3748
+ notify('subagents → inherit current model')
3749
+ setSubagentOpen(false)
3750
+ },
3751
+ close: () => setSubagentOpen(false),
3752
+ })
3753
+ : undefined,
3486
3754
  notice === undefined
3487
3755
  ? undefined
3488
3756
  : createElement(NoticeLine, {
@@ -3542,7 +3810,12 @@ export function App(props: AppProps): ReactElement {
3542
3810
  return
3543
3811
  }
3544
3812
  if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
3545
- notify('current model does not expose reasoning efforts', 'warning')
3813
+ // A model that advertises no levels still opens the stage: the
3814
+ // panel itself carries the empty state (the web effort pane's
3815
+ // "no levels" copy), instead of a bare notice that reads like
3816
+ // a failure.
3817
+ setEffortFor(row)
3818
+ setModelOpen(true)
3546
3819
  return
3547
3820
  }
3548
3821
  setEffortFor(row)
@@ -3556,11 +3829,22 @@ export function App(props: AppProps): ReactElement {
3556
3829
  },
3557
3830
  openMode: () => setModeOpen(true),
3558
3831
  openPermission: () => setPermissionOpen(true),
3559
- openResume: () => setResumeOpen(true),
3832
+ openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
3560
3833
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
3561
3834
  openStatusline: () => setStatuslineOpen(true),
3562
3835
  openTheme: () => setThemeOpen(true),
3563
3836
  openHistory: () => setHistoryOpen(true),
3837
+ openAgents: () => setAgentsOpen(true),
3838
+ openSubagent: () => setSubagentOpen(true),
3839
+ openDelete: (id?: string) => {
3840
+ const armed = id === undefined || id === '' ? undefined : id
3841
+ setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
3842
+ setDeleteConfirmId(armed)
3843
+ setResumeOpen(true)
3844
+ },
3845
+ deleteConfirm: deleteConfirmId,
3846
+ confirmDelete,
3847
+ cancelDelete,
3564
3848
  createSession: props.createSession,
3565
3849
  cancelSessionSwitch: props.cancelSessionSwitch,
3566
3850
  notify,