dsh-code 0.4.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.
Files changed (49) hide show
  1. package/README.en.md +217 -0
  2. package/README.md +216 -67
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/devtools-CdTl3MNy.mjs +3643 -0
  6. package/lib/index.mjs +27467 -673
  7. package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
  8. package/lib/startup.mjs +34 -17
  9. package/lib/types/app.d.ts +29 -1
  10. package/lib/types/commands.d.ts +2 -0
  11. package/lib/types/history.d.ts +79 -0
  12. package/lib/types/index.d.ts +5 -5
  13. package/lib/types/internals.d.ts +2 -0
  14. package/lib/types/kernel-panels.d.ts +48 -0
  15. package/lib/types/plugin-inventory.d.ts +11 -0
  16. package/lib/types/presets.d.ts +32 -0
  17. package/lib/types/render/animations.d.ts +10 -1
  18. package/lib/types/render/inspector.d.ts +6 -0
  19. package/lib/types/render/projection.d.ts +21 -1
  20. package/lib/types/render/status.d.ts +131 -14
  21. package/lib/types/render/text.d.ts +9 -0
  22. package/lib/types/session-directory.d.ts +54 -0
  23. package/lib/types/session-switch.d.ts +17 -0
  24. package/lib/types/skills.d.ts +2 -0
  25. package/lib/types/startup.d.ts +11 -1
  26. package/package.json +117 -112
  27. package/src/app.ts +2543 -1969
  28. package/src/commands.ts +15 -1
  29. package/src/history.ts +136 -0
  30. package/src/index.ts +550 -155
  31. package/src/internals.ts +5 -0
  32. package/src/kernel-panels.ts +419 -0
  33. package/src/plugin-inventory.ts +47 -0
  34. package/src/presets.ts +64 -0
  35. package/src/render/animations.ts +14 -1
  36. package/src/render/export.ts +4 -0
  37. package/src/render/inspector.ts +23 -5
  38. package/src/render/lines.ts +21 -10
  39. package/src/render/markdown.ts +15 -1
  40. package/src/render/projection.ts +71 -8
  41. package/src/render/status.ts +522 -65
  42. package/src/render/text.ts +34 -6
  43. package/src/session-directory.ts +102 -0
  44. package/src/session-switch.ts +58 -0
  45. package/src/skills.ts +20 -7
  46. package/src/startup.ts +38 -20
  47. package/src/whale-glyph.ts +23 -23
  48. package/README.zh.md +0 -65
  49. package/src/pictures/1.png +0 -0
