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/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
- return text + ' '.repeat(Math.max(0, width - visibleColumns(text)))
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
- ...line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
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({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
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 first = selectionWindow(cursor, rows.length, viewport.bodyRows)
926
- const visible = rows.slice(first, first + viewport.bodyRows)
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
- directory === undefined && error === undefined
932
- ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ' loading models…')
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(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · esc/q close', viewport.contentColumns))),
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(1, viewport.contentColumns - nameWidth - 2)
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 ? [] : [createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills')]),
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 nameWidth = Math.min(18, Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
1288
- const descBudget = Math.max(24, columns - nameWidth - 8)
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 limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)))
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
- notify(text: string): void
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
- const menuActive = (slashActive || mentionActive || pathActive) && !busy
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
- const next = cyclePermission()
1431
- if (next !== '') notify(`permission → ${next}`)
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
- notify(renameTitle(text.slice(7)))
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 [notices, setNotices] = useState<readonly string[]>([])
1698
- const notify = (text: string): void => {
1699
- setNotices(current => [...current, text])
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 || directory !== undefined) return
1880
+ if (!modelOpen) return
1707
1881
  let cancelled = false
1882
+ setDirectory(undefined)
1708
1883
  setModelError(undefined)
1709
- props.loadModels().then((loaded) => {
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
- if (entry.kind === 'user' && index > 0) {
1759
- rows.push(createElement(Box, { key: `gap-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
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 dynamicRows = Math.max(1, terminalRows - 12)
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, { approval: props.approval, locked: questionPending }),
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
- setModelLabel(props.selectModel(row))
1884
- notify(`model → next step uses ${row.provider}/${row.model}`)
1885
- setModelOpen(false)
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
- transcriptVisible
1908
- ? createElement(
1909
- Box,
1910
- { flexDirection: 'column' },
1911
- ...notices.slice(-1).map((notice, index) => createElement(Text, { key: index, dimColor: true, wrap: 'truncate-end' }, displayText(notice))),
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,