dsh-code 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +12 -3
- package/README.md +12 -3
- package/lib/index.mjs +1079 -204
- package/lib/types/app.d.ts +13 -0
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/kernel-panels.d.ts +58 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/render/projection.d.ts +2 -0
- package/lib/types/render/tool-preview.d.ts +10 -0
- package/lib/types/session-directory.d.ts +46 -2
- package/lib/types/subagents.d.ts +60 -0
- package/package.json +1 -1
- package/src/app.ts +359 -75
- package/src/approval.ts +161 -135
- package/src/index.ts +175 -8
- package/src/kernel-panels.ts +310 -30
- package/src/models.ts +26 -0
- package/src/render/lines.ts +236 -233
- package/src/render/projection.ts +5 -1
- package/src/render/tool-preview.ts +77 -50
- package/src/session-directory.ts +128 -6
- package/src/subagents.ts +165 -0
package/src/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 {
|
|
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
|
-
/**
|
|
881
|
-
|
|
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 [
|
|
947
|
+
const [cursor, setCursor] = useState(0)
|
|
885
948
|
const pending = snapshot.pending
|
|
886
|
-
const active = !locked &&
|
|
887
|
-
const
|
|
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
|
-
|
|
955
|
+
setCursor(0)
|
|
897
956
|
}, [pending])
|
|
898
957
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
972
|
+
const ask = snapshot.pending
|
|
973
|
+
if (ask === undefined || snapshot.answered) return
|
|
905
974
|
if (key.upArrow) {
|
|
906
|
-
|
|
975
|
+
setCursor(current => (current + APPROVAL_OPTIONS.length - 1) % APPROVAL_OPTIONS.length)
|
|
907
976
|
return
|
|
908
977
|
}
|
|
909
978
|
if (key.downArrow) {
|
|
910
|
-
|
|
979
|
+
setCursor(current => (current + 1) % APPROVAL_OPTIONS.length)
|
|
911
980
|
return
|
|
912
981
|
}
|
|
913
|
-
if (key.
|
|
914
|
-
|
|
982
|
+
if (key.return) {
|
|
983
|
+
decide(APPROVAL_OPTIONS[cursor]!)
|
|
915
984
|
return
|
|
916
985
|
}
|
|
917
|
-
if (key.
|
|
918
|
-
|
|
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
|
-
|
|
991
|
+
decide(APPROVAL_OPTIONS[0]!)
|
|
924
992
|
return
|
|
925
993
|
}
|
|
926
994
|
if (input === 'n' || input === 'N') {
|
|
927
|
-
|
|
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
|
-
|
|
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(
|
|
1012
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`approval${queuedSuffix} · enter/y allow · esc/n reject`, viewport.contentColumns))
|
|
934
1013
|
}
|
|
935
|
-
|
|
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(
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
: '
|
|
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
|
-
|
|
1235
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|