dsh-code 0.4.0 → 0.5.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.md +201 -61
- package/README.zh.md +204 -65
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +1108 -250
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +19 -1
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/inspector.d.ts +4 -0
- package/lib/types/render/status.d.ts +2 -0
- package/lib/types/render/text.d.ts +9 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/package.json +6 -1
- package/src/app.ts +311 -75
- package/src/commands.ts +15 -1
- package/src/index.ts +332 -133
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +254 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/inspector.ts +13 -4
- package/src/render/markdown.ts +15 -1
- package/src/render/status.ts +3 -0
- package/src/render/text.ts +34 -6
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- package/src/pictures/1.png +0 -0
package/src/app.ts
CHANGED
|
@@ -29,12 +29,16 @@ import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
|
29
29
|
import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
30
30
|
import type { ToolDetail } from './render/tool-detail.ts'
|
|
31
31
|
import { caretVisible, pulseFrame } from './render/animations.ts'
|
|
32
|
-
import type { ApprovalStore } from './approval.ts'
|
|
32
|
+
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
33
33
|
import type { CommandsView } from './commands.ts'
|
|
34
34
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
35
|
-
import type { QuestionStore } from './questions.ts'
|
|
35
|
+
import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
36
36
|
import type { SkillsView, SkillRow } from './skills.ts'
|
|
37
37
|
import type { MentionCandidate } from './mentions.ts'
|
|
38
|
+
import { ModePanel, PluginPanel, ResumePanel } from './kernel-panels.ts'
|
|
39
|
+
import type { PresetRow } from './presets.ts'
|
|
40
|
+
import type { PluginRow } from './plugin-inventory.ts'
|
|
41
|
+
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
38
42
|
|
|
39
43
|
/** Match Codex's settled-resize window before rebuilding terminal scrollback. */
|
|
40
44
|
const RESIZE_REFLOW_DELAY_MS = 75
|
|
@@ -42,11 +46,12 @@ const RESIZE_REFLOW_DELAY_MS = 75
|
|
|
42
46
|
/** Reset region/style, clear the visible screen and scrollback, then home. */
|
|
43
47
|
const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
|
|
44
48
|
import { buildStatusGroups, formatTokens, type StatusFacts } from './render/status.ts'
|
|
45
|
-
import { displayTail, displayText } from './render/text.ts'
|
|
49
|
+
import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
46
50
|
import {
|
|
47
51
|
clampScroll,
|
|
48
52
|
followInspectorCursor,
|
|
49
53
|
inspectorViewport,
|
|
54
|
+
layoutGutterRows,
|
|
50
55
|
moveScroll,
|
|
51
56
|
panelViewport,
|
|
52
57
|
revealRow,
|
|
@@ -62,6 +67,9 @@ import {
|
|
|
62
67
|
type StyledLine,
|
|
63
68
|
} from './render/lines.ts'
|
|
64
69
|
|
|
70
|
+
/** Visual priority for one bounded local notice. */
|
|
71
|
+
export type NoticeTone = 'info' | 'warning' | 'error'
|
|
72
|
+
|
|
65
73
|
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
66
74
|
export interface AppProps {
|
|
67
75
|
/** Event-fed transcript store for the live session. */
|
|
@@ -78,12 +86,16 @@ export interface AppProps {
|
|
|
78
86
|
model: string
|
|
79
87
|
/** Working-directory basename the session serves. */
|
|
80
88
|
cwd: string
|
|
89
|
+
/** Absolute working directory used by session filters and references. */
|
|
90
|
+
workspaceRoot: string
|
|
81
91
|
/** Git branch name, empty outside a repository. */
|
|
82
92
|
branch: string
|
|
83
93
|
/** Short session identifier. */
|
|
84
94
|
sessionId: string
|
|
85
95
|
/** Whether this session was resumed from persistence. */
|
|
86
96
|
resumed: boolean
|
|
97
|
+
/** Agent preset currently composing the session. */
|
|
98
|
+
mode: string
|
|
87
99
|
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
88
100
|
dispatch(text: string): void
|
|
89
101
|
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
@@ -104,8 +116,17 @@ export interface AppProps {
|
|
|
104
116
|
exportTranscript(argument: string): Promise<void>
|
|
105
117
|
/** Rename the session (/title <text>); returns the outcome line for the notice. */
|
|
106
118
|
renameTitle(argument: string): string
|
|
119
|
+
/** Preset/session/plugin kernel operations. */
|
|
120
|
+
loadPresets(): Promise<readonly PresetRow[]>
|
|
121
|
+
switchMode(id: string): Promise<string>
|
|
122
|
+
createSession(mode?: string): void
|
|
123
|
+
loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
124
|
+
loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
125
|
+
switchSession(row: SessionRow): void
|
|
126
|
+
cancelSessionSwitch(): boolean
|
|
127
|
+
loadPlugins(): readonly PluginRow[]
|
|
107
128
|
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
108
|
-
onBridgeReady(bridge: { notify(text: string): void }): void
|
|
129
|
+
onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
/** Ink `color` string for one palette triple. */
|
|
@@ -113,23 +134,10 @@ function inkColor(triple: readonly [number, number, number]): string {
|
|
|
113
134
|
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
|
|
114
135
|
}
|
|
115
136
|
|
|
116
|
-
/** Truncate text to a visible-column budget, appending … when cut. */
|
|
117
|
-
function truncateColumns(text: string, max: number): string {
|
|
118
|
-
let columns = 0
|
|
119
|
-
let out = ''
|
|
120
|
-
for (const char of text) {
|
|
121
|
-
const code = char.codePointAt(0) ?? 0
|
|
122
|
-
const width = code > 0x2e7f ? 2 : 1
|
|
123
|
-
if (columns + width > max) return `${out}…`
|
|
124
|
-
out += char
|
|
125
|
-
columns += width
|
|
126
|
-
}
|
|
127
|
-
return out
|
|
128
|
-
}
|
|
129
|
-
|
|
130
137
|
/** Pad text with spaces to a visible-column target (menu name column). */
|
|
131
138
|
function padColumns(text: string, width: number): string {
|
|
132
|
-
|
|
139
|
+
const clipped = truncateColumns(singleLineText(text), width)
|
|
140
|
+
return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
/** Interval-driven frame counter for one self-contained animated leaf. */
|
|
@@ -290,6 +298,11 @@ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
|
|
|
290
298
|
)
|
|
291
299
|
}
|
|
292
300
|
|
|
301
|
+
/** Codex-style panel rhythm that still participates in the row budget. */
|
|
302
|
+
function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
|
|
303
|
+
return visible ? createElement(Text, null, ' ') : undefined
|
|
304
|
+
}
|
|
305
|
+
|
|
293
306
|
/** One settled markdown document rendered as styled lines at the terminal width. */
|
|
294
307
|
function MarkdownBody({ text }: { text: string }): ReactElement {
|
|
295
308
|
const columns = useStdout().stdout?.columns ?? 80
|
|
@@ -304,7 +317,9 @@ function MarkdownBody({ text }: { text: string }): ReactElement {
|
|
|
304
317
|
...lines.map((line, index) => createElement(
|
|
305
318
|
Text,
|
|
306
319
|
{ key: index },
|
|
307
|
-
|
|
320
|
+
line.segments.length === 0
|
|
321
|
+
? ' '
|
|
322
|
+
: line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
|
|
308
323
|
)),
|
|
309
324
|
)
|
|
310
325
|
}
|
|
@@ -574,9 +589,35 @@ function StatusLine({ facts, stats, busy }: {
|
|
|
574
589
|
)
|
|
575
590
|
}
|
|
576
591
|
|
|
592
|
+
/**
|
|
593
|
+
* One fixed-height local feedback row. Errors remain visible while a slash
|
|
594
|
+
* subpage is open, but arbitrary exception text can never add physical rows
|
|
595
|
+
* above the composer.
|
|
596
|
+
*/
|
|
597
|
+
function NoticeLine({ text, tone, columns }: {
|
|
598
|
+
text: string
|
|
599
|
+
tone: NoticeTone
|
|
600
|
+
columns: number
|
|
601
|
+
}): ReactElement {
|
|
602
|
+
const color = tone === 'error'
|
|
603
|
+
? TUI_RGB.error
|
|
604
|
+
: tone === 'warning'
|
|
605
|
+
? TUI_RGB.warn
|
|
606
|
+
: TUI_RGB.brandBright
|
|
607
|
+
const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•'
|
|
608
|
+
return createElement(
|
|
609
|
+
Box,
|
|
610
|
+
{ paddingLeft: 2 },
|
|
611
|
+
createElement(
|
|
612
|
+
Text,
|
|
613
|
+
{ color: inkColor(color), wrap: 'truncate-end' },
|
|
614
|
+
truncateColumns(`${mark} ${singleLineText(text)}`, Math.max(1, columns - 2)),
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
577
619
|
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
578
|
-
function ApprovalBar({
|
|
579
|
-
const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
|
|
620
|
+
function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
|
|
580
621
|
const stdout = useStdout().stdout
|
|
581
622
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
582
623
|
const [scroll, setScroll] = useState(0)
|
|
@@ -635,7 +676,9 @@ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: bo
|
|
|
635
676
|
Box,
|
|
636
677
|
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
|
|
637
678
|
createElement(Text, { color: inkColor(TUI_RGB.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)),
|
|
679
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
638
680
|
createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
|
|
681
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
639
682
|
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
|
|
640
683
|
? 'submitted…'
|
|
641
684
|
: '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
|
|
@@ -650,8 +693,7 @@ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: bo
|
|
|
650
693
|
* service with a `plan-review` intent — the approve option gets a ✓ mark,
|
|
651
694
|
* the answer encoding stays identical.
|
|
652
695
|
*/
|
|
653
|
-
function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
|
|
654
|
-
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
696
|
+
function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapshot: QuestionSnapshot; locked: boolean }): ReactElement | undefined {
|
|
655
697
|
const stdout = useStdout().stdout
|
|
656
698
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
657
699
|
const pending = snapshot.pending
|
|
@@ -857,16 +899,19 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
|
|
|
857
899
|
{ color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
|
|
858
900
|
truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
|
|
859
901
|
),
|
|
902
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
860
903
|
createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
|
|
904
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
861
905
|
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
|
|
862
906
|
)
|
|
863
907
|
}
|
|
864
908
|
|
|
865
909
|
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
866
|
-
function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
910
|
+
function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
867
911
|
directory: ModelDirectory | undefined
|
|
868
912
|
error: string | undefined
|
|
869
913
|
onSelect(row: ModelRow): void
|
|
914
|
+
onRetry(): void
|
|
870
915
|
onClose(): void
|
|
871
916
|
}): ReactElement {
|
|
872
917
|
const [cursor, setCursor] = useState(0)
|
|
@@ -887,6 +932,10 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
|
887
932
|
onClose()
|
|
888
933
|
return
|
|
889
934
|
}
|
|
935
|
+
if (input === 'r') {
|
|
936
|
+
onRetry()
|
|
937
|
+
return
|
|
938
|
+
}
|
|
890
939
|
if (rows.length === 0) return
|
|
891
940
|
if (key.upArrow) {
|
|
892
941
|
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
@@ -919,21 +968,41 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
|
919
968
|
|
|
920
969
|
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
921
970
|
if (viewport.compact) {
|
|
922
|
-
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · esc/q close', viewport.contentColumns))
|
|
971
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
|
|
923
972
|
}
|
|
924
973
|
|
|
925
|
-
const
|
|
926
|
-
|
|
974
|
+
const stateRows: ReactElement[] = directory === undefined && error === undefined
|
|
975
|
+
? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ' loading models…')]
|
|
976
|
+
: error !== undefined
|
|
977
|
+
? [createElement(
|
|
978
|
+
Text,
|
|
979
|
+
{ key: 'error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
|
|
980
|
+
truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
|
|
981
|
+
)]
|
|
982
|
+
: [
|
|
983
|
+
...(directory?.failures.length === 0
|
|
984
|
+
? []
|
|
985
|
+
: [createElement(
|
|
986
|
+
Text,
|
|
987
|
+
{ key: 'failures', color: inkColor(TUI_RGB.warn), wrap: 'truncate-end' },
|
|
988
|
+
truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
|
|
989
|
+
)]),
|
|
990
|
+
...(rows.length === 0
|
|
991
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
|
|
992
|
+
: []),
|
|
993
|
+
]
|
|
994
|
+
// Measurement and rendering share the same physical-row budget: state
|
|
995
|
+
// messages consume body rows before selectable entries, as in Codex's
|
|
996
|
+
// list-selection views.
|
|
997
|
+
const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length)
|
|
998
|
+
const first = selectionWindow(cursor, rows.length, rowBudget)
|
|
999
|
+
const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
|
|
927
1000
|
return createElement(
|
|
928
1001
|
Box,
|
|
929
1002
|
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
|
|
930
1003
|
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
: undefined,
|
|
934
|
-
error !== undefined
|
|
935
|
-
? createElement(Text, { color: inkColor(TUI_RGB.error), wrap: 'truncate-end' }, truncateColumns(` ${displayText(error)}`, viewport.contentColumns))
|
|
936
|
-
: undefined,
|
|
1004
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1005
|
+
...stateRows,
|
|
937
1006
|
...visible.map((row) => {
|
|
938
1007
|
const index = rows.indexOf(row)
|
|
939
1008
|
const label = displayText(`${row.providerName} · ${row.modelName}`)
|
|
@@ -947,7 +1016,8 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
|
947
1016
|
truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
|
|
948
1017
|
)
|
|
949
1018
|
}),
|
|
950
|
-
createElement(
|
|
1019
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1020
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · r retry · esc/q close', viewport.contentColumns))),
|
|
951
1021
|
)
|
|
952
1022
|
}
|
|
953
1023
|
|
|
@@ -956,17 +1026,19 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
|
956
1026
|
* commands, the live registry commands, and the user-invocable skills — the
|
|
957
1027
|
* real command surface, replacing the one-line notice.
|
|
958
1028
|
*/
|
|
959
|
-
function HelpPanel({ descriptors, skills, onClose }: {
|
|
1029
|
+
function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
960
1030
|
descriptors: readonly CommandDescriptor[]
|
|
961
1031
|
skills: readonly SkillRow[]
|
|
1032
|
+
commandError: string | undefined
|
|
1033
|
+
skillError: string | undefined
|
|
962
1034
|
onClose(): void
|
|
963
1035
|
}): ReactElement {
|
|
964
1036
|
const stdout = useStdout().stdout
|
|
965
1037
|
const columns = stdout?.columns ?? 80
|
|
966
1038
|
const viewport = panelViewport(columns, stdout?.rows ?? 30)
|
|
967
1039
|
const [scroll, setScroll] = useState(0)
|
|
968
|
-
const nameWidth = 18
|
|
969
|
-
const descBudget = Math.max(
|
|
1040
|
+
const nameWidth = Math.min(18, Math.max(1, viewport.contentColumns - 2))
|
|
1041
|
+
const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
|
|
970
1042
|
const row = (label: string, description: string): ReactElement => createElement(
|
|
971
1043
|
Text,
|
|
972
1044
|
{ dimColor: true, wrap: 'truncate-end' },
|
|
@@ -979,9 +1051,21 @@ function HelpPanel({ descriptors, skills, onClose }: {
|
|
|
979
1051
|
createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
|
|
980
1052
|
createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
|
|
981
1053
|
createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
|
|
1054
|
+
createElement(Text, { key: 'commands-gap' }, ' '),
|
|
982
1055
|
createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
|
|
1056
|
+
...(commandError === undefined
|
|
1057
|
+
? []
|
|
1058
|
+
: [createElement(
|
|
1059
|
+
Text,
|
|
1060
|
+
{ key: 'commands-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
|
|
1061
|
+
truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
|
|
1062
|
+
)]),
|
|
983
1063
|
createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
|
|
984
1064
|
createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
|
|
1065
|
+
createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
|
|
1066
|
+
createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
|
|
1067
|
+
createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
|
|
1068
|
+
createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
|
|
985
1069
|
createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
|
|
986
1070
|
createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
|
|
987
1071
|
createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
|
|
@@ -991,7 +1075,19 @@ function HelpPanel({ descriptors, skills, onClose }: {
|
|
|
991
1075
|
{ key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
|
|
992
1076
|
` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
|
|
993
1077
|
)),
|
|
994
|
-
...(skills.length === 0
|
|
1078
|
+
...(skills.length === 0 && skillError === undefined
|
|
1079
|
+
? []
|
|
1080
|
+
: [
|
|
1081
|
+
createElement(Text, { key: 'skills-gap' }, ' '),
|
|
1082
|
+
createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills'),
|
|
1083
|
+
]),
|
|
1084
|
+
...(skillError === undefined
|
|
1085
|
+
? []
|
|
1086
|
+
: [createElement(
|
|
1087
|
+
Text,
|
|
1088
|
+
{ key: 'skills-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
|
|
1089
|
+
truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns),
|
|
1090
|
+
)]),
|
|
995
1091
|
...skills.map(skill => createElement(
|
|
996
1092
|
Text,
|
|
997
1093
|
{ key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
|
|
@@ -1029,7 +1125,9 @@ function HelpPanel({ descriptors, skills, onClose }: {
|
|
|
1029
1125
|
Box,
|
|
1030
1126
|
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
|
|
1031
1127
|
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
|
|
1128
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1032
1129
|
...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
|
|
1130
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1033
1131
|
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
|
|
1034
1132
|
)
|
|
1035
1133
|
}
|
|
@@ -1179,6 +1277,7 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
|
|
|
1179
1277
|
{ color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
|
|
1180
1278
|
truncateColumns(title, viewport.contentColumns),
|
|
1181
1279
|
),
|
|
1280
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1182
1281
|
createElement(
|
|
1183
1282
|
Box,
|
|
1184
1283
|
{ flexDirection: 'column' },
|
|
@@ -1186,6 +1285,7 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
|
|
|
1186
1285
|
? createElement(Text, { dimColor: true }, ' no durable entries yet')
|
|
1187
1286
|
: createElement(StyledRows, { lines: visible }),
|
|
1188
1287
|
),
|
|
1288
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1189
1289
|
createElement(
|
|
1190
1290
|
Text,
|
|
1191
1291
|
{ dimColor: true, wrap: 'truncate-end' },
|
|
@@ -1234,6 +1334,10 @@ function completionCandidates(
|
|
|
1234
1334
|
const local: CompletionCandidate[] = [
|
|
1235
1335
|
{ label: '/help', description: 'show commands', origin: 'command' },
|
|
1236
1336
|
{ label: '/model', description: 'switch the model', origin: 'command' },
|
|
1337
|
+
{ label: '/mode', description: 'select the agent preset', origin: 'command' },
|
|
1338
|
+
{ label: '/new', description: 'start a fresh session', origin: 'command' },
|
|
1339
|
+
{ label: '/resume', description: 'browse or switch sessions', origin: 'command' },
|
|
1340
|
+
{ label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
|
|
1237
1341
|
{ label: '/clear', description: 'clear the screen', origin: 'command' },
|
|
1238
1342
|
{ label: '/export', description: 'export the transcript to markdown', origin: 'command' },
|
|
1239
1343
|
{ label: '/title', description: 'rename this session', origin: 'command' },
|
|
@@ -1284,16 +1388,19 @@ function CompletionMenu({ active, mention, index, rows }: {
|
|
|
1284
1388
|
const columns = stdout?.columns ?? 80
|
|
1285
1389
|
const terminalRows = stdout?.rows ?? 30
|
|
1286
1390
|
if (!active) return undefined
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1391
|
+
const contentColumns = Math.max(1, columns - 4)
|
|
1392
|
+
const nameWidth = Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
|
|
1393
|
+
const descBudget = Math.max(0, contentColumns - nameWidth - 2)
|
|
1289
1394
|
const showFooter = terminalRows >= 12
|
|
1290
|
-
const
|
|
1395
|
+
const spacious = terminalRows >= 14
|
|
1396
|
+
const verticalPadding = spacious ? 1 : 0
|
|
1397
|
+
const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10) - verticalPadding * 2))
|
|
1291
1398
|
const selected = rows.length === 0 ? 0 : index % rows.length
|
|
1292
1399
|
const first = selectionWindow(selected, rows.length, limit)
|
|
1293
1400
|
const visible = rows.slice(first, first + limit)
|
|
1294
1401
|
return createElement(
|
|
1295
1402
|
Box,
|
|
1296
|
-
{ flexDirection: 'column', marginLeft: 2 },
|
|
1403
|
+
{ flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
|
|
1297
1404
|
...(rows.length === 0
|
|
1298
1405
|
? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
|
|
1299
1406
|
: visible.map((candidate, at) => {
|
|
@@ -1318,7 +1425,7 @@ function CompletionMenu({ active, mention, index, rows }: {
|
|
|
1318
1425
|
* While a modal (approval / question / model panel) owns the keys, the
|
|
1319
1426
|
* box passes every key through untouched.
|
|
1320
1427
|
*/
|
|
1321
|
-
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }: {
|
|
1428
|
+
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, openMode, openResume, openPlugin, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }: {
|
|
1322
1429
|
active: boolean
|
|
1323
1430
|
frozen: boolean
|
|
1324
1431
|
busy: boolean
|
|
@@ -1330,7 +1437,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1330
1437
|
quit(): void
|
|
1331
1438
|
openModel(): void
|
|
1332
1439
|
openHelp(): void
|
|
1333
|
-
|
|
1440
|
+
openMode(): void
|
|
1441
|
+
openResume(): void
|
|
1442
|
+
openPlugin(query?: string): void
|
|
1443
|
+
createSession(mode?: string): void
|
|
1444
|
+
cancelSessionSwitch(): boolean
|
|
1445
|
+
notify(text: string, tone?: NoticeTone): void
|
|
1446
|
+
hasNotice: boolean
|
|
1447
|
+
dismissNotice(): void
|
|
1334
1448
|
toggleReasoning(): void
|
|
1335
1449
|
openVerbose(): void
|
|
1336
1450
|
clearView(): void
|
|
@@ -1347,6 +1461,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1347
1461
|
const historyIndex = useRef<number | null>(null)
|
|
1348
1462
|
const draft = useRef('')
|
|
1349
1463
|
const [completionIndex, setCompletionIndex] = useState(0)
|
|
1464
|
+
const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
|
|
1350
1465
|
const candidates = completionCandidates(value, descriptors, skills)
|
|
1351
1466
|
const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
|
|
1352
1467
|
|
|
@@ -1405,7 +1520,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1405
1520
|
}
|
|
1406
1521
|
}, [active, mentionActive, mentionToken?.query])
|
|
1407
1522
|
|
|
1408
|
-
|
|
1523
|
+
// Codex routes keys to the topmost surface first. Completion therefore
|
|
1524
|
+
// remains available while a turn runs, and Esc dismisses it before the
|
|
1525
|
+
// same key is allowed to interrupt the turn.
|
|
1526
|
+
const menuActive = (slashActive || mentionActive || pathActive) && dismissedMenuValue !== value
|
|
1409
1527
|
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
1410
1528
|
? mentionRows.map(row => ({
|
|
1411
1529
|
label: row.label.startsWith('@')
|
|
@@ -1427,8 +1545,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1427
1545
|
if (!active) return
|
|
1428
1546
|
// Shift+Tab cycles the permission preset (Claude-Code convention).
|
|
1429
1547
|
if (key.tab && key.shift) {
|
|
1430
|
-
|
|
1431
|
-
|
|
1548
|
+
try {
|
|
1549
|
+
const next = cyclePermission()
|
|
1550
|
+
if (next !== '') notify(`permission → ${next}`)
|
|
1551
|
+
} catch (error: unknown) {
|
|
1552
|
+
notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1553
|
+
}
|
|
1432
1554
|
return
|
|
1433
1555
|
}
|
|
1434
1556
|
// Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
|
|
@@ -1453,17 +1575,26 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1453
1575
|
setValue('')
|
|
1454
1576
|
setCursor(0)
|
|
1455
1577
|
setCompletionIndex(0)
|
|
1578
|
+
setDismissedMenuValue(undefined)
|
|
1456
1579
|
} else {
|
|
1457
1580
|
quit()
|
|
1458
1581
|
}
|
|
1459
1582
|
return
|
|
1460
1583
|
}
|
|
1461
1584
|
if (key.ctrl && input === 'd') {
|
|
1462
|
-
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
|
|
1585
|
+
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
|
|
1463
1586
|
else quit()
|
|
1464
1587
|
return
|
|
1465
1588
|
}
|
|
1466
1589
|
if (key.escape) {
|
|
1590
|
+
if (menuActive) {
|
|
1591
|
+
setDismissedMenuValue(value)
|
|
1592
|
+
return
|
|
1593
|
+
}
|
|
1594
|
+
if (hasNotice) {
|
|
1595
|
+
dismissNotice()
|
|
1596
|
+
return
|
|
1597
|
+
}
|
|
1467
1598
|
if (busy) interrupt()
|
|
1468
1599
|
return
|
|
1469
1600
|
}
|
|
@@ -1474,13 +1605,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1474
1605
|
if (key.meta || (key.ctrl && input === 'j')) {
|
|
1475
1606
|
setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
|
|
1476
1607
|
setCursor(cursor + 1)
|
|
1608
|
+
setDismissedMenuValue(undefined)
|
|
1477
1609
|
return
|
|
1478
1610
|
}
|
|
1479
1611
|
const text = value.trim()
|
|
1480
1612
|
setValue('')
|
|
1481
1613
|
setCursor(0)
|
|
1482
1614
|
setCompletionIndex(0)
|
|
1615
|
+
setDismissedMenuValue(undefined)
|
|
1483
1616
|
if (text === '') return
|
|
1617
|
+
dismissNotice()
|
|
1484
1618
|
history.current = [...history.current, text]
|
|
1485
1619
|
historyIndex.current = null
|
|
1486
1620
|
if (text === '/quit') {
|
|
@@ -1497,6 +1631,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1497
1631
|
// store resets so the rebuilt transcript starts empty.
|
|
1498
1632
|
refresh()
|
|
1499
1633
|
clearView()
|
|
1634
|
+
dismissNotice()
|
|
1500
1635
|
return
|
|
1501
1636
|
}
|
|
1502
1637
|
if (text === '/export' || text.startsWith('/export ')) {
|
|
@@ -1504,13 +1639,43 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1504
1639
|
return
|
|
1505
1640
|
}
|
|
1506
1641
|
if (text === '/title' || text.startsWith('/title ')) {
|
|
1507
|
-
|
|
1642
|
+
const outcome = renameTitle(text.slice(7))
|
|
1643
|
+
const tone: NoticeTone = outcome.startsWith('rename failed:')
|
|
1644
|
+
? 'error'
|
|
1645
|
+
: outcome.startsWith('usage:') || outcome.includes('unavailable')
|
|
1646
|
+
? 'warning'
|
|
1647
|
+
: 'info'
|
|
1648
|
+
notify(outcome, tone)
|
|
1508
1649
|
return
|
|
1509
1650
|
}
|
|
1510
1651
|
if (text === '/model' || text.startsWith('/model ')) {
|
|
1511
1652
|
openModel()
|
|
1512
1653
|
return
|
|
1513
1654
|
}
|
|
1655
|
+
if (text === '/mode' || text.startsWith('/mode ')) {
|
|
1656
|
+
const mode = text.slice(5).trim()
|
|
1657
|
+
if (mode === '') openMode()
|
|
1658
|
+
else dispatch(text)
|
|
1659
|
+
return
|
|
1660
|
+
}
|
|
1661
|
+
if (text === '/resume cancel') {
|
|
1662
|
+
notify(cancelSessionSwitch() ? 'pending session switch cancelled' : 'no pending session switch', 'info')
|
|
1663
|
+
return
|
|
1664
|
+
}
|
|
1665
|
+
if (text === '/resume' || text.startsWith('/resume ')) {
|
|
1666
|
+
const id = text.slice(7).trim()
|
|
1667
|
+
if (id === '') openResume()
|
|
1668
|
+
else dispatch(text)
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
if (text === '/new' || text.startsWith('/new ')) {
|
|
1672
|
+
createSession(text.slice(4).trim() || undefined)
|
|
1673
|
+
return
|
|
1674
|
+
}
|
|
1675
|
+
if (text === '/plugin' || text.startsWith('/plugin ')) {
|
|
1676
|
+
openPlugin(text.slice(7).trim())
|
|
1677
|
+
return
|
|
1678
|
+
}
|
|
1514
1679
|
if (busy && !text.startsWith('/')) {
|
|
1515
1680
|
// A running turn is steered, not blocked: the inbox delivers this
|
|
1516
1681
|
// text at the next step boundary (Esc/Ctrl+C still cancels outright).
|
|
@@ -1537,6 +1702,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1537
1702
|
historyIndex.current = next
|
|
1538
1703
|
setValue(entries[next] ?? '')
|
|
1539
1704
|
setCursor((entries[next] ?? '').length)
|
|
1705
|
+
setDismissedMenuValue(undefined)
|
|
1540
1706
|
return
|
|
1541
1707
|
}
|
|
1542
1708
|
if (key.downArrow) {
|
|
@@ -1547,11 +1713,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1547
1713
|
historyIndex.current = null
|
|
1548
1714
|
setValue(draft.current)
|
|
1549
1715
|
setCursor(draft.current.length)
|
|
1716
|
+
setDismissedMenuValue(undefined)
|
|
1550
1717
|
return
|
|
1551
1718
|
}
|
|
1552
1719
|
historyIndex.current = next
|
|
1553
1720
|
setValue(entries[next] ?? '')
|
|
1554
1721
|
setCursor((entries[next] ?? '').length)
|
|
1722
|
+
setDismissedMenuValue(undefined)
|
|
1555
1723
|
return
|
|
1556
1724
|
}
|
|
1557
1725
|
if (key.tab && menuActive) {
|
|
@@ -1583,6 +1751,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1583
1751
|
}
|
|
1584
1752
|
}
|
|
1585
1753
|
setCompletionIndex(0)
|
|
1754
|
+
setDismissedMenuValue(undefined)
|
|
1586
1755
|
return
|
|
1587
1756
|
}
|
|
1588
1757
|
if (key.backspace || key.delete) {
|
|
@@ -1590,6 +1759,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1590
1759
|
setValue(value.slice(0, cursor - 1) + value.slice(cursor))
|
|
1591
1760
|
setCursor(cursor - 1)
|
|
1592
1761
|
setCompletionIndex(0)
|
|
1762
|
+
setDismissedMenuValue(undefined)
|
|
1593
1763
|
}
|
|
1594
1764
|
return
|
|
1595
1765
|
}
|
|
@@ -1604,11 +1774,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1604
1774
|
if (key.ctrl && input === 'u') {
|
|
1605
1775
|
setValue('')
|
|
1606
1776
|
setCursor(0)
|
|
1777
|
+
setDismissedMenuValue(undefined)
|
|
1607
1778
|
return
|
|
1608
1779
|
}
|
|
1609
1780
|
// Readline parity: Ctrl+K cuts from the cursor to the end of the line.
|
|
1610
1781
|
if (key.ctrl && input === 'k') {
|
|
1611
1782
|
setValue(value.slice(0, cursor))
|
|
1783
|
+
setDismissedMenuValue(undefined)
|
|
1612
1784
|
return
|
|
1613
1785
|
}
|
|
1614
1786
|
// Ctrl+L refreshes the screen (readline convention): raw ANSI clear
|
|
@@ -1630,6 +1802,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
1630
1802
|
setValue(value.slice(0, cursor) + input + value.slice(cursor))
|
|
1631
1803
|
setCursor(cursor + input.length)
|
|
1632
1804
|
setCompletionIndex(0)
|
|
1805
|
+
setDismissedMenuValue(undefined)
|
|
1633
1806
|
}
|
|
1634
1807
|
})
|
|
1635
1808
|
|
|
@@ -1694,19 +1867,24 @@ export function App(props: AppProps): ReactElement {
|
|
|
1694
1867
|
const [modelOpen, setModelOpen] = useState(false)
|
|
1695
1868
|
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
1696
1869
|
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
1697
|
-
const [
|
|
1698
|
-
const
|
|
1699
|
-
|
|
1700
|
-
|
|
1870
|
+
const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
|
|
1871
|
+
const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
|
|
1872
|
+
const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
|
|
1873
|
+
setNotice({ text, tone })
|
|
1874
|
+
}, [])
|
|
1701
1875
|
|
|
1702
1876
|
useEffect(() => {
|
|
1703
1877
|
props.onBridgeReady({ notify })
|
|
1704
1878
|
}, [])
|
|
1705
1879
|
useEffect(() => {
|
|
1706
|
-
if (!modelOpen
|
|
1880
|
+
if (!modelOpen) return
|
|
1707
1881
|
let cancelled = false
|
|
1882
|
+
setDirectory(undefined)
|
|
1708
1883
|
setModelError(undefined)
|
|
1709
|
-
|
|
1884
|
+
// Enter the promise chain before invoking the loader so a provider that
|
|
1885
|
+
// throws synchronously becomes an in-panel error instead of escaping the
|
|
1886
|
+
// React effect and tearing down Ink.
|
|
1887
|
+
Promise.resolve().then(() => props.loadModels()).then((loaded) => {
|
|
1710
1888
|
if (!cancelled) setDirectory(loaded)
|
|
1711
1889
|
}, (error: unknown) => {
|
|
1712
1890
|
if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
|
|
@@ -1714,19 +1892,23 @@ export function App(props: AppProps): ReactElement {
|
|
|
1714
1892
|
return () => {
|
|
1715
1893
|
cancelled = true
|
|
1716
1894
|
}
|
|
1717
|
-
}, [modelOpen])
|
|
1895
|
+
}, [modelOpen, modelLoadEpoch, props.loadModels])
|
|
1718
1896
|
|
|
1719
1897
|
const busy = view.busy
|
|
1720
1898
|
const [showReasoning, setShowReasoning] = useState(false)
|
|
1721
1899
|
const [verboseOpen, setVerboseOpen] = useState(false)
|
|
1722
1900
|
const [helpOpen, setHelpOpen] = useState(false)
|
|
1901
|
+
const [modeOpen, setModeOpen] = useState(false)
|
|
1902
|
+
const [resumeOpen, setResumeOpen] = useState(false)
|
|
1903
|
+
const [pluginOpen, setPluginOpen] = useState(false)
|
|
1904
|
+
const [pluginQuery, setPluginQuery] = useState('')
|
|
1723
1905
|
const [refreshEpoch, setRefreshEpoch] = useState(0)
|
|
1724
1906
|
const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
|
|
1725
1907
|
const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
|
|
1726
1908
|
const approvalPending = approvalSnapshot.pending !== undefined
|
|
1727
1909
|
const questionPending = questionSnapshot.pending !== undefined
|
|
1728
1910
|
// While any modal owns the keys, the prompt box passes everything through.
|
|
1729
|
-
const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
|
|
1911
|
+
const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending
|
|
1730
1912
|
|
|
1731
1913
|
// Human questions outrank local inspectors. Close the lower modal instead
|
|
1732
1914
|
// of leaving an approval/question visible but keyboard-locked behind it.
|
|
@@ -1734,6 +1916,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
1734
1916
|
if (!approvalPending && !questionPending) return
|
|
1735
1917
|
setModelOpen(false)
|
|
1736
1918
|
setHelpOpen(false)
|
|
1919
|
+
setModeOpen(false)
|
|
1920
|
+
setResumeOpen(false)
|
|
1921
|
+
setPluginOpen(false)
|
|
1737
1922
|
setVerboseOpen(false)
|
|
1738
1923
|
}, [approvalPending, questionPending])
|
|
1739
1924
|
|
|
@@ -1755,10 +1940,14 @@ export function App(props: AppProps): ReactElement {
|
|
|
1755
1940
|
const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
|
|
1756
1941
|
view.entries.slice(0, settled).forEach((entry, index) => {
|
|
1757
1942
|
const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
|
|
1758
|
-
|
|
1759
|
-
|
|
1943
|
+
const roomyPrompt = entry.kind === 'user' && !entry.notice
|
|
1944
|
+
if (roomyPrompt) {
|
|
1945
|
+
rows.push(createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
|
|
1760
1946
|
}
|
|
1761
1947
|
rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
|
|
1948
|
+
if (roomyPrompt) {
|
|
1949
|
+
rows.push(createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
|
|
1950
|
+
}
|
|
1762
1951
|
})
|
|
1763
1952
|
return rows
|
|
1764
1953
|
}, [view.entries, settled, showReasoning, props.resumed])
|
|
@@ -1803,7 +1992,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
1803
1992
|
}, [appStdout])
|
|
1804
1993
|
const terminalRows = terminalSize.rows
|
|
1805
1994
|
const terminalColumns = terminalSize.columns
|
|
1806
|
-
const
|
|
1995
|
+
const composerGutterRows = layoutGutterRows(terminalRows)
|
|
1996
|
+
const dynamicRows = Math.max(1, terminalRows - 12 - composerGutterRows)
|
|
1807
1997
|
const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
|
|
1808
1998
|
const deepDivingVisible = busy && !streamingActive
|
|
1809
1999
|
const allLiveLines = useMemo(
|
|
@@ -1831,9 +2021,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
1831
2021
|
? Math.max(1, Math.floor(streamRows / 3))
|
|
1832
2022
|
: 1
|
|
1833
2023
|
const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
|
|
1834
|
-
const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
|
|
2024
|
+
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending
|
|
1835
2025
|
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
1836
|
-
const modalVisible = modelOpen || helpOpen || inspectorVisible || approvalPending || questionPending
|
|
2026
|
+
const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || inspectorVisible || approvalPending || questionPending
|
|
1837
2027
|
const closeInspector = useCallback((): void => {
|
|
1838
2028
|
setVerboseOpen(false)
|
|
1839
2029
|
}, [])
|
|
@@ -1873,16 +2063,23 @@ export function App(props: AppProps): ReactElement {
|
|
|
1873
2063
|
)
|
|
1874
2064
|
: undefined,
|
|
1875
2065
|
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
1876
|
-
createElement(QuestionBar, { store: props.questions, locked: false }),
|
|
1877
|
-
createElement(ApprovalBar, {
|
|
2066
|
+
createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
|
|
2067
|
+
createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
|
|
1878
2068
|
modelOpen && !approvalPending && !questionPending
|
|
1879
2069
|
? createElement(ModelPanel, {
|
|
1880
2070
|
directory,
|
|
1881
2071
|
error: modelError,
|
|
1882
2072
|
onSelect: (row: ModelRow) => {
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2073
|
+
try {
|
|
2074
|
+
setModelLabel(props.selectModel(row))
|
|
2075
|
+
notify(`model → next step uses ${row.provider}/${row.model}`)
|
|
2076
|
+
setModelOpen(false)
|
|
2077
|
+
} catch (error: unknown) {
|
|
2078
|
+
notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2081
|
+
onRetry: () => {
|
|
2082
|
+
setModelLoadEpoch(epoch => epoch + 1)
|
|
1886
2083
|
},
|
|
1887
2084
|
onClose: () => {
|
|
1888
2085
|
setModelOpen(false)
|
|
@@ -1893,6 +2090,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
1893
2090
|
? createElement(HelpPanel, {
|
|
1894
2091
|
descriptors,
|
|
1895
2092
|
skills,
|
|
2093
|
+
commandError: props.commands.error,
|
|
2094
|
+
skillError: props.skills.error,
|
|
1896
2095
|
onClose: () => {
|
|
1897
2096
|
setHelpOpen(false)
|
|
1898
2097
|
},
|
|
@@ -1904,19 +2103,44 @@ export function App(props: AppProps): ReactElement {
|
|
|
1904
2103
|
onClose: closeInspector,
|
|
1905
2104
|
})
|
|
1906
2105
|
: undefined,
|
|
1907
|
-
|
|
1908
|
-
? createElement(
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2106
|
+
modeOpen && !approvalPending && !questionPending
|
|
2107
|
+
? createElement(ModePanel, {
|
|
2108
|
+
current: props.mode,
|
|
2109
|
+
load: props.loadPresets,
|
|
2110
|
+
select: (id: string) => {
|
|
2111
|
+
void props.switchMode(id).then(label => {
|
|
2112
|
+
notify(`mode → ${label}`)
|
|
2113
|
+
setModeOpen(false)
|
|
2114
|
+
}, (reason: unknown) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error'))
|
|
2115
|
+
},
|
|
2116
|
+
close: () => setModeOpen(false),
|
|
2117
|
+
})
|
|
2118
|
+
: undefined,
|
|
2119
|
+
resumeOpen && !approvalPending && !questionPending
|
|
2120
|
+
? createElement(ResumePanel, {
|
|
2121
|
+
currentCwd: props.workspaceRoot,
|
|
2122
|
+
load: props.loadSessions,
|
|
2123
|
+
readTranscript: props.loadSessionTranscript,
|
|
2124
|
+
select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
|
|
2125
|
+
close: () => setResumeOpen(false),
|
|
2126
|
+
})
|
|
2127
|
+
: undefined,
|
|
2128
|
+
pluginOpen && !approvalPending && !questionPending
|
|
2129
|
+
? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
|
|
1913
2130
|
: undefined,
|
|
2131
|
+
notice === undefined
|
|
2132
|
+
? undefined
|
|
2133
|
+
: createElement(NoticeLine, {
|
|
2134
|
+
text: notice.text,
|
|
2135
|
+
tone: notice.tone,
|
|
2136
|
+
columns: terminalColumns,
|
|
2137
|
+
}),
|
|
1914
2138
|
// Persistent bottom chrome: every interface owns exactly the same
|
|
1915
2139
|
// composer/status geometry. Panels may change above it, but can no longer
|
|
1916
2140
|
// reorder the status or introduce mode-specific vertical margins.
|
|
1917
2141
|
createElement(
|
|
1918
2142
|
Box,
|
|
1919
|
-
{ flexDirection: 'column' },
|
|
2143
|
+
{ flexDirection: 'column', marginTop: composerGutterRows },
|
|
1920
2144
|
createElement(Input, {
|
|
1921
2145
|
active: inputActive,
|
|
1922
2146
|
frozen: modalVisible,
|
|
@@ -1928,12 +2152,23 @@ export function App(props: AppProps): ReactElement {
|
|
|
1928
2152
|
interrupt: props.interrupt,
|
|
1929
2153
|
quit: props.quit,
|
|
1930
2154
|
openModel: () => {
|
|
2155
|
+
setDirectory(undefined)
|
|
2156
|
+
setModelError(undefined)
|
|
1931
2157
|
setModelOpen(true)
|
|
1932
2158
|
},
|
|
1933
2159
|
openHelp: () => {
|
|
1934
2160
|
setHelpOpen(true)
|
|
1935
2161
|
},
|
|
2162
|
+
openMode: () => setModeOpen(true),
|
|
2163
|
+
openResume: () => setResumeOpen(true),
|
|
2164
|
+
openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
|
|
2165
|
+
createSession: props.createSession,
|
|
2166
|
+
cancelSessionSwitch: props.cancelSessionSwitch,
|
|
1936
2167
|
notify,
|
|
2168
|
+
hasNotice: notice !== undefined,
|
|
2169
|
+
dismissNotice: () => {
|
|
2170
|
+
setNotice(undefined)
|
|
2171
|
+
},
|
|
1937
2172
|
openVerbose: () => {
|
|
1938
2173
|
setVerboseOpen(true)
|
|
1939
2174
|
},
|
|
@@ -1952,6 +2187,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
1952
2187
|
createElement(StatusLine, {
|
|
1953
2188
|
facts: {
|
|
1954
2189
|
model: modelLabel,
|
|
2190
|
+
mode: props.mode,
|
|
1955
2191
|
cwd: props.cwd,
|
|
1956
2192
|
branch: props.branch,
|
|
1957
2193
|
sessionId: props.sessionId,
|