dsh-code 0.5.0 → 0.6.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
@@ -1,2205 +1,2543 @@
1
- /**
2
- * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
3
- * transcript, the todo panel, the streaming line, the approval bar, the model
4
- * panel, local notices, and the input box with history and slash-command
5
- * completion. All state arrives through the transcript store (derived from
6
- * the durable session log) plus local input state; the app owns no session
7
- * mutation of its own.
8
- *
9
- * Element construction uses `createElement` (not JSX): the `dsh` source launch
10
- * compiles this file through tsx's ESM-only hook, which does not adopt this
11
- * package's `jsx: react-jsx` compiler option, and the classic JSX runtime
12
- * would demand a React global.
13
- *
14
- * @module @deepseek-ai/dsh-code/app
15
- */
16
-
17
- import {
18
- createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
19
- } from 'react'
20
- import { Box, Static, Text, useInput, useStdout } from 'ink'
21
- import { assertNever } from '@deepseek-ai/dsh-llm'
22
- import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
- import type { TodoItem } from '@deepseek-ai/dsh-session'
24
- import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
25
- import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
26
- import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
27
- import type { TranscriptStore } from './store.ts'
28
- import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
29
- import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
- import type { ToolDetail } from './render/tool-detail.ts'
31
- import { caretVisible, pulseFrame } from './render/animations.ts'
32
- import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
33
- import type { CommandsView } from './commands.ts'
34
- import type { ModelDirectory, ModelRow } from './models.ts'
35
- import type { QuestionSnapshot, QuestionStore } from './questions.ts'
36
- import type { SkillsView, SkillRow } from './skills.ts'
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'
42
-
43
- /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
44
- const RESIZE_REFLOW_DELAY_MS = 75
45
-
46
- /** Reset region/style, clear the visible screen and scrollback, then home. */
47
- const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
48
- import { buildStatusGroups, formatTokens, type StatusFacts } from './render/status.ts'
49
- import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
50
- import {
51
- clampScroll,
52
- followInspectorCursor,
53
- inspectorViewport,
54
- layoutGutterRows,
55
- moveScroll,
56
- panelViewport,
57
- revealRow,
58
- selectionWindow,
59
- } from './render/inspector.ts'
60
- import {
61
- lineSegment,
62
- markdownLines,
63
- styledLines,
64
- textLines,
65
- transcriptEntryLines,
66
- type LineStyle,
67
- type StyledLine,
68
- } from './render/lines.ts'
69
-
70
- /** Visual priority for one bounded local notice. */
71
- export type NoticeTone = 'info' | 'warning' | 'error'
72
-
73
- /** Props the runner hands the app; callbacks stay owned by the runner. */
74
- export interface AppProps {
75
- /** Event-fed transcript store for the live session. */
76
- store: TranscriptStore
77
- /** Approval-question store fed by the answerer listener. */
78
- approval: ApprovalStore
79
- /** ask_user_question store fed by the single UI provider. */
80
- questions: QuestionStore
81
- /** Live slash-command descriptor list (completion candidates). */
82
- commands: CommandsView
83
- /** Live user-invocable skill catalog (completion candidates). */
84
- skills: SkillsView
85
- /** `provider/model` selection serving this session (updated on /model). */
86
- model: string
87
- /** Working-directory basename the session serves. */
88
- cwd: string
89
- /** Absolute working directory used by session filters and references. */
90
- workspaceRoot: string
91
- /** Git branch name, empty outside a repository. */
92
- branch: string
93
- /** Short session identifier. */
94
- sessionId: string
95
- /** Whether this session was resumed from persistence. */
96
- resumed: boolean
97
- /** Agent preset currently composing the session. */
98
- mode: string
99
- /** Submit one line: slash commands to the registry, other text to the agent. */
100
- dispatch(text: string): void
101
- /** Submit steering: consumed at the running turn's next step boundary. */
102
- steer(text: string): void
103
- /** Interrupt the running turn (Esc); true when a turn was cancelled. */
104
- interrupt(): boolean
105
- /** Quit: unmount, flush, and request process exit. */
106
- quit(): void
107
- /** Load the selectable model directory (called when /model opens). */
108
- loadModels(): Promise<ModelDirectory>
109
- /** Load @mention candidates for the typed query (files + sessions). */
110
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
111
- /** Apply one /model selection; returns the display label. */
112
- selectModel(row: ModelRow): string
113
- /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
114
- cyclePermission(): string
115
- /** Export the transcript to a markdown file (/export [path]); reports via notices. */
116
- exportTranscript(argument: string): Promise<void>
117
- /** Rename the session (/title <text>); returns the outcome line for the notice. */
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[]
128
- /** Registers the app's notice channel with the runner (called once on mount). */
129
- onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
130
- }
131
-
132
- /** Ink `color` string for one palette triple. */
133
- function inkColor(triple: readonly [number, number, number]): string {
134
- return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
135
- }
136
-
137
- /** Pad text with spaces to a visible-column target (menu name column). */
138
- function padColumns(text: string, width: number): string {
139
- const clipped = truncateColumns(singleLineText(text), width)
140
- return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
141
- }
142
-
143
- /** Interval-driven frame counter for one self-contained animated leaf. */
144
- function useFrames(intervalMs: number): number {
145
- const [tick, setTick] = useState(0)
146
- useEffect(() => {
147
- const id = setInterval(() => setTick(current => current + 1), intervalMs)
148
- return () => {
149
- clearInterval(id)
150
- }
151
- }, [intervalMs])
152
- return tick
153
- }
154
-
155
- /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
156
- function Pulse(): ReactElement {
157
- const tick = useFrames(125)
158
- return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
159
- }
160
-
161
- /** Blinking block caret appended to streaming text. */
162
- function Caret(): ReactElement {
163
- const tick = useFrames(530)
164
- return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
165
- }
166
-
167
- /** Blinking input cursor: inverse block while the caret phase is on. */
168
- function CursorBlock({ char }: { char: string }): ReactElement {
169
- const tick = useFrames(530)
170
- return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
171
- }
172
-
173
- /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
174
- function runClock(ms: number): string {
175
- const total = Math.max(0, Math.floor(ms / 1000))
176
- const minutes = Math.floor(total / 60)
177
- const seconds = total % 60
178
- return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
179
- }
180
-
181
- /**
182
- * The busy line, web TurnStatus contract: the plain `Deep diving...` label,
183
- * with the elapsed clock appended only once the turn has clearly been running
184
- * (15s) — anchored to `turn/start` so a resumed mid-turn keeps the real time.
185
- */
186
- function DeepDivingLine({ since }: { since: number }): ReactElement {
187
- useFrames(1000)
188
- const elapsed = since === 0 ? 0 : Date.now() - since
189
- return createElement(
190
- Text,
191
- { dimColor: true },
192
- elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
193
- )
194
- }
195
-
196
- /**
197
- * The streaming buffer rendered with a hard size cap: the live region must
198
- * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
199
- * than the screen freezes (cursor-up past the top, garbage, no scroll). The
200
- * cap counts explicit newlines and terminal wrapping, slicing from the END so
201
- * the freshest tokens stay visible while a long reply streams; the complete
202
- * text lands in the flushed scrollback once the turn assembles it.
203
- */
204
- function StreamTail({ text, dim, maxRows, prefix, children }: {
205
- text: string
206
- dim: boolean
207
- maxRows: number
208
- prefix?: string
209
- children?: ReactElement
210
- }): ReactElement {
211
- const columns = useStdout().stdout?.columns ?? 80
212
- const safeRows = Math.max(1, maxRows)
213
- // App padding consumes two columns; the final extra column keeps a caret
214
- // from wrapping onto an unbudgeted row.
215
- const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
216
- const initial = displayTail(text, contentColumns, safeRows)
217
- // Reserve one row for the omission marker only when a marker is needed.
218
- const tail = initial.truncated && safeRows > 1
219
- ? displayTail(text, contentColumns, safeRows - 1)
220
- : initial
221
- return createElement(
222
- Box,
223
- { flexDirection: 'column' },
224
- tail.truncated && safeRows > 1
225
- ? createElement(Text, { dimColor: true }, ' …')
226
- : undefined,
227
- createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
228
- )
229
- }
230
-
231
- /** Ink props for one markdown style class. */
232
- function segmentProps(style: MdSegment['style']): {
233
- color: string | undefined
234
- bold: boolean | undefined
235
- italic: boolean | undefined
236
- strikethrough: boolean | undefined
237
- } {
238
- switch (style) {
239
- case 'accent':
240
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
241
- case 'code':
242
- return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
243
- case 'dim':
244
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
245
- case 'bold':
246
- return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
247
- case 'italic':
248
- return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
249
- case 'boldItalic':
250
- return { color: undefined, bold: true, italic: true, strikethrough: undefined }
251
- case 'strike':
252
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
253
- default:
254
- return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
255
- }
256
- }
257
-
258
- /** Ink props for the richer line model used by bounded scrolling panels. */
259
- function lineStyleProps(style: LineStyle): {
260
- color: string | undefined
261
- bold: boolean | undefined
262
- italic: boolean | undefined
263
- strikethrough: boolean | undefined
264
- dimColor: boolean | undefined
265
- } {
266
- switch (style) {
267
- case 'brand':
268
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
269
- case 'success':
270
- return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
271
- case 'error':
272
- return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
273
- case 'warn':
274
- return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
275
- case 'dimItalic':
276
- return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
277
- default:
278
- return { ...segmentProps(style), dimColor: undefined }
279
- }
280
- }
281
-
282
- /** Render width-safe rows; every child is exactly one terminal row. */
283
- function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
284
- return createElement(
285
- Box,
286
- { flexDirection: 'column' },
287
- ...lines.map((line, index) => createElement(
288
- Text,
289
- { key: index, wrap: 'truncate-end' },
290
- line.segments.length === 0
291
- ? ' '
292
- : line.segments.map((segment, at) => createElement(
293
- Text,
294
- { key: at, ...lineStyleProps(segment.style) },
295
- segment.text,
296
- )),
297
- )),
298
- )
299
- }
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
-
306
- /** One settled markdown document rendered as styled lines at the terminal width. */
307
- function MarkdownBody({ text }: { text: string }): ReactElement {
308
- const columns = useStdout().stdout?.columns ?? 80
309
- // Cached by (text, width): settled replies re-layout only when either moves.
310
- const lines = useMemo(
311
- () => renderMarkdown(displayText(text), Math.max(20, columns - 2)),
312
- [text, columns],
313
- )
314
- return createElement(
315
- Box,
316
- { flexDirection: 'column' },
317
- ...lines.map((line, index) => createElement(
318
- Text,
319
- { key: index },
320
- line.segments.length === 0
321
- ? ' '
322
- : line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
323
- )),
324
- )
325
- }
326
-
327
- /**
328
- * One expanded tool-card body for the verbose transcript (Ctrl+O): the
329
- * presentation contract's structured cards inline diffs, read windows,
330
- * web sources — rendered as plain terminal rows, degradation-safe against
331
- * replayed metadata.
332
- */
333
- function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
334
- switch (detail.kind) {
335
- case 'diff':
336
- return createElement(
337
- Box,
338
- { flexDirection: 'column' },
339
- ...detail.diffs.map((diff, index) => createElement(
340
- Box,
341
- { key: index, flexDirection: 'column' },
342
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
343
- ...diff.lines.map((line, at) => createElement(
344
- Text,
345
- {
346
- key: at,
347
- color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
348
- wrap: 'truncate-end',
349
- },
350
- ` ${line.mark}${displayText(line.text)}`,
351
- )),
352
- )),
353
- )
354
- case 'read':
355
- return createElement(
356
- Box,
357
- { flexDirection: 'column' },
358
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
359
- ...detail.lines.map((line, at) => createElement(
360
- Text,
361
- { key: at, dimColor: true, wrap: 'truncate-end' },
362
- ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
363
- )),
364
- )
365
- case 'web-search':
366
- return createElement(
367
- Box,
368
- { flexDirection: 'column' },
369
- ...detail.sources.map((source, at) => createElement(
370
- Text,
371
- { key: at, wrap: 'truncate-end' },
372
- brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
373
- createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
374
- )),
375
- createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
376
- )
377
- case 'web-fetch':
378
- return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
379
- case 'raw':
380
- return createElement(
381
- Box,
382
- { flexDirection: 'column' },
383
- ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
384
- createElement(Text, { dimColor: true }, detail.truncated ? ' (output truncated)' : ' (end of output)'),
385
- )
386
- default:
387
- return assertNever(detail, 'tool detail kind')
388
- }
389
- }
390
- /** One settled transcript row. */
391
- function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
392
- switch (entry.kind) {
393
- case 'user':
394
- // Collapsed injected context reads as a dim ↳ row; only direct human
395
- // prompts get the brand ❯ (they are different surfaces, not the same).
396
- return entry.notice
397
- ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
398
- : createElement(Text, null, brand(''), displayText(entry.text))
399
- case 'assistant':
400
- // Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
401
- // text dim-italic expanded (Ctrl+R toggles globally). The collapsed
402
- // row is static — an animated counter inside the text would jitter the
403
- // line width every frame.
404
- return createElement(
405
- Box,
406
- { flexDirection: 'column' },
407
- entry.reasoning === ''
408
- ? undefined
409
- : showReasoning
410
- ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
411
- : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
412
- createElement(MarkdownBody, { text: entry.text }),
413
- )
414
- case 'tool': {
415
- // Claude-Code-style tool card: the invocation row plus a nested ⎿
416
- // result line, so the summary reads under its call instead of inline.
417
- const mark = entry.state === 'running'
418
- ? createElement(Pulse)
419
- : entry.state === 'error'
420
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
421
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '')
422
- return createElement(
423
- Box,
424
- { flexDirection: 'column' },
425
- createElement(
426
- Text,
427
- { wrap: verbose ? 'truncate-end' : undefined },
428
- mark,
429
- ' ',
430
- brand(entry.name),
431
- entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
432
- ),
433
- entry.summary === ''
434
- ? undefined
435
- : createElement(
436
- Text,
437
- { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
438
- ` ${displayText(entry.summary)}`,
439
- ),
440
- verbose && entry.detail !== undefined
441
- ? createElement(ToolDetailBody, { detail: entry.detail })
442
- : undefined,
443
- )
444
- }
445
- case 'command': {
446
- const mark = entry.state === 'running'
447
- ? createElement(Pulse)
448
- : entry.state === 'error'
449
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
450
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
451
- return createElement(
452
- Box,
453
- { flexDirection: 'column' },
454
- createElement(
455
- Text,
456
- { wrap: verbose ? 'truncate-end' : undefined },
457
- mark,
458
- ' ',
459
- brand(`/${entry.name}`),
460
- entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
461
- ),
462
- entry.summary === ''
463
- ? undefined
464
- : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
465
- )
466
- }
467
- case 'turn-marker':
468
- // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
469
- return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
470
- case 'compaction':
471
- // Completed compaction lifecycle: what it reclaimed, or why it failed.
472
- return createElement(
473
- Text,
474
- { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
475
- entry.ok
476
- ? ` compacted ~${formatTokens(entry.tokens)} tokens`
477
- : ` ⧉ compaction failed: ${displayText(entry.error)}`,
478
- )
479
- case 'retry':
480
- // Provider-routed retry: amber while the backoff waits, dim once the
481
- // next attempt is underway.
482
- return createElement(
483
- Text,
484
- { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
485
- ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
486
- )
487
- case 'files': {
488
- // Turn-tail deliverables: the turn's mutated files (web turnTail chips).
489
- const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
490
- const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
491
- return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⎄ ${shown}${more}`)
492
- }
493
- case 'error':
494
- return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
495
- default:
496
- return assertNever(entry, 'transcript entry kind')
497
- }
498
- }
499
-
500
- /**
501
- * The whale wordmark header in DeepSeek blue, hugging its content width.
502
- * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
503
- * short to show it whole (or mid-resize) the clipped pairs garble the
504
- * screen — below the height floor the header collapses to a single-line
505
- * wordmark that stays correct at any size.
506
- */
507
- function Header({ resumed }: { resumed: boolean }): ReactElement {
508
- const rows = useStdout().stdout?.rows ?? 40
509
- const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
510
- if (rows < 20) {
511
- return createElement(
512
- Box,
513
- { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
514
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
515
- createElement(Text, { dimColor: true }, hint),
516
- )
517
- }
518
- return createElement(
519
- Box,
520
- // alignSelf shrinks the border to the whale-plus-wordmark content instead
521
- // of stretching across the terminal and stranding empty space on the right
522
- // (the compact-banner treatment the Claude Code welcome uses).
523
- { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
524
- createElement(
525
- Box,
526
- { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS },
527
- ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
528
- ),
529
- createElement(
530
- Box,
531
- { flexDirection: 'column', justifyContent: 'center' },
532
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
533
- createElement(Text, { dimColor: true }, hint),
534
- ),
535
- )
536
- }
537
-
538
- /** Todo status glyph: web TodoPanel's three-state marker. */
539
- function todoMark(status: TodoItem['status']): string {
540
- return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
541
- }
542
-
543
- /** One-row todo summary: task count cannot grow the live Ink tree. */
544
- function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
545
- if (todos.length === 0) return undefined
546
- const completed = todos.filter(todo => todo.status === 'completed').length
547
- const inProgress = todos.filter(todo => todo.status === 'in_progress').length
548
- const pending = todos.length - completed - inProgress
549
- const current = todos.find(todo => todo.status === 'in_progress')
550
- return createElement(
551
- Box,
552
- { paddingX: 1 },
553
- createElement(
554
- Text,
555
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
556
- `todos ${completed}/${todos.length}`,
557
- createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
558
- current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
559
- ),
560
- )
561
- }
562
-
563
- /**
564
- * The footer status line: Claude-Code-style identity facts (model, working
565
- * directory, git branch, session) beside the web composer's session figures
566
- * (turns/steps, model and tool wall time, cache hit, token totals), joined
567
- * by brand-colored pipes.
568
- */
569
- function StatusLine({ facts, stats, busy }: {
570
- facts: StatusFacts
571
- stats: Parameters<typeof buildStatusGroups>[1]
572
- busy: boolean
573
- }): ReactElement {
574
- const groups = buildStatusGroups(facts, stats)
575
- // One physical row in every mode: a narrow terminal must truncate instead
576
- // of wrapping the status groups into an unbudgeted second/third row.
577
- return createElement(
578
- Box,
579
- // Match the prompt text inside the bordered composer: one border column
580
- // plus one padding column. Keeping this row margin-free also makes the
581
- // composer and status a fixed four-row unit in every interface.
582
- { paddingLeft: 2 },
583
- createElement(
584
- Text,
585
- { dimColor: true, wrap: 'truncate-end' },
586
- busy ? '● ' : '○ ',
587
- groups.join(' | '),
588
- ),
589
- )
590
- }
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
-
619
- /** The y/n approval bar rendered while an approval ask is pending. */
620
- function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
621
- const stdout = useStdout().stdout
622
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
623
- const [scroll, setScroll] = useState(0)
624
- const pending = snapshot.pending
625
- const active = !locked && snapshot.pending !== undefined && !snapshot.answered
626
- const content = useMemo<readonly StyledLine[]>(() => pending === undefined
627
- ? []
628
- : [
629
- ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
630
- ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
631
- ], [pending, viewport.contentColumns])
632
- const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
633
-
634
- useEffect(() => {
635
- setScroll(0)
636
- }, [pending])
637
-
638
- useEffect(() => {
639
- if (visibleScroll !== scroll) setScroll(visibleScroll)
640
- }, [visibleScroll, scroll])
641
-
642
- useInput((input, key) => {
643
- if (snapshot.pending === undefined) return
644
- if (key.upArrow) {
645
- setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
646
- return
647
- }
648
- if (key.downArrow) {
649
- setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
650
- return
651
- }
652
- if (key.pageUp) {
653
- setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
654
- return
655
- }
656
- if (key.pageDown) {
657
- setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
658
- return
659
- }
660
- if (snapshot.answered) return
661
- if (input === 'y' || input === 'Y') {
662
- snapshot.pending.answer('allowed-once')
663
- return
664
- }
665
- if (input === 'n' || input === 'N') {
666
- snapshot.pending.answer('rejected')
667
- }
668
- }, { isActive: active })
669
- if (snapshot.pending === undefined) return undefined
670
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
671
- if (viewport.compact) {
672
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
673
- }
674
- const { answered } = snapshot
675
- return createElement(
676
- Box,
677
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
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 }),
680
- createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
681
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
682
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
683
- ? 'submitted…'
684
- : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
685
- )
686
- }
687
-
688
- /**
689
- * The ask_user_question bar: walks one request question by question,
690
- * renders the option menu (Claude-Code style: arrows move, space toggles a
691
- * multi-select, enter submits, `c` opens the custom-answer box, Esc
692
- * interrupts the question as aborted). Plan reviews arrive through the same
693
- * service with a `plan-review` intent the approve option gets a mark,
694
- * the answer encoding stays identical.
695
- */
696
- function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapshot: QuestionSnapshot; locked: boolean }): ReactElement | undefined {
697
- const stdout = useStdout().stdout
698
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
699
- const pending = snapshot.pending
700
- const [index, setIndex] = useState(0)
701
- const [cursor, setCursor] = useState(0)
702
- const [selected, setSelected] = useState<readonly number[]>([])
703
- const [mode, setMode] = useState<'options' | 'custom'>('options')
704
- const [custom, setCustom] = useState('')
705
- const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
706
- const [submitted, setSubmitted] = useState(false)
707
- const [scroll, setScroll] = useState(0)
708
-
709
- // A new request resets the walk; questions without options start in the
710
- // custom-answer box (a free-form question).
711
- useEffect(() => {
712
- const question = pending?.request.questions[0]
713
- setIndex(0)
714
- setCursor(0)
715
- setSelected([])
716
- setMode(question?.options === undefined || question.options.length === 0 ? 'custom' : 'options')
717
- setCustom('')
718
- setAnswers([])
719
- setSubmitted(false)
720
- setScroll(0)
721
- }, [pending])
722
-
723
- const question = pending?.request.questions[index]
724
- const options = question?.options ?? []
725
- const isPlan = question?.intent?.kind === 'plan-review'
726
- const isMulti = question?.multiSelect === true
727
- const active = !locked && pending !== undefined && question !== undefined && !submitted
728
- const rendered = useMemo(() => {
729
- if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
730
- const lines: StyledLine[] = []
731
- const optionRows: number[] = []
732
- if (question.header !== undefined) {
733
- lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
734
- }
735
- lines.push(...textLines(question.question, viewport.contentColumns))
736
- if (question.detail !== undefined) {
737
- lines.push(...(isPlan
738
- ? markdownLines(question.detail, viewport.contentColumns)
739
- : textLines(question.detail, viewport.contentColumns, 'dim')))
740
- }
741
- if (submitted) {
742
- lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
743
- } else if (mode === 'custom' || options.length === 0) {
744
- lines.push(...styledLines([
745
- lineSegment(' custom: ', 'brand'),
746
- lineSegment(custom, 'plain'),
747
- lineSegment('▌', 'brand'),
748
- ], viewport.contentColumns))
749
- } else {
750
- options.forEach((option, at) => {
751
- optionRows.push(lines.length)
752
- const chosen = isMulti && selected.includes(at)
753
- const approve = isPlan && question.intent?.approve === option.label
754
- const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
755
- const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
756
- lines.push(...styledLines([
757
- lineSegment(mark, style),
758
- lineSegment(option.label, style),
759
- lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
760
- ], viewport.contentColumns))
761
- })
762
- }
763
- return { lines, optionRows }
764
- }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
765
- const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows)
766
-
767
- useEffect(() => {
768
- if (visibleScroll !== scroll) setScroll(visibleScroll)
769
- }, [visibleScroll, scroll])
770
-
771
- useEffect(() => {
772
- if (mode === 'options' && options.length > 0) {
773
- const focused = rendered.optionRows[cursor] ?? 0
774
- setScroll(current => revealRow(current, focused, rendered.lines.length, viewport.bodyRows))
775
- return
776
- }
777
- setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows))
778
- }, [cursor, mode, custom.length, rendered.lines.length, viewport.bodyRows])
779
-
780
- const commit = (answer: AskUserQuestionAnswerItem): void => {
781
- if (pending === undefined) return
782
- const next = [...answers, answer]
783
- const total = pending.request.questions.length
784
- if (index + 1 >= total) {
785
- setSubmitted(true)
786
- store.submit(pending, { answers: next })
787
- return
788
- }
789
- setAnswers(next)
790
- const nextIndex = index + 1
791
- const nextQuestion = pending.request.questions[nextIndex]
792
- setIndex(nextIndex)
793
- setCursor(0)
794
- setSelected([])
795
- setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
796
- setCustom('')
797
- setScroll(0)
798
- }
799
-
800
- const commitOption = (): void => {
801
- if (pending === undefined || question === undefined) return
802
- if (isMulti) {
803
- const labels = selected
804
- .map(at => options[at]?.label)
805
- .filter((label): label is string => label !== undefined)
806
- const customText = custom.trim()
807
- commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
808
- return
809
- }
810
- const option = options[cursor]
811
- if (option === undefined) return
812
- commit({ id: question.id, selected: [option.label] })
813
- }
814
-
815
- useInput((input, key) => {
816
- if (pending === undefined || question === undefined || submitted) return
817
- if (key.escape) {
818
- store.cancel(pending)
819
- return
820
- }
821
- if (key.pageUp) {
822
- setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
823
- return
824
- }
825
- if (key.pageDown) {
826
- setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
827
- return
828
- }
829
- if (mode === 'custom' || options.length === 0) {
830
- if (key.upArrow) {
831
- setScroll(current => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows))
832
- return
833
- }
834
- if (key.downArrow) {
835
- setScroll(current => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows))
836
- return
837
- }
838
- if (key.return) {
839
- if (custom.trim() === '' && options.length > 0) {
840
- commitOption()
841
- return
842
- }
843
- commit({
844
- id: question.id,
845
- selected: isMulti
846
- ? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
847
- : [],
848
- ...(custom.trim() === '' ? {} : { custom: custom.trim() }),
849
- })
850
- return
851
- }
852
- if (key.backspace) {
853
- setCustom(current => current.slice(0, -1))
854
- return
855
- }
856
- if (input !== '' && !key.ctrl && !key.meta) {
857
- setCustom(current => current + input)
858
- }
859
- return
860
- }
861
- if (key.upArrow) {
862
- setCursor(current => (current + options.length - 1) % options.length)
863
- return
864
- }
865
- if (key.downArrow) {
866
- setCursor(current => (current + 1) % options.length)
867
- return
868
- }
869
- if (key.return) {
870
- commitOption()
871
- return
872
- }
873
- if (key.tab || input === 'c' || input === 'C') {
874
- setMode('custom')
875
- return
876
- }
877
- if (input === ' ' && isMulti) {
878
- setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
879
- }
880
- }, { isActive: active })
881
-
882
- if (pending === undefined || question === undefined) return undefined
883
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
884
- if (viewport.compact) {
885
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
886
- }
887
- const footer = submitted
888
- ? 'submitted…'
889
- : mode === 'custom' || options.length === 0
890
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
891
- : isMulti
892
- ? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
893
- : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
894
- return createElement(
895
- Box,
896
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
897
- createElement(
898
- Text,
899
- { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
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),
901
- ),
902
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
903
- createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
904
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
905
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
906
- )
907
- }
908
-
909
- /** The /model panel: a scrolling list over the advisory model directory. */
910
- function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
911
- directory: ModelDirectory | undefined
912
- error: string | undefined
913
- onSelect(row: ModelRow): void
914
- onRetry(): void
915
- onClose(): void
916
- }): ReactElement {
917
- const [cursor, setCursor] = useState(0)
918
- const stdout = useStdout().stdout
919
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
920
- const rows = directory?.rows ?? []
921
-
922
- useEffect(() => {
923
- if (rows.length === 0) {
924
- if (cursor !== 0) setCursor(0)
925
- return
926
- }
927
- if (cursor >= rows.length) setCursor(rows.length - 1)
928
- }, [rows.length, cursor])
929
-
930
- useInput((input, key) => {
931
- if (key.escape || input === 'q') {
932
- onClose()
933
- return
934
- }
935
- if (input === 'r') {
936
- onRetry()
937
- return
938
- }
939
- if (rows.length === 0) return
940
- if (key.upArrow) {
941
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
942
- return
943
- }
944
- if (key.downArrow) {
945
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
946
- return
947
- }
948
- if (key.pageUp) {
949
- setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
950
- return
951
- }
952
- if (key.pageDown) {
953
- setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
954
- return
955
- }
956
- if (input === 'g') {
957
- setCursor(0)
958
- return
959
- }
960
- if (input === 'G') {
961
- setCursor(rows.length - 1)
962
- return
963
- }
964
- if (key.return && rows[cursor] !== undefined) {
965
- onSelect(rows[cursor])
966
- }
967
- })
968
-
969
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
970
- if (viewport.compact) {
971
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
972
- }
973
-
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)
1000
- return createElement(
1001
- Box,
1002
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
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)),
1004
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1005
- ...stateRows,
1006
- ...visible.map((row) => {
1007
- const index = rows.indexOf(row)
1008
- const label = displayText(`${row.providerName} · ${row.modelName}`)
1009
- return createElement(
1010
- Text,
1011
- {
1012
- key: `${row.provider}/${row.model}`,
1013
- color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1014
- wrap: 'truncate-end',
1015
- },
1016
- truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
1017
- )
1018
- }),
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))),
1021
- )
1022
- }
1023
-
1024
- /**
1025
- * The /help overlay: one scrolling card with the keyboard map, the TUI-local
1026
- * commands, the live registry commands, and the user-invocable skills — the
1027
- * real command surface, replacing the one-line notice.
1028
- */
1029
- function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1030
- descriptors: readonly CommandDescriptor[]
1031
- skills: readonly SkillRow[]
1032
- commandError: string | undefined
1033
- skillError: string | undefined
1034
- onClose(): void
1035
- }): ReactElement {
1036
- const stdout = useStdout().stdout
1037
- const columns = stdout?.columns ?? 80
1038
- const viewport = panelViewport(columns, stdout?.rows ?? 30)
1039
- const [scroll, setScroll] = useState(0)
1040
- const nameWidth = Math.min(18, Math.max(1, viewport.contentColumns - 2))
1041
- const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
1042
- const row = (label: string, description: string): ReactElement => createElement(
1043
- Text,
1044
- { dimColor: true, wrap: 'truncate-end' },
1045
- ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
1046
- )
1047
- const content: ReactElement[] = [
1048
- createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
1049
- createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
1050
- createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
1051
- createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
1052
- createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
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' }, ' '),
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
- )]),
1063
- createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
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')),
1069
- createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1070
- createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
1071
- createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
1072
- createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
1073
- ...descriptors.map(descriptor => createElement(
1074
- Text,
1075
- { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
1076
- ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
1077
- )),
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
- )]),
1091
- ...skills.map(skill => createElement(
1092
- Text,
1093
- { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
1094
- ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
1095
- )),
1096
- ]
1097
- const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
1098
- const scrollBy = (delta: number): void => {
1099
- setScroll(current => moveScroll(current, delta, content.length, viewport.bodyRows))
1100
- }
1101
-
1102
- useEffect(() => {
1103
- if (visibleScroll !== scroll) setScroll(visibleScroll)
1104
- }, [visibleScroll, scroll])
1105
-
1106
- useInput((input, key) => {
1107
- if (key.escape || input === 'q') {
1108
- onClose()
1109
- return
1110
- }
1111
- if (key.upArrow) scrollBy(-1)
1112
- else if (key.downArrow) scrollBy(1)
1113
- else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
1114
- else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
1115
- else if (input === 'g') setScroll(0)
1116
- else if (input === 'G') setScroll(Math.max(0, content.length - viewport.bodyRows))
1117
- })
1118
-
1119
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1120
- if (viewport.compact) {
1121
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
1122
- }
1123
-
1124
- return createElement(
1125
- Box,
1126
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
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 }),
1129
- ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1130
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1131
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
1132
- )
1133
- }
1134
-
1135
- /** Collapse arbitrary metadata to one terminal row before verbose rendering. */
1136
- function verboseLine(text: string, columns: number): string {
1137
- return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
1138
- }
1139
-
1140
- /** One-row editor window keeping the logical cursor visible in long drafts. */
1141
- function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
1142
- const width = Math.max(1, columns)
1143
- const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
1144
- const caretSource = value.slice(cursor, cursor + 1)
1145
- const caret = caretSource === '' ? ' ' : normalize(caretSource)
1146
- const remaining = Math.max(0, width - visibleColumns(caret))
1147
- const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
1148
- const beforeBudget = Math.max(0, remaining - afterBudget)
1149
- const before = beforeBudget === 0
1150
- ? ''
1151
- : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
1152
- const after = afterBudget === 0
1153
- ? ''
1154
- : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
1155
- return { before, caret, after }
1156
- }
1157
-
1158
- /**
1159
- * The Ctrl+O transcript inspector: one selected durable entry at a time,
1160
- * with independent history selection and content scrolling. The complete
1161
- * retained entry is converted to physical rows, but only one viewport slice
1162
- * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
1163
- */
1164
- function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
1165
- const stdout = useStdout().stdout
1166
- const columns = stdout?.columns ?? 80
1167
- const rows = stdout?.rows ?? 30
1168
- const viewport = inspectorViewport(columns, rows)
1169
- const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
1170
- const [scroll, setScroll] = useState(0)
1171
- const savedScroll = useRef(new Map<number, number>())
1172
- const cursorRef = useRef(cursor)
1173
- const previousLength = useRef(entries.length)
1174
- const entry = entries[cursor]
1175
- const allLines = useMemo(
1176
- () => entry === undefined ? [] : transcriptEntryLines(entry, viewport.contentColumns),
1177
- [entry, viewport.contentColumns],
1178
- )
1179
- const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
1180
-
1181
- useEffect(() => {
1182
- cursorRef.current = cursor
1183
- }, [cursor])
1184
-
1185
- useEffect(() => {
1186
- const current = cursorRef.current
1187
- const next = followInspectorCursor(current, previousLength.current, entries.length)
1188
- if (next !== current) {
1189
- savedScroll.current.set(current, visibleScroll)
1190
- setCursor(next)
1191
- setScroll(savedScroll.current.get(next) ?? 0)
1192
- }
1193
- previousLength.current = entries.length
1194
- }, [entries.length])
1195
-
1196
- useEffect(() => {
1197
- const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows)
1198
- if (clamped !== scroll) setScroll(clamped)
1199
- savedScroll.current.set(cursor, clamped)
1200
- }, [cursor, scroll, allLines.length, viewport.bodyRows])
1201
-
1202
- const selectEntry = (next: number): void => {
1203
- if (entries.length === 0) return
1204
- const selected = Math.max(0, Math.min(entries.length - 1, next))
1205
- if (selected === cursor) return
1206
- savedScroll.current.set(cursor, visibleScroll)
1207
- setCursor(selected)
1208
- setScroll(savedScroll.current.get(selected) ?? 0)
1209
- }
1210
-
1211
- const scrollBy = (delta: number): void => {
1212
- setScroll(current => moveScroll(current, delta, allLines.length, viewport.bodyRows))
1213
- }
1214
-
1215
- useInput((input, key) => {
1216
- if (key.escape || input === 'q' || (key.ctrl && input === 'o')) {
1217
- onClose()
1218
- return
1219
- }
1220
- if (entries.length === 0) return
1221
- if (key.leftArrow) {
1222
- selectEntry(cursor - 1)
1223
- return
1224
- }
1225
- if (key.rightArrow) {
1226
- selectEntry(cursor + 1)
1227
- return
1228
- }
1229
- if (key.upArrow) {
1230
- scrollBy(-1)
1231
- return
1232
- }
1233
- if (key.downArrow) {
1234
- scrollBy(1)
1235
- return
1236
- }
1237
- if (key.pageUp) {
1238
- scrollBy(-Math.max(1, viewport.bodyRows - 1))
1239
- return
1240
- }
1241
- if (key.pageDown) {
1242
- scrollBy(Math.max(1, viewport.bodyRows - 1))
1243
- return
1244
- }
1245
- if (input === 'g') {
1246
- setScroll(0)
1247
- return
1248
- }
1249
- if (input === 'G') {
1250
- setScroll(Math.max(0, allLines.length - viewport.bodyRows))
1251
- }
1252
- })
1253
-
1254
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1255
- if (viewport.compact) {
1256
- return createElement(
1257
- Text,
1258
- { wrap: 'truncate-end' },
1259
- truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
1260
- )
1261
- }
1262
-
1263
- const title = entries.length === 0
1264
- ? 'history details · empty'
1265
- : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
1266
- const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
1267
- return createElement(
1268
- Box,
1269
- {
1270
- flexDirection: 'column',
1271
- paddingX: 1,
1272
- borderStyle: 'round',
1273
- borderColor: inkColor(TUI_RGB.brand),
1274
- },
1275
- createElement(
1276
- Text,
1277
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1278
- truncateColumns(title, viewport.contentColumns),
1279
- ),
1280
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1281
- createElement(
1282
- Box,
1283
- { flexDirection: 'column' },
1284
- entry === undefined
1285
- ? createElement(Text, { dimColor: true }, ' no durable entries yet')
1286
- : createElement(StyledRows, { lines: visible }),
1287
- ),
1288
- createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1289
- createElement(
1290
- Text,
1291
- { dimColor: true, wrap: 'truncate-end' },
1292
- dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
1293
- ),
1294
- )
1295
- }
1296
-
1297
- /** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
1298
- const MemoVerbosePanel = memo(VerbosePanel)
1299
-
1300
- /** Stable append-only boundary: modal updates must never revisit Static rows. */
1301
- function staticRow(item: unknown): ReactElement {
1302
- return item as ReactElement
1303
- }
1304
-
1305
- function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
1306
- return createElement(Static, { items, children: staticRow })
1307
- }
1308
-
1309
- const MemoStaticTranscript = memo(StaticTranscript)
1310
-
1311
- /** One completion candidate row. */
1312
- interface CompletionCandidate {
1313
- /** Insertion text for the command name (with leading slash). */
1314
- label: string
1315
- /** Human-readable description shown beside the label. */
1316
- description: string
1317
- /** Candidate origin; skills land the same literal text but route through the prompt. */
1318
- origin: 'command' | 'skill' | 'mention' | 'path'
1319
- }
1320
-
1321
- /**
1322
- * Resolve completion candidates for the current input: TUI-local commands,
1323
- * the live registry descriptors, and user-invocable skills, filtered by the
1324
- * typed prefix. Command names win collisions (the dispatch tries the
1325
- * registry first and only then falls through to the skill gesture).
1326
- */
1327
- function completionCandidates(
1328
- value: string,
1329
- descriptors: readonly CommandDescriptor[],
1330
- skills: readonly SkillRow[],
1331
- ): readonly CompletionCandidate[] {
1332
- if (!value.startsWith('/')) return []
1333
- const prefix = value.slice(1).split(' ')[0] ?? ''
1334
- const local: CompletionCandidate[] = [
1335
- { label: '/help', description: 'show commands', origin: 'command' },
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' },
1341
- { label: '/clear', description: 'clear the screen', origin: 'command' },
1342
- { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1343
- { label: '/title', description: 'rename this session', origin: 'command' },
1344
- { label: '/quit', description: 'exit', origin: 'command' },
1345
- ]
1346
- // Local commands shadow registry names (e.g. the plugin-registered
1347
- // /permission is served by the registry itself, never duplicated here),
1348
- // so collisions cannot render two rows with the same key.
1349
- const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1350
- const registry = descriptors
1351
- .filter(descriptor => !localNames.has(descriptor.name))
1352
- .map((descriptor): CompletionCandidate => ({
1353
- label: `/${descriptor.name}`,
1354
- description: descriptor.description,
1355
- origin: 'command',
1356
- }))
1357
- const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
1358
- const skillRows = skills
1359
- .filter(skill => !taken.has(skill.name))
1360
- .map((skill): CompletionCandidate => ({
1361
- label: `/${skill.name}`,
1362
- description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
1363
- origin: 'skill',
1364
- }))
1365
- const all = [...local, ...registry, ...skillRows]
1366
- if (prefix === '') return all.slice(0, 10)
1367
- return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
1368
- }
1369
-
1370
- /**
1371
- * The completion menu, rendered inside the composer's subtree directly above
1372
- * the framed box attached the way Claude-Code anchors its dropdown. Opening
1373
- * it grows the stack downward: the composer stays the last element on screen
1374
- * and everything above (the flushed static transcript, the status line) never
1375
- * moves. Props-only (no lifted state): the menu is a pure view of the input
1376
- * editor's live completion state, so no cross-component effect ever resyncs
1377
- * it (a state lift here previously deadlocked the menu after a resize).
1378
- */
1379
- function CompletionMenu({ active, mention, index, rows }: {
1380
- active: boolean
1381
- mention: boolean
1382
- index: number
1383
- rows: readonly CompletionCandidate[]
1384
- }): ReactElement | undefined {
1385
- // Hook order is unconditional: `active` toggling must not change the hook
1386
- // count (the early return used to sit above useStdout).
1387
- const stdout = useStdout().stdout
1388
- const columns = stdout?.columns ?? 80
1389
- const terminalRows = stdout?.rows ?? 30
1390
- if (!active) return undefined
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)
1394
- const showFooter = terminalRows >= 12
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))
1398
- const selected = rows.length === 0 ? 0 : index % rows.length
1399
- const first = selectionWindow(selected, rows.length, limit)
1400
- const visible = rows.slice(first, first + limit)
1401
- return createElement(
1402
- Box,
1403
- { flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
1404
- ...(rows.length === 0
1405
- ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
1406
- : visible.map((candidate, at) => {
1407
- const absolute = first + at
1408
- return createElement(
1409
- Text,
1410
- {
1411
- key: candidate.label,
1412
- color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1413
- wrap: 'truncate-end',
1414
- },
1415
- `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1416
- )
1417
- })),
1418
- showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
1419
- )
1420
- }
1421
-
1422
- /**
1423
- * The prompt box: TUI-local slash commands handled locally, other lines
1424
- * dispatched; input editing keeps a cursor with history and completion.
1425
- * While a modal (approval / question / model panel) owns the keys, the
1426
- * box passes every key through untouched.
1427
- */
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 }: {
1429
- active: boolean
1430
- frozen: boolean
1431
- busy: boolean
1432
- descriptors: readonly CommandDescriptor[]
1433
- skills: readonly SkillRow[]
1434
- dispatch(text: string): void
1435
- steer(text: string): void
1436
- interrupt(): boolean
1437
- quit(): void
1438
- openModel(): void
1439
- openHelp(): 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
1448
- toggleReasoning(): void
1449
- openVerbose(): void
1450
- clearView(): void
1451
- refresh(): void
1452
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
1453
- cyclePermission(): string
1454
- exportTranscript(argument: string): Promise<void>
1455
- renameTitle(argument: string): string
1456
- }): ReactElement {
1457
- const columns = useStdout().stdout?.columns ?? 80
1458
- const [value, setValue] = useState('')
1459
- const [cursor, setCursor] = useState(0)
1460
- const history = useRef<readonly string[]>([])
1461
- const historyIndex = useRef<number | null>(null)
1462
- const draft = useRef('')
1463
- const [completionIndex, setCompletionIndex] = useState(0)
1464
- const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
1465
- const candidates = completionCandidates(value, descriptors, skills)
1466
- const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
1467
-
1468
- // @mention token: the last `@word` on the cursor's line before the cursor.
1469
- const beforeCursor = value.slice(0, cursor)
1470
- const lastLine = beforeCursor.split('\n').at(-1) ?? ''
1471
- const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
1472
- const mentionToken = tokenMatch === null
1473
- ? undefined
1474
- : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
1475
- const mentionActive = mentionToken !== undefined
1476
- const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
1477
-
1478
- // Bare path token: the last whitespace-delimited run on the cursor's line
1479
- // when it already looks like a path (Claude-Code bare Tab completion). A
1480
- // LEADING '/' is the command namespace, never a path — without this guard
1481
- // typing the bare '/' hijacked the menu into the workspace file scan and
1482
- // the slash-command candidates never appeared.
1483
- const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
1484
- const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
1485
- const pathActive = !mentionActive
1486
- && !bareToken.startsWith('/')
1487
- && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
1488
- const pathTokenStart = beforeCursor.length - bareToken.length
1489
- const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
1490
-
1491
- useEffect(() => {
1492
- if (!active || !pathActive) {
1493
- setPathRows([])
1494
- return
1495
- }
1496
- const controller = new AbortController()
1497
- setPathRows([])
1498
- loadMentions(bareToken, controller.signal).then(
1499
- rows => setPathRows(rows.filter(row => row.kind !== 'session')),
1500
- () => {},
1501
- )
1502
- return () => {
1503
- controller.abort()
1504
- }
1505
- }, [active, pathActive, bareToken])
1506
-
1507
- useEffect(() => {
1508
- if (!active || !mentionActive) {
1509
- setMentionRows([])
1510
- return
1511
- }
1512
- const controller = new AbortController()
1513
- setMentionRows([])
1514
- loadMentions(mentionToken.query, controller.signal).then(
1515
- rows => setMentionRows(rows),
1516
- () => {},
1517
- )
1518
- return () => {
1519
- controller.abort()
1520
- }
1521
- }, [active, mentionActive, mentionToken?.query])
1522
-
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
1527
- const menuRows: readonly CompletionCandidate[] = mentionActive
1528
- ? mentionRows.map(row => ({
1529
- label: row.label.startsWith('@')
1530
- ? row.label
1531
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
1532
- description: row.description,
1533
- origin: 'mention',
1534
- }))
1535
- : pathActive
1536
- ? pathRows.map(row => ({
1537
- label: row.label,
1538
- description: row.description,
1539
- origin: 'path',
1540
- }))
1541
- : candidates
1542
-
1543
- useInput((input, key) => {
1544
- // Modal ownership: approval/question/model dialogs consume all keys.
1545
- if (!active) return
1546
- // Shift+Tab cycles the permission preset (Claude-Code convention).
1547
- if (key.tab && key.shift) {
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
- }
1554
- return
1555
- }
1556
- // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
1557
- if (key.ctrl && input === 'r') {
1558
- toggleReasoning()
1559
- return
1560
- }
1561
- // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
1562
- // adapted to append-only static rows): one history entry at a time with
1563
- // tool cards and reasoning expanded, Esc returns.
1564
- if (key.ctrl && input === 'o') {
1565
- openVerbose()
1566
- return
1567
- }
1568
- // Ctrl+C is three-state (community-TUI convention): a running turn is
1569
- // cancelled, a non-empty draft is cleared, and only an idle empty input
1570
- // exits. Ctrl+D always means exit but refuses mid-turn.
1571
- if (key.ctrl && input === 'c') {
1572
- if (busy) {
1573
- interrupt()
1574
- } else if (value !== '') {
1575
- setValue('')
1576
- setCursor(0)
1577
- setCompletionIndex(0)
1578
- setDismissedMenuValue(undefined)
1579
- } else {
1580
- quit()
1581
- }
1582
- return
1583
- }
1584
- if (key.ctrl && input === 'd') {
1585
- if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
1586
- else quit()
1587
- return
1588
- }
1589
- if (key.escape) {
1590
- if (menuActive) {
1591
- setDismissedMenuValue(value)
1592
- return
1593
- }
1594
- if (hasNotice) {
1595
- dismissNotice()
1596
- return
1597
- }
1598
- if (busy) interrupt()
1599
- return
1600
- }
1601
- if (key.return) {
1602
- // Multi-line editing: most terminals send the same byte for
1603
- // shift+enter as enter, so newline insertion rides alt/meta+enter
1604
- // and ctrl+j (the two distinguishable bindings); a bare return submits.
1605
- if (key.meta || (key.ctrl && input === 'j')) {
1606
- setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
1607
- setCursor(cursor + 1)
1608
- setDismissedMenuValue(undefined)
1609
- return
1610
- }
1611
- const text = value.trim()
1612
- setValue('')
1613
- setCursor(0)
1614
- setCompletionIndex(0)
1615
- setDismissedMenuValue(undefined)
1616
- if (text === '') return
1617
- dismissNotice()
1618
- history.current = [...history.current, text]
1619
- historyIndex.current = null
1620
- if (text === '/quit') {
1621
- quit()
1622
- return
1623
- }
1624
- if (text === '/help') {
1625
- openHelp()
1626
- return
1627
- }
1628
- if (text === '/clear') {
1629
- // Clear the screen AND drop the folded view: the raw ANSI clear + a
1630
- // Static remount (refresh) so the ledger stays in sync, then the
1631
- // store resets so the rebuilt transcript starts empty.
1632
- refresh()
1633
- clearView()
1634
- dismissNotice()
1635
- return
1636
- }
1637
- if (text === '/export' || text.startsWith('/export ')) {
1638
- void exportTranscript(text.slice(8))
1639
- return
1640
- }
1641
- if (text === '/title' || text.startsWith('/title ')) {
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)
1649
- return
1650
- }
1651
- if (text === '/model' || text.startsWith('/model ')) {
1652
- openModel()
1653
- return
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
- }
1679
- if (busy && !text.startsWith('/')) {
1680
- // A running turn is steered, not blocked: the inbox delivers this
1681
- // text at the next step boundary (Esc/Ctrl+C still cancels outright).
1682
- // Slash lines keep the registry path — commands run out of band.
1683
- steer(text)
1684
- return
1685
- }
1686
- dispatch(text)
1687
- return
1688
- }
1689
- if (menuActive && key.upArrow) {
1690
- setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
1691
- return
1692
- }
1693
- if (menuActive && key.downArrow) {
1694
- setCompletionIndex(index => (index + 1) % menuRows.length)
1695
- return
1696
- }
1697
- if (key.upArrow) {
1698
- const entries = history.current
1699
- if (entries.length === 0) return
1700
- const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1)
1701
- if (historyIndex.current === null) draft.current = value
1702
- historyIndex.current = next
1703
- setValue(entries[next] ?? '')
1704
- setCursor((entries[next] ?? '').length)
1705
- setDismissedMenuValue(undefined)
1706
- return
1707
- }
1708
- if (key.downArrow) {
1709
- const entries = history.current
1710
- if (historyIndex.current === null) return
1711
- const next = historyIndex.current + 1
1712
- if (next >= entries.length) {
1713
- historyIndex.current = null
1714
- setValue(draft.current)
1715
- setCursor(draft.current.length)
1716
- setDismissedMenuValue(undefined)
1717
- return
1718
- }
1719
- historyIndex.current = next
1720
- setValue(entries[next] ?? '')
1721
- setCursor((entries[next] ?? '').length)
1722
- setDismissedMenuValue(undefined)
1723
- return
1724
- }
1725
- if (key.tab && menuActive) {
1726
- if (mentionActive && mentionToken !== undefined) {
1727
- const row = mentionRows[completionIndex % mentionRows.length]
1728
- if (row !== undefined) {
1729
- // Session rows carry the canonical @[label](dsh-session:…) token;
1730
- // file rows insert `@path` (directories keep their trailing slash).
1731
- const insertion = row.label.startsWith('@')
1732
- ? row.label
1733
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
1734
- setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
1735
- setCursor(mentionToken.start + insertion.length)
1736
- }
1737
- } else if (pathActive) {
1738
- const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
1739
- if (row !== undefined) {
1740
- // Bare path completion replaces the typed token with the chosen
1741
- // workspace path (directories keep their trailing slash).
1742
- const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
1743
- setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
1744
- setCursor(pathTokenStart + insertion.length)
1745
- }
1746
- } else {
1747
- const candidate = candidates[completionIndex % candidates.length]
1748
- if (candidate !== undefined) {
1749
- setValue(`${candidate.label} `)
1750
- setCursor(candidate.label.length + 1)
1751
- }
1752
- }
1753
- setCompletionIndex(0)
1754
- setDismissedMenuValue(undefined)
1755
- return
1756
- }
1757
- if (key.backspace || key.delete) {
1758
- if (cursor > 0) {
1759
- setValue(value.slice(0, cursor - 1) + value.slice(cursor))
1760
- setCursor(cursor - 1)
1761
- setCompletionIndex(0)
1762
- setDismissedMenuValue(undefined)
1763
- }
1764
- return
1765
- }
1766
- if (key.leftArrow) {
1767
- setCursor(Math.max(0, cursor - 1))
1768
- return
1769
- }
1770
- if (key.rightArrow) {
1771
- setCursor(Math.min(value.length, cursor + 1))
1772
- return
1773
- }
1774
- if (key.ctrl && input === 'u') {
1775
- setValue('')
1776
- setCursor(0)
1777
- setDismissedMenuValue(undefined)
1778
- return
1779
- }
1780
- // Readline parity: Ctrl+K cuts from the cursor to the end of the line.
1781
- if (key.ctrl && input === 'k') {
1782
- setValue(value.slice(0, cursor))
1783
- setDismissedMenuValue(undefined)
1784
- return
1785
- }
1786
- // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
1787
- // plus a Static remount so the flushed transcript re-emits (a bare
1788
- // console.clear() would desync Ink's ledger against the static rows).
1789
- if (key.ctrl && input === 'l') {
1790
- refresh()
1791
- return
1792
- }
1793
- if (key.ctrl && input === 'a') {
1794
- setCursor(0)
1795
- return
1796
- }
1797
- if (key.ctrl && input === 'e') {
1798
- setCursor(value.length)
1799
- return
1800
- }
1801
- if (input !== '' && !key.ctrl && !key.meta) {
1802
- setValue(value.slice(0, cursor) + input + value.slice(cursor))
1803
- setCursor(cursor + input.length)
1804
- setCompletionIndex(0)
1805
- setDismissedMenuValue(undefined)
1806
- }
1807
- })
1808
-
1809
- // Every exclusive panel keeps the composer as a stable visual anchor, but
1810
- // freezes it to one row: no menu, multiline wrap, or animation.
1811
- if (frozen) {
1812
- const frozen = value === ''
1813
- ? 'type a message'
1814
- : verboseLine(value, Math.max(1, columns - 6))
1815
- return createElement(
1816
- Box,
1817
- { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1818
- createElement(
1819
- Text,
1820
- { wrap: 'truncate-end' },
1821
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1822
- frozen,
1823
- ),
1824
- )
1825
- }
1826
-
1827
- const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
1828
-
1829
- return createElement(
1830
- Box,
1831
- { flexDirection: 'column' },
1832
- // The completion dropdown rides directly above the box (Claude-Code
1833
- // anchor): rendered from the editor's own live state, never lifted.
1834
- createElement(CompletionMenu, {
1835
- active: menuActive,
1836
- mention: mentionActive,
1837
- index: completionIndex,
1838
- rows: menuRows,
1839
- }),
1840
- // The framed input box: a visible boundary so the prompt never blends
1841
- // into the transcript above it; the cursor block sits immediately after
1842
- // the prompt marker (leftmost), with the dim placeholder trailing it —
1843
- // no extra space, so the empty state reads `❯ ▮type a message…`.
1844
- createElement(
1845
- Box,
1846
- { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1847
- createElement(
1848
- Text,
1849
- { wrap: 'truncate-end' },
1850
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1851
- value === '' ? undefined : editor.before,
1852
- createElement(CursorBlock, { char: editor.caret }),
1853
- value === '' && !busy
1854
- ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
1855
- : editor.after,
1856
- ),
1857
- ),
1858
- )
1859
- }
1860
-
1861
- /** The whole terminal app; state arrives via the store, output via Ink. */
1862
- export function App(props: AppProps): ReactElement {
1863
- const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
1864
- const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
1865
- const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
1866
- const [modelLabel, setModelLabel] = useState(props.model)
1867
- const [modelOpen, setModelOpen] = useState(false)
1868
- const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
1869
- const [modelError, setModelError] = useState<string | undefined>(undefined)
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
- }, [])
1875
-
1876
- useEffect(() => {
1877
- props.onBridgeReady({ notify })
1878
- }, [])
1879
- useEffect(() => {
1880
- if (!modelOpen) return
1881
- let cancelled = false
1882
- setDirectory(undefined)
1883
- setModelError(undefined)
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) => {
1888
- if (!cancelled) setDirectory(loaded)
1889
- }, (error: unknown) => {
1890
- if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
1891
- })
1892
- return () => {
1893
- cancelled = true
1894
- }
1895
- }, [modelOpen, modelLoadEpoch, props.loadModels])
1896
-
1897
- const busy = view.busy
1898
- const [showReasoning, setShowReasoning] = useState(false)
1899
- const [verboseOpen, setVerboseOpen] = useState(false)
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('')
1905
- const [refreshEpoch, setRefreshEpoch] = useState(0)
1906
- const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
1907
- const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
1908
- const approvalPending = approvalSnapshot.pending !== undefined
1909
- const questionPending = questionSnapshot.pending !== undefined
1910
- // While any modal owns the keys, the prompt box passes everything through.
1911
- const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending
1912
-
1913
- // Human questions outrank local inspectors. Close the lower modal instead
1914
- // of leaving an approval/question visible but keyboard-locked behind it.
1915
- useEffect(() => {
1916
- if (!approvalPending && !questionPending) return
1917
- setModelOpen(false)
1918
- setHelpOpen(false)
1919
- setModeOpen(false)
1920
- setResumeOpen(false)
1921
- setPluginOpen(false)
1922
- setVerboseOpen(false)
1923
- }, [approvalPending, questionPending])
1924
-
1925
- // Append-only transcript: everything up to the first still-mutable entry
1926
- // (a running tool/retry) flushes through Ink's `<Static>` into native
1927
- // scrollback and is normally never rewritten — the Claude-Code stability
1928
- // contract
1929
- // that lets arbitrarily long conversations scroll instead of freezing when
1930
- // the live tree exceeds the terminal height. The dynamic region below stays
1931
- // small: the streaming tail, modals, composer, and its status footer.
1932
- // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
1933
- // keeps long settled histories out of the per-token render path.
1934
- const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
1935
- // Claude-Code spacing: one blank row before each user prompt (except the
1936
- // first) separates replies from the next turn. Settled rows flush once with
1937
- // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
1938
- // Ctrl+O browses the frozen history through a bounded selected-entry view.
1939
- const settledRows = useMemo(() => {
1940
- const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
1941
- view.entries.slice(0, settled).forEach((entry, index) => {
1942
- const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
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, ' ')))
1946
- }
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
- }
1951
- })
1952
- return rows
1953
- }, [view.entries, settled, showReasoning, props.resumed])
1954
-
1955
- // Hook order is unconditional. Its dimensions drive every live-region
1956
- // budget before any dynamic rows are constructed.
1957
- const appStdout = useStdout().stdout
1958
- const [terminalSize, setTerminalSize] = useState(() => ({
1959
- columns: appStdout?.columns ?? 80,
1960
- rows: appStdout?.rows ?? 30,
1961
- }))
1962
- const terminalSizeRef = useRef(terminalSize)
1963
- useEffect(() => {
1964
- if (appStdout === undefined) return
1965
- let replayTimer: ReturnType<typeof setTimeout> | undefined
1966
- const handleResize = (): void => {
1967
- const next = {
1968
- columns: appStdout.columns ?? 80,
1969
- rows: appStdout.rows ?? 30,
1970
- }
1971
- if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
1972
- terminalSizeRef.current = next
1973
-
1974
- // Ink 5 erases by the old logical line count. Once the terminal reflows
1975
- // a full-width border at a new width, that count is no longer enough and
1976
- // stale frames remain visible. Follow Codex's source-backed reflow
1977
- // policy: update live geometry immediately, but wait for the resize
1978
- // burst to settle before one hard reset and one transcript replay at the
1979
- // final width. Replaying Static on every event appends duplicate history.
1980
- setTerminalSize(next)
1981
- if (replayTimer !== undefined) clearTimeout(replayTimer)
1982
- replayTimer = setTimeout(() => {
1983
- appStdout.write(RESIZE_REFLOW_CLEAR)
1984
- setRefreshEpoch(epoch => epoch + 1)
1985
- }, RESIZE_REFLOW_DELAY_MS)
1986
- }
1987
- appStdout.on('resize', handleResize)
1988
- return () => {
1989
- appStdout.off('resize', handleResize)
1990
- if (replayTimer !== undefined) clearTimeout(replayTimer)
1991
- }
1992
- }, [appStdout])
1993
- const terminalRows = terminalSize.rows
1994
- const terminalColumns = terminalSize.columns
1995
- const composerGutterRows = layoutGutterRows(terminalRows)
1996
- const dynamicRows = Math.max(1, terminalRows - 12 - composerGutterRows)
1997
- const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
1998
- const deepDivingVisible = busy && !streamingActive
1999
- const allLiveLines = useMemo(
2000
- () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
2001
- [view.entries, settled, terminalColumns],
2002
- )
2003
- const liveBudget = streamingActive
2004
- ? Math.max(1, Math.floor(dynamicRows / 3))
2005
- : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
2006
- const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
2007
-
2008
- // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
2009
- // screen AND scrollback, home the cursor) then a Static remount via the
2010
- // key change, which re-flushes the current items from index 0. NEVER
2011
- // console.clear() it desyncs Ink's internal line ledger against the
2012
- // flushed static rows and garbles every frame after.
2013
- const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
2014
- const reasoningRows = view.streamingReasoning === ''
2015
- ? 0
2016
- : view.streaming === ''
2017
- ? streamRows
2018
- : streamRows <= 1
2019
- ? 0
2020
- : showReasoning
2021
- ? Math.max(1, Math.floor(streamRows / 3))
2022
- : 1
2023
- const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2024
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending
2025
- const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2026
- const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || inspectorVisible || approvalPending || questionPending
2027
- const closeInspector = useCallback((): void => {
2028
- setVerboseOpen(false)
2029
- }, [])
2030
- const refreshScreen = (): void => {
2031
- if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
2032
- setRefreshEpoch(epoch => epoch + 1)
2033
- }
2034
-
2035
- return createElement(
2036
- Box,
2037
- { flexDirection: 'column' },
2038
- createElement(MemoStaticTranscript, {
2039
- key: refreshEpoch,
2040
- items: settledRows,
2041
- }),
2042
- transcriptVisible
2043
- ? createElement(
2044
- Box,
2045
- { flexDirection: 'column', paddingX: 1 },
2046
- visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
2047
- view.streamingReasoning !== '' && reasoningRows > 0
2048
- ? createElement(StreamTail, {
2049
- text: showReasoning ? view.streamingReasoning : 'Thinking…',
2050
- prefix: ' ',
2051
- dim: true,
2052
- maxRows: reasoningRows,
2053
- })
2054
- : undefined,
2055
- view.streaming !== '' && answerRows > 0
2056
- ? createElement(
2057
- StreamTail,
2058
- { text: view.streaming, dim: false, maxRows: answerRows },
2059
- busy ? createElement(Caret) : undefined,
2060
- )
2061
- : undefined,
2062
- deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
2063
- )
2064
- : undefined,
2065
- transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
2066
- createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2067
- createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
2068
- modelOpen && !approvalPending && !questionPending
2069
- ? createElement(ModelPanel, {
2070
- directory,
2071
- error: modelError,
2072
- onSelect: (row: ModelRow) => {
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)
2083
- },
2084
- onClose: () => {
2085
- setModelOpen(false)
2086
- },
2087
- })
2088
- : undefined,
2089
- helpOpen && !approvalPending && !questionPending
2090
- ? createElement(HelpPanel, {
2091
- descriptors,
2092
- skills,
2093
- commandError: props.commands.error,
2094
- skillError: props.skills.error,
2095
- onClose: () => {
2096
- setHelpOpen(false)
2097
- },
2098
- })
2099
- : undefined,
2100
- verboseOpen && !approvalPending && !questionPending
2101
- ? createElement(MemoVerbosePanel, {
2102
- entries: view.entries,
2103
- onClose: closeInspector,
2104
- })
2105
- : undefined,
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) })
2130
- : undefined,
2131
- notice === undefined
2132
- ? undefined
2133
- : createElement(NoticeLine, {
2134
- text: notice.text,
2135
- tone: notice.tone,
2136
- columns: terminalColumns,
2137
- }),
2138
- // Persistent bottom chrome: every interface owns exactly the same
2139
- // composer/status geometry. Panels may change above it, but can no longer
2140
- // reorder the status or introduce mode-specific vertical margins.
2141
- createElement(
2142
- Box,
2143
- { flexDirection: 'column', marginTop: composerGutterRows },
2144
- createElement(Input, {
2145
- active: inputActive,
2146
- frozen: modalVisible,
2147
- busy,
2148
- descriptors,
2149
- skills,
2150
- dispatch: props.dispatch,
2151
- steer: props.steer,
2152
- interrupt: props.interrupt,
2153
- quit: props.quit,
2154
- openModel: () => {
2155
- setDirectory(undefined)
2156
- setModelError(undefined)
2157
- setModelOpen(true)
2158
- },
2159
- openHelp: () => {
2160
- setHelpOpen(true)
2161
- },
2162
- openMode: () => setModeOpen(true),
2163
- openResume: () => setResumeOpen(true),
2164
- openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2165
- createSession: props.createSession,
2166
- cancelSessionSwitch: props.cancelSessionSwitch,
2167
- notify,
2168
- hasNotice: notice !== undefined,
2169
- dismissNotice: () => {
2170
- setNotice(undefined)
2171
- },
2172
- openVerbose: () => {
2173
- setVerboseOpen(true)
2174
- },
2175
- clearView: () => {
2176
- props.store.reset()
2177
- },
2178
- refresh: refreshScreen,
2179
- toggleReasoning: () => {
2180
- setShowReasoning(current => !current)
2181
- },
2182
- loadMentions: props.loadMentions,
2183
- cyclePermission: props.cyclePermission,
2184
- exportTranscript: props.exportTranscript,
2185
- renameTitle: props.renameTitle,
2186
- }),
2187
- createElement(StatusLine, {
2188
- facts: {
2189
- model: modelLabel,
2190
- mode: props.mode,
2191
- cwd: props.cwd,
2192
- branch: props.branch,
2193
- sessionId: props.sessionId,
2194
- title: view.title,
2195
- plan: view.plan,
2196
- permission: view.permission,
2197
- sandbox: view.sandbox,
2198
- goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
2199
- },
2200
- stats: view.stats,
2201
- busy,
2202
- }),
2203
- ),
2204
- )
2205
- }
1
+ /**
2
+ * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
3
+ * transcript, the todo panel, the streaming line, the approval bar, the model
4
+ * panel, local notices, and the input box with history and slash-command
5
+ * completion. All state arrives through the transcript store (derived from
6
+ * the durable session log) plus local input state; the app owns no session
7
+ * mutation of its own.
8
+ *
9
+ * Element construction uses `createElement` (not JSX): the `dsh` source launch
10
+ * compiles this file through tsx's ESM-only hook, which does not adopt this
11
+ * package's `jsx: react-jsx` compiler option, and the classic JSX runtime
12
+ * would demand a React global.
13
+ *
14
+ * @module @deepseek-ai/dsh-code/app
15
+ */
16
+
17
+ import {
18
+ createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
19
+ } from 'react'
20
+ import { Box, Static, Text, useInput, useStdout, type Key } from 'ink'
21
+ import { assertNever } from '@deepseek-ai/dsh-llm'
22
+ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
+ import type { TodoItem } from '@deepseek-ai/dsh-session'
24
+ import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
25
+ import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
26
+ import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
27
+ import type { TranscriptStore } from './store.ts'
28
+ import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
29
+ import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
+ import type { ToolDetail } from './render/tool-detail.ts'
31
+ import { busyChaseFrame, caretVisible, pulseFrame } from './render/animations.ts'
32
+ import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
33
+ import type { CommandsView } from './commands.ts'
34
+ import type { ModelDirectory, ModelRow } from './models.ts'
35
+ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
36
+ import type { SkillsView, SkillRow } from './skills.ts'
37
+ import type { MentionCandidate } from './mentions.ts'
38
+ import { ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
39
+ import type { PresetRow } from './presets.ts'
40
+ import type { PluginRow } from './plugin-inventory.ts'
41
+ import {
42
+ recallEntries,
43
+ recallNewer,
44
+ recallOlder,
45
+ recordLocalEntry,
46
+ type RecallState,
47
+ } from './history.ts'
48
+ import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
49
+
50
+ /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
51
+ const RESIZE_REFLOW_DELAY_MS = 75
52
+
53
+ /** Reset region/style, clear the visible screen and scrollback, then home. */
54
+ const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
55
+ import {
56
+ formatTokens,
57
+ layoutStatusBar,
58
+ parseStatuslineItems,
59
+ STATUS_CYCLE_HINT,
60
+ STATUS_GROUP_SEPARATOR,
61
+ STATUS_ITEM_SEPARATOR,
62
+ type StatusFacts,
63
+ type StatusGroup,
64
+ type StatusItemId,
65
+ type StatusSpan,
66
+ type StatusTone,
67
+ } from './render/status.ts'
68
+ import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
69
+ import {
70
+ clampScroll,
71
+ followInspectorCursor,
72
+ inspectorViewport,
73
+ layoutGutterRows,
74
+ moveScroll,
75
+ panelViewport,
76
+ revealRow,
77
+ selectionWindow,
78
+ } from './render/inspector.ts'
79
+ import {
80
+ lineSegment,
81
+ markdownLines,
82
+ styledLines,
83
+ textLines,
84
+ transcriptEntryLines,
85
+ type LineStyle,
86
+ type StyledLine,
87
+ } from './render/lines.ts'
88
+
89
+ /** Visual priority for one bounded local notice. */
90
+ export type NoticeTone = 'info' | 'warning' | 'error'
91
+
92
+ /** Props the runner hands the app; callbacks stay owned by the runner. */
93
+ export interface AppProps {
94
+ /** Event-fed transcript store for the live session. */
95
+ store: TranscriptStore
96
+ /** Approval-question store fed by the answerer listener. */
97
+ approval: ApprovalStore
98
+ /** ask_user_question store fed by the single UI provider. */
99
+ questions: QuestionStore
100
+ /** Live slash-command descriptor list (completion candidates). */
101
+ commands: CommandsView
102
+ /** Live user-invocable skill catalog (completion candidates). */
103
+ skills: SkillsView
104
+ /** `provider/model` selection serving this session (updated on /model). */
105
+ model: string
106
+ /** Working-directory basename the session serves. */
107
+ cwd: string
108
+ /** Absolute working directory used by session filters and references. */
109
+ workspaceRoot: string
110
+ /** Git branch name, empty outside a repository. */
111
+ branch: string
112
+ /** Short session identifier. */
113
+ sessionId: string
114
+ /** Whether this session was resumed from persistence. */
115
+ resumed: boolean
116
+ /** Agent preset currently composing the session. */
117
+ mode: string
118
+ /** Submit one line: slash commands to the registry, other text to the agent. */
119
+ dispatch(text: string): void
120
+ /** Submit steering: consumed at the running turn's next step boundary. */
121
+ steer(text: string): void
122
+ /** Interrupt the running turn (Esc); true when a turn was cancelled. */
123
+ interrupt(): boolean
124
+ /** Quit: unmount, flush, and request process exit. */
125
+ quit(): void
126
+ /** Load the selectable model directory (called when /model opens). */
127
+ loadModels(): Promise<ModelDirectory>
128
+ /** Load @mention candidates for the typed query (files + sessions). */
129
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
130
+ /** Apply one /model selection; returns the display label. */
131
+ selectModel(row: ModelRow): string
132
+ /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
133
+ cyclePermission(): string
134
+ /** Export the transcript to a markdown file (/export [path]); reports via notices. */
135
+ exportTranscript(argument: string): Promise<void>
136
+ /** Rename the session (/title <text>); returns the outcome line for the notice. */
137
+ renameTitle(argument: string): string
138
+ /** Preset/session/plugin kernel operations. */
139
+ loadPresets(): Promise<readonly PresetRow[]>
140
+ switchMode(id: string): Promise<string>
141
+ createSession(mode?: string): void
142
+ loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
143
+ loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
144
+ switchSession(row: SessionRow): void
145
+ cancelSessionSwitch(): boolean
146
+ loadPlugins(): readonly PluginRow[]
147
+ /** Registers the app's notice channel with the runner (called once on mount). */
148
+ onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
149
+ /** Ordered enabled status items (/statusline config); the runner owns persistence. */
150
+ statusline: readonly string[]
151
+ /** Persist a new statusline item set; the runner surfaces IO failures as notices. */
152
+ saveStatusline(items: readonly string[]): void
153
+ /** Persistent cross-session input history (oldest first); the runner owns the file. */
154
+ history: readonly string[]
155
+ /** Persist one submitted prompt to the global history file. */
156
+ recordHistory(text: string): void
157
+ /** Cancel one queued inbox message by identity (Delete on the empty composer). */
158
+ cancelQueued(messageId: string): void
159
+ }
160
+
161
+ /** Ink `color` string for one palette triple. */
162
+ function inkColor(triple: readonly [number, number, number]): string {
163
+ return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
164
+ }
165
+
166
+ /** Pad text with spaces to a visible-column target (menu name column). */
167
+ function padColumns(text: string, width: number): string {
168
+ const clipped = truncateColumns(singleLineText(text), width)
169
+ return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
170
+ }
171
+
172
+ /** Interval-driven frame counter for one self-contained animated leaf. */
173
+ function useFrames(intervalMs: number): number {
174
+ const [tick, setTick] = useState(0)
175
+ useEffect(() => {
176
+ const id = setInterval(() => setTick(current => current + 1), intervalMs)
177
+ return () => {
178
+ clearInterval(id)
179
+ }
180
+ }, [intervalMs])
181
+ return tick
182
+ }
183
+
184
+ /**
185
+ * Ink re-subscribes its input effect whenever the handler identity changes.
186
+ * Keep terminal input ownership stable while a local surface updates cursor,
187
+ * scroll, or draft state; otherwise every key toggles raw mode and can make
188
+ * Ink repeatedly repaint the live region.
189
+ */
190
+ function useStableInput(handler: (input: string, key: Key) => void, active: boolean): void {
191
+ const handlerRef = useRef(handler)
192
+ handlerRef.current = handler
193
+ const stableHandler = useCallback((input: string, key: Key): void => {
194
+ handlerRef.current(input, key)
195
+ }, [])
196
+ useInput(stableHandler, { isActive: active })
197
+ }
198
+
199
+ /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
200
+ function Pulse(): ReactElement {
201
+ const tick = useFrames(125)
202
+ return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
203
+ }
204
+
205
+ /**
206
+ * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
207
+ * ring trail clockwise around the eight outer positions (8 frames × 125ms =
208
+ * the web's 1s cycle). Replaces the plain busy ellipsis as the composer's
209
+ * prompt marker and leads the Deep-diving line.
210
+ */
211
+ function BusyChase(): ReactElement {
212
+ const tick = useFrames(125)
213
+ return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, busyChaseFrame(tick) + ' ')
214
+ }
215
+
216
+ /** Blinking block caret appended to streaming text. */
217
+ function Caret(): ReactElement {
218
+ const tick = useFrames(530)
219
+ return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
220
+ }
221
+
222
+ /** Blinking input cursor: inverse block while the caret phase is on. */
223
+ function CursorBlock({ char }: { char: string }): ReactElement {
224
+ const tick = useFrames(530)
225
+ return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
226
+ }
227
+
228
+ /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
229
+ function runClock(ms: number): string {
230
+ const total = Math.max(0, Math.floor(ms / 1000))
231
+ const minutes = Math.floor(total / 60)
232
+ const seconds = total % 60
233
+ return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
234
+ }
235
+
236
+ /**
237
+ * The busy line, web TurnStatus contract: the StateDot chase leads the plain
238
+ * `Deep diving...` label, with the elapsed clock appended only once the turn
239
+ * has clearly been running (15s) — anchored to `turn/start` so a resumed
240
+ * mid-turn keeps the real time.
241
+ */
242
+ function DeepDivingLine({ since }: { since: number }): ReactElement {
243
+ useFrames(1000)
244
+ const elapsed = since === 0 ? 0 : Date.now() - since
245
+ return createElement(
246
+ Box,
247
+ { flexDirection: 'row' },
248
+ createElement(BusyChase),
249
+ createElement(
250
+ Text,
251
+ { dimColor: true },
252
+ elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
253
+ ),
254
+ )
255
+ }
256
+
257
+ /**
258
+ * The streaming buffer rendered with a hard size cap: the live region must
259
+ * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
260
+ * than the screen freezes (cursor-up past the top, garbage, no scroll). The
261
+ * cap counts explicit newlines and terminal wrapping, slicing from the END so
262
+ * the freshest tokens stay visible while a long reply streams; the complete
263
+ * text lands in the flushed scrollback once the turn assembles it.
264
+ */
265
+ function StreamTail({ text, dim, maxRows, prefix, children }: {
266
+ text: string
267
+ dim: boolean
268
+ maxRows: number
269
+ prefix?: string
270
+ children?: ReactElement
271
+ }): ReactElement {
272
+ const columns = useStdout().stdout?.columns ?? 80
273
+ const safeRows = Math.max(1, maxRows)
274
+ // App padding consumes two columns; the final extra column keeps a caret
275
+ // from wrapping onto an unbudgeted row.
276
+ const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
277
+ const initial = displayTail(text, contentColumns, safeRows)
278
+ // Reserve one row for the omission marker only when a marker is needed.
279
+ const tail = initial.truncated && safeRows > 1
280
+ ? displayTail(text, contentColumns, safeRows - 1)
281
+ : initial
282
+ return createElement(
283
+ Box,
284
+ { flexDirection: 'column' },
285
+ tail.truncated && safeRows > 1
286
+ ? createElement(Text, { dimColor: true }, ' …')
287
+ : undefined,
288
+ createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
289
+ )
290
+ }
291
+
292
+ /** Ink props for one markdown style class. */
293
+ function segmentProps(style: MdSegment['style']): {
294
+ color: string | undefined
295
+ bold: boolean | undefined
296
+ italic: boolean | undefined
297
+ strikethrough: boolean | undefined
298
+ } {
299
+ switch (style) {
300
+ case 'accent':
301
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
302
+ case 'code':
303
+ return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
304
+ case 'dim':
305
+ return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
306
+ case 'bold':
307
+ return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
308
+ case 'italic':
309
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
310
+ case 'boldItalic':
311
+ return { color: undefined, bold: true, italic: true, strikethrough: undefined }
312
+ case 'strike':
313
+ return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
314
+ default:
315
+ return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
316
+ }
317
+ }
318
+
319
+ /** Ink props for the richer line model used by bounded scrolling panels. */
320
+ function lineStyleProps(style: LineStyle): {
321
+ color: string | undefined
322
+ bold: boolean | undefined
323
+ italic: boolean | undefined
324
+ strikethrough: boolean | undefined
325
+ dimColor: boolean | undefined
326
+ } {
327
+ switch (style) {
328
+ case 'brand':
329
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
330
+ case 'success':
331
+ return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
332
+ case 'error':
333
+ return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
334
+ case 'warn':
335
+ return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
336
+ case 'dimItalic':
337
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
338
+ default:
339
+ return { ...segmentProps(style), dimColor: undefined }
340
+ }
341
+ }
342
+
343
+ /** Render width-safe rows; every child is exactly one terminal row. */
344
+ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
345
+ return createElement(
346
+ Box,
347
+ { flexDirection: 'column' },
348
+ ...lines.map((line, index) => createElement(
349
+ Text,
350
+ { key: index, wrap: 'truncate-end' },
351
+ line.segments.length === 0
352
+ ? ' '
353
+ : line.segments.map((segment, at) => createElement(
354
+ Text,
355
+ { key: at, ...lineStyleProps(segment.style) },
356
+ segment.text,
357
+ )),
358
+ )),
359
+ )
360
+ }
361
+
362
+ /** Codex-style panel rhythm that still participates in the row budget. */
363
+ function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
364
+ return visible ? createElement(Text, null, ' ') : undefined
365
+ }
366
+
367
+ /** One settled markdown document rendered as styled lines at the terminal width. */
368
+ function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }): ReactElement {
369
+ const columns = useStdout().stdout?.columns ?? 80
370
+ // Cached by (text, width): settled replies re-layout only when either moves.
371
+ // The indent participates in the wrap budget so padded replies never
372
+ // double-wrap inside the padded box.
373
+ const lines = useMemo(
374
+ () => renderMarkdown(displayText(text), Math.max(20, columns - 2 - indent)),
375
+ [text, columns, indent],
376
+ )
377
+ return createElement(
378
+ Box,
379
+ { flexDirection: 'column', paddingLeft: indent },
380
+ ...lines.map((line, index) => createElement(
381
+ Text,
382
+ { key: index },
383
+ line.segments.length === 0
384
+ ? ' '
385
+ : line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
386
+ )),
387
+ )
388
+ }
389
+
390
+ /**
391
+ * One expanded tool-card body for the verbose transcript (Ctrl+O): the
392
+ * presentation contract's structured cards — inline diffs, read windows,
393
+ * web sources — rendered as plain terminal rows, degradation-safe against
394
+ * replayed metadata.
395
+ */
396
+ function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
397
+ switch (detail.kind) {
398
+ case 'diff':
399
+ return createElement(
400
+ Box,
401
+ { flexDirection: 'column' },
402
+ ...detail.diffs.map((diff, index) => createElement(
403
+ Box,
404
+ { key: index, flexDirection: 'column' },
405
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
406
+ ...diff.lines.map((line, at) => createElement(
407
+ Text,
408
+ {
409
+ key: at,
410
+ color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
411
+ wrap: 'truncate-end',
412
+ },
413
+ ` ${line.mark}${displayText(line.text)}`,
414
+ )),
415
+ )),
416
+ )
417
+ case 'read':
418
+ return createElement(
419
+ Box,
420
+ { flexDirection: 'column' },
421
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
422
+ ...detail.lines.map((line, at) => createElement(
423
+ Text,
424
+ { key: at, dimColor: true, wrap: 'truncate-end' },
425
+ ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
426
+ )),
427
+ )
428
+ case 'web-search':
429
+ return createElement(
430
+ Box,
431
+ { flexDirection: 'column' },
432
+ ...detail.sources.map((source, at) => createElement(
433
+ Text,
434
+ { key: at, wrap: 'truncate-end' },
435
+ brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
436
+ createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
437
+ )),
438
+ createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
439
+ )
440
+ case 'web-fetch':
441
+ return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
442
+ case 'raw':
443
+ return createElement(
444
+ Box,
445
+ { flexDirection: 'column' },
446
+ ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
447
+ createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
448
+ )
449
+ default:
450
+ return assertNever(detail, 'tool detail kind')
451
+ }
452
+ }
453
+ /** One settled transcript row. */
454
+ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
455
+ switch (entry.kind) {
456
+ case 'user':
457
+ // Collapsed injected context reads as a dim ↳ row; only direct human
458
+ // prompts get the brand ❯ (they are different surfaces, not the same).
459
+ return entry.notice
460
+ ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
461
+ : createElement(Text, null, brand('❯ '), displayText(entry.text))
462
+ case 'assistant':
463
+ // Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
464
+ // text dim-italic expanded (Ctrl+R toggles globally). The collapsed
465
+ // row is static — an animated counter inside the text would jitter the
466
+ // line width every frame. The reply body carries the same two-column
467
+ // gutter as the composer, so reply text aligns with the input cursor
468
+ // (Codex LIVE_PREFIX alignment).
469
+ return createElement(
470
+ Box,
471
+ { flexDirection: 'column' },
472
+ entry.reasoning === ''
473
+ ? undefined
474
+ : showReasoning
475
+ ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
476
+ : createElement(Text, { dimColor: true }, ` Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
477
+ createElement(MarkdownBody, { text: entry.text, indent: 2 }),
478
+ )
479
+ case 'tool': {
480
+ // Claude-Code-style tool card: the invocation row plus a nested
481
+ // result line, so the summary reads under its call instead of inline.
482
+ const mark = entry.state === 'running'
483
+ ? createElement(Pulse)
484
+ : entry.state === 'error'
485
+ ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
486
+ : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
487
+ return createElement(
488
+ Box,
489
+ { flexDirection: 'column' },
490
+ createElement(
491
+ Text,
492
+ { wrap: verbose ? 'truncate-end' : undefined },
493
+ mark,
494
+ ' ',
495
+ brand(entry.name),
496
+ entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
497
+ ),
498
+ entry.summary === ''
499
+ ? undefined
500
+ : createElement(
501
+ Text,
502
+ { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
503
+ ` ⎿ ${displayText(entry.summary)}`,
504
+ ),
505
+ verbose && entry.detail !== undefined
506
+ ? createElement(ToolDetailBody, { detail: entry.detail })
507
+ : undefined,
508
+ )
509
+ }
510
+ case 'command': {
511
+ const mark = entry.state === 'running'
512
+ ? createElement(Pulse)
513
+ : entry.state === 'error'
514
+ ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '')
515
+ : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
516
+ return createElement(
517
+ Box,
518
+ { flexDirection: 'column' },
519
+ createElement(
520
+ Text,
521
+ { wrap: verbose ? 'truncate-end' : undefined },
522
+ mark,
523
+ ' ',
524
+ brand(`/${entry.name}`),
525
+ entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
526
+ ),
527
+ entry.summary === ''
528
+ ? undefined
529
+ : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
530
+ )
531
+ }
532
+ case 'turn-marker':
533
+ // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
534
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
535
+ case 'compaction':
536
+ // Completed compaction lifecycle: what it reclaimed, or why it failed.
537
+ return createElement(
538
+ Text,
539
+ { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
540
+ entry.ok
541
+ ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
542
+ : ` ⧉ compaction failed: ${displayText(entry.error)}`,
543
+ )
544
+ case 'retry':
545
+ // Provider-routed retry: amber while the backoff waits, dim once the
546
+ // next attempt is underway.
547
+ return createElement(
548
+ Text,
549
+ { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
550
+ ` retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
551
+ )
552
+ case 'files': {
553
+ // Turn-tail deliverables: the turn's mutated files (web turnTail chips).
554
+ const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
555
+ const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
556
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ${shown}${more}`)
557
+ }
558
+ case 'pending':
559
+ // Codex PendingSteer: queued prompts render as ordinary user rows; the
560
+ // durable user/message retires them seamlessly.
561
+ return createElement(
562
+ Text,
563
+ { wrap: verbose ? 'truncate-end' : undefined },
564
+ brand('❯ '),
565
+ displayText(entry.text),
566
+ )
567
+ case 'error':
568
+ return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
569
+ default:
570
+ return assertNever(entry, 'transcript entry kind')
571
+ }
572
+ }
573
+
574
+ /**
575
+ * The whale wordmark header in DeepSeek blue, hugging its content width.
576
+ * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
577
+ * short to show it whole (or mid-resize) the clipped pairs garble the
578
+ * screen — below the height floor the header collapses to a single-line
579
+ * wordmark that stays correct at any size.
580
+ */
581
+ function Header({ resumed }: { resumed: boolean }): ReactElement {
582
+ const rows = useStdout().stdout?.rows ?? 40
583
+ const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
584
+ if (rows < 20) {
585
+ return createElement(
586
+ Box,
587
+ { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
588
+ createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
589
+ createElement(Text, { dimColor: true }, hint),
590
+ )
591
+ }
592
+ return createElement(
593
+ Box,
594
+ // alignSelf shrinks the border to the whale-plus-wordmark content instead
595
+ // of stretching across the terminal and stranding empty space on the right
596
+ // (the compact-banner treatment the Claude Code welcome uses).
597
+ { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
598
+ createElement(
599
+ Box,
600
+ { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
601
+ ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
602
+ ),
603
+ createElement(
604
+ Box,
605
+ { flexDirection: 'column', justifyContent: 'center' },
606
+ createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
607
+ createElement(Text, { dimColor: true }, hint),
608
+ ),
609
+ )
610
+ }
611
+
612
+ /** Todo status glyph: web TodoPanel's three-state marker. */
613
+ function todoMark(status: TodoItem['status']): string {
614
+ return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
615
+ }
616
+
617
+ /** One-row todo summary: task count cannot grow the live Ink tree. */
618
+ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
619
+ if (todos.length === 0) return undefined
620
+ const completed = todos.filter(todo => todo.status === 'completed').length
621
+ const inProgress = todos.filter(todo => todo.status === 'in_progress').length
622
+ const pending = todos.length - completed - inProgress
623
+ const current = todos.find(todo => todo.status === 'in_progress')
624
+ return createElement(
625
+ Box,
626
+ { paddingX: 1 },
627
+ createElement(
628
+ Text,
629
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
630
+ `todos ${completed}/${todos.length}`,
631
+ createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
632
+ current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
633
+ ),
634
+ )
635
+ }
636
+
637
+ /**
638
+ * Ink props for one status tone: the Codex status-line accent mapping over
639
+ * the DeepSeek palette, all blue by design — the status bar speaks only in
640
+ * degrees of blue (deep accent, primary figures, bright model identity, sky
641
+ * paths and done states), with amber/red reserved for warnings and errors.
642
+ */
643
+ function statusToneProps(tone: StatusTone): {
644
+ color: string | undefined
645
+ bold: boolean | undefined
646
+ dimColor: boolean | undefined
647
+ } {
648
+ switch (tone) {
649
+ case 'model':
650
+ return { color: inkColor(TUI_RGB.brandBright), bold: true, dimColor: undefined }
651
+ case 'live':
652
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, dimColor: undefined }
653
+ case 'path':
654
+ return { color: inkColor(TUI_RGB.code), bold: undefined, dimColor: undefined }
655
+ case 'branch':
656
+ return { color: inkColor(TUI_RGB.text), bold: undefined, dimColor: undefined }
657
+ case 'value':
658
+ return { color: inkColor(TUI_RGB.brand), bold: undefined, dimColor: undefined }
659
+ case 'label':
660
+ case 'meta':
661
+ return { color: undefined, bold: undefined, dimColor: true }
662
+ case 'accent':
663
+ return { color: inkColor(TUI_RGB.brandDeep), bold: undefined, dimColor: undefined }
664
+ case 'success':
665
+ return { color: inkColor(TUI_RGB.code), bold: true, dimColor: undefined }
666
+ case 'warn':
667
+ return { color: inkColor(TUI_RGB.warn), bold: true, dimColor: undefined }
668
+ case 'error':
669
+ return { color: inkColor(TUI_RGB.error), bold: true, dimColor: undefined }
670
+ default:
671
+ return { color: undefined, bold: undefined, dimColor: true }
672
+ }
673
+ }
674
+
675
+ /**
676
+ * The footer status line: two stacked physical rows in every mode. Row 1
677
+ * carries Claude-Code-style identity facts and session figures from the left
678
+ * with the Codex-style permission badge the autonomous-selection anchor
679
+ * with its shift+tab cycle hint — pinned to the right edge. Row 2 (mode,
680
+ * context progress bar, cache, duration figures) renders only while it has
681
+ * content, so the footer degrades to a single row on narrow terminals. Both
682
+ * layouts arrive pre-measured from the pure reducer, so Ink only paints;
683
+ * truncation degrades groups, it never wraps a row.
684
+ */
685
+ function StatusLine({ facts, stats, busy, columns, items }: {
686
+ facts: StatusFacts
687
+ stats: Parameters<typeof layoutStatusBar>[1]
688
+ busy: boolean
689
+ columns: number
690
+ items: readonly string[]
691
+ }): ReactElement {
692
+ const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
693
+ const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
694
+ const leftParts: ReactElement[] = []
695
+ row.left.forEach((group, groupIndex) => {
696
+ if (groupIndex > 0) {
697
+ leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, dimColor: true }, STATUS_GROUP_SEPARATOR))
698
+ }
699
+ group.spans.forEach((span, spanIndex) => {
700
+ leftParts.push(createElement(
701
+ Text,
702
+ { key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone) },
703
+ span.text,
704
+ ))
705
+ })
706
+ })
707
+ const rightParts: ReactElement[] = []
708
+ row.right.forEach((span, index) => {
709
+ if (index > 0) {
710
+ rightParts.push(createElement(Text, { key: key + 'rs' + index, dimColor: true }, STATUS_ITEM_SEPARATOR))
711
+ }
712
+ rightParts.push(createElement(
713
+ Text,
714
+ { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone) },
715
+ span.text,
716
+ ))
717
+ })
718
+ if (row.hint) {
719
+ rightParts.push(createElement(Text, { key: key + 'hint', dimColor: true }, STATUS_CYCLE_HINT))
720
+ }
721
+ // Each row already fits the column budget; truncate-end stays as the
722
+ // terminal-measurement backstop so a drifting cell count clips instead
723
+ // of wrapping.
724
+ return createElement(
725
+ Box,
726
+ // Match the prompt text inside the bordered composer: one border column
727
+ // plus one padding column. Keeping these rows margin-free also makes
728
+ // the composer and status a fixed bottom unit in every interface.
729
+ { paddingLeft: 2, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
730
+ createElement(Text, { wrap: 'truncate-end' }, ...leftParts),
731
+ rightParts.length > 0 ? createElement(Text, { wrap: 'truncate-end' }, ...rightParts) : undefined,
732
+ )
733
+ }
734
+ const row2Present = layout.row2.left.length > 0
735
+ return createElement(
736
+ Box,
737
+ { flexDirection: 'column' },
738
+ renderRow(layout.row1, 's1'),
739
+ row2Present ? renderRow(layout.row2, 's2') : undefined,
740
+ )
741
+ }
742
+
743
+ /**
744
+ * One fixed-height local feedback row. Errors remain visible while a slash
745
+ * subpage is open, but arbitrary exception text can never add physical rows
746
+ * above the composer.
747
+ */
748
+ function NoticeLine({ text, tone, columns }: {
749
+ text: string
750
+ tone: NoticeTone
751
+ columns: number
752
+ }): ReactElement {
753
+ const color = tone === 'error'
754
+ ? TUI_RGB.error
755
+ : tone === 'warning'
756
+ ? TUI_RGB.warn
757
+ : TUI_RGB.brandBright
758
+ const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•'
759
+ return createElement(
760
+ Box,
761
+ { paddingLeft: 2 },
762
+ createElement(
763
+ Text,
764
+ { color: inkColor(color), wrap: 'truncate-end' },
765
+ truncateColumns(`${mark} ${singleLineText(text)}`, Math.max(1, columns - 2)),
766
+ ),
767
+ )
768
+ }
769
+
770
+ /** The y/n approval bar rendered while an approval ask is pending. */
771
+ function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
772
+ const stdout = useStdout().stdout
773
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
774
+ const [scroll, setScroll] = useState(0)
775
+ const pending = snapshot.pending
776
+ const active = !locked && snapshot.pending !== undefined && !snapshot.answered
777
+ const content = useMemo<readonly StyledLine[]>(() => pending === undefined
778
+ ? []
779
+ : [
780
+ ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
781
+ ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
782
+ ], [pending, viewport.contentColumns])
783
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
784
+
785
+ useEffect(() => {
786
+ setScroll(0)
787
+ }, [pending])
788
+
789
+ useEffect(() => {
790
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
791
+ }, [visibleScroll, scroll])
792
+
793
+ useInput((input, key) => {
794
+ if (snapshot.pending === undefined) return
795
+ if (key.upArrow) {
796
+ setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
797
+ return
798
+ }
799
+ if (key.downArrow) {
800
+ setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
801
+ return
802
+ }
803
+ if (key.pageUp) {
804
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
805
+ return
806
+ }
807
+ if (key.pageDown) {
808
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
809
+ return
810
+ }
811
+ if (snapshot.answered) return
812
+ if (input === 'y' || input === 'Y') {
813
+ snapshot.pending.answer('allowed-once')
814
+ return
815
+ }
816
+ if (input === 'n' || input === 'N') {
817
+ snapshot.pending.answer('rejected')
818
+ }
819
+ }, { isActive: active })
820
+ if (snapshot.pending === undefined) return undefined
821
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
822
+ if (viewport.compact) {
823
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
824
+ }
825
+ const { answered } = snapshot
826
+ return createElement(
827
+ Box,
828
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
829
+ 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)),
830
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
831
+ createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
832
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
833
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
834
+ ? 'submitted…'
835
+ : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
836
+ )
837
+ }
838
+
839
+ /**
840
+ * The ask_user_question bar: walks one request question by question,
841
+ * renders the option menu (Claude-Code style: arrows move, space toggles a
842
+ * multi-select, enter submits, `c` opens the custom-answer box, Esc
843
+ * interrupts the question as aborted). Plan reviews arrive through the same
844
+ * service with a `plan-review` intent — the approve option gets a ✓ mark,
845
+ * the answer encoding stays identical.
846
+ */
847
+ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapshot: QuestionSnapshot; locked: boolean }): ReactElement | undefined {
848
+ const stdout = useStdout().stdout
849
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
850
+ const pending = snapshot.pending
851
+ const request = pending?.request
852
+ const [index, setIndex] = useState(0)
853
+ const [cursor, setCursor] = useState(0)
854
+ const [selected, setSelected] = useState<readonly number[]>([])
855
+ const [mode, setMode] = useState<'options' | 'custom'>('options')
856
+ const [custom, setCustom] = useState('')
857
+ const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
858
+ const [submitted, setSubmitted] = useState(false)
859
+ const [scroll, setScroll] = useState(0)
860
+ const [manualScroll, setManualScroll] = useState(false)
861
+ const [followCustomTail, setFollowCustomTail] = useState(false)
862
+
863
+ // A new request resets the walk; questions without options start in the
864
+ // custom-answer box (a free-form question). Depend on the request rather
865
+ // than its wrapper snapshot: external stores may refresh that wrapper while
866
+ // a question is still active, and a reset must never become a render loop.
867
+ useEffect(() => {
868
+ const first = request?.questions[0]
869
+ const initialMode = first?.options === undefined || first.options.length === 0 ? 'custom' : 'options'
870
+ setIndex(current => current === 0 ? current : 0)
871
+ setCursor(current => current === 0 ? current : 0)
872
+ setSelected(current => current.length === 0 ? current : [])
873
+ setMode(current => current === initialMode ? current : initialMode)
874
+ setCustom(current => current === '' ? current : '')
875
+ setAnswers(current => current.length === 0 ? current : [])
876
+ setSubmitted(current => current ? false : current)
877
+ setScroll(current => current === 0 ? current : 0)
878
+ setManualScroll(current => current ? false : current)
879
+ setFollowCustomTail(current => current === (initialMode === 'custom') ? current : initialMode === 'custom')
880
+ }, [request])
881
+
882
+ const question = pending?.request.questions[index]
883
+ const options = question?.options ?? []
884
+ const isPlan = question?.intent?.kind === 'plan-review'
885
+ const isMulti = question?.multiSelect === true
886
+ const active = !locked && pending !== undefined && question !== undefined && !submitted
887
+ const rendered = useMemo(() => {
888
+ if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
889
+ const lines: StyledLine[] = []
890
+ const optionRows: number[] = []
891
+ if (question.header !== undefined) {
892
+ lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
893
+ }
894
+ lines.push(...textLines(question.question, viewport.contentColumns))
895
+ if (question.detail !== undefined) {
896
+ lines.push(...(isPlan
897
+ ? markdownLines(question.detail, viewport.contentColumns)
898
+ : textLines(question.detail, viewport.contentColumns, 'dim')))
899
+ }
900
+ if (submitted) {
901
+ lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
902
+ } else if (mode === 'custom' || options.length === 0) {
903
+ lines.push(...styledLines([
904
+ lineSegment(' custom: ', 'brand'),
905
+ lineSegment(custom, 'plain'),
906
+ lineSegment('▌', 'brand'),
907
+ ], viewport.contentColumns))
908
+ } else {
909
+ options.forEach((option, at) => {
910
+ optionRows.push(lines.length)
911
+ const chosen = isMulti && selected.includes(at)
912
+ const approve = isPlan && question.intent?.approve === option.label
913
+ const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
914
+ const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
915
+ lines.push(...styledLines([
916
+ lineSegment(mark, style),
917
+ lineSegment(option.label, style),
918
+ lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
919
+ ], viewport.contentColumns))
920
+ })
921
+ }
922
+ return { lines, optionRows }
923
+ }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
924
+ // Keeping a focused option visible is derived from the current render. It
925
+ // deliberately does not write state from an effect: keyboard selection
926
+ // then has one update path, rather than a cursor update repeatedly causing
927
+ // a post-render scroll update (and, under rapid input, an update-depth
928
+ // loop). Page scrolling explicitly takes ownership until focus moves again.
929
+ const focusedRow = rendered.optionRows[cursor] ?? 0
930
+ const automaticScroll = mode === 'options' && options.length > 0 && !manualScroll
931
+ ? revealRow(scroll, focusedRow, rendered.lines.length, viewport.bodyRows)
932
+ : (mode === 'custom' || options.length === 0) && followCustomTail
933
+ ? Math.max(0, rendered.lines.length - viewport.bodyRows)
934
+ : scroll
935
+ const visibleScroll = clampScroll(automaticScroll, rendered.lines.length, viewport.bodyRows)
936
+
937
+ const commit = (answer: AskUserQuestionAnswerItem): void => {
938
+ if (pending === undefined) return
939
+ const next = [...answers, answer]
940
+ const total = pending.request.questions.length
941
+ if (index + 1 >= total) {
942
+ setSubmitted(true)
943
+ store.submit(pending, { answers: next })
944
+ return
945
+ }
946
+ setAnswers(next)
947
+ const nextIndex = index + 1
948
+ const nextQuestion = pending.request.questions[nextIndex]
949
+ setIndex(nextIndex)
950
+ setCursor(0)
951
+ setSelected([])
952
+ setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
953
+ setCustom('')
954
+ setScroll(0)
955
+ setManualScroll(false)
956
+ setFollowCustomTail(nextQuestion?.options === undefined || nextQuestion.options.length === 0)
957
+ }
958
+
959
+ const commitOption = (): void => {
960
+ if (pending === undefined || question === undefined) return
961
+ if (isMulti) {
962
+ const labels = selected
963
+ .map(at => options[at]?.label)
964
+ .filter((label): label is string => label !== undefined)
965
+ const customText = custom.trim()
966
+ commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
967
+ return
968
+ }
969
+ const option = options[cursor]
970
+ if (option === undefined) return
971
+ commit({ id: question.id, selected: [option.label] })
972
+ }
973
+
974
+ /**
975
+ * A question with choices has two local focus surfaces, just like Codex:
976
+ * the choice list and the optional custom-answer editor. Returning to the
977
+ * list keeps the user's current choice (and multi-select state), but drops
978
+ * the transient custom draft so a second Escape can cancel the question.
979
+ */
980
+ const returnToOptions = (): void => {
981
+ if (options.length === 0) return
982
+ setMode('options')
983
+ setCustom('')
984
+ setScroll(0)
985
+ setManualScroll(false)
986
+ setFollowCustomTail(false)
987
+ }
988
+
989
+ useStableInput((input, key) => {
990
+ if (pending === undefined || question === undefined || submitted) return
991
+ if (key.escape) {
992
+ if (mode === 'custom' && options.length > 0) {
993
+ returnToOptions()
994
+ return
995
+ }
996
+ store.cancel(pending)
997
+ return
998
+ }
999
+ if (key.pageUp) {
1000
+ setManualScroll(true)
1001
+ setFollowCustomTail(false)
1002
+ setScroll(moveScroll(visibleScroll, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
1003
+ return
1004
+ }
1005
+ if (key.pageDown) {
1006
+ setManualScroll(true)
1007
+ setFollowCustomTail(false)
1008
+ setScroll(moveScroll(visibleScroll, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
1009
+ return
1010
+ }
1011
+ if (mode === 'custom' || options.length === 0) {
1012
+ if (key.tab && options.length > 0) {
1013
+ returnToOptions()
1014
+ return
1015
+ }
1016
+ if (key.upArrow) {
1017
+ setFollowCustomTail(false)
1018
+ setScroll(moveScroll(visibleScroll, -1, rendered.lines.length, viewport.bodyRows))
1019
+ return
1020
+ }
1021
+ if (key.downArrow) {
1022
+ setFollowCustomTail(false)
1023
+ setScroll(moveScroll(visibleScroll, 1, rendered.lines.length, viewport.bodyRows))
1024
+ return
1025
+ }
1026
+ if (key.return) {
1027
+ if (custom.trim() === '' && options.length > 0) {
1028
+ commitOption()
1029
+ return
1030
+ }
1031
+ commit({
1032
+ id: question.id,
1033
+ selected: isMulti
1034
+ ? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
1035
+ : [],
1036
+ ...(custom.trim() === '' ? {} : { custom: custom.trim() }),
1037
+ })
1038
+ return
1039
+ }
1040
+ if (key.backspace || key.delete) {
1041
+ if (custom === '' && options.length > 0) {
1042
+ returnToOptions()
1043
+ return
1044
+ }
1045
+ setCustom(current => current.slice(0, -1))
1046
+ return
1047
+ }
1048
+ if (input !== '' && !key.ctrl && !key.meta) {
1049
+ setCustom(current => current + input)
1050
+ }
1051
+ return
1052
+ }
1053
+ if (key.upArrow) {
1054
+ setManualScroll(false)
1055
+ setCursor(current => (current + options.length - 1) % options.length)
1056
+ return
1057
+ }
1058
+ if (key.downArrow) {
1059
+ setManualScroll(false)
1060
+ setCursor(current => (current + 1) % options.length)
1061
+ return
1062
+ }
1063
+ if (key.return) {
1064
+ commitOption()
1065
+ return
1066
+ }
1067
+ if (key.tab || input === 'c' || input === 'C') {
1068
+ setMode('custom')
1069
+ setManualScroll(false)
1070
+ setFollowCustomTail(true)
1071
+ return
1072
+ }
1073
+ if (input === ' ' && isMulti) {
1074
+ setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
1075
+ }
1076
+ }, active)
1077
+
1078
+ if (pending === undefined || question === undefined) return undefined
1079
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1080
+ if (viewport.compact) {
1081
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
1082
+ }
1083
+ const footer = submitted
1084
+ ? 'submitted…'
1085
+ : mode === 'custom'
1086
+ ? options.length === 0
1087
+ ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1088
+ : '↑↓/pgup/pgdn scroll · type answer · enter submit · tab/esc or empty backspace: options'
1089
+ : options.length === 0
1090
+ ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1091
+ : isMulti
1092
+ ? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
1093
+ : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
1094
+ return createElement(
1095
+ Box,
1096
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
1097
+ createElement(
1098
+ Text,
1099
+ { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
1100
+ 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),
1101
+ ),
1102
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1103
+ createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
1104
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1105
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1106
+ )
1107
+ }
1108
+
1109
+ /** The /model panel: a scrolling list over the advisory model directory. */
1110
+ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
1111
+ directory: ModelDirectory | undefined
1112
+ error: string | undefined
1113
+ onSelect(row: ModelRow): void
1114
+ onRetry(): void
1115
+ onClose(): void
1116
+ }): ReactElement {
1117
+ const [cursor, setCursor] = useState(0)
1118
+ const stdout = useStdout().stdout
1119
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1120
+ const rows = directory?.rows ?? []
1121
+
1122
+ useEffect(() => {
1123
+ if (rows.length === 0) {
1124
+ if (cursor !== 0) setCursor(0)
1125
+ return
1126
+ }
1127
+ if (cursor >= rows.length) setCursor(rows.length - 1)
1128
+ }, [rows.length, cursor])
1129
+
1130
+ useInput((input, key) => {
1131
+ if (key.escape || input === 'q') {
1132
+ onClose()
1133
+ return
1134
+ }
1135
+ if (input === 'r') {
1136
+ onRetry()
1137
+ return
1138
+ }
1139
+ if (rows.length === 0) return
1140
+ if (key.upArrow) {
1141
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
1142
+ return
1143
+ }
1144
+ if (key.downArrow) {
1145
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
1146
+ return
1147
+ }
1148
+ if (key.pageUp) {
1149
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
1150
+ return
1151
+ }
1152
+ if (key.pageDown) {
1153
+ setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1154
+ return
1155
+ }
1156
+ if (input === 'g') {
1157
+ setCursor(0)
1158
+ return
1159
+ }
1160
+ if (input === 'G') {
1161
+ setCursor(rows.length - 1)
1162
+ return
1163
+ }
1164
+ if (key.return && rows[cursor] !== undefined) {
1165
+ onSelect(rows[cursor])
1166
+ }
1167
+ })
1168
+
1169
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1170
+ if (viewport.compact) {
1171
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
1172
+ }
1173
+
1174
+ const stateRows: ReactElement[] = directory === undefined && error === undefined
1175
+ ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ' loading models…')]
1176
+ : error !== undefined
1177
+ ? [createElement(
1178
+ Text,
1179
+ { key: 'error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1180
+ truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
1181
+ )]
1182
+ : [
1183
+ ...(directory?.failures.length === 0
1184
+ ? []
1185
+ : [createElement(
1186
+ Text,
1187
+ { key: 'failures', color: inkColor(TUI_RGB.warn), wrap: 'truncate-end' },
1188
+ truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
1189
+ )]),
1190
+ ...(rows.length === 0
1191
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
1192
+ : []),
1193
+ ]
1194
+ // Measurement and rendering share the same physical-row budget: state
1195
+ // messages consume body rows before selectable entries, as in Codex's
1196
+ // list-selection views.
1197
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length)
1198
+ const first = selectionWindow(cursor, rows.length, rowBudget)
1199
+ const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1200
+ return createElement(
1201
+ Box,
1202
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1203
+ 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)),
1204
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1205
+ ...stateRows,
1206
+ ...visible.map((row) => {
1207
+ const index = rows.indexOf(row)
1208
+ const label = displayText(`${row.providerName} · ${row.modelName}`)
1209
+ return createElement(
1210
+ Text,
1211
+ {
1212
+ key: `${row.provider}/${row.model}`,
1213
+ color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1214
+ wrap: 'truncate-end',
1215
+ },
1216
+ truncateColumns(`${index === cursor ? '' : ' '}${label}`, viewport.contentColumns),
1217
+ )
1218
+ }),
1219
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1220
+ 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))),
1221
+ )
1222
+ }
1223
+
1224
+ /**
1225
+ * The /help overlay: one scrolling card with the keyboard map, the TUI-local
1226
+ * commands, the live registry commands, and the user-invocable skills — the
1227
+ * real command surface, replacing the one-line notice.
1228
+ */
1229
+ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1230
+ descriptors: readonly CommandDescriptor[]
1231
+ skills: readonly SkillRow[]
1232
+ commandError: string | undefined
1233
+ skillError: string | undefined
1234
+ onClose(): void
1235
+ }): ReactElement {
1236
+ const stdout = useStdout().stdout
1237
+ const columns = stdout?.columns ?? 80
1238
+ const viewport = panelViewport(columns, stdout?.rows ?? 30)
1239
+ const [scroll, setScroll] = useState(0)
1240
+ const nameWidth = Math.min(18, Math.max(1, viewport.contentColumns - 2))
1241
+ const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
1242
+ const row = (label: string, description: string): ReactElement => createElement(
1243
+ Text,
1244
+ { dimColor: true, wrap: 'truncate-end' },
1245
+ ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
1246
+ )
1247
+ const content: ReactElement[] = [
1248
+ createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
1249
+ createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
1250
+ createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
1251
+ createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
1252
+ createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
1253
+ createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
1254
+ 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'),
1255
+ createElement(Text, { key: 'commands-gap' }, ' '),
1256
+ createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
1257
+ ...(commandError === undefined
1258
+ ? []
1259
+ : [createElement(
1260
+ Text,
1261
+ { key: 'commands-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1262
+ truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
1263
+ )]),
1264
+ createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
1265
+ createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1266
+ createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
1267
+ createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1268
+ createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1269
+ createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
1270
+ createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
1271
+ createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
1272
+ createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1273
+ createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
1274
+ createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
1275
+ createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
1276
+ ...descriptors.map(descriptor => createElement(
1277
+ Text,
1278
+ { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
1279
+ ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
1280
+ )),
1281
+ ...(skills.length === 0 && skillError === undefined
1282
+ ? []
1283
+ : [
1284
+ createElement(Text, { key: 'skills-gap' }, ' '),
1285
+ createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills'),
1286
+ ]),
1287
+ ...(skillError === undefined
1288
+ ? []
1289
+ : [createElement(
1290
+ Text,
1291
+ { key: 'skills-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1292
+ truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns),
1293
+ )]),
1294
+ ...skills.map(skill => createElement(
1295
+ Text,
1296
+ { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
1297
+ ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
1298
+ )),
1299
+ ]
1300
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
1301
+ const scrollBy = (delta: number): void => {
1302
+ setScroll(current => moveScroll(current, delta, content.length, viewport.bodyRows))
1303
+ }
1304
+
1305
+ useEffect(() => {
1306
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
1307
+ }, [visibleScroll, scroll])
1308
+
1309
+ useInput((input, key) => {
1310
+ if (key.escape || input === 'q') {
1311
+ onClose()
1312
+ return
1313
+ }
1314
+ if (key.upArrow) scrollBy(-1)
1315
+ else if (key.downArrow) scrollBy(1)
1316
+ else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
1317
+ else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
1318
+ else if (input === 'g') setScroll(0)
1319
+ else if (input === 'G') setScroll(Math.max(0, content.length - viewport.bodyRows))
1320
+ })
1321
+
1322
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1323
+ if (viewport.compact) {
1324
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
1325
+ }
1326
+
1327
+ return createElement(
1328
+ Box,
1329
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1330
+ 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)),
1331
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1332
+ ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1333
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1334
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
1335
+ )
1336
+ }
1337
+
1338
+ /** Collapse arbitrary metadata to one terminal row before verbose rendering. */
1339
+ function verboseLine(text: string, columns: number): string {
1340
+ return truncateColumns(displayText(text).replace(/\n/gu, ' ').replace(/\t/gu, ' '), Math.max(1, columns))
1341
+ }
1342
+
1343
+ /** One-row editor window keeping the logical cursor visible in long drafts. */
1344
+ function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
1345
+ const width = Math.max(1, columns)
1346
+ const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
1347
+ const caretSource = value.slice(cursor, cursor + 1)
1348
+ const caret = caretSource === '' ? ' ' : normalize(caretSource)
1349
+ const remaining = Math.max(0, width - visibleColumns(caret))
1350
+ const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
1351
+ const beforeBudget = Math.max(0, remaining - afterBudget)
1352
+ const before = beforeBudget === 0
1353
+ ? ''
1354
+ : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
1355
+ const after = afterBudget === 0
1356
+ ? ''
1357
+ : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
1358
+ return { before, caret, after }
1359
+ }
1360
+
1361
+ /**
1362
+ * The Ctrl+O transcript inspector: one selected durable entry at a time,
1363
+ * with independent history selection and content scrolling. The complete
1364
+ * retained entry is converted to physical rows, but only one viewport slice
1365
+ * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
1366
+ */
1367
+ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
1368
+ const stdout = useStdout().stdout
1369
+ const columns = stdout?.columns ?? 80
1370
+ const rows = stdout?.rows ?? 30
1371
+ const viewport = inspectorViewport(columns, rows)
1372
+ const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
1373
+ const [scroll, setScroll] = useState(0)
1374
+ const savedScroll = useRef(new Map<number, number>())
1375
+ const cursorRef = useRef(cursor)
1376
+ const previousLength = useRef(entries.length)
1377
+ const entry = entries[cursor]
1378
+ const allLines = useMemo(
1379
+ () => entry === undefined ? [] : transcriptEntryLines(entry, viewport.contentColumns),
1380
+ [entry, viewport.contentColumns],
1381
+ )
1382
+ const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
1383
+
1384
+ useEffect(() => {
1385
+ cursorRef.current = cursor
1386
+ }, [cursor])
1387
+
1388
+ useEffect(() => {
1389
+ const current = cursorRef.current
1390
+ const next = followInspectorCursor(current, previousLength.current, entries.length)
1391
+ if (next !== current) {
1392
+ savedScroll.current.set(current, visibleScroll)
1393
+ setCursor(next)
1394
+ setScroll(savedScroll.current.get(next) ?? 0)
1395
+ }
1396
+ previousLength.current = entries.length
1397
+ }, [entries.length])
1398
+
1399
+ useEffect(() => {
1400
+ const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows)
1401
+ if (clamped !== scroll) setScroll(clamped)
1402
+ savedScroll.current.set(cursor, clamped)
1403
+ }, [cursor, scroll, allLines.length, viewport.bodyRows])
1404
+
1405
+ const selectEntry = (next: number): void => {
1406
+ if (entries.length === 0) return
1407
+ const selected = Math.max(0, Math.min(entries.length - 1, next))
1408
+ if (selected === cursor) return
1409
+ savedScroll.current.set(cursor, visibleScroll)
1410
+ setCursor(selected)
1411
+ setScroll(savedScroll.current.get(selected) ?? 0)
1412
+ }
1413
+
1414
+ const scrollBy = (delta: number): void => {
1415
+ setScroll(current => moveScroll(current, delta, allLines.length, viewport.bodyRows))
1416
+ }
1417
+
1418
+ useInput((input, key) => {
1419
+ if (key.escape || input === 'q' || (key.ctrl && input === 'o')) {
1420
+ onClose()
1421
+ return
1422
+ }
1423
+ if (entries.length === 0) return
1424
+ if (key.leftArrow) {
1425
+ selectEntry(cursor - 1)
1426
+ return
1427
+ }
1428
+ if (key.rightArrow) {
1429
+ selectEntry(cursor + 1)
1430
+ return
1431
+ }
1432
+ if (key.upArrow) {
1433
+ scrollBy(-1)
1434
+ return
1435
+ }
1436
+ if (key.downArrow) {
1437
+ scrollBy(1)
1438
+ return
1439
+ }
1440
+ if (key.pageUp) {
1441
+ scrollBy(-Math.max(1, viewport.bodyRows - 1))
1442
+ return
1443
+ }
1444
+ if (key.pageDown) {
1445
+ scrollBy(Math.max(1, viewport.bodyRows - 1))
1446
+ return
1447
+ }
1448
+ if (input === 'g') {
1449
+ setScroll(0)
1450
+ return
1451
+ }
1452
+ if (input === 'G') {
1453
+ setScroll(Math.max(0, allLines.length - viewport.bodyRows))
1454
+ }
1455
+ })
1456
+
1457
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1458
+ if (viewport.compact) {
1459
+ return createElement(
1460
+ Text,
1461
+ { wrap: 'truncate-end' },
1462
+ truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
1463
+ )
1464
+ }
1465
+
1466
+ const title = entries.length === 0
1467
+ ? 'history details · empty'
1468
+ : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
1469
+ const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
1470
+ return createElement(
1471
+ Box,
1472
+ {
1473
+ flexDirection: 'column',
1474
+ width: viewport.outerColumns,
1475
+ paddingX: 1,
1476
+ borderStyle: 'round',
1477
+ borderColor: inkColor(TUI_RGB.brand),
1478
+ },
1479
+ createElement(
1480
+ Text,
1481
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1482
+ truncateColumns(title, viewport.contentColumns),
1483
+ ),
1484
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1485
+ createElement(
1486
+ Box,
1487
+ { flexDirection: 'column' },
1488
+ entry === undefined
1489
+ ? createElement(Text, { dimColor: true }, ' no durable entries yet')
1490
+ : createElement(StyledRows, { lines: visible }),
1491
+ ),
1492
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1493
+ createElement(
1494
+ Text,
1495
+ { dimColor: true, wrap: 'truncate-end' },
1496
+ dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
1497
+ ),
1498
+ )
1499
+ }
1500
+
1501
+ /** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
1502
+ const MemoVerbosePanel = memo(VerbosePanel)
1503
+
1504
+ /** Stable append-only boundary: modal updates must never revisit Static rows. */
1505
+ function staticRow(item: unknown): ReactElement {
1506
+ return item as ReactElement
1507
+ }
1508
+
1509
+ function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
1510
+ return createElement(Static, { items, children: staticRow })
1511
+ }
1512
+
1513
+ const MemoStaticTranscript = memo(StaticTranscript)
1514
+
1515
+ /** One completion candidate row. */
1516
+ interface CompletionCandidate {
1517
+ /** Insertion text for the command name (with leading slash). */
1518
+ label: string
1519
+ /** Human-readable description shown beside the label. */
1520
+ description: string
1521
+ /** Candidate origin; skills land the same literal text but route through the prompt. */
1522
+ origin: 'command' | 'skill' | 'mention' | 'path'
1523
+ }
1524
+
1525
+ /**
1526
+ * Resolve completion candidates for the current input: TUI-local commands,
1527
+ * the live registry descriptors, and user-invocable skills, filtered by the
1528
+ * typed prefix. Command names win collisions (the dispatch tries the
1529
+ * registry first and only then falls through to the skill gesture).
1530
+ */
1531
+ function completionCandidates(
1532
+ value: string,
1533
+ descriptors: readonly CommandDescriptor[],
1534
+ skills: readonly SkillRow[],
1535
+ ): readonly CompletionCandidate[] {
1536
+ if (!value.startsWith('/')) return []
1537
+ const prefix = value.slice(1).split(' ')[0] ?? ''
1538
+ const local: CompletionCandidate[] = [
1539
+ { label: '/help', description: 'show commands', origin: 'command' },
1540
+ { label: '/model', description: 'switch the model', origin: 'command' },
1541
+ { label: '/mode', description: 'select the agent preset', origin: 'command' },
1542
+ { label: '/new', description: 'start a fresh session', origin: 'command' },
1543
+ { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
1544
+ { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
1545
+ { label: '/statusline', description: 'customize the status line', origin: 'command' },
1546
+ { label: '/history', description: 'search and recall past prompts', origin: 'command' },
1547
+ { label: '/clear', description: 'clear the screen', origin: 'command' },
1548
+ { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1549
+ { label: '/title', description: 'rename this session', origin: 'command' },
1550
+ { label: '/quit', description: 'exit', origin: 'command' },
1551
+ ]
1552
+ // Local commands shadow registry names (e.g. the plugin-registered
1553
+ // /permission is served by the registry itself, never duplicated here),
1554
+ // so collisions cannot render two rows with the same key.
1555
+ const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1556
+ const registry = descriptors
1557
+ .filter(descriptor => !localNames.has(descriptor.name))
1558
+ .map((descriptor): CompletionCandidate => ({
1559
+ label: `/${descriptor.name}`,
1560
+ description: descriptor.description,
1561
+ origin: 'command',
1562
+ }))
1563
+ const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
1564
+ const skillRows = skills
1565
+ .filter(skill => !taken.has(skill.name))
1566
+ .map((skill): CompletionCandidate => ({
1567
+ label: `/${skill.name}`,
1568
+ description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
1569
+ origin: 'skill',
1570
+ }))
1571
+ const all = [...local, ...registry, ...skillRows]
1572
+ // The menu itself caps its visible rows behind a scroll window, so the
1573
+ // candidate cap only bounds how many entries cycling can reach; 11 keeps
1574
+ // every TUI-local command reachable with an empty prefix.
1575
+ if (prefix === '') return all.slice(0, 11)
1576
+ return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 11)
1577
+ }
1578
+
1579
+ /**
1580
+ * The completion menu, rendered inside the composer's subtree directly above
1581
+ * the framed box — attached the way Claude-Code anchors its dropdown. Opening
1582
+ * it grows the stack downward: the composer stays the last element on screen
1583
+ * and everything above (the flushed static transcript, the status line) never
1584
+ * moves. Props-only (no lifted state): the menu is a pure view of the input
1585
+ * editor's live completion state, so no cross-component effect ever resyncs
1586
+ * it (a state lift here previously deadlocked the menu after a resize).
1587
+ */
1588
+ function CompletionMenu({ active, mention, index, rows }: {
1589
+ active: boolean
1590
+ mention: boolean
1591
+ index: number
1592
+ rows: readonly CompletionCandidate[]
1593
+ }): ReactElement | undefined {
1594
+ // Hook order is unconditional: `active` toggling must not change the hook
1595
+ // count (the early return used to sit above useStdout).
1596
+ const stdout = useStdout().stdout
1597
+ const columns = stdout?.columns ?? 80
1598
+ const terminalRows = stdout?.rows ?? 30
1599
+ if (!active) return undefined
1600
+ const contentColumns = Math.max(1, columns - 4)
1601
+ const nameWidth = Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
1602
+ const descBudget = Math.max(0, contentColumns - nameWidth - 2)
1603
+ const showFooter = terminalRows >= 12
1604
+ const spacious = terminalRows >= 14
1605
+ const verticalPadding = spacious ? 1 : 0
1606
+ const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10) - verticalPadding * 2))
1607
+ const selected = rows.length === 0 ? 0 : index % rows.length
1608
+ const first = selectionWindow(selected, rows.length, limit)
1609
+ const visible = rows.slice(first, first + limit)
1610
+ return createElement(
1611
+ Box,
1612
+ { flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
1613
+ ...(rows.length === 0
1614
+ ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
1615
+ : visible.map((candidate, at) => {
1616
+ const absolute = first + at
1617
+ return createElement(
1618
+ Text,
1619
+ {
1620
+ key: candidate.label,
1621
+ color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1622
+ wrap: 'truncate-end',
1623
+ },
1624
+ `${absolute === selected ? '' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1625
+ )
1626
+ })),
1627
+ showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
1628
+ )
1629
+ }
1630
+
1631
+ /**
1632
+ * The prompt box: TUI-local slash commands handled locally, other lines
1633
+ * dispatched; input editing keeps a cursor with history and completion.
1634
+ * While a modal (approval / question / model panel) owns the keys, the
1635
+ * box passes every key through untouched.
1636
+ */
1637
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, openMode, openResume, openPlugin, openStatusline, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed }: {
1638
+ active: boolean
1639
+ frozen: boolean
1640
+ busy: boolean
1641
+ descriptors: readonly CommandDescriptor[]
1642
+ skills: readonly SkillRow[]
1643
+ dispatch(text: string): void
1644
+ steer(text: string): void
1645
+ interrupt(): boolean
1646
+ quit(): void
1647
+ openModel(): void
1648
+ openHelp(): void
1649
+ openMode(): void
1650
+ openResume(): void
1651
+ openPlugin(query?: string): void
1652
+ openStatusline(): void
1653
+ openHistory(): void
1654
+ createSession(mode?: string): void
1655
+ cancelSessionSwitch(): boolean
1656
+ notify(text: string, tone?: NoticeTone): void
1657
+ hasNotice: boolean
1658
+ dismissNotice(): void
1659
+ toggleReasoning(): void
1660
+ openVerbose(): void
1661
+ clearView(): void
1662
+ refresh(): void
1663
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
1664
+ cyclePermission(): string
1665
+ exportTranscript(argument: string): Promise<void>
1666
+ renameTitle(argument: string): string
1667
+ /** Newest-first recall space (persistent + in-session, deduped). */
1668
+ recallSpace: readonly string[]
1669
+ /** Record one in-session submission (deduped, local only). */
1670
+ recordLocal(text: string): void
1671
+ /** Persist one submission to the global history file. */
1672
+ recordHistory(text: string): void
1673
+ /** Live queued inbox rows; Delete on the empty composer cancels the newest. */
1674
+ queued: readonly { messageId: string; target: 'next-turn' | 'next-step'; text: string }[]
1675
+ /** Cancel one queued inbox message by identity. */
1676
+ cancelQueued(messageId: string): void
1677
+ /** Accepted /history entry waiting to be placed into the composer. */
1678
+ historyFill: { text: string; index: number } | undefined
1679
+ /** Marks the accepted entry consumed (called after the fill is applied). */
1680
+ historyConsumed(): void
1681
+ }): ReactElement {
1682
+ const columns = useStdout().stdout?.columns ?? 80
1683
+ const [value, setValue] = useState('')
1684
+ const [cursor, setCursor] = useState(0)
1685
+ // Codex shell-style recall: the navigation cursor, the saved draft restored
1686
+ // on Down past the newest entry, and the boundary-gate anchor.
1687
+ const recall = useRef<RecallState>({ entries: [], index: null, savedDraft: '', lastRecalled: null })
1688
+
1689
+ // A /history panel acceptance lands as a fill: place the text at the end of
1690
+ // the composer and resume recall from that entry.
1691
+ useEffect(() => {
1692
+ if (historyFill === undefined) return
1693
+ setValue(historyFill.text)
1694
+ setCursor(historyFill.text.length)
1695
+ setDismissedMenuValue(undefined)
1696
+ recall.current = {
1697
+ entries: recallSpace,
1698
+ index: historyFill.index,
1699
+ savedDraft: historyFill.text,
1700
+ lastRecalled: historyFill.text,
1701
+ }
1702
+ historyConsumed()
1703
+ }, [historyFill, recallSpace, historyConsumed])
1704
+
1705
+ // Keep the navigation's recall space fresh while browsing state survives
1706
+ // (new local submissions extend the space; the index stays valid unless
1707
+ // the space shrank, in which case browsing ends at the current position).
1708
+ if (recall.current.entries !== recallSpace) {
1709
+ const index = recall.current.index === null || recall.current.index < recallSpace.length
1710
+ ? recall.current.index
1711
+ : null
1712
+ recall.current = { ...recall.current, entries: recallSpace, index }
1713
+ }
1714
+ const [completionIndex, setCompletionIndex] = useState(0)
1715
+ const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
1716
+ const candidates = completionCandidates(value, descriptors, skills)
1717
+ const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
1718
+
1719
+ // @mention token: the last `@word` on the cursor's line before the cursor.
1720
+ const beforeCursor = value.slice(0, cursor)
1721
+ const lastLine = beforeCursor.split('\n').at(-1) ?? ''
1722
+ const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
1723
+ const mentionToken = tokenMatch === null
1724
+ ? undefined
1725
+ : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
1726
+ const mentionActive = mentionToken !== undefined
1727
+ const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
1728
+
1729
+ // Bare path token: the last whitespace-delimited run on the cursor's line
1730
+ // when it already looks like a path (Claude-Code bare Tab completion). A
1731
+ // LEADING '/' is the command namespace, never a path — without this guard
1732
+ // typing the bare '/' hijacked the menu into the workspace file scan and
1733
+ // the slash-command candidates never appeared.
1734
+ const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
1735
+ const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
1736
+ const pathActive = !mentionActive
1737
+ && !bareToken.startsWith('/')
1738
+ && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
1739
+ const pathTokenStart = beforeCursor.length - bareToken.length
1740
+ const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
1741
+
1742
+ useEffect(() => {
1743
+ if (!active || !pathActive) {
1744
+ setPathRows([])
1745
+ return
1746
+ }
1747
+ const controller = new AbortController()
1748
+ setPathRows([])
1749
+ loadMentions(bareToken, controller.signal).then(
1750
+ rows => setPathRows(rows.filter(row => row.kind !== 'session')),
1751
+ () => {},
1752
+ )
1753
+ return () => {
1754
+ controller.abort()
1755
+ }
1756
+ }, [active, pathActive, bareToken])
1757
+
1758
+ useEffect(() => {
1759
+ if (!active || !mentionActive) {
1760
+ setMentionRows([])
1761
+ return
1762
+ }
1763
+ const controller = new AbortController()
1764
+ setMentionRows([])
1765
+ loadMentions(mentionToken.query, controller.signal).then(
1766
+ rows => setMentionRows(rows),
1767
+ () => {},
1768
+ )
1769
+ return () => {
1770
+ controller.abort()
1771
+ }
1772
+ }, [active, mentionActive, mentionToken?.query])
1773
+
1774
+ // Codex routes keys to the topmost surface first. Completion therefore
1775
+ // remains available while a turn runs, and Esc dismisses it before the
1776
+ // same key is allowed to interrupt the turn.
1777
+ const menuActive = (slashActive || mentionActive || pathActive) && dismissedMenuValue !== value
1778
+ const menuRows: readonly CompletionCandidate[] = mentionActive
1779
+ ? mentionRows.map(row => ({
1780
+ label: row.label.startsWith('@')
1781
+ ? row.label
1782
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
1783
+ description: row.description,
1784
+ origin: 'mention',
1785
+ }))
1786
+ : pathActive
1787
+ ? pathRows.map(row => ({
1788
+ label: row.label,
1789
+ description: row.description,
1790
+ origin: 'path',
1791
+ }))
1792
+ : candidates
1793
+
1794
+ useInput((input, key) => {
1795
+ // Modal ownership: approval/question/model dialogs consume all keys.
1796
+ if (!active) return
1797
+ // Shift+Tab cycles the permission preset (Claude-Code convention).
1798
+ if (key.tab && key.shift) {
1799
+ try {
1800
+ const next = cyclePermission()
1801
+ if (next !== '') notify(`permission ${next}`)
1802
+ } catch (error: unknown) {
1803
+ notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1804
+ }
1805
+ return
1806
+ }
1807
+ // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
1808
+ if (key.ctrl && input === 'r') {
1809
+ toggleReasoning()
1810
+ return
1811
+ }
1812
+ // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
1813
+ // adapted to append-only static rows): one history entry at a time with
1814
+ // tool cards and reasoning expanded, Esc returns.
1815
+ if (key.ctrl && input === 'o') {
1816
+ openVerbose()
1817
+ return
1818
+ }
1819
+ // Ctrl+C is three-state (community-TUI convention): a running turn is
1820
+ // cancelled, a non-empty draft is cleared, and only an idle empty input
1821
+ // exits. Ctrl+D always means exit but refuses mid-turn.
1822
+ if (key.ctrl && input === 'c') {
1823
+ if (busy) {
1824
+ interrupt()
1825
+ } else if (value !== '') {
1826
+ setValue('')
1827
+ setCursor(0)
1828
+ setCompletionIndex(0)
1829
+ setDismissedMenuValue(undefined)
1830
+ } else {
1831
+ quit()
1832
+ }
1833
+ return
1834
+ }
1835
+ if (key.ctrl && input === 'd') {
1836
+ if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
1837
+ else quit()
1838
+ return
1839
+ }
1840
+ if (key.escape) {
1841
+ if (menuActive) {
1842
+ setDismissedMenuValue(value)
1843
+ return
1844
+ }
1845
+ if (hasNotice) {
1846
+ dismissNotice()
1847
+ return
1848
+ }
1849
+ if (busy) interrupt()
1850
+ return
1851
+ }
1852
+ // Delete on the empty composer cancels the newest queued message (the
1853
+ // web queue-mirror contract: the durable splice drops the pending row).
1854
+ if (key.delete && value === '' && queued.length > 0) {
1855
+ cancelQueued(queued[queued.length - 1]!.messageId)
1856
+ return
1857
+ }
1858
+ if (key.return) {
1859
+ // Multi-line editing: most terminals send the same byte for
1860
+ // shift+enter as enter, so newline insertion rides alt/meta+enter
1861
+ // and ctrl+j (the two distinguishable bindings); a bare return submits.
1862
+ if (key.meta || (key.ctrl && input === 'j')) {
1863
+ setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
1864
+ setCursor(cursor + 1)
1865
+ setDismissedMenuValue(undefined)
1866
+ return
1867
+ }
1868
+ const text = value.trim()
1869
+ setValue('')
1870
+ setCursor(0)
1871
+ setCompletionIndex(0)
1872
+ setDismissedMenuValue(undefined)
1873
+ if (text === '') return
1874
+ dismissNotice()
1875
+ // Global recall records non-slash submissions only (slash lines are
1876
+ // commands, not prompts) Codex record_local_submission semantics;
1877
+ // the submission resets any active recall browsing.
1878
+ if (!text.startsWith('/')) {
1879
+ recordLocal(text)
1880
+ recordHistory(text)
1881
+ }
1882
+ recall.current = { entries: recallSpace, index: null, savedDraft: '', lastRecalled: null }
1883
+ if (text === '/quit') {
1884
+ quit()
1885
+ return
1886
+ }
1887
+ if (text === '/help') {
1888
+ openHelp()
1889
+ return
1890
+ }
1891
+ if (text === '/clear') {
1892
+ // Clear the screen AND drop the folded view: the raw ANSI clear + a
1893
+ // Static remount (refresh) so the ledger stays in sync, then the
1894
+ // store resets so the rebuilt transcript starts empty.
1895
+ refresh()
1896
+ clearView()
1897
+ dismissNotice()
1898
+ return
1899
+ }
1900
+ if (text === '/export' || text.startsWith('/export ')) {
1901
+ void exportTranscript(text.slice(8))
1902
+ return
1903
+ }
1904
+ if (text === '/title' || text.startsWith('/title ')) {
1905
+ const outcome = renameTitle(text.slice(7))
1906
+ const tone: NoticeTone = outcome.startsWith('rename failed:')
1907
+ ? 'error'
1908
+ : outcome.startsWith('usage:') || outcome.includes('unavailable')
1909
+ ? 'warning'
1910
+ : 'info'
1911
+ notify(outcome, tone)
1912
+ return
1913
+ }
1914
+ if (text === '/model' || text.startsWith('/model ')) {
1915
+ openModel()
1916
+ return
1917
+ }
1918
+ if (text === '/mode' || text.startsWith('/mode ')) {
1919
+ const mode = text.slice(5).trim()
1920
+ if (mode === '') openMode()
1921
+ else dispatch(text)
1922
+ return
1923
+ }
1924
+ if (text === '/resume cancel') {
1925
+ notify(cancelSessionSwitch() ? 'pending session switch cancelled' : 'no pending session switch', 'info')
1926
+ return
1927
+ }
1928
+ if (text === '/resume' || text.startsWith('/resume ')) {
1929
+ const id = text.slice(7).trim()
1930
+ if (id === '') openResume()
1931
+ else dispatch(text)
1932
+ return
1933
+ }
1934
+ if (text === '/new' || text.startsWith('/new ')) {
1935
+ createSession(text.slice(4).trim() || undefined)
1936
+ return
1937
+ }
1938
+ if (text === '/plugin' || text.startsWith('/plugin ')) {
1939
+ openPlugin(text.slice(7).trim())
1940
+ return
1941
+ }
1942
+ if (text === '/statusline') {
1943
+ openStatusline()
1944
+ return
1945
+ }
1946
+ if (text === '/history') {
1947
+ openHistory()
1948
+ return
1949
+ }
1950
+ if (busy && !text.startsWith('/')) {
1951
+ // A running turn is steered, not blocked: the inbox delivers this
1952
+ // text at the next step boundary (Esc/Ctrl+C still cancels outright).
1953
+ // Slash lines keep the registry path — commands run out of band.
1954
+ steer(text)
1955
+ return
1956
+ }
1957
+ dispatch(text)
1958
+ return
1959
+ }
1960
+ if (menuActive && key.upArrow) {
1961
+ setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
1962
+ return
1963
+ }
1964
+ if (menuActive && key.downArrow) {
1965
+ setCompletionIndex(index => (index + 1) % menuRows.length)
1966
+ return
1967
+ }
1968
+ if (key.upArrow) {
1969
+ // Claude-Code shell recall: Up always walks the global history (the
1970
+ // current draft is saved for Down-past-newest restore); the boundary
1971
+ // gate from Codex only blocks interior multiline movement, which the
1972
+ // user experience here deliberately skips.
1973
+ if (recall.current.entries.length === 0) return
1974
+ const step = recallOlder(recall.current, value)
1975
+ recall.current = step.state
1976
+ if (step.entry !== undefined) {
1977
+ setValue(step.entry)
1978
+ setCursor(step.entry.length)
1979
+ setDismissedMenuValue(undefined)
1980
+ }
1981
+ return
1982
+ }
1983
+ if (key.downArrow) {
1984
+ if (recall.current.entries.length === 0) return
1985
+ const step = recallNewer(recall.current)
1986
+ recall.current = step.state
1987
+ if (step.entry !== undefined) {
1988
+ setValue(step.entry)
1989
+ setCursor(step.entry.length)
1990
+ setDismissedMenuValue(undefined)
1991
+ }
1992
+ return
1993
+ }
1994
+ if (key.tab && menuActive) {
1995
+ if (mentionActive && mentionToken !== undefined) {
1996
+ const row = mentionRows[completionIndex % mentionRows.length]
1997
+ if (row !== undefined) {
1998
+ // Session rows carry the canonical @[label](dsh-session:…) token;
1999
+ // file rows insert `@path` (directories keep their trailing slash).
2000
+ const insertion = row.label.startsWith('@')
2001
+ ? row.label
2002
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2003
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2004
+ setCursor(mentionToken.start + insertion.length)
2005
+ }
2006
+ } else if (pathActive) {
2007
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
2008
+ if (row !== undefined) {
2009
+ // Bare path completion replaces the typed token with the chosen
2010
+ // workspace path (directories keep their trailing slash).
2011
+ const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
2012
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
2013
+ setCursor(pathTokenStart + insertion.length)
2014
+ }
2015
+ } else {
2016
+ const candidate = candidates[completionIndex % candidates.length]
2017
+ if (candidate !== undefined) {
2018
+ setValue(`${candidate.label} `)
2019
+ setCursor(candidate.label.length + 1)
2020
+ }
2021
+ }
2022
+ setCompletionIndex(0)
2023
+ setDismissedMenuValue(undefined)
2024
+ return
2025
+ }
2026
+ if (key.backspace || key.delete) {
2027
+ if (cursor > 0) {
2028
+ setValue(value.slice(0, cursor - 1) + value.slice(cursor))
2029
+ setCursor(cursor - 1)
2030
+ setCompletionIndex(0)
2031
+ setDismissedMenuValue(undefined)
2032
+ }
2033
+ return
2034
+ }
2035
+ if (key.leftArrow) {
2036
+ setCursor(Math.max(0, cursor - 1))
2037
+ return
2038
+ }
2039
+ if (key.rightArrow) {
2040
+ setCursor(Math.min(value.length, cursor + 1))
2041
+ return
2042
+ }
2043
+ if (key.ctrl && input === 'u') {
2044
+ setValue('')
2045
+ setCursor(0)
2046
+ setDismissedMenuValue(undefined)
2047
+ return
2048
+ }
2049
+ // Readline parity: Ctrl+K cuts from the cursor to the end of the line.
2050
+ if (key.ctrl && input === 'k') {
2051
+ setValue(value.slice(0, cursor))
2052
+ setDismissedMenuValue(undefined)
2053
+ return
2054
+ }
2055
+ // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
2056
+ // plus a Static remount so the flushed transcript re-emits (a bare
2057
+ // console.clear() would desync Ink's ledger against the static rows).
2058
+ if (key.ctrl && input === 'l') {
2059
+ refresh()
2060
+ return
2061
+ }
2062
+ if (key.ctrl && input === 'a') {
2063
+ setCursor(0)
2064
+ return
2065
+ }
2066
+ if (key.ctrl && input === 'e') {
2067
+ setCursor(value.length)
2068
+ return
2069
+ }
2070
+ if (input !== '' && !key.ctrl && !key.meta) {
2071
+ setValue(value.slice(0, cursor) + input + value.slice(cursor))
2072
+ setCursor(cursor + input.length)
2073
+ setCompletionIndex(0)
2074
+ setDismissedMenuValue(undefined)
2075
+ }
2076
+ })
2077
+
2078
+ // Every exclusive panel keeps the composer as a stable visual anchor, but
2079
+ // freezes it to one row: no menu, multiline wrap, or animation.
2080
+ if (frozen) {
2081
+ const frozen = value === ''
2082
+ ? 'type a message'
2083
+ : verboseLine(value, Math.max(1, columns - 6))
2084
+ return createElement(
2085
+ Box,
2086
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
2087
+ createElement(
2088
+ Text,
2089
+ { wrap: 'truncate-end' },
2090
+ createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
2091
+ frozen,
2092
+ ),
2093
+ )
2094
+ }
2095
+
2096
+ const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
2097
+
2098
+ return createElement(
2099
+ Box,
2100
+ { flexDirection: 'column' },
2101
+ // The completion dropdown rides directly above the box (Claude-Code
2102
+ // anchor): rendered from the editor's own live state, never lifted.
2103
+ createElement(CompletionMenu, {
2104
+ active: menuActive,
2105
+ mention: mentionActive,
2106
+ index: completionIndex,
2107
+ rows: menuRows,
2108
+ }),
2109
+ // The framed input box: a visible boundary so the prompt never blends
2110
+ // into the transcript above it; the cursor block sits immediately after
2111
+ // the prompt marker (leftmost), with the dim placeholder trailing it —
2112
+ // no extra space, so the empty state reads `❯ ▮type a message…`.
2113
+ createElement(
2114
+ Box,
2115
+ { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
2116
+ createElement(
2117
+ Text,
2118
+ { wrap: 'truncate-end' },
2119
+ busy
2120
+ ? createElement(BusyChase)
2121
+ : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '❯ '),
2122
+ value === '' ? undefined : editor.before,
2123
+ createElement(CursorBlock, { char: editor.caret }),
2124
+ value === '' && !busy
2125
+ ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
2126
+ : editor.after,
2127
+ ),
2128
+ ),
2129
+ )
2130
+ }
2131
+
2132
+ /** The whole terminal app; state arrives via the store, output via Ink. */
2133
+ export function App(props: AppProps): ReactElement {
2134
+ const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
2135
+ const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
2136
+ const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
2137
+ const [modelLabel, setModelLabel] = useState(props.model)
2138
+ const [modelOpen, setModelOpen] = useState(false)
2139
+ const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
2140
+ const [modelError, setModelError] = useState<string | undefined>(undefined)
2141
+ const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
2142
+ const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
2143
+ const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
2144
+ setNotice({ text, tone })
2145
+ }, [])
2146
+
2147
+ useEffect(() => {
2148
+ props.onBridgeReady({ notify })
2149
+ }, [])
2150
+ useEffect(() => {
2151
+ if (!modelOpen) return
2152
+ let cancelled = false
2153
+ setDirectory(undefined)
2154
+ setModelError(undefined)
2155
+ // Enter the promise chain before invoking the loader so a provider that
2156
+ // throws synchronously becomes an in-panel error instead of escaping the
2157
+ // React effect and tearing down Ink.
2158
+ Promise.resolve().then(() => props.loadModels()).then((loaded) => {
2159
+ if (!cancelled) setDirectory(loaded)
2160
+ }, (error: unknown) => {
2161
+ if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
2162
+ })
2163
+ return () => {
2164
+ cancelled = true
2165
+ }
2166
+ }, [modelOpen, modelLoadEpoch, props.loadModels])
2167
+
2168
+ const busy = view.busy
2169
+ const [showReasoning, setShowReasoning] = useState(false)
2170
+ const [verboseOpen, setVerboseOpen] = useState(false)
2171
+ const [helpOpen, setHelpOpen] = useState(false)
2172
+ const [modeOpen, setModeOpen] = useState(false)
2173
+ const [resumeOpen, setResumeOpen] = useState(false)
2174
+ const [pluginOpen, setPluginOpen] = useState(false)
2175
+ const [pluginQuery, setPluginQuery] = useState('')
2176
+ const [statuslineOpen, setStatuslineOpen] = useState(false)
2177
+ const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
2178
+ const [historyOpen, setHistoryOpen] = useState(false)
2179
+ /** The /history panel's accepted entry: text plus its recall-space index. */
2180
+ const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
2181
+ /** Submissions recorded in this process (Codex local history; persistent file stays in the runner). */
2182
+ const [localHistory, setLocalHistory] = useState<readonly string[]>([])
2183
+ const recordLocal = useCallback((text: string): void => {
2184
+ setLocalHistory(current => recordLocalEntry(current, text))
2185
+ }, [])
2186
+ /** Newest-first recall space shared by the composer and the /history panel. */
2187
+ const recallSpace = useMemo(
2188
+ () => recallEntries(props.history, localHistory),
2189
+ [props.history, localHistory],
2190
+ )
2191
+ const historyConsumed = useCallback((): void => {
2192
+ setHistoryFill(undefined)
2193
+ }, [])
2194
+ /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). */
2195
+ const queuedRows = useMemo(
2196
+ () => view.entries.filter((entry): entry is Extract<TranscriptEntry, { kind: 'pending' }> => entry.kind === 'pending'),
2197
+ [view.entries],
2198
+ )
2199
+ const [refreshEpoch, setRefreshEpoch] = useState(0)
2200
+ const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
2201
+ const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
2202
+ const approvalPending = approvalSnapshot.pending !== undefined
2203
+ const questionPending = questionSnapshot.pending !== undefined
2204
+ // While any modal owns the keys, the prompt box passes everything through.
2205
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2206
+
2207
+ // Human questions outrank local inspectors. Close the lower modal instead
2208
+ // of leaving an approval/question visible but keyboard-locked behind it.
2209
+ useEffect(() => {
2210
+ if (!approvalPending && !questionPending) return
2211
+ setModelOpen(false)
2212
+ setHelpOpen(false)
2213
+ setModeOpen(false)
2214
+ setResumeOpen(false)
2215
+ setPluginOpen(false)
2216
+ setStatuslineOpen(false)
2217
+ setHistoryOpen(false)
2218
+ setVerboseOpen(false)
2219
+ }, [approvalPending, questionPending])
2220
+
2221
+ // Append-only transcript: everything up to the first still-mutable entry
2222
+ // (a running tool/retry) flushes through Ink's `<Static>` into native
2223
+ // scrollback and is normally never rewritten — the Claude-Code stability
2224
+ // contract
2225
+ // that lets arbitrarily long conversations scroll instead of freezing when
2226
+ // the live tree exceeds the terminal height. The dynamic region below stays
2227
+ // small: the streaming tail, modals, composer, and its status footer.
2228
+ // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
2229
+ // keeps long settled histories out of the per-token render path.
2230
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
2231
+ // Claude-Code spacing: one blank row before each user prompt (except the
2232
+ // first) separates replies from the next turn. Settled rows flush once with
2233
+ // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
2234
+ // Ctrl+O browses the frozen history through a bounded selected-entry view.
2235
+ const settledRows = useMemo(() => {
2236
+ const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
2237
+ view.entries.slice(0, settled).forEach((entry, index) => {
2238
+ const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
2239
+ const roomyPrompt = entry.kind === 'user' && !entry.notice
2240
+ if (roomyPrompt) {
2241
+ rows.push(createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
2242
+ }
2243
+ rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
2244
+ if (roomyPrompt) {
2245
+ rows.push(createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
2246
+ }
2247
+ })
2248
+ return rows
2249
+ }, [view.entries, settled, showReasoning, props.resumed])
2250
+
2251
+ // Hook order is unconditional. Its dimensions drive every live-region
2252
+ // budget before any dynamic rows are constructed.
2253
+ const appStdout = useStdout().stdout
2254
+ const [terminalSize, setTerminalSize] = useState(() => ({
2255
+ columns: appStdout?.columns ?? 80,
2256
+ rows: appStdout?.rows ?? 30,
2257
+ }))
2258
+ const terminalSizeRef = useRef(terminalSize)
2259
+ useEffect(() => {
2260
+ if (appStdout === undefined) return
2261
+ let replayTimer: ReturnType<typeof setTimeout> | undefined
2262
+ const handleResize = (): void => {
2263
+ const next = {
2264
+ columns: appStdout.columns ?? 80,
2265
+ rows: appStdout.rows ?? 30,
2266
+ }
2267
+ if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
2268
+ terminalSizeRef.current = next
2269
+
2270
+ // Ink 5 erases by the old logical line count. Once the terminal reflows
2271
+ // a full-width border at a new width, that count is no longer enough and
2272
+ // stale frames remain visible. Follow Codex's source-backed reflow
2273
+ // policy: update live geometry immediately, but wait for the resize
2274
+ // burst to settle before one hard reset and one transcript replay at the
2275
+ // final width. Replaying Static on every event appends duplicate history.
2276
+ setTerminalSize(next)
2277
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
2278
+ replayTimer = setTimeout(() => {
2279
+ appStdout.write(RESIZE_REFLOW_CLEAR)
2280
+ setRefreshEpoch(epoch => epoch + 1)
2281
+ }, RESIZE_REFLOW_DELAY_MS)
2282
+ }
2283
+ appStdout.on('resize', handleResize)
2284
+ return () => {
2285
+ appStdout.off('resize', handleResize)
2286
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
2287
+ }
2288
+ }, [appStdout])
2289
+ const terminalRows = terminalSize.rows
2290
+ const terminalColumns = terminalSize.columns
2291
+ const composerGutterRows = layoutGutterRows(terminalRows)
2292
+ // Bottom chrome is now composer (3) + status (up to 2 rows); the budget
2293
+ // keeps the live/streaming area strictly below the terminal height.
2294
+ const dynamicRows = Math.max(1, terminalRows - 13 - composerGutterRows)
2295
+ const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
2296
+ const deepDivingVisible = busy && !streamingActive
2297
+ const allLiveLines = useMemo(
2298
+ () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
2299
+ [view.entries, settled, terminalColumns],
2300
+ )
2301
+ const liveBudget = streamingActive
2302
+ ? Math.max(1, Math.floor(dynamicRows / 3))
2303
+ : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
2304
+ const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
2305
+
2306
+ // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
2307
+ // screen AND scrollback, home the cursor) then a Static remount via the
2308
+ // key change, which re-flushes the current items from index 0. NEVER
2309
+ // console.clear() — it desyncs Ink's internal line ledger against the
2310
+ // flushed static rows and garbles every frame after.
2311
+ const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
2312
+ const reasoningRows = view.streamingReasoning === ''
2313
+ ? 0
2314
+ : view.streaming === ''
2315
+ ? streamRows
2316
+ : streamRows <= 1
2317
+ ? 0
2318
+ : showReasoning
2319
+ ? Math.max(1, Math.floor(streamRows / 3))
2320
+ : 1
2321
+ const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2322
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
2323
+ const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2324
+ const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || historyOpen || inspectorVisible || approvalPending || questionPending
2325
+ const closeInspector = useCallback((): void => {
2326
+ setVerboseOpen(false)
2327
+ }, [])
2328
+ const refreshScreen = (): void => {
2329
+ if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
2330
+ setRefreshEpoch(epoch => epoch + 1)
2331
+ }
2332
+
2333
+ return createElement(
2334
+ Box,
2335
+ { flexDirection: 'column' },
2336
+ createElement(MemoStaticTranscript, {
2337
+ key: refreshEpoch,
2338
+ items: settledRows,
2339
+ }),
2340
+ transcriptVisible
2341
+ ? createElement(
2342
+ Box,
2343
+ // The two-column gutter matches the composer's border + padding, so
2344
+ // message text aligns with the input cursor (Codex LIVE_PREFIX).
2345
+ { flexDirection: 'column', paddingX: 2 },
2346
+ visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
2347
+ view.streamingReasoning !== '' && reasoningRows > 0
2348
+ ? createElement(StreamTail, {
2349
+ text: showReasoning ? view.streamingReasoning : 'Thinking…',
2350
+ prefix: ' ✻ ',
2351
+ dim: true,
2352
+ maxRows: reasoningRows,
2353
+ })
2354
+ : undefined,
2355
+ view.streaming !== '' && answerRows > 0
2356
+ ? createElement(
2357
+ StreamTail,
2358
+ // The same two-column gutter as settled replies: streamed text
2359
+ // lands exactly where the assembled message will render.
2360
+ { text: view.streaming, dim: false, maxRows: answerRows, prefix: ' ' },
2361
+ busy ? createElement(Caret) : undefined,
2362
+ )
2363
+ : undefined,
2364
+ deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
2365
+ )
2366
+ : undefined,
2367
+ transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
2368
+ createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2369
+ createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
2370
+ modelOpen && !approvalPending && !questionPending
2371
+ ? createElement(ModelPanel, {
2372
+ directory,
2373
+ error: modelError,
2374
+ onSelect: (row: ModelRow) => {
2375
+ try {
2376
+ setModelLabel(props.selectModel(row))
2377
+ notify(`model → next step uses ${row.provider}/${row.model}`)
2378
+ setModelOpen(false)
2379
+ } catch (error: unknown) {
2380
+ notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2381
+ }
2382
+ },
2383
+ onRetry: () => {
2384
+ setModelLoadEpoch(epoch => epoch + 1)
2385
+ },
2386
+ onClose: () => {
2387
+ setModelOpen(false)
2388
+ },
2389
+ })
2390
+ : undefined,
2391
+ helpOpen && !approvalPending && !questionPending
2392
+ ? createElement(HelpPanel, {
2393
+ descriptors,
2394
+ skills,
2395
+ commandError: props.commands.error,
2396
+ skillError: props.skills.error,
2397
+ onClose: () => {
2398
+ setHelpOpen(false)
2399
+ },
2400
+ })
2401
+ : undefined,
2402
+ verboseOpen && !approvalPending && !questionPending
2403
+ ? createElement(MemoVerbosePanel, {
2404
+ entries: view.entries,
2405
+ onClose: closeInspector,
2406
+ })
2407
+ : undefined,
2408
+ modeOpen && !approvalPending && !questionPending
2409
+ ? createElement(ModePanel, {
2410
+ current: props.mode,
2411
+ load: props.loadPresets,
2412
+ select: (id: string) => {
2413
+ void props.switchMode(id).then(label => {
2414
+ notify(`mode → ${label}`)
2415
+ setModeOpen(false)
2416
+ }, (reason: unknown) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error'))
2417
+ },
2418
+ close: () => setModeOpen(false),
2419
+ })
2420
+ : undefined,
2421
+ resumeOpen && !approvalPending && !questionPending
2422
+ ? createElement(ResumePanel, {
2423
+ currentCwd: props.workspaceRoot,
2424
+ load: props.loadSessions,
2425
+ readTranscript: props.loadSessionTranscript,
2426
+ select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
2427
+ close: () => setResumeOpen(false),
2428
+ })
2429
+ : undefined,
2430
+ pluginOpen && !approvalPending && !questionPending
2431
+ ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
2432
+ : undefined,
2433
+ statuslineOpen && !approvalPending && !questionPending
2434
+ ? createElement(StatuslinePanel, {
2435
+ enabled: statuslineItems,
2436
+ change: items => {
2437
+ setStatuslineItems(items)
2438
+ props.saveStatusline(items)
2439
+ },
2440
+ close: () => setStatuslineOpen(false),
2441
+ })
2442
+ : undefined,
2443
+ historyOpen && !approvalPending && !questionPending
2444
+ ? createElement(HistoryPanel, {
2445
+ entries: recallSpace,
2446
+ fill: (text: string, index: number) => {
2447
+ setHistoryFill({ text, index })
2448
+ setHistoryOpen(false)
2449
+ },
2450
+ close: () => setHistoryOpen(false),
2451
+ })
2452
+ : undefined,
2453
+ notice === undefined
2454
+ ? undefined
2455
+ : createElement(NoticeLine, {
2456
+ text: notice.text,
2457
+ tone: notice.tone,
2458
+ columns: terminalColumns,
2459
+ }),
2460
+ // Persistent bottom chrome: every interface owns exactly the same
2461
+ // composer/status geometry. Panels may change above it, but can no longer
2462
+ // reorder the status or introduce mode-specific vertical margins.
2463
+ createElement(
2464
+ Box,
2465
+ { flexDirection: 'column', marginTop: composerGutterRows },
2466
+ createElement(Input, {
2467
+ active: inputActive,
2468
+ frozen: modalVisible,
2469
+ busy,
2470
+ descriptors,
2471
+ skills,
2472
+ dispatch: props.dispatch,
2473
+ steer: props.steer,
2474
+ interrupt: props.interrupt,
2475
+ quit: props.quit,
2476
+ openModel: () => {
2477
+ setDirectory(undefined)
2478
+ setModelError(undefined)
2479
+ setModelOpen(true)
2480
+ },
2481
+ openHelp: () => {
2482
+ setHelpOpen(true)
2483
+ },
2484
+ openMode: () => setModeOpen(true),
2485
+ openResume: () => setResumeOpen(true),
2486
+ openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2487
+ openStatusline: () => setStatuslineOpen(true),
2488
+ openHistory: () => setHistoryOpen(true),
2489
+ createSession: props.createSession,
2490
+ cancelSessionSwitch: props.cancelSessionSwitch,
2491
+ notify,
2492
+ hasNotice: notice !== undefined,
2493
+ dismissNotice: () => {
2494
+ setNotice(undefined)
2495
+ },
2496
+ openVerbose: () => {
2497
+ setVerboseOpen(true)
2498
+ },
2499
+ clearView: () => {
2500
+ props.store.reset()
2501
+ },
2502
+ refresh: refreshScreen,
2503
+ // Ctrl+R must re-render already-settled history too: settled rows
2504
+ // flush through <Static> once, so the toggle rides the same
2505
+ // source-backed clear+replay the resize path uses — one clear, one
2506
+ // authoritative re-flush at the new visibility.
2507
+ toggleReasoning: () => {
2508
+ setShowReasoning(current => !current)
2509
+ refreshScreen()
2510
+ },
2511
+ loadMentions: props.loadMentions,
2512
+ cyclePermission: props.cyclePermission,
2513
+ exportTranscript: props.exportTranscript,
2514
+ renameTitle: props.renameTitle,
2515
+ recallSpace,
2516
+ recordLocal,
2517
+ recordHistory: props.recordHistory,
2518
+ queued: queuedRows,
2519
+ cancelQueued: props.cancelQueued,
2520
+ historyFill,
2521
+ historyConsumed,
2522
+ }),
2523
+ createElement(StatusLine, {
2524
+ facts: {
2525
+ model: modelLabel,
2526
+ mode: props.mode,
2527
+ cwd: props.cwd,
2528
+ branch: props.branch,
2529
+ sessionId: props.sessionId,
2530
+ title: view.title,
2531
+ plan: view.plan,
2532
+ permission: view.permission,
2533
+ sandbox: view.sandbox,
2534
+ goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
2535
+ },
2536
+ stats: view.stats,
2537
+ busy,
2538
+ columns: terminalColumns,
2539
+ items: statuslineItems,
2540
+ }),
2541
+ ),
2542
+ )
2543
+ }