package/src/app.ts CHANGED
@@ -1,1969 +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 { ApprovalStore } from './approval.ts'
33
- import type { CommandsView } from './commands.ts'
34
- import type { ModelDirectory, ModelRow } from './models.ts'
35
- import type { QuestionStore } from './questions.ts'
36
- import type { SkillsView, SkillRow } from './skills.ts'
37
- import type { MentionCandidate } from './mentions.ts'
38
-
39
- /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
40
- const RESIZE_REFLOW_DELAY_MS = 75
41
-
42
- /** Reset region/style, clear the visible screen and scrollback, then home. */
43
- const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
44
- import { buildStatusGroups, formatTokens, type StatusFacts } from './render/status.ts'
45
- import { displayTail, displayText } from './render/text.ts'
46
- import {
47
- clampScroll,
48
- followInspectorCursor,
49
- inspectorViewport,
50
- moveScroll,
51
- panelViewport,
52
- revealRow,
53
- selectionWindow,
54
- } from './render/inspector.ts'
55
- import {
56
- lineSegment,
57
- markdownLines,
58
- styledLines,
59
- textLines,
60
- transcriptEntryLines,
61
- type LineStyle,
62
- type StyledLine,
63
- } from './render/lines.ts'
64
-
65
- /** Props the runner hands the app; callbacks stay owned by the runner. */
66
- export interface AppProps {
67
- /** Event-fed transcript store for the live session. */
68
- store: TranscriptStore
69
- /** Approval-question store fed by the answerer listener. */
70
- approval: ApprovalStore
71
- /** ask_user_question store fed by the single UI provider. */
72
- questions: QuestionStore
73
- /** Live slash-command descriptor list (completion candidates). */
74
- commands: CommandsView
75
- /** Live user-invocable skill catalog (completion candidates). */
76
- skills: SkillsView
77
- /** `provider/model` selection serving this session (updated on /model). */
78
- model: string
79
- /** Working-directory basename the session serves. */
80
- cwd: string
81
- /** Git branch name, empty outside a repository. */
82
- branch: string
83
- /** Short session identifier. */
84
- sessionId: string
85
- /** Whether this session was resumed from persistence. */
86
- resumed: boolean
87
- /** Submit one line: slash commands to the registry, other text to the agent. */
88
- dispatch(text: string): void
89
- /** Submit steering: consumed at the running turn's next step boundary. */
90
- steer(text: string): void
91
- /** Interrupt the running turn (Esc); true when a turn was cancelled. */
92
- interrupt(): boolean
93
- /** Quit: unmount, flush, and request process exit. */
94
- quit(): void
95
- /** Load the selectable model directory (called when /model opens). */
96
- loadModels(): Promise<ModelDirectory>
97
- /** Load @mention candidates for the typed query (files + sessions). */
98
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
99
- /** Apply one /model selection; returns the display label. */
100
- selectModel(row: ModelRow): string
101
- /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
102
- cyclePermission(): string
103
- /** Export the transcript to a markdown file (/export [path]); reports via notices. */
104
- exportTranscript(argument: string): Promise<void>
105
- /** Rename the session (/title <text>); returns the outcome line for the notice. */
106
- renameTitle(argument: string): string
107
- /** Registers the app's notice channel with the runner (called once on mount). */
108
- onBridgeReady(bridge: { notify(text: string): void }): void
109
- }
110
-
111
- /** Ink `color` string for one palette triple. */
112
- function inkColor(triple: readonly [number, number, number]): string {
113
- return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
114
- }
115
-
116
- /** Truncate text to a visible-column budget, appending … when cut. */
117
- function truncateColumns(text: string, max: number): string {
118
- let columns = 0
119
- let out = ''
120
- for (const char of text) {
121
- const code = char.codePointAt(0) ?? 0
122
- const width = code > 0x2e7f ? 2 : 1
123
- if (columns + width > max) return `${out}…`
124
- out += char
125
- columns += width
126
- }
127
- return out
128
- }
129
-
130
- /** Pad text with spaces to a visible-column target (menu name column). */
131
- function padColumns(text: string, width: number): string {
132
- return text + ' '.repeat(Math.max(0, width - visibleColumns(text)))
133
- }
134
-
135
- /** Interval-driven frame counter for one self-contained animated leaf. */
136
- function useFrames(intervalMs: number): number {
137
- const [tick, setTick] = useState(0)
138
- useEffect(() => {
139
- const id = setInterval(() => setTick(current => current + 1), intervalMs)
140
- return () => {
141
- clearInterval(id)
142
- }
143
- }, [intervalMs])
144
- return tick
145
- }
146
-
147
- /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
148
- function Pulse(): ReactElement {
149
- const tick = useFrames(125)
150
- return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
151
- }
152
-
153
- /** Blinking block caret appended to streaming text. */
154
- function Caret(): ReactElement {
155
- const tick = useFrames(530)
156
- return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
157
- }
158
-
159
- /** Blinking input cursor: inverse block while the caret phase is on. */
160
- function CursorBlock({ char }: { char: string }): ReactElement {
161
- const tick = useFrames(530)
162
- return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
163
- }
164
-
165
- /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
166
- function runClock(ms: number): string {
167
- const total = Math.max(0, Math.floor(ms / 1000))
168
- const minutes = Math.floor(total / 60)
169
- const seconds = total % 60
170
- return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
171
- }
172
-
173
- /**
174
- * The busy line, web TurnStatus contract: the plain `Deep diving...` label,
175
- * with the elapsed clock appended only once the turn has clearly been running
176
- * (15s) anchored to `turn/start` so a resumed mid-turn keeps the real time.
177
- */
178
- function DeepDivingLine({ since }: { since: number }): ReactElement {
179
- useFrames(1000)
180
- const elapsed = since === 0 ? 0 : Date.now() - since
181
- return createElement(
182
- Text,
183
- { dimColor: true },
184
- elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
185
- )
186
- }
187
-
188
- /**
189
- * The streaming buffer rendered with a hard size cap: the live region must
190
- * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
191
- * than the screen freezes (cursor-up past the top, garbage, no scroll). The
192
- * cap counts explicit newlines and terminal wrapping, slicing from the END so
193
- * the freshest tokens stay visible while a long reply streams; the complete
194
- * text lands in the flushed scrollback once the turn assembles it.
195
- */
196
- function StreamTail({ text, dim, maxRows, prefix, children }: {
197
- text: string
198
- dim: boolean
199
- maxRows: number
200
- prefix?: string
201
- children?: ReactElement
202
- }): ReactElement {
203
- const columns = useStdout().stdout?.columns ?? 80
204
- const safeRows = Math.max(1, maxRows)
205
- // App padding consumes two columns; the final extra column keeps a caret
206
- // from wrapping onto an unbudgeted row.
207
- const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
208
- const initial = displayTail(text, contentColumns, safeRows)
209
- // Reserve one row for the omission marker only when a marker is needed.
210
- const tail = initial.truncated && safeRows > 1
211
- ? displayTail(text, contentColumns, safeRows - 1)
212
- : initial
213
- return createElement(
214
- Box,
215
- { flexDirection: 'column' },
216
- tail.truncated && safeRows > 1
217
- ? createElement(Text, { dimColor: true }, ' …')
218
- : undefined,
219
- createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
220
- )
221
- }
222
-
223
- /** Ink props for one markdown style class. */
224
- function segmentProps(style: MdSegment['style']): {
225
- color: string | undefined
226
- bold: boolean | undefined
227
- italic: boolean | undefined
228
- strikethrough: boolean | undefined
229
- } {
230
- switch (style) {
231
- case 'accent':
232
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
233
- case 'code':
234
- return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
235
- case 'dim':
236
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
237
- case 'bold':
238
- return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
239
- case 'italic':
240
- return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
241
- case 'boldItalic':
242
- return { color: undefined, bold: true, italic: true, strikethrough: undefined }
243
- case 'strike':
244
- return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
245
- default:
246
- return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
247
- }
248
- }
249
-
250
- /** Ink props for the richer line model used by bounded scrolling panels. */
251
- function lineStyleProps(style: LineStyle): {
252
- color: string | undefined
253
- bold: boolean | undefined
254
- italic: boolean | undefined
255
- strikethrough: boolean | undefined
256
- dimColor: boolean | undefined
257
- } {
258
- switch (style) {
259
- case 'brand':
260
- return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
261
- case 'success':
262
- return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
263
- case 'error':
264
- return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
265
- case 'warn':
266
- return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
267
- case 'dimItalic':
268
- return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
269
- default:
270
- return { ...segmentProps(style), dimColor: undefined }
271
- }
272
- }
273
-
274
- /** Render width-safe rows; every child is exactly one terminal row. */
275
- function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
276
- return createElement(
277
- Box,
278
- { flexDirection: 'column' },
279
- ...lines.map((line, index) => createElement(
280
- Text,
281
- { key: index, wrap: 'truncate-end' },
282
- line.segments.length === 0
283
- ? ' '
284
- : line.segments.map((segment, at) => createElement(
285
- Text,
286
- { key: at, ...lineStyleProps(segment.style) },
287
- segment.text,
288
- )),
289
- )),
290
- )
291
- }
292
-
293
- /** One settled markdown document rendered as styled lines at the terminal width. */
294
- function MarkdownBody({ text }: { text: string }): ReactElement {
295
- const columns = useStdout().stdout?.columns ?? 80
296
- // Cached by (text, width): settled replies re-layout only when either moves.
297
- const lines = useMemo(
298
- () => renderMarkdown(displayText(text), Math.max(20, columns - 2)),
299
- [text, columns],
300
- )
301
- return createElement(
302
- Box,
303
- { flexDirection: 'column' },
304
- ...lines.map((line, index) => createElement(
305
- Text,
306
- { key: index },
307
- ...line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
308
- )),
309
- )
310
- }
311
-
312
- /**
313
- * One expanded tool-card body for the verbose transcript (Ctrl+O): the
314
- * presentation contract's structured cards — inline diffs, read windows,
315
- * web sources rendered as plain terminal rows, degradation-safe against
316
- * replayed metadata.
317
- */
318
- function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
319
- switch (detail.kind) {
320
- case 'diff':
321
- return createElement(
322
- Box,
323
- { flexDirection: 'column' },
324
- ...detail.diffs.map((diff, index) => createElement(
325
- Box,
326
- { key: index, flexDirection: 'column' },
327
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
328
- ...diff.lines.map((line, at) => createElement(
329
- Text,
330
- {
331
- key: at,
332
- color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
333
- wrap: 'truncate-end',
334
- },
335
- ` ${line.mark}${displayText(line.text)}`,
336
- )),
337
- )),
338
- )
339
- case 'read':
340
- return createElement(
341
- Box,
342
- { flexDirection: 'column' },
343
- 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)' : ''}`),
344
- ...detail.lines.map((line, at) => createElement(
345
- Text,
346
- { key: at, dimColor: true, wrap: 'truncate-end' },
347
- ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
348
- )),
349
- )
350
- case 'web-search':
351
- return createElement(
352
- Box,
353
- { flexDirection: 'column' },
354
- ...detail.sources.map((source, at) => createElement(
355
- Text,
356
- { key: at, wrap: 'truncate-end' },
357
- brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
358
- createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
359
- )),
360
- createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
361
- )
362
- case 'web-fetch':
363
- return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
364
- case 'raw':
365
- return createElement(
366
- Box,
367
- { flexDirection: 'column' },
368
- ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
369
- createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
370
- )
371
- default:
372
- return assertNever(detail, 'tool detail kind')
373
- }
374
- }
375
- /** One settled transcript row. */
376
- function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
377
- switch (entry.kind) {
378
- case 'user':
379
- // Collapsed injected context reads as a dim ↳ row; only direct human
380
- // prompts get the brand ❯ (they are different surfaces, not the same).
381
- return entry.notice
382
- ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
383
- : createElement(Text, null, brand('❯ '), displayText(entry.text))
384
- case 'assistant':
385
- // Claude-Code-style thinking: a dim marker collapsed, the reasoning
386
- // text dim-italic expanded (Ctrl+R toggles globally). The collapsed
387
- // row is static — an animated counter inside the text would jitter the
388
- // line width every frame.
389
- return createElement(
390
- Box,
391
- { flexDirection: 'column' },
392
- entry.reasoning === ''
393
- ? undefined
394
- : showReasoning
395
- ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
396
- : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
397
- createElement(MarkdownBody, { text: entry.text }),
398
- )
399
- case 'tool': {
400
- // Claude-Code-style tool card: the invocation row plus a nested ⎿
401
- // result line, so the summary reads under its call instead of inline.
402
- const mark = entry.state === 'running'
403
- ? createElement(Pulse)
404
- : entry.state === 'error'
405
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '')
406
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
407
- return createElement(
408
- Box,
409
- { flexDirection: 'column' },
410
- createElement(
411
- Text,
412
- { wrap: verbose ? 'truncate-end' : undefined },
413
- mark,
414
- ' ',
415
- brand(entry.name),
416
- entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
417
- ),
418
- entry.summary === ''
419
- ? undefined
420
- : createElement(
421
- Text,
422
- { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
423
- ` ⎿ ${displayText(entry.summary)}`,
424
- ),
425
- verbose && entry.detail !== undefined
426
- ? createElement(ToolDetailBody, { detail: entry.detail })
427
- : undefined,
428
- )
429
- }
430
- case 'command': {
431
- const mark = entry.state === 'running'
432
- ? createElement(Pulse)
433
- : entry.state === 'error'
434
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
435
- : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
436
- return createElement(
437
- Box,
438
- { flexDirection: 'column' },
439
- createElement(
440
- Text,
441
- { wrap: verbose ? 'truncate-end' : undefined },
442
- mark,
443
- ' ',
444
- brand(`/${entry.name}`),
445
- entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
446
- ),
447
- entry.summary === ''
448
- ? undefined
449
- : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
450
- )
451
- }
452
- case 'turn-marker':
453
- // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
454
- return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
455
- case 'compaction':
456
- // Completed compaction lifecycle: what it reclaimed, or why it failed.
457
- return createElement(
458
- Text,
459
- { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
460
- entry.ok
461
- ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
462
- : ` ⧉ compaction failed: ${displayText(entry.error)}`,
463
- )
464
- case 'retry':
465
- // Provider-routed retry: amber while the backoff waits, dim once the
466
- // next attempt is underway.
467
- return createElement(
468
- Text,
469
- { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
470
- ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
471
- )
472
- case 'files': {
473
- // Turn-tail deliverables: the turn's mutated files (web turnTail chips).
474
- const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
475
- const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
476
- return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ${shown}${more}`)
477
- }
478
- case 'error':
479
- return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
480
- default:
481
- return assertNever(entry, 'transcript entry kind')
482
- }
483
- }
484
-
485
- /**
486
- * The whale wordmark header in DeepSeek blue, hugging its content width.
487
- * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
488
- * short to show it whole (or mid-resize) the clipped pairs garble the
489
- * screen — below the height floor the header collapses to a single-line
490
- * wordmark that stays correct at any size.
491
- */
492
- function Header({ resumed }: { resumed: boolean }): ReactElement {
493
- const rows = useStdout().stdout?.rows ?? 40
494
- const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
495
- if (rows < 20) {
496
- return createElement(
497
- Box,
498
- { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
499
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
500
- createElement(Text, { dimColor: true }, hint),
501
- )
502
- }
503
- return createElement(
504
- Box,
505
- // alignSelf shrinks the border to the whale-plus-wordmark content instead
506
- // of stretching across the terminal and stranding empty space on the right
507
- // (the compact-banner treatment the Claude Code welcome uses).
508
- { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
509
- createElement(
510
- Box,
511
- { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS },
512
- ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
513
- ),
514
- createElement(
515
- Box,
516
- { flexDirection: 'column', justifyContent: 'center' },
517
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
518
- createElement(Text, { dimColor: true }, hint),
519
- ),
520
- )
521
- }
522
-
523
- /** Todo status glyph: web TodoPanel's three-state marker. */
524
- function todoMark(status: TodoItem['status']): string {
525
- return status === 'completed' ? '' : status === 'in_progress' ? '●' : '○'
526
- }
527
-
528
- /** One-row todo summary: task count cannot grow the live Ink tree. */
529
- function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
530
- if (todos.length === 0) return undefined
531
- const completed = todos.filter(todo => todo.status === 'completed').length
532
- const inProgress = todos.filter(todo => todo.status === 'in_progress').length
533
- const pending = todos.length - completed - inProgress
534
- const current = todos.find(todo => todo.status === 'in_progress')
535
- return createElement(
536
- Box,
537
- { paddingX: 1 },
538
- createElement(
539
- Text,
540
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
541
- `todos ${completed}/${todos.length}`,
542
- createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
543
- current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
544
- ),
545
- )
546
- }
547
-
548
- /**
549
- * The footer status line: Claude-Code-style identity facts (model, working
550
- * directory, git branch, session) beside the web composer's session figures
551
- * (turns/steps, model and tool wall time, cache hit, token totals), joined
552
- * by brand-colored pipes.
553
- */
554
- function StatusLine({ facts, stats, busy }: {
555
- facts: StatusFacts
556
- stats: Parameters<typeof buildStatusGroups>[1]
557
- busy: boolean
558
- }): ReactElement {
559
- const groups = buildStatusGroups(facts, stats)
560
- // One physical row in every mode: a narrow terminal must truncate instead
561
- // of wrapping the status groups into an unbudgeted second/third row.
562
- return createElement(
563
- Box,
564
- // Match the prompt text inside the bordered composer: one border column
565
- // plus one padding column. Keeping this row margin-free also makes the
566
- // composer and status a fixed four-row unit in every interface.
567
- { paddingLeft: 2 },
568
- createElement(
569
- Text,
570
- { dimColor: true, wrap: 'truncate-end' },
571
- busy ? '● ' : '○ ',
572
- groups.join(' | '),
573
- ),
574
- )
575
- }
576
-
577
- /** The y/n approval bar rendered while an approval ask is pending. */
578
- function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
579
- const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
580
- const stdout = useStdout().stdout
581
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
582
- const [scroll, setScroll] = useState(0)
583
- const pending = snapshot.pending
584
- const active = !locked && snapshot.pending !== undefined && !snapshot.answered
585
- const content = useMemo<readonly StyledLine[]>(() => pending === undefined
586
- ? []
587
- : [
588
- ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
589
- ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
590
- ], [pending, viewport.contentColumns])
591
- const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
592
-
593
- useEffect(() => {
594
- setScroll(0)
595
- }, [pending])
596
-
597
- useEffect(() => {
598
- if (visibleScroll !== scroll) setScroll(visibleScroll)
599
- }, [visibleScroll, scroll])
600
-
601
- useInput((input, key) => {
602
- if (snapshot.pending === undefined) return
603
- if (key.upArrow) {
604
- setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
605
- return
606
- }
607
- if (key.downArrow) {
608
- setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
609
- return
610
- }
611
- if (key.pageUp) {
612
- setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
613
- return
614
- }
615
- if (key.pageDown) {
616
- setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
617
- return
618
- }
619
- if (snapshot.answered) return
620
- if (input === 'y' || input === 'Y') {
621
- snapshot.pending.answer('allowed-once')
622
- return
623
- }
624
- if (input === 'n' || input === 'N') {
625
- snapshot.pending.answer('rejected')
626
- }
627
- }, { isActive: active })
628
- if (snapshot.pending === undefined) return undefined
629
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
630
- if (viewport.compact) {
631
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
632
- }
633
- const { answered } = snapshot
634
- return createElement(
635
- Box,
636
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
637
- 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)),
638
- createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
639
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
640
- ? 'submitted…'
641
- : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
642
- )
643
- }
644
-
645
- /**
646
- * The ask_user_question bar: walks one request question by question,
647
- * renders the option menu (Claude-Code style: arrows move, space toggles a
648
- * multi-select, enter submits, `c` opens the custom-answer box, Esc
649
- * interrupts the question as aborted). Plan reviews arrive through the same
650
- * service with a `plan-review` intent the approve option gets a ✓ mark,
651
- * the answer encoding stays identical.
652
- */
653
- function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
654
- const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
655
- const stdout = useStdout().stdout
656
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
657
- const pending = snapshot.pending
658
- const [index, setIndex] = useState(0)
659
- const [cursor, setCursor] = useState(0)
660
- const [selected, setSelected] = useState<readonly number[]>([])
661
- const [mode, setMode] = useState<'options' | 'custom'>('options')
662
- const [custom, setCustom] = useState('')
663
- const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
664
- const [submitted, setSubmitted] = useState(false)
665
- const [scroll, setScroll] = useState(0)
666
-
667
- // A new request resets the walk; questions without options start in the
668
- // custom-answer box (a free-form question).
669
- useEffect(() => {
670
- const question = pending?.request.questions[0]
671
- setIndex(0)
672
- setCursor(0)
673
- setSelected([])
674
- setMode(question?.options === undefined || question.options.length === 0 ? 'custom' : 'options')
675
- setCustom('')
676
- setAnswers([])
677
- setSubmitted(false)
678
- setScroll(0)
679
- }, [pending])
680
-
681
- const question = pending?.request.questions[index]
682
- const options = question?.options ?? []
683
- const isPlan = question?.intent?.kind === 'plan-review'
684
- const isMulti = question?.multiSelect === true
685
- const active = !locked && pending !== undefined && question !== undefined && !submitted
686
- const rendered = useMemo(() => {
687
- if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
688
- const lines: StyledLine[] = []
689
- const optionRows: number[] = []
690
- if (question.header !== undefined) {
691
- lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
692
- }
693
- lines.push(...textLines(question.question, viewport.contentColumns))
694
- if (question.detail !== undefined) {
695
- lines.push(...(isPlan
696
- ? markdownLines(question.detail, viewport.contentColumns)
697
- : textLines(question.detail, viewport.contentColumns, 'dim')))
698
- }
699
- if (submitted) {
700
- lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
701
- } else if (mode === 'custom' || options.length === 0) {
702
- lines.push(...styledLines([
703
- lineSegment(' custom: ', 'brand'),
704
- lineSegment(custom, 'plain'),
705
- lineSegment('▌', 'brand'),
706
- ], viewport.contentColumns))
707
- } else {
708
- options.forEach((option, at) => {
709
- optionRows.push(lines.length)
710
- const chosen = isMulti && selected.includes(at)
711
- const approve = isPlan && question.intent?.approve === option.label
712
- const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
713
- const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
714
- lines.push(...styledLines([
715
- lineSegment(mark, style),
716
- lineSegment(option.label, style),
717
- lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
718
- ], viewport.contentColumns))
719
- })
720
- }
721
- return { lines, optionRows }
722
- }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
723
- const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows)
724
-
725
- useEffect(() => {
726
- if (visibleScroll !== scroll) setScroll(visibleScroll)
727
- }, [visibleScroll, scroll])
728
-
729
- useEffect(() => {
730
- if (mode === 'options' && options.length > 0) {
731
- const focused = rendered.optionRows[cursor] ?? 0
732
- setScroll(current => revealRow(current, focused, rendered.lines.length, viewport.bodyRows))
733
- return
734
- }
735
- setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows))
736
- }, [cursor, mode, custom.length, rendered.lines.length, viewport.bodyRows])
737
-
738
- const commit = (answer: AskUserQuestionAnswerItem): void => {
739
- if (pending === undefined) return
740
- const next = [...answers, answer]
741
- const total = pending.request.questions.length
742
- if (index + 1 >= total) {
743
- setSubmitted(true)
744
- store.submit(pending, { answers: next })
745
- return
746
- }
747
- setAnswers(next)
748
- const nextIndex = index + 1
749
- const nextQuestion = pending.request.questions[nextIndex]
750
- setIndex(nextIndex)
751
- setCursor(0)
752
- setSelected([])
753
- setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
754
- setCustom('')
755
- setScroll(0)
756
- }
757
-
758
- const commitOption = (): void => {
759
- if (pending === undefined || question === undefined) return
760
- if (isMulti) {
761
- const labels = selected
762
- .map(at => options[at]?.label)
763
- .filter((label): label is string => label !== undefined)
764
- const customText = custom.trim()
765
- commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
766
- return
767
- }
768
- const option = options[cursor]
769
- if (option === undefined) return
770
- commit({ id: question.id, selected: [option.label] })
771
- }
772
-
773
- useInput((input, key) => {
774
- if (pending === undefined || question === undefined || submitted) return
775
- if (key.escape) {
776
- store.cancel(pending)
777
- return
778
- }
779
- if (key.pageUp) {
780
- setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
781
- return
782
- }
783
- if (key.pageDown) {
784
- setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
785
- return
786
- }
787
- if (mode === 'custom' || options.length === 0) {
788
- if (key.upArrow) {
789
- setScroll(current => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows))
790
- return
791
- }
792
- if (key.downArrow) {
793
- setScroll(current => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows))
794
- return
795
- }
796
- if (key.return) {
797
- if (custom.trim() === '' && options.length > 0) {
798
- commitOption()
799
- return
800
- }
801
- commit({
802
- id: question.id,
803
- selected: isMulti
804
- ? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
805
- : [],
806
- ...(custom.trim() === '' ? {} : { custom: custom.trim() }),
807
- })
808
- return
809
- }
810
- if (key.backspace) {
811
- setCustom(current => current.slice(0, -1))
812
- return
813
- }
814
- if (input !== '' && !key.ctrl && !key.meta) {
815
- setCustom(current => current + input)
816
- }
817
- return
818
- }
819
- if (key.upArrow) {
820
- setCursor(current => (current + options.length - 1) % options.length)
821
- return
822
- }
823
- if (key.downArrow) {
824
- setCursor(current => (current + 1) % options.length)
825
- return
826
- }
827
- if (key.return) {
828
- commitOption()
829
- return
830
- }
831
- if (key.tab || input === 'c' || input === 'C') {
832
- setMode('custom')
833
- return
834
- }
835
- if (input === ' ' && isMulti) {
836
- setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
837
- }
838
- }, { isActive: active })
839
-
840
- if (pending === undefined || question === undefined) return undefined
841
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
842
- if (viewport.compact) {
843
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
844
- }
845
- const footer = submitted
846
- ? 'submitted…'
847
- : mode === 'custom' || options.length === 0
848
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
849
- : isMulti
850
- ? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
851
- : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
852
- return createElement(
853
- Box,
854
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
855
- createElement(
856
- Text,
857
- { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
858
- truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
859
- ),
860
- createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
861
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
862
- )
863
- }
864
-
865
- /** The /model panel: a scrolling list over the advisory model directory. */
866
- function ModelPanel({ directory, error, onSelect, onClose }: {
867
- directory: ModelDirectory | undefined
868
- error: string | undefined
869
- onSelect(row: ModelRow): void
870
- onClose(): void
871
- }): ReactElement {
872
- const [cursor, setCursor] = useState(0)
873
- const stdout = useStdout().stdout
874
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
875
- const rows = directory?.rows ?? []
876
-
877
- useEffect(() => {
878
- if (rows.length === 0) {
879
- if (cursor !== 0) setCursor(0)
880
- return
881
- }
882
- if (cursor >= rows.length) setCursor(rows.length - 1)
883
- }, [rows.length, cursor])
884
-
885
- useInput((input, key) => {
886
- if (key.escape || input === 'q') {
887
- onClose()
888
- return
889
- }
890
- if (rows.length === 0) return
891
- if (key.upArrow) {
892
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
893
- return
894
- }
895
- if (key.downArrow) {
896
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
897
- return
898
- }
899
- if (key.pageUp) {
900
- setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
901
- return
902
- }
903
- if (key.pageDown) {
904
- setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
905
- return
906
- }
907
- if (input === 'g') {
908
- setCursor(0)
909
- return
910
- }
911
- if (input === 'G') {
912
- setCursor(rows.length - 1)
913
- return
914
- }
915
- if (key.return && rows[cursor] !== undefined) {
916
- onSelect(rows[cursor])
917
- }
918
- })
919
-
920
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
921
- if (viewport.compact) {
922
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · esc/q close', viewport.contentColumns))
923
- }
924
-
925
- const first = selectionWindow(cursor, rows.length, viewport.bodyRows)
926
- const visible = rows.slice(first, first + viewport.bodyRows)
927
- return createElement(
928
- Box,
929
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
930
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
931
- directory === undefined && error === undefined
932
- ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ' loading models…')
933
- : undefined,
934
- error !== undefined
935
- ? createElement(Text, { color: inkColor(TUI_RGB.error), wrap: 'truncate-end' }, truncateColumns(` ${displayText(error)}`, viewport.contentColumns))
936
- : undefined,
937
- ...visible.map((row) => {
938
- const index = rows.indexOf(row)
939
- const label = displayText(`${row.providerName} · ${row.modelName}`)
940
- return createElement(
941
- Text,
942
- {
943
- key: `${row.provider}/${row.model}`,
944
- color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
945
- wrap: 'truncate-end',
946
- },
947
- truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
948
- )
949
- }),
950
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · esc/q close', viewport.contentColumns))),
951
- )
952
- }
953
-
954
- /**
955
- * The /help overlay: one scrolling card with the keyboard map, the TUI-local
956
- * commands, the live registry commands, and the user-invocable skills — the
957
- * real command surface, replacing the one-line notice.
958
- */
959
- function HelpPanel({ descriptors, skills, onClose }: {
960
- descriptors: readonly CommandDescriptor[]
961
- skills: readonly SkillRow[]
962
- onClose(): void
963
- }): ReactElement {
964
- const stdout = useStdout().stdout
965
- const columns = stdout?.columns ?? 80
966
- const viewport = panelViewport(columns, stdout?.rows ?? 30)
967
- const [scroll, setScroll] = useState(0)
968
- const nameWidth = 18
969
- const descBudget = Math.max(1, viewport.contentColumns - nameWidth - 2)
970
- const row = (label: string, description: string): ReactElement => createElement(
971
- Text,
972
- { dimColor: true, wrap: 'truncate-end' },
973
- ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
974
- )
975
- const content: ReactElement[] = [
976
- createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
977
- createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
978
- createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
979
- createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
980
- createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
981
- 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'),
982
- createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
983
- createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
984
- createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
985
- createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
986
- createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
987
- createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
988
- createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
989
- ...descriptors.map(descriptor => createElement(
990
- Text,
991
- { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
992
- ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
993
- )),
994
- ...(skills.length === 0 ? [] : [createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills')]),
995
- ...skills.map(skill => createElement(
996
- Text,
997
- { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
998
- ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
999
- )),
1000
- ]
1001
- const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
1002
- const scrollBy = (delta: number): void => {
1003
- setScroll(current => moveScroll(current, delta, content.length, viewport.bodyRows))
1004
- }
1005
-
1006
- useEffect(() => {
1007
- if (visibleScroll !== scroll) setScroll(visibleScroll)
1008
- }, [visibleScroll, scroll])
1009
-
1010
- useInput((input, key) => {
1011
- if (key.escape || input === 'q') {
1012
- onClose()
1013
- return
1014
- }
1015
- if (key.upArrow) scrollBy(-1)
1016
- else if (key.downArrow) scrollBy(1)
1017
- else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
1018
- else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
1019
- else if (input === 'g') setScroll(0)
1020
- else if (input === 'G') setScroll(Math.max(0, content.length - viewport.bodyRows))
1021
- })
1022
-
1023
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1024
- if (viewport.compact) {
1025
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
1026
- }
1027
-
1028
- return createElement(
1029
- Box,
1030
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1031
- 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)),
1032
- ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1033
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
1034
- )
1035
- }
1036
-
1037
- /** Collapse arbitrary metadata to one terminal row before verbose rendering. */
1038
- function verboseLine(text: string, columns: number): string {
1039
- return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
1040
- }
1041
-
1042
- /** One-row editor window keeping the logical cursor visible in long drafts. */
1043
- function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
1044
- const width = Math.max(1, columns)
1045
- const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
1046
- const caretSource = value.slice(cursor, cursor + 1)
1047
- const caret = caretSource === '' ? ' ' : normalize(caretSource)
1048
- const remaining = Math.max(0, width - visibleColumns(caret))
1049
- const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
1050
- const beforeBudget = Math.max(0, remaining - afterBudget)
1051
- const before = beforeBudget === 0
1052
- ? ''
1053
- : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
1054
- const after = afterBudget === 0
1055
- ? ''
1056
- : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
1057
- return { before, caret, after }
1058
- }
1059
-
1060
- /**
1061
- * The Ctrl+O transcript inspector: one selected durable entry at a time,
1062
- * with independent history selection and content scrolling. The complete
1063
- * retained entry is converted to physical rows, but only one viewport slice
1064
- * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
1065
- */
1066
- function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
1067
- const stdout = useStdout().stdout
1068
- const columns = stdout?.columns ?? 80
1069
- const rows = stdout?.rows ?? 30
1070
- const viewport = inspectorViewport(columns, rows)
1071
- const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
1072
- const [scroll, setScroll] = useState(0)
1073
- const savedScroll = useRef(new Map<number, number>())
1074
- const cursorRef = useRef(cursor)
1075
- const previousLength = useRef(entries.length)
1076
- const entry = entries[cursor]
1077
- const allLines = useMemo(
1078
- () => entry === undefined ? [] : transcriptEntryLines(entry, viewport.contentColumns),
1079
- [entry, viewport.contentColumns],
1080
- )
1081
- const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
1082
-
1083
- useEffect(() => {
1084
- cursorRef.current = cursor
1085
- }, [cursor])
1086
-
1087
- useEffect(() => {
1088
- const current = cursorRef.current
1089
- const next = followInspectorCursor(current, previousLength.current, entries.length)
1090
- if (next !== current) {
1091
- savedScroll.current.set(current, visibleScroll)
1092
- setCursor(next)
1093
- setScroll(savedScroll.current.get(next) ?? 0)
1094
- }
1095
- previousLength.current = entries.length
1096
- }, [entries.length])
1097
-
1098
- useEffect(() => {
1099
- const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows)
1100
- if (clamped !== scroll) setScroll(clamped)
1101
- savedScroll.current.set(cursor, clamped)
1102
- }, [cursor, scroll, allLines.length, viewport.bodyRows])
1103
-
1104
- const selectEntry = (next: number): void => {
1105
- if (entries.length === 0) return
1106
- const selected = Math.max(0, Math.min(entries.length - 1, next))
1107
- if (selected === cursor) return
1108
- savedScroll.current.set(cursor, visibleScroll)
1109
- setCursor(selected)
1110
- setScroll(savedScroll.current.get(selected) ?? 0)
1111
- }
1112
-
1113
- const scrollBy = (delta: number): void => {
1114
- setScroll(current => moveScroll(current, delta, allLines.length, viewport.bodyRows))
1115
- }
1116
-
1117
- useInput((input, key) => {
1118
- if (key.escape || input === 'q' || (key.ctrl && input === 'o')) {
1119
- onClose()
1120
- return
1121
- }
1122
- if (entries.length === 0) return
1123
- if (key.leftArrow) {
1124
- selectEntry(cursor - 1)
1125
- return
1126
- }
1127
- if (key.rightArrow) {
1128
- selectEntry(cursor + 1)
1129
- return
1130
- }
1131
- if (key.upArrow) {
1132
- scrollBy(-1)
1133
- return
1134
- }
1135
- if (key.downArrow) {
1136
- scrollBy(1)
1137
- return
1138
- }
1139
- if (key.pageUp) {
1140
- scrollBy(-Math.max(1, viewport.bodyRows - 1))
1141
- return
1142
- }
1143
- if (key.pageDown) {
1144
- scrollBy(Math.max(1, viewport.bodyRows - 1))
1145
- return
1146
- }
1147
- if (input === 'g') {
1148
- setScroll(0)
1149
- return
1150
- }
1151
- if (input === 'G') {
1152
- setScroll(Math.max(0, allLines.length - viewport.bodyRows))
1153
- }
1154
- })
1155
-
1156
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1157
- if (viewport.compact) {
1158
- return createElement(
1159
- Text,
1160
- { wrap: 'truncate-end' },
1161
- truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
1162
- )
1163
- }
1164
-
1165
- const title = entries.length === 0
1166
- ? 'history details · empty'
1167
- : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
1168
- const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
1169
- return createElement(
1170
- Box,
1171
- {
1172
- flexDirection: 'column',
1173
- paddingX: 1,
1174
- borderStyle: 'round',
1175
- borderColor: inkColor(TUI_RGB.brand),
1176
- },
1177
- createElement(
1178
- Text,
1179
- { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1180
- truncateColumns(title, viewport.contentColumns),
1181
- ),
1182
- createElement(
1183
- Box,
1184
- { flexDirection: 'column' },
1185
- entry === undefined
1186
- ? createElement(Text, { dimColor: true }, ' no durable entries yet')
1187
- : createElement(StyledRows, { lines: visible }),
1188
- ),
1189
- createElement(
1190
- Text,
1191
- { dimColor: true, wrap: 'truncate-end' },
1192
- dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
1193
- ),
1194
- )
1195
- }
1196
-
1197
- /** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
1198
- const MemoVerbosePanel = memo(VerbosePanel)
1199
-
1200
- /** Stable append-only boundary: modal updates must never revisit Static rows. */
1201
- function staticRow(item: unknown): ReactElement {
1202
- return item as ReactElement
1203
- }
1204
-
1205
- function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
1206
- return createElement(Static, { items, children: staticRow })
1207
- }
1208
-
1209
- const MemoStaticTranscript = memo(StaticTranscript)
1210
-
1211
- /** One completion candidate row. */
1212
- interface CompletionCandidate {
1213
- /** Insertion text for the command name (with leading slash). */
1214
- label: string
1215
- /** Human-readable description shown beside the label. */
1216
- description: string
1217
- /** Candidate origin; skills land the same literal text but route through the prompt. */
1218
- origin: 'command' | 'skill' | 'mention' | 'path'
1219
- }
1220
-
1221
- /**
1222
- * Resolve completion candidates for the current input: TUI-local commands,
1223
- * the live registry descriptors, and user-invocable skills, filtered by the
1224
- * typed prefix. Command names win collisions (the dispatch tries the
1225
- * registry first and only then falls through to the skill gesture).
1226
- */
1227
- function completionCandidates(
1228
- value: string,
1229
- descriptors: readonly CommandDescriptor[],
1230
- skills: readonly SkillRow[],
1231
- ): readonly CompletionCandidate[] {
1232
- if (!value.startsWith('/')) return []
1233
- const prefix = value.slice(1).split(' ')[0] ?? ''
1234
- const local: CompletionCandidate[] = [
1235
- { label: '/help', description: 'show commands', origin: 'command' },
1236
- { label: '/model', description: 'switch the model', origin: 'command' },
1237
- { label: '/clear', description: 'clear the screen', origin: 'command' },
1238
- { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1239
- { label: '/title', description: 'rename this session', origin: 'command' },
1240
- { label: '/quit', description: 'exit', origin: 'command' },
1241
- ]
1242
- // Local commands shadow registry names (e.g. the plugin-registered
1243
- // /permission is served by the registry itself, never duplicated here),
1244
- // so collisions cannot render two rows with the same key.
1245
- const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1246
- const registry = descriptors
1247
- .filter(descriptor => !localNames.has(descriptor.name))
1248
- .map((descriptor): CompletionCandidate => ({
1249
- label: `/${descriptor.name}`,
1250
- description: descriptor.description,
1251
- origin: 'command',
1252
- }))
1253
- const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
1254
- const skillRows = skills
1255
- .filter(skill => !taken.has(skill.name))
1256
- .map((skill): CompletionCandidate => ({
1257
- label: `/${skill.name}`,
1258
- description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
1259
- origin: 'skill',
1260
- }))
1261
- const all = [...local, ...registry, ...skillRows]
1262
- if (prefix === '') return all.slice(0, 10)
1263
- return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
1264
- }
1265
-
1266
- /**
1267
- * The completion menu, rendered inside the composer's subtree directly above
1268
- * the framed box attached the way Claude-Code anchors its dropdown. Opening
1269
- * it grows the stack downward: the composer stays the last element on screen
1270
- * and everything above (the flushed static transcript, the status line) never
1271
- * moves. Props-only (no lifted state): the menu is a pure view of the input
1272
- * editor's live completion state, so no cross-component effect ever resyncs
1273
- * it (a state lift here previously deadlocked the menu after a resize).
1274
- */
1275
- function CompletionMenu({ active, mention, index, rows }: {
1276
- active: boolean
1277
- mention: boolean
1278
- index: number
1279
- rows: readonly CompletionCandidate[]
1280
- }): ReactElement | undefined {
1281
- // Hook order is unconditional: `active` toggling must not change the hook
1282
- // count (the early return used to sit above useStdout).
1283
- const stdout = useStdout().stdout
1284
- const columns = stdout?.columns ?? 80
1285
- const terminalRows = stdout?.rows ?? 30
1286
- if (!active) return undefined
1287
- const nameWidth = Math.min(18, Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
1288
- const descBudget = Math.max(24, columns - nameWidth - 8)
1289
- const showFooter = terminalRows >= 12
1290
- const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)))
1291
- const selected = rows.length === 0 ? 0 : index % rows.length
1292
- const first = selectionWindow(selected, rows.length, limit)
1293
- const visible = rows.slice(first, first + limit)
1294
- return createElement(
1295
- Box,
1296
- { flexDirection: 'column', marginLeft: 2 },
1297
- ...(rows.length === 0
1298
- ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
1299
- : visible.map((candidate, at) => {
1300
- const absolute = first + at
1301
- return createElement(
1302
- Text,
1303
- {
1304
- key: candidate.label,
1305
- color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1306
- wrap: 'truncate-end',
1307
- },
1308
- `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1309
- )
1310
- })),
1311
- showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
1312
- )
1313
- }
1314
-
1315
- /**
1316
- * The prompt box: TUI-local slash commands handled locally, other lines
1317
- * dispatched; input editing keeps a cursor with history and completion.
1318
- * While a modal (approval / question / model panel) owns the keys, the
1319
- * box passes every key through untouched.
1320
- */
1321
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }: {
1322
- active: boolean
1323
- frozen: boolean
1324
- busy: boolean
1325
- descriptors: readonly CommandDescriptor[]
1326
- skills: readonly SkillRow[]
1327
- dispatch(text: string): void
1328
- steer(text: string): void
1329
- interrupt(): boolean
1330
- quit(): void
1331
- openModel(): void
1332
- openHelp(): void
1333
- notify(text: string): void
1334
- toggleReasoning(): void
1335
- openVerbose(): void
1336
- clearView(): void
1337
- refresh(): void
1338
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
1339
- cyclePermission(): string
1340
- exportTranscript(argument: string): Promise<void>
1341
- renameTitle(argument: string): string
1342
- }): ReactElement {
1343
- const columns = useStdout().stdout?.columns ?? 80
1344
- const [value, setValue] = useState('')
1345
- const [cursor, setCursor] = useState(0)
1346
- const history = useRef<readonly string[]>([])
1347
- const historyIndex = useRef<number | null>(null)
1348
- const draft = useRef('')
1349
- const [completionIndex, setCompletionIndex] = useState(0)
1350
- const candidates = completionCandidates(value, descriptors, skills)
1351
- const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
1352
-
1353
- // @mention token: the last `@word` on the cursor's line before the cursor.
1354
- const beforeCursor = value.slice(0, cursor)
1355
- const lastLine = beforeCursor.split('\n').at(-1) ?? ''
1356
- const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
1357
- const mentionToken = tokenMatch === null
1358
- ? undefined
1359
- : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
1360
- const mentionActive = mentionToken !== undefined
1361
- const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
1362
-
1363
- // Bare path token: the last whitespace-delimited run on the cursor's line
1364
- // when it already looks like a path (Claude-Code bare Tab completion). A
1365
- // LEADING '/' is the command namespace, never a path without this guard
1366
- // typing the bare '/' hijacked the menu into the workspace file scan and
1367
- // the slash-command candidates never appeared.
1368
- const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
1369
- const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
1370
- const pathActive = !mentionActive
1371
- && !bareToken.startsWith('/')
1372
- && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
1373
- const pathTokenStart = beforeCursor.length - bareToken.length
1374
- const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
1375
-
1376
- useEffect(() => {
1377
- if (!active || !pathActive) {
1378
- setPathRows([])
1379
- return
1380
- }
1381
- const controller = new AbortController()
1382
- setPathRows([])
1383
- loadMentions(bareToken, controller.signal).then(
1384
- rows => setPathRows(rows.filter(row => row.kind !== 'session')),
1385
- () => {},
1386
- )
1387
- return () => {
1388
- controller.abort()
1389
- }
1390
- }, [active, pathActive, bareToken])
1391
-
1392
- useEffect(() => {
1393
- if (!active || !mentionActive) {
1394
- setMentionRows([])
1395
- return
1396
- }
1397
- const controller = new AbortController()
1398
- setMentionRows([])
1399
- loadMentions(mentionToken.query, controller.signal).then(
1400
- rows => setMentionRows(rows),
1401
- () => {},
1402
- )
1403
- return () => {
1404
- controller.abort()
1405
- }
1406
- }, [active, mentionActive, mentionToken?.query])
1407
-
1408
- const menuActive = (slashActive || mentionActive || pathActive) && !busy
1409
- const menuRows: readonly CompletionCandidate[] = mentionActive
1410
- ? mentionRows.map(row => ({
1411
- label: row.label.startsWith('@')
1412
- ? row.label
1413
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
1414
- description: row.description,
1415
- origin: 'mention',
1416
- }))
1417
- : pathActive
1418
- ? pathRows.map(row => ({
1419
- label: row.label,
1420
- description: row.description,
1421
- origin: 'path',
1422
- }))
1423
- : candidates
1424
-
1425
- useInput((input, key) => {
1426
- // Modal ownership: approval/question/model dialogs consume all keys.
1427
- if (!active) return
1428
- // Shift+Tab cycles the permission preset (Claude-Code convention).
1429
- if (key.tab && key.shift) {
1430
- const next = cyclePermission()
1431
- if (next !== '') notify(`permission → ${next}`)
1432
- return
1433
- }
1434
- // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
1435
- if (key.ctrl && input === 'r') {
1436
- toggleReasoning()
1437
- return
1438
- }
1439
- // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
1440
- // adapted to append-only static rows): one history entry at a time with
1441
- // tool cards and reasoning expanded, Esc returns.
1442
- if (key.ctrl && input === 'o') {
1443
- openVerbose()
1444
- return
1445
- }
1446
- // Ctrl+C is three-state (community-TUI convention): a running turn is
1447
- // cancelled, a non-empty draft is cleared, and only an idle empty input
1448
- // exits. Ctrl+D always means exit but refuses mid-turn.
1449
- if (key.ctrl && input === 'c') {
1450
- if (busy) {
1451
- interrupt()
1452
- } else if (value !== '') {
1453
- setValue('')
1454
- setCursor(0)
1455
- setCompletionIndex(0)
1456
- } else {
1457
- quit()
1458
- }
1459
- return
1460
- }
1461
- if (key.ctrl && input === 'd') {
1462
- if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
1463
- else quit()
1464
- return
1465
- }
1466
- if (key.escape) {
1467
- if (busy) interrupt()
1468
- return
1469
- }
1470
- if (key.return) {
1471
- // Multi-line editing: most terminals send the same byte for
1472
- // shift+enter as enter, so newline insertion rides alt/meta+enter
1473
- // and ctrl+j (the two distinguishable bindings); a bare return submits.
1474
- if (key.meta || (key.ctrl && input === 'j')) {
1475
- setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
1476
- setCursor(cursor + 1)
1477
- return
1478
- }
1479
- const text = value.trim()
1480
- setValue('')
1481
- setCursor(0)
1482
- setCompletionIndex(0)
1483
- if (text === '') return
1484
- history.current = [...history.current, text]
1485
- historyIndex.current = null
1486
- if (text === '/quit') {
1487
- quit()
1488
- return
1489
- }
1490
- if (text === '/help') {
1491
- openHelp()
1492
- return
1493
- }
1494
- if (text === '/clear') {
1495
- // Clear the screen AND drop the folded view: the raw ANSI clear + a
1496
- // Static remount (refresh) so the ledger stays in sync, then the
1497
- // store resets so the rebuilt transcript starts empty.
1498
- refresh()
1499
- clearView()
1500
- return
1501
- }
1502
- if (text === '/export' || text.startsWith('/export ')) {
1503
- void exportTranscript(text.slice(8))
1504
- return
1505
- }
1506
- if (text === '/title' || text.startsWith('/title ')) {
1507
- notify(renameTitle(text.slice(7)))
1508
- return
1509
- }
1510
- if (text === '/model' || text.startsWith('/model ')) {
1511
- openModel()
1512
- return
1513
- }
1514
- if (busy && !text.startsWith('/')) {
1515
- // A running turn is steered, not blocked: the inbox delivers this
1516
- // text at the next step boundary (Esc/Ctrl+C still cancels outright).
1517
- // Slash lines keep the registry path commands run out of band.
1518
- steer(text)
1519
- return
1520
- }
1521
- dispatch(text)
1522
- return
1523
- }
1524
- if (menuActive && key.upArrow) {
1525
- setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
1526
- return
1527
- }
1528
- if (menuActive && key.downArrow) {
1529
- setCompletionIndex(index => (index + 1) % menuRows.length)
1530
- return
1531
- }
1532
- if (key.upArrow) {
1533
- const entries = history.current
1534
- if (entries.length === 0) return
1535
- const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1)
1536
- if (historyIndex.current === null) draft.current = value
1537
- historyIndex.current = next
1538
- setValue(entries[next] ?? '')
1539
- setCursor((entries[next] ?? '').length)
1540
- return
1541
- }
1542
- if (key.downArrow) {
1543
- const entries = history.current
1544
- if (historyIndex.current === null) return
1545
- const next = historyIndex.current + 1
1546
- if (next >= entries.length) {
1547
- historyIndex.current = null
1548
- setValue(draft.current)
1549
- setCursor(draft.current.length)
1550
- return
1551
- }
1552
- historyIndex.current = next
1553
- setValue(entries[next] ?? '')
1554
- setCursor((entries[next] ?? '').length)
1555
- return
1556
- }
1557
- if (key.tab && menuActive) {
1558
- if (mentionActive && mentionToken !== undefined) {
1559
- const row = mentionRows[completionIndex % mentionRows.length]
1560
- if (row !== undefined) {
1561
- // Session rows carry the canonical @[label](dsh-session:…) token;
1562
- // file rows insert `@path` (directories keep their trailing slash).
1563
- const insertion = row.label.startsWith('@')
1564
- ? row.label
1565
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
1566
- setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
1567
- setCursor(mentionToken.start + insertion.length)
1568
- }
1569
- } else if (pathActive) {
1570
- const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
1571
- if (row !== undefined) {
1572
- // Bare path completion replaces the typed token with the chosen
1573
- // workspace path (directories keep their trailing slash).
1574
- const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
1575
- setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
1576
- setCursor(pathTokenStart + insertion.length)
1577
- }
1578
- } else {
1579
- const candidate = candidates[completionIndex % candidates.length]
1580
- if (candidate !== undefined) {
1581
- setValue(`${candidate.label} `)
1582
- setCursor(candidate.label.length + 1)
1583
- }
1584
- }
1585
- setCompletionIndex(0)
1586
- return
1587
- }
1588
- if (key.backspace || key.delete) {
1589
- if (cursor > 0) {
1590
- setValue(value.slice(0, cursor - 1) + value.slice(cursor))
1591
- setCursor(cursor - 1)
1592
- setCompletionIndex(0)
1593
- }
1594
- return
1595
- }
1596
- if (key.leftArrow) {
1597
- setCursor(Math.max(0, cursor - 1))
1598
- return
1599
- }
1600
- if (key.rightArrow) {
1601
- setCursor(Math.min(value.length, cursor + 1))
1602
- return
1603
- }
1604
- if (key.ctrl && input === 'u') {
1605
- setValue('')
1606
- setCursor(0)
1607
- return
1608
- }
1609
- // Readline parity: Ctrl+K cuts from the cursor to the end of the line.
1610
- if (key.ctrl && input === 'k') {
1611
- setValue(value.slice(0, cursor))
1612
- return
1613
- }
1614
- // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
1615
- // plus a Static remount so the flushed transcript re-emits (a bare
1616
- // console.clear() would desync Ink's ledger against the static rows).
1617
- if (key.ctrl && input === 'l') {
1618
- refresh()
1619
- return
1620
- }
1621
- if (key.ctrl && input === 'a') {
1622
- setCursor(0)
1623
- return
1624
- }
1625
- if (key.ctrl && input === 'e') {
1626
- setCursor(value.length)
1627
- return
1628
- }
1629
- if (input !== '' && !key.ctrl && !key.meta) {
1630
- setValue(value.slice(0, cursor) + input + value.slice(cursor))
1631
- setCursor(cursor + input.length)
1632
- setCompletionIndex(0)
1633
- }
1634
- })
1635
-
1636
- // Every exclusive panel keeps the composer as a stable visual anchor, but
1637
- // freezes it to one row: no menu, multiline wrap, or animation.
1638
- if (frozen) {
1639
- const frozen = value === ''
1640
- ? 'type a message'
1641
- : verboseLine(value, Math.max(1, columns - 6))
1642
- return createElement(
1643
- Box,
1644
- { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1645
- createElement(
1646
- Text,
1647
- { wrap: 'truncate-end' },
1648
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1649
- frozen,
1650
- ),
1651
- )
1652
- }
1653
-
1654
- const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
1655
-
1656
- return createElement(
1657
- Box,
1658
- { flexDirection: 'column' },
1659
- // The completion dropdown rides directly above the box (Claude-Code
1660
- // anchor): rendered from the editor's own live state, never lifted.
1661
- createElement(CompletionMenu, {
1662
- active: menuActive,
1663
- mention: mentionActive,
1664
- index: completionIndex,
1665
- rows: menuRows,
1666
- }),
1667
- // The framed input box: a visible boundary so the prompt never blends
1668
- // into the transcript above it; the cursor block sits immediately after
1669
- // the prompt marker (leftmost), with the dim placeholder trailing it —
1670
- // no extra space, so the empty state reads `❯ ▮type a message…`.
1671
- createElement(
1672
- Box,
1673
- { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1674
- createElement(
1675
- Text,
1676
- { wrap: 'truncate-end' },
1677
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1678
- value === '' ? undefined : editor.before,
1679
- createElement(CursorBlock, { char: editor.caret }),
1680
- value === '' && !busy
1681
- ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
1682
- : editor.after,
1683
- ),
1684
- ),
1685
- )
1686
- }
1687
-
1688
- /** The whole terminal app; state arrives via the store, output via Ink. */
1689
- export function App(props: AppProps): ReactElement {
1690
- const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
1691
- const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
1692
- const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
1693
- const [modelLabel, setModelLabel] = useState(props.model)
1694
- const [modelOpen, setModelOpen] = useState(false)
1695
- const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
1696
- const [modelError, setModelError] = useState<string | undefined>(undefined)
1697
- const [notices, setNotices] = useState<readonly string[]>([])
1698
- const notify = (text: string): void => {
1699
- setNotices(current => [...current, text])
1700
- }
1701
-
1702
- useEffect(() => {
1703
- props.onBridgeReady({ notify })
1704
- }, [])
1705
- useEffect(() => {
1706
- if (!modelOpen || directory !== undefined) return
1707
- let cancelled = false
1708
- setModelError(undefined)
1709
- props.loadModels().then((loaded) => {
1710
- if (!cancelled) setDirectory(loaded)
1711
- }, (error: unknown) => {
1712
- if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
1713
- })
1714
- return () => {
1715
- cancelled = true
1716
- }
1717
- }, [modelOpen])
1718
-
1719
- const busy = view.busy
1720
- const [showReasoning, setShowReasoning] = useState(false)
1721
- const [verboseOpen, setVerboseOpen] = useState(false)
1722
- const [helpOpen, setHelpOpen] = useState(false)
1723
- const [refreshEpoch, setRefreshEpoch] = useState(0)
1724
- const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
1725
- const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
1726
- const approvalPending = approvalSnapshot.pending !== undefined
1727
- const questionPending = questionSnapshot.pending !== undefined
1728
- // While any modal owns the keys, the prompt box passes everything through.
1729
- const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
1730
-
1731
- // Human questions outrank local inspectors. Close the lower modal instead
1732
- // of leaving an approval/question visible but keyboard-locked behind it.
1733
- useEffect(() => {
1734
- if (!approvalPending && !questionPending) return
1735
- setModelOpen(false)
1736
- setHelpOpen(false)
1737
- setVerboseOpen(false)
1738
- }, [approvalPending, questionPending])
1739
-
1740
- // Append-only transcript: everything up to the first still-mutable entry
1741
- // (a running tool/retry) flushes through Ink's `<Static>` into native
1742
- // scrollback and is normally never rewritten — the Claude-Code stability
1743
- // contract
1744
- // that lets arbitrarily long conversations scroll instead of freezing when
1745
- // the live tree exceeds the terminal height. The dynamic region below stays
1746
- // small: the streaming tail, modals, composer, and its status footer.
1747
- // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
1748
- // keeps long settled histories out of the per-token render path.
1749
- const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
1750
- // Claude-Code spacing: one blank row before each user prompt (except the
1751
- // first) separates replies from the next turn. Settled rows flush once with
1752
- // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
1753
- // Ctrl+O browses the frozen history through a bounded selected-entry view.
1754
- const settledRows = useMemo(() => {
1755
- const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
1756
- view.entries.slice(0, settled).forEach((entry, index) => {
1757
- const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
1758
- if (entry.kind === 'user' && index > 0) {
1759
- rows.push(createElement(Box, { key: `gap-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
1760
- }
1761
- rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
1762
- })
1763
- return rows
1764
- }, [view.entries, settled, showReasoning, props.resumed])
1765
-
1766
- // Hook order is unconditional. Its dimensions drive every live-region
1767
- // budget before any dynamic rows are constructed.
1768
- const appStdout = useStdout().stdout
1769
- const [terminalSize, setTerminalSize] = useState(() => ({
1770
- columns: appStdout?.columns ?? 80,
1771
- rows: appStdout?.rows ?? 30,
1772
- }))
1773
- const terminalSizeRef = useRef(terminalSize)
1774
- useEffect(() => {
1775
- if (appStdout === undefined) return
1776
- let replayTimer: ReturnType<typeof setTimeout> | undefined
1777
- const handleResize = (): void => {
1778
- const next = {
1779
- columns: appStdout.columns ?? 80,
1780
- rows: appStdout.rows ?? 30,
1781
- }
1782
- if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
1783
- terminalSizeRef.current = next
1784
-
1785
- // Ink 5 erases by the old logical line count. Once the terminal reflows
1786
- // a full-width border at a new width, that count is no longer enough and
1787
- // stale frames remain visible. Follow Codex's source-backed reflow
1788
- // policy: update live geometry immediately, but wait for the resize
1789
- // burst to settle before one hard reset and one transcript replay at the
1790
- // final width. Replaying Static on every event appends duplicate history.
1791
- setTerminalSize(next)
1792
- if (replayTimer !== undefined) clearTimeout(replayTimer)
1793
- replayTimer = setTimeout(() => {
1794
- appStdout.write(RESIZE_REFLOW_CLEAR)
1795
- setRefreshEpoch(epoch => epoch + 1)
1796
- }, RESIZE_REFLOW_DELAY_MS)
1797
- }
1798
- appStdout.on('resize', handleResize)
1799
- return () => {
1800
- appStdout.off('resize', handleResize)
1801
- if (replayTimer !== undefined) clearTimeout(replayTimer)
1802
- }
1803
- }, [appStdout])
1804
- const terminalRows = terminalSize.rows
1805
- const terminalColumns = terminalSize.columns
1806
- const dynamicRows = Math.max(1, terminalRows - 12)
1807
- const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
1808
- const deepDivingVisible = busy && !streamingActive
1809
- const allLiveLines = useMemo(
1810
- () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
1811
- [view.entries, settled, terminalColumns],
1812
- )
1813
- const liveBudget = streamingActive
1814
- ? Math.max(1, Math.floor(dynamicRows / 3))
1815
- : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
1816
- const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
1817
-
1818
- // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
1819
- // screen AND scrollback, home the cursor) then a Static remount via the
1820
- // key change, which re-flushes the current items from index 0. NEVER
1821
- // console.clear() it desyncs Ink's internal line ledger against the
1822
- // flushed static rows and garbles every frame after.
1823
- const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
1824
- const reasoningRows = view.streamingReasoning === ''
1825
- ? 0
1826
- : view.streaming === ''
1827
- ? streamRows
1828
- : streamRows <= 1
1829
- ? 0
1830
- : showReasoning
1831
- ? Math.max(1, Math.floor(streamRows / 3))
1832
- : 1
1833
- const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
1834
- const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
1835
- const inspectorVisible = verboseOpen && !approvalPending && !questionPending
1836
- const modalVisible = modelOpen || helpOpen || inspectorVisible || approvalPending || questionPending
1837
- const closeInspector = useCallback((): void => {
1838
- setVerboseOpen(false)
1839
- }, [])
1840
- const refreshScreen = (): void => {
1841
- if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
1842
- setRefreshEpoch(epoch => epoch + 1)
1843
- }
1844
-
1845
- return createElement(
1846
- Box,
1847
- { flexDirection: 'column' },
1848
- createElement(MemoStaticTranscript, {
1849
- key: refreshEpoch,
1850
- items: settledRows,
1851
- }),
1852
- transcriptVisible
1853
- ? createElement(
1854
- Box,
1855
- { flexDirection: 'column', paddingX: 1 },
1856
- visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
1857
- view.streamingReasoning !== '' && reasoningRows > 0
1858
- ? createElement(StreamTail, {
1859
- text: showReasoning ? view.streamingReasoning : 'Thinking…',
1860
- prefix: ' ✻ ',
1861
- dim: true,
1862
- maxRows: reasoningRows,
1863
- })
1864
- : undefined,
1865
- view.streaming !== '' && answerRows > 0
1866
- ? createElement(
1867
- StreamTail,
1868
- { text: view.streaming, dim: false, maxRows: answerRows },
1869
- busy ? createElement(Caret) : undefined,
1870
- )
1871
- : undefined,
1872
- deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
1873
- )
1874
- : undefined,
1875
- transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
1876
- createElement(QuestionBar, { store: props.questions, locked: false }),
1877
- createElement(ApprovalBar, { approval: props.approval, locked: questionPending }),
1878
- modelOpen && !approvalPending && !questionPending
1879
- ? createElement(ModelPanel, {
1880
- directory,
1881
- error: modelError,
1882
- onSelect: (row: ModelRow) => {
1883
- setModelLabel(props.selectModel(row))
1884
- notify(`model → next step uses ${row.provider}/${row.model}`)
1885
- setModelOpen(false)
1886
- },
1887
- onClose: () => {
1888
- setModelOpen(false)
1889
- },
1890
- })
1891
- : undefined,
1892
- helpOpen && !approvalPending && !questionPending
1893
- ? createElement(HelpPanel, {
1894
- descriptors,
1895
- skills,
1896
- onClose: () => {
1897
- setHelpOpen(false)
1898
- },
1899
- })
1900
- : undefined,
1901
- verboseOpen && !approvalPending && !questionPending
1902
- ? createElement(MemoVerbosePanel, {
1903
- entries: view.entries,
1904
- onClose: closeInspector,
1905
- })
1906
- : undefined,
1907
- transcriptVisible
1908
- ? createElement(
1909
- Box,
1910
- { flexDirection: 'column' },
1911
- ...notices.slice(-1).map((notice, index) => createElement(Text, { key: index, dimColor: true, wrap: 'truncate-end' }, displayText(notice))),
1912
- )
1913
- : undefined,
1914
- // Persistent bottom chrome: every interface owns exactly the same
1915
- // composer/status geometry. Panels may change above it, but can no longer
1916
- // reorder the status or introduce mode-specific vertical margins.
1917
- createElement(
1918
- Box,
1919
- { flexDirection: 'column' },
1920
- createElement(Input, {
1921
- active: inputActive,
1922
- frozen: modalVisible,
1923
- busy,
1924
- descriptors,
1925
- skills,
1926
- dispatch: props.dispatch,
1927
- steer: props.steer,
1928
- interrupt: props.interrupt,
1929
- quit: props.quit,
1930
- openModel: () => {
1931
- setModelOpen(true)
1932
- },
1933
- openHelp: () => {
1934
- setHelpOpen(true)
1935
- },
1936
- notify,
1937
- openVerbose: () => {
1938
- setVerboseOpen(true)
1939
- },
1940
- clearView: () => {
1941
- props.store.reset()
1942
- },
1943
- refresh: refreshScreen,
1944
- toggleReasoning: () => {
1945
- setShowReasoning(current => !current)
1946
- },
1947
- loadMentions: props.loadMentions,
1948
- cyclePermission: props.cyclePermission,
1949
- exportTranscript: props.exportTranscript,
1950
- renameTitle: props.renameTitle,
1951
- }),
1952
- createElement(StatusLine, {
1953
- facts: {
1954
- model: modelLabel,
1955
- cwd: props.cwd,
1956
- branch: props.branch,
1957
- sessionId: props.sessionId,
1958
- title: view.title,
1959
- plan: view.plan,
1960
- permission: view.permission,
1961
- sandbox: view.sandbox,
1962
- goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
1963
- },
1964
- stats: view.stats,
1965
- busy,
1966
- }),
1967
- ),
1968
- )
1969
- }
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
+ }