dsh-code 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +201 -55
  2. package/README.zh.md +204 -61
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/index.mjs +4073 -1802
  6. package/lib/startup.mjs +34 -17
  7. package/lib/types/app.d.ts +23 -22
  8. package/lib/types/commands.d.ts +2 -0
  9. package/lib/types/index.d.ts +5 -5
  10. package/lib/types/internals.d.ts +2 -0
  11. package/lib/types/kernel-panels.d.ts +23 -0
  12. package/lib/types/plugin-inventory.d.ts +11 -0
  13. package/lib/types/presets.d.ts +32 -0
  14. package/lib/types/render/export.d.ts +15 -0
  15. package/lib/types/render/inspector.d.ts +34 -0
  16. package/lib/types/render/lines.d.ts +31 -0
  17. package/lib/types/render/projection.d.ts +95 -3
  18. package/lib/types/render/status.d.ts +19 -0
  19. package/lib/types/render/text.d.ts +27 -0
  20. package/lib/types/render/tool-detail.d.ts +92 -0
  21. package/lib/types/session-directory.d.ts +54 -0
  22. package/lib/types/session-switch.d.ts +17 -0
  23. package/lib/types/skills.d.ts +2 -0
  24. package/lib/types/startup.d.ts +11 -1
  25. package/lib/types/store.d.ts +2 -0
  26. package/package.json +16 -1
  27. package/src/app.ts +1367 -277
  28. package/src/commands.ts +15 -1
  29. package/src/index.ts +373 -128
  30. package/src/internals.ts +5 -0
  31. package/src/kernel-panels.ts +254 -0
  32. package/src/plugin-inventory.ts +47 -0
  33. package/src/presets.ts +64 -0
  34. package/src/render/export.ts +81 -0
  35. package/src/render/inspector.ts +88 -0
  36. package/src/render/lines.ts +207 -0
  37. package/src/render/markdown.ts +15 -1
  38. package/src/render/projection.ts +279 -16
  39. package/src/render/status.ts +51 -1
  40. package/src/render/text.ts +107 -0
  41. package/src/render/tool-detail.ts +197 -0
  42. package/src/session-directory.ts +102 -0
  43. package/src/session-switch.ts +58 -0
  44. package/src/skills.ts +20 -7
  45. package/src/startup.ts +38 -20
  46. package/src/store.ts +8 -0
package/src/app.ts CHANGED
@@ -15,27 +15,60 @@
15
15
  */
16
16
 
17
17
  import {
18
- createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
18
+ createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
19
19
  } from 'react'
20
- import { Box, Text, useInput, useStdout } from 'ink'
20
+ import { Box, Static, Text, useInput, useStdout } from 'ink'
21
21
  import { assertNever } from '@deepseek-ai/dsh-llm'
22
22
  import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
23
  import type { TodoItem } from '@deepseek-ai/dsh-session'
24
24
  import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
25
- import { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
25
+ import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
26
26
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
27
27
  import type { TranscriptStore } from './store.ts'
28
- import type { TranscriptEntry } from './render/projection.ts'
28
+ import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
29
29
  import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
+ import type { ToolDetail } from './render/tool-detail.ts'
30
31
  import { caretVisible, pulseFrame } from './render/animations.ts'
31
- import type { ApprovalStore } from './approval.ts'
32
+ import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
32
33
  import type { CommandsView } from './commands.ts'
33
34
  import type { ModelDirectory, ModelRow } from './models.ts'
34
- import type { QuestionStore } from './questions.ts'
35
+ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
35
36
  import type { SkillsView, SkillRow } from './skills.ts'
36
37
  import type { MentionCandidate } from './mentions.ts'
37
- import { buildStatusGroups, type StatusFacts } from './render/status.ts'
38
- import { displayText } from './render/text.ts'
38
+ import { ModePanel, PluginPanel, ResumePanel } from './kernel-panels.ts'
39
+ import type { PresetRow } from './presets.ts'
40
+ import type { PluginRow } from './plugin-inventory.ts'
41
+ import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
42
+
43
+ /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
44
+ const RESIZE_REFLOW_DELAY_MS = 75
45
+
46
+ /** Reset region/style, clear the visible screen and scrollback, then home. */
47
+ const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
48
+ import { buildStatusGroups, formatTokens, type StatusFacts } from './render/status.ts'
49
+ import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
50
+ import {
51
+ clampScroll,
52
+ followInspectorCursor,
53
+ inspectorViewport,
54
+ layoutGutterRows,
55
+ moveScroll,
56
+ panelViewport,
57
+ revealRow,
58
+ selectionWindow,
59
+ } from './render/inspector.ts'
60
+ import {
61
+ lineSegment,
62
+ markdownLines,
63
+ styledLines,
64
+ textLines,
65
+ transcriptEntryLines,
66
+ type LineStyle,
67
+ type StyledLine,
68
+ } from './render/lines.ts'
69
+
70
+ /** Visual priority for one bounded local notice. */
71
+ export type NoticeTone = 'info' | 'warning' | 'error'
39
72
 
40
73
  /** Props the runner hands the app; callbacks stay owned by the runner. */
41
74
  export interface AppProps {
@@ -53,12 +86,16 @@ export interface AppProps {
53
86
  model: string
54
87
  /** Working-directory basename the session serves. */
55
88
  cwd: string
89
+ /** Absolute working directory used by session filters and references. */
90
+ workspaceRoot: string
56
91
  /** Git branch name, empty outside a repository. */
57
92
  branch: string
58
93
  /** Short session identifier. */
59
94
  sessionId: string
60
95
  /** Whether this session was resumed from persistence. */
61
96
  resumed: boolean
97
+ /** Agent preset currently composing the session. */
98
+ mode: string
62
99
  /** Submit one line: slash commands to the registry, other text to the agent. */
63
100
  dispatch(text: string): void
64
101
  /** Submit steering: consumed at the running turn's next step boundary. */
@@ -75,8 +112,21 @@ export interface AppProps {
75
112
  selectModel(row: ModelRow): string
76
113
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
77
114
  cyclePermission(): string
115
+ /** Export the transcript to a markdown file (/export [path]); reports via notices. */
116
+ exportTranscript(argument: string): Promise<void>
117
+ /** Rename the session (/title <text>); returns the outcome line for the notice. */
118
+ renameTitle(argument: string): string
119
+ /** Preset/session/plugin kernel operations. */
120
+ loadPresets(): Promise<readonly PresetRow[]>
121
+ switchMode(id: string): Promise<string>
122
+ createSession(mode?: string): void
123
+ loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
124
+ loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
125
+ switchSession(row: SessionRow): void
126
+ cancelSessionSwitch(): boolean
127
+ loadPlugins(): readonly PluginRow[]
78
128
  /** Registers the app's notice channel with the runner (called once on mount). */
79
- onBridgeReady(bridge: { notify(text: string): void }): void
129
+ onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
80
130
  }
81
131
 
82
132
  /** Ink `color` string for one palette triple. */
@@ -84,23 +134,10 @@ function inkColor(triple: readonly [number, number, number]): string {
84
134
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
85
135
  }
86
136
 
87
- /** Truncate text to a visible-column budget, appending … when cut. */
88
- function truncateColumns(text: string, max: number): string {
89
- let columns = 0
90
- let out = ''
91
- for (const char of text) {
92
- const code = char.codePointAt(0) ?? 0
93
- const width = code > 0x2e7f ? 2 : 1
94
- if (columns + width > max) return `${out}…`
95
- out += char
96
- columns += width
97
- }
98
- return out
99
- }
100
-
101
137
  /** Pad text with spaces to a visible-column target (menu name column). */
102
138
  function padColumns(text: string, width: number): string {
103
- return text + ' '.repeat(Math.max(0, width - visibleColumns(text)))
139
+ const clipped = truncateColumns(singleLineText(text), width)
140
+ return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
104
141
  }
105
142
 
106
143
  /** Interval-driven frame counter for one self-contained animated leaf. */
@@ -133,6 +170,64 @@ function CursorBlock({ char }: { char: string }): ReactElement {
133
170
  return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
134
171
  }
135
172
 
173
+ /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
174
+ function runClock(ms: number): string {
175
+ const total = Math.max(0, Math.floor(ms / 1000))
176
+ const minutes = Math.floor(total / 60)
177
+ const seconds = total % 60
178
+ return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
179
+ }
180
+
181
+ /**
182
+ * The busy line, web TurnStatus contract: the plain `Deep diving...` label,
183
+ * with the elapsed clock appended only once the turn has clearly been running
184
+ * (15s) — anchored to `turn/start` so a resumed mid-turn keeps the real time.
185
+ */
186
+ function DeepDivingLine({ since }: { since: number }): ReactElement {
187
+ useFrames(1000)
188
+ const elapsed = since === 0 ? 0 : Date.now() - since
189
+ return createElement(
190
+ Text,
191
+ { dimColor: true },
192
+ elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
193
+ )
194
+ }
195
+
196
+ /**
197
+ * The streaming buffer rendered with a hard size cap: the live region must
198
+ * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
199
+ * than the screen freezes (cursor-up past the top, garbage, no scroll). The
200
+ * cap counts explicit newlines and terminal wrapping, slicing from the END so
201
+ * the freshest tokens stay visible while a long reply streams; the complete
202
+ * text lands in the flushed scrollback once the turn assembles it.
203
+ */
204
+ function StreamTail({ text, dim, maxRows, prefix, children }: {
205
+ text: string
206
+ dim: boolean
207
+ maxRows: number
208
+ prefix?: string
209
+ children?: ReactElement
210
+ }): ReactElement {
211
+ const columns = useStdout().stdout?.columns ?? 80
212
+ const safeRows = Math.max(1, maxRows)
213
+ // App padding consumes two columns; the final extra column keeps a caret
214
+ // from wrapping onto an unbudgeted row.
215
+ const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
216
+ const initial = displayTail(text, contentColumns, safeRows)
217
+ // Reserve one row for the omission marker only when a marker is needed.
218
+ const tail = initial.truncated && safeRows > 1
219
+ ? displayTail(text, contentColumns, safeRows - 1)
220
+ : initial
221
+ return createElement(
222
+ Box,
223
+ { flexDirection: 'column' },
224
+ tail.truncated && safeRows > 1
225
+ ? createElement(Text, { dimColor: true }, ' …')
226
+ : undefined,
227
+ createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
228
+ )
229
+ }
230
+
136
231
  /** Ink props for one markdown style class. */
137
232
  function segmentProps(style: MdSegment['style']): {
138
233
  color: string | undefined
@@ -160,6 +255,54 @@ function segmentProps(style: MdSegment['style']): {
160
255
  }
161
256
  }
162
257
 
258
+ /** Ink props for the richer line model used by bounded scrolling panels. */
259
+ function lineStyleProps(style: LineStyle): {
260
+ color: string | undefined
261
+ bold: boolean | undefined
262
+ italic: boolean | undefined
263
+ strikethrough: boolean | undefined
264
+ dimColor: boolean | undefined
265
+ } {
266
+ switch (style) {
267
+ case 'brand':
268
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
269
+ case 'success':
270
+ return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
271
+ case 'error':
272
+ return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
273
+ case 'warn':
274
+ return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
275
+ case 'dimItalic':
276
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
277
+ default:
278
+ return { ...segmentProps(style), dimColor: undefined }
279
+ }
280
+ }
281
+
282
+ /** Render width-safe rows; every child is exactly one terminal row. */
283
+ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
284
+ return createElement(
285
+ Box,
286
+ { flexDirection: 'column' },
287
+ ...lines.map((line, index) => createElement(
288
+ Text,
289
+ { key: index, wrap: 'truncate-end' },
290
+ line.segments.length === 0
291
+ ? ' '
292
+ : line.segments.map((segment, at) => createElement(
293
+ Text,
294
+ { key: at, ...lineStyleProps(segment.style) },
295
+ segment.text,
296
+ )),
297
+ )),
298
+ )
299
+ }
300
+
301
+ /** Codex-style panel rhythm that still participates in the row budget. */
302
+ function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
303
+ return visible ? createElement(Text, null, ' ') : undefined
304
+ }
305
+
163
306
  /** One settled markdown document rendered as styled lines at the terminal width. */
164
307
  function MarkdownBody({ text }: { text: string }): ReactElement {
165
308
  const columns = useStdout().stdout?.columns ?? 80
@@ -174,13 +317,78 @@ function MarkdownBody({ text }: { text: string }): ReactElement {
174
317
  ...lines.map((line, index) => createElement(
175
318
  Text,
176
319
  { key: index },
177
- ...line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
320
+ line.segments.length === 0
321
+ ? ' '
322
+ : line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
178
323
  )),
179
324
  )
180
325
  }
181
326
 
327
+ /**
328
+ * One expanded tool-card body for the verbose transcript (Ctrl+O): the
329
+ * presentation contract's structured cards — inline diffs, read windows,
330
+ * web sources — rendered as plain terminal rows, degradation-safe against
331
+ * replayed metadata.
332
+ */
333
+ function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
334
+ switch (detail.kind) {
335
+ case 'diff':
336
+ return createElement(
337
+ Box,
338
+ { flexDirection: 'column' },
339
+ ...detail.diffs.map((diff, index) => createElement(
340
+ Box,
341
+ { key: index, flexDirection: 'column' },
342
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
343
+ ...diff.lines.map((line, at) => createElement(
344
+ Text,
345
+ {
346
+ key: at,
347
+ color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
348
+ wrap: 'truncate-end',
349
+ },
350
+ ` ${line.mark}${displayText(line.text)}`,
351
+ )),
352
+ )),
353
+ )
354
+ case 'read':
355
+ return createElement(
356
+ Box,
357
+ { flexDirection: 'column' },
358
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
359
+ ...detail.lines.map((line, at) => createElement(
360
+ Text,
361
+ { key: at, dimColor: true, wrap: 'truncate-end' },
362
+ ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
363
+ )),
364
+ )
365
+ case 'web-search':
366
+ return createElement(
367
+ Box,
368
+ { flexDirection: 'column' },
369
+ ...detail.sources.map((source, at) => createElement(
370
+ Text,
371
+ { key: at, wrap: 'truncate-end' },
372
+ brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
373
+ createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
374
+ )),
375
+ createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
376
+ )
377
+ case 'web-fetch':
378
+ return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
379
+ case 'raw':
380
+ return createElement(
381
+ Box,
382
+ { flexDirection: 'column' },
383
+ ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
384
+ createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
385
+ )
386
+ default:
387
+ return assertNever(detail, 'tool detail kind')
388
+ }
389
+ }
182
390
  /** One settled transcript row. */
183
- function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReasoning: boolean }): ReactElement {
391
+ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
184
392
  switch (entry.kind) {
185
393
  case 'user':
186
394
  // Collapsed injected context reads as a dim ↳ row; only direct human
@@ -216,7 +424,7 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
216
424
  { flexDirection: 'column' },
217
425
  createElement(
218
426
  Text,
219
- null,
427
+ { wrap: verbose ? 'truncate-end' : undefined },
220
428
  mark,
221
429
  ' ',
222
430
  brand(entry.name),
@@ -226,9 +434,12 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
226
434
  ? undefined
227
435
  : createElement(
228
436
  Text,
229
- { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim) },
437
+ { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
230
438
  ` ⎿ ${displayText(entry.summary)}`,
231
439
  ),
440
+ verbose && entry.detail !== undefined
441
+ ? createElement(ToolDetailBody, { detail: entry.detail })
442
+ : undefined,
232
443
  )
233
444
  }
234
445
  case 'command': {
@@ -242,7 +453,7 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
242
453
  { flexDirection: 'column' },
243
454
  createElement(
244
455
  Text,
245
- null,
456
+ { wrap: verbose ? 'truncate-end' : undefined },
246
457
  mark,
247
458
  ' ',
248
459
  brand(`/${entry.name}`),
@@ -250,11 +461,37 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
250
461
  ),
251
462
  entry.summary === ''
252
463
  ? undefined
253
- : createElement(Text, { color: inkColor(TUI_RGB.dim) }, ` ⎿ ${displayText(entry.summary)}`),
464
+ : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
254
465
  )
255
466
  }
467
+ case 'turn-marker':
468
+ // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
469
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
470
+ case 'compaction':
471
+ // Completed compaction lifecycle: what it reclaimed, or why it failed.
472
+ return createElement(
473
+ Text,
474
+ { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
475
+ entry.ok
476
+ ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
477
+ : ` ⧉ compaction failed: ${displayText(entry.error)}`,
478
+ )
479
+ case 'retry':
480
+ // Provider-routed retry: amber while the backoff waits, dim once the
481
+ // next attempt is underway.
482
+ return createElement(
483
+ Text,
484
+ { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
485
+ ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
486
+ )
487
+ case 'files': {
488
+ // Turn-tail deliverables: the turn's mutated files (web turnTail chips).
489
+ const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
490
+ const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
491
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⎄ ${shown}${more}`)
492
+ }
256
493
  case 'error':
257
- return createElement(Text, null, paintError(displayText(entry.text)))
494
+ return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
258
495
  default:
259
496
  return assertNever(entry, 'transcript entry kind')
260
497
  }
@@ -303,33 +540,23 @@ function todoMark(status: TodoItem['status']): string {
303
540
  return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
304
541
  }
305
542
 
306
- /** Inline todo list (web TodoPanel's compact terminal form). */
543
+ /** One-row todo summary: task count cannot grow the live Ink tree. */
307
544
  function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
308
545
  if (todos.length === 0) return undefined
309
546
  const completed = todos.filter(todo => todo.status === 'completed').length
310
547
  const inProgress = todos.filter(todo => todo.status === 'in_progress').length
311
548
  const pending = todos.length - completed - inProgress
549
+ const current = todos.find(todo => todo.status === 'in_progress')
312
550
  return createElement(
313
551
  Box,
314
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
552
+ { paddingX: 1 },
315
553
  createElement(
316
554
  Text,
317
- { color: inkColor(TUI_RGB.brand), bold: true },
555
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
318
556
  `todos ${completed}/${todos.length}`,
319
557
  createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
558
+ current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
320
559
  ),
321
- ...todos.map((todo, index) => createElement(
322
- Text,
323
- {
324
- key: index,
325
- color: todo.status === 'completed'
326
- ? inkColor(TUI_RGB.success)
327
- : todo.status === 'in_progress'
328
- ? inkColor(TUI_RGB.brandBright)
329
- : inkColor(TUI_RGB.dim),
330
- },
331
- `${todoMark(todo.status)} ${displayText(todo.content)}`,
332
- )),
333
560
  )
334
561
  }
335
562
 
@@ -345,29 +572,92 @@ function StatusLine({ facts, stats, busy }: {
345
572
  busy: boolean
346
573
  }): ReactElement {
347
574
  const groups = buildStatusGroups(facts, stats)
348
- const children: ReactElement[] = [
349
- busy
350
- ? createElement(Pulse)
351
- : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○'),
352
- createElement(Text, null, ' '),
353
- ]
354
- groups.forEach((group, index) => {
355
- if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
356
- children.push(createElement(Text, { dimColor: true }, group))
357
- })
358
- // Left-aligned status bar; the top margin keeps it clear of the input box.
575
+ // One physical row in every mode: a narrow terminal must truncate instead
576
+ // of wrapping the status groups into an unbudgeted second/third row.
359
577
  return createElement(
360
578
  Box,
361
- { paddingX: 1, marginTop: 1 },
362
- ...children,
579
+ // Match the prompt text inside the bordered composer: one border column
580
+ // plus one padding column. Keeping this row margin-free also makes the
581
+ // composer and status a fixed four-row unit in every interface.
582
+ { paddingLeft: 2 },
583
+ createElement(
584
+ Text,
585
+ { dimColor: true, wrap: 'truncate-end' },
586
+ busy ? '● ' : '○ ',
587
+ groups.join(' | '),
588
+ ),
589
+ )
590
+ }
591
+
592
+ /**
593
+ * One fixed-height local feedback row. Errors remain visible while a slash
594
+ * subpage is open, but arbitrary exception text can never add physical rows
595
+ * above the composer.
596
+ */
597
+ function NoticeLine({ text, tone, columns }: {
598
+ text: string
599
+ tone: NoticeTone
600
+ columns: number
601
+ }): ReactElement {
602
+ const color = tone === 'error'
603
+ ? TUI_RGB.error
604
+ : tone === 'warning'
605
+ ? TUI_RGB.warn
606
+ : TUI_RGB.brandBright
607
+ const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•'
608
+ return createElement(
609
+ Box,
610
+ { paddingLeft: 2 },
611
+ createElement(
612
+ Text,
613
+ { color: inkColor(color), wrap: 'truncate-end' },
614
+ truncateColumns(`${mark} ${singleLineText(text)}`, Math.max(1, columns - 2)),
615
+ ),
363
616
  )
364
617
  }
365
618
 
366
619
  /** The y/n approval bar rendered while an approval ask is pending. */
367
- function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
368
- const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
369
- useInput((input) => {
370
- if (locked || snapshot.pending === undefined || snapshot.answered) return
620
+ function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
621
+ const stdout = useStdout().stdout
622
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
623
+ const [scroll, setScroll] = useState(0)
624
+ const pending = snapshot.pending
625
+ const active = !locked && snapshot.pending !== undefined && !snapshot.answered
626
+ const content = useMemo<readonly StyledLine[]>(() => pending === undefined
627
+ ? []
628
+ : [
629
+ ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
630
+ ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
631
+ ], [pending, viewport.contentColumns])
632
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
633
+
634
+ useEffect(() => {
635
+ setScroll(0)
636
+ }, [pending])
637
+
638
+ useEffect(() => {
639
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
640
+ }, [visibleScroll, scroll])
641
+
642
+ useInput((input, key) => {
643
+ if (snapshot.pending === undefined) return
644
+ if (key.upArrow) {
645
+ setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
646
+ return
647
+ }
648
+ if (key.downArrow) {
649
+ setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
650
+ return
651
+ }
652
+ if (key.pageUp) {
653
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
654
+ return
655
+ }
656
+ if (key.pageDown) {
657
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
658
+ return
659
+ }
660
+ if (snapshot.answered) return
371
661
  if (input === 'y' || input === 'Y') {
372
662
  snapshot.pending.answer('allowed-once')
373
663
  return
@@ -375,18 +665,23 @@ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: bo
375
665
  if (input === 'n' || input === 'N') {
376
666
  snapshot.pending.answer('rejected')
377
667
  }
378
- })
668
+ }, { isActive: active })
379
669
  if (snapshot.pending === undefined) return undefined
380
- const { pending, answered } = snapshot
670
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
671
+ if (viewport.compact) {
672
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
673
+ }
674
+ const { answered } = snapshot
381
675
  return createElement(
382
676
  Box,
383
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
384
- createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
385
- createElement(Text, null, warn(displayText(pending.headline))),
386
- pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
387
- answered
388
- ? createElement(Text, { dimColor: true }, ' submitted…')
389
- : createElement(Text, { dimColor: true }, dim(' y allow once · n reject')),
677
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
678
+ createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
679
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
680
+ createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
681
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
682
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
683
+ ? 'submitted…'
684
+ : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
390
685
  )
391
686
  }
392
687
 
@@ -398,8 +693,9 @@ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: bo
398
693
  * service with a `plan-review` intent — the approve option gets a ✓ mark,
399
694
  * the answer encoding stays identical.
400
695
  */
401
- function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
402
- const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
696
+ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapshot: QuestionSnapshot; locked: boolean }): ReactElement | undefined {
697
+ const stdout = useStdout().stdout
698
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
403
699
  const pending = snapshot.pending
404
700
  const [index, setIndex] = useState(0)
405
701
  const [cursor, setCursor] = useState(0)
@@ -408,6 +704,7 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
408
704
  const [custom, setCustom] = useState('')
409
705
  const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
410
706
  const [submitted, setSubmitted] = useState(false)
707
+ const [scroll, setScroll] = useState(0)
411
708
 
412
709
  // A new request resets the walk; questions without options start in the
413
710
  // custom-answer box (a free-form question).
@@ -420,12 +717,65 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
420
717
  setCustom('')
421
718
  setAnswers([])
422
719
  setSubmitted(false)
720
+ setScroll(0)
423
721
  }, [pending])
424
722
 
425
723
  const question = pending?.request.questions[index]
426
724
  const options = question?.options ?? []
427
725
  const isPlan = question?.intent?.kind === 'plan-review'
428
726
  const isMulti = question?.multiSelect === true
727
+ const active = !locked && pending !== undefined && question !== undefined && !submitted
728
+ const rendered = useMemo(() => {
729
+ if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
730
+ const lines: StyledLine[] = []
731
+ const optionRows: number[] = []
732
+ if (question.header !== undefined) {
733
+ lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
734
+ }
735
+ lines.push(...textLines(question.question, viewport.contentColumns))
736
+ if (question.detail !== undefined) {
737
+ lines.push(...(isPlan
738
+ ? markdownLines(question.detail, viewport.contentColumns)
739
+ : textLines(question.detail, viewport.contentColumns, 'dim')))
740
+ }
741
+ if (submitted) {
742
+ lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
743
+ } else if (mode === 'custom' || options.length === 0) {
744
+ lines.push(...styledLines([
745
+ lineSegment(' custom: ', 'brand'),
746
+ lineSegment(custom, 'plain'),
747
+ lineSegment('▌', 'brand'),
748
+ ], viewport.contentColumns))
749
+ } else {
750
+ options.forEach((option, at) => {
751
+ optionRows.push(lines.length)
752
+ const chosen = isMulti && selected.includes(at)
753
+ const approve = isPlan && question.intent?.approve === option.label
754
+ const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
755
+ const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
756
+ lines.push(...styledLines([
757
+ lineSegment(mark, style),
758
+ lineSegment(option.label, style),
759
+ lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
760
+ ], viewport.contentColumns))
761
+ })
762
+ }
763
+ return { lines, optionRows }
764
+ }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
765
+ const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows)
766
+
767
+ useEffect(() => {
768
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
769
+ }, [visibleScroll, scroll])
770
+
771
+ useEffect(() => {
772
+ if (mode === 'options' && options.length > 0) {
773
+ const focused = rendered.optionRows[cursor] ?? 0
774
+ setScroll(current => revealRow(current, focused, rendered.lines.length, viewport.bodyRows))
775
+ return
776
+ }
777
+ setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows))
778
+ }, [cursor, mode, custom.length, rendered.lines.length, viewport.bodyRows])
429
779
 
430
780
  const commit = (answer: AskUserQuestionAnswerItem): void => {
431
781
  if (pending === undefined) return
@@ -437,11 +787,14 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
437
787
  return
438
788
  }
439
789
  setAnswers(next)
440
- setIndex(index + 1)
790
+ const nextIndex = index + 1
791
+ const nextQuestion = pending.request.questions[nextIndex]
792
+ setIndex(nextIndex)
441
793
  setCursor(0)
442
794
  setSelected([])
443
- setMode('options')
795
+ setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
444
796
  setCustom('')
797
+ setScroll(0)
445
798
  }
446
799
 
447
800
  const commitOption = (): void => {
@@ -460,12 +813,28 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
460
813
  }
461
814
 
462
815
  useInput((input, key) => {
463
- if (locked || pending === undefined || question === undefined || submitted) return
816
+ if (pending === undefined || question === undefined || submitted) return
464
817
  if (key.escape) {
465
818
  store.cancel(pending)
466
819
  return
467
820
  }
821
+ if (key.pageUp) {
822
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
823
+ return
824
+ }
825
+ if (key.pageDown) {
826
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
827
+ return
828
+ }
468
829
  if (mode === 'custom' || options.length === 0) {
830
+ if (key.upArrow) {
831
+ setScroll(current => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows))
832
+ return
833
+ }
834
+ if (key.downArrow) {
835
+ setScroll(current => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows))
836
+ return
837
+ }
469
838
  if (key.return) {
470
839
  if (custom.trim() === '' && options.length > 0) {
471
840
  commitOption()
@@ -508,68 +877,66 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
508
877
  if (input === ' ' && isMulti) {
509
878
  setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
510
879
  }
511
- })
880
+ }, { isActive: active })
512
881
 
513
882
  if (pending === undefined || question === undefined) return undefined
883
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
884
+ if (viewport.compact) {
885
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
886
+ }
887
+ const footer = submitted
888
+ ? 'submitted…'
889
+ : mode === 'custom' || options.length === 0
890
+ ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
891
+ : isMulti
892
+ ? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
893
+ : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
514
894
  return createElement(
515
895
  Box,
516
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
896
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
517
897
  createElement(
518
898
  Text,
519
- { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true },
520
- isPlan ? `📋 plan review (${index + 1}/${pending.request.questions.length})` : `❓ question ${index + 1}/${pending.request.questions.length}`,
899
+ { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
900
+ truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
521
901
  ),
522
- question.header === undefined ? undefined : createElement(Text, { bold: true }, displayText(question.header)),
523
- createElement(Text, null, displayText(question.question)),
524
- question.detail === undefined
525
- ? undefined
526
- : isPlan
527
- ? createElement(MarkdownBody, { text: question.detail })
528
- : createElement(Text, { dimColor: true }, displayText(question.detail)),
529
- submitted
530
- ? createElement(Text, { dimColor: true }, ' submitted…')
531
- : createElement(
532
- Box,
533
- { flexDirection: 'column', marginLeft: 1 },
534
- ...(mode === 'custom' || options.length === 0
535
- ? [
536
- createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` custom: ${custom}${submitted ? '' : '▌'}`),
537
- createElement(Text, { dimColor: true }, dim(' type your answer · enter submit · esc interrupt')),
538
- ]
539
- : options.map((option, at) => {
540
- const chosen = isMulti && selected.includes(at)
541
- const approve = isPlan && question.intent?.approve === option.label
542
- const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
543
- return createElement(
544
- Text,
545
- {
546
- key: at,
547
- color: at === cursor ? inkColor(TUI_RGB.brandBright) : chosen || approve ? inkColor(TUI_RGB.success) : inkColor(TUI_RGB.text),
548
- },
549
- `${mark}${displayText(option.label)}${option.description === undefined ? '' : dim(` — ${displayText(option.description)}`)}`,
550
- )
551
- })),
552
- createElement(Text, { dimColor: true }, dim(isMulti
553
- ? ' ↑↓ move · space toggle · enter submit · c custom · esc interrupt'
554
- : ' ↑↓ move · enter submit · c custom · esc interrupt')),
555
- ),
902
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
903
+ createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
904
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
905
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
556
906
  )
557
907
  }
558
908
 
559
909
  /** The /model panel: a scrolling list over the advisory model directory. */
560
- function ModelPanel({ directory, error, onSelect, onClose }: {
910
+ function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
561
911
  directory: ModelDirectory | undefined
562
912
  error: string | undefined
563
913
  onSelect(row: ModelRow): void
914
+ onRetry(): void
564
915
  onClose(): void
565
916
  }): ReactElement {
566
917
  const [cursor, setCursor] = useState(0)
918
+ const stdout = useStdout().stdout
919
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
920
+ const rows = directory?.rows ?? []
921
+
922
+ useEffect(() => {
923
+ if (rows.length === 0) {
924
+ if (cursor !== 0) setCursor(0)
925
+ return
926
+ }
927
+ if (cursor >= rows.length) setCursor(rows.length - 1)
928
+ }, [rows.length, cursor])
929
+
567
930
  useInput((input, key) => {
568
931
  if (key.escape || input === 'q') {
569
932
  onClose()
570
933
  return
571
934
  }
572
- const rows = directory?.rows ?? []
935
+ if (input === 'r') {
936
+ onRetry()
937
+ return
938
+ }
939
+ if (rows.length === 0) return
573
940
  if (key.upArrow) {
574
941
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
575
942
  return
@@ -578,24 +945,64 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
578
945
  setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
579
946
  return
580
947
  }
948
+ if (key.pageUp) {
949
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
950
+ return
951
+ }
952
+ if (key.pageDown) {
953
+ setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
954
+ return
955
+ }
956
+ if (input === 'g') {
957
+ setCursor(0)
958
+ return
959
+ }
960
+ if (input === 'G') {
961
+ setCursor(rows.length - 1)
962
+ return
963
+ }
581
964
  if (key.return && rows[cursor] !== undefined) {
582
965
  onSelect(rows[cursor])
583
966
  }
584
967
  })
585
- const rows = directory?.rows ?? []
586
- const window = 8
587
- const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
588
- const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
968
+
969
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
970
+ if (viewport.compact) {
971
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
972
+ }
973
+
974
+ const stateRows: ReactElement[] = directory === undefined && error === undefined
975
+ ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ' loading models…')]
976
+ : error !== undefined
977
+ ? [createElement(
978
+ Text,
979
+ { key: 'error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
980
+ truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
981
+ )]
982
+ : [
983
+ ...(directory?.failures.length === 0
984
+ ? []
985
+ : [createElement(
986
+ Text,
987
+ { key: 'failures', color: inkColor(TUI_RGB.warn), wrap: 'truncate-end' },
988
+ truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
989
+ )]),
990
+ ...(rows.length === 0
991
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
992
+ : []),
993
+ ]
994
+ // Measurement and rendering share the same physical-row budget: state
995
+ // messages consume body rows before selectable entries, as in Codex's
996
+ // list-selection views.
997
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length)
998
+ const first = selectionWindow(cursor, rows.length, rowBudget)
999
+ const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
589
1000
  return createElement(
590
1001
  Box,
591
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
592
- createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
593
- directory === undefined && error === undefined
594
- ? createElement(Text, { dimColor: true }, ' loading models…')
595
- : undefined,
596
- error !== undefined
597
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
598
- : undefined,
1002
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1003
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1004
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1005
+ ...stateRows,
599
1006
  ...visible.map((row) => {
600
1007
  const index = rows.indexOf(row)
601
1008
  const label = displayText(`${row.providerName} · ${row.modelName}`)
@@ -604,14 +1011,303 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
604
1011
  {
605
1012
  key: `${row.provider}/${row.model}`,
606
1013
  color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1014
+ wrap: 'truncate-end',
607
1015
  },
608
- `${index === cursor ? '❯ ' : ' '}${label}`,
1016
+ truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
609
1017
  )
610
1018
  }),
611
- createElement(Text, { dimColor: true }, dim(' ↑↓ move · enter select · esc close')),
1019
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1020
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · r retry · esc/q close', viewport.contentColumns))),
612
1021
  )
613
1022
  }
614
1023
 
1024
+ /**
1025
+ * The /help overlay: one scrolling card with the keyboard map, the TUI-local
1026
+ * commands, the live registry commands, and the user-invocable skills — the
1027
+ * real command surface, replacing the one-line notice.
1028
+ */
1029
+ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1030
+ descriptors: readonly CommandDescriptor[]
1031
+ skills: readonly SkillRow[]
1032
+ commandError: string | undefined
1033
+ skillError: string | undefined
1034
+ onClose(): void
1035
+ }): ReactElement {
1036
+ const stdout = useStdout().stdout
1037
+ const columns = stdout?.columns ?? 80
1038
+ const viewport = panelViewport(columns, stdout?.rows ?? 30)
1039
+ const [scroll, setScroll] = useState(0)
1040
+ const nameWidth = Math.min(18, Math.max(1, viewport.contentColumns - 2))
1041
+ const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
1042
+ const row = (label: string, description: string): ReactElement => createElement(
1043
+ Text,
1044
+ { dimColor: true, wrap: 'truncate-end' },
1045
+ ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
1046
+ )
1047
+ const content: ReactElement[] = [
1048
+ createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
1049
+ createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
1050
+ createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
1051
+ createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
1052
+ createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
1053
+ createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
1054
+ createElement(Text, { key: 'commands-gap' }, ' '),
1055
+ createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
1056
+ ...(commandError === undefined
1057
+ ? []
1058
+ : [createElement(
1059
+ Text,
1060
+ { key: 'commands-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1061
+ truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
1062
+ )]),
1063
+ createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
1064
+ createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1065
+ createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
1066
+ createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1067
+ createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1068
+ createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
1069
+ createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1070
+ createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
1071
+ createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
1072
+ createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
1073
+ ...descriptors.map(descriptor => createElement(
1074
+ Text,
1075
+ { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
1076
+ ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
1077
+ )),
1078
+ ...(skills.length === 0 && skillError === undefined
1079
+ ? []
1080
+ : [
1081
+ createElement(Text, { key: 'skills-gap' }, ' '),
1082
+ createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills'),
1083
+ ]),
1084
+ ...(skillError === undefined
1085
+ ? []
1086
+ : [createElement(
1087
+ Text,
1088
+ { key: 'skills-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
1089
+ truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns),
1090
+ )]),
1091
+ ...skills.map(skill => createElement(
1092
+ Text,
1093
+ { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
1094
+ ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
1095
+ )),
1096
+ ]
1097
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
1098
+ const scrollBy = (delta: number): void => {
1099
+ setScroll(current => moveScroll(current, delta, content.length, viewport.bodyRows))
1100
+ }
1101
+
1102
+ useEffect(() => {
1103
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
1104
+ }, [visibleScroll, scroll])
1105
+
1106
+ useInput((input, key) => {
1107
+ if (key.escape || input === 'q') {
1108
+ onClose()
1109
+ return
1110
+ }
1111
+ if (key.upArrow) scrollBy(-1)
1112
+ else if (key.downArrow) scrollBy(1)
1113
+ else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
1114
+ else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
1115
+ else if (input === 'g') setScroll(0)
1116
+ else if (input === 'G') setScroll(Math.max(0, content.length - viewport.bodyRows))
1117
+ })
1118
+
1119
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1120
+ if (viewport.compact) {
1121
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
1122
+ }
1123
+
1124
+ return createElement(
1125
+ Box,
1126
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1127
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
1128
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1129
+ ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1130
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1131
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
1132
+ )
1133
+ }
1134
+
1135
+ /** Collapse arbitrary metadata to one terminal row before verbose rendering. */
1136
+ function verboseLine(text: string, columns: number): string {
1137
+ return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
1138
+ }
1139
+
1140
+ /** One-row editor window keeping the logical cursor visible in long drafts. */
1141
+ function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
1142
+ const width = Math.max(1, columns)
1143
+ const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
1144
+ const caretSource = value.slice(cursor, cursor + 1)
1145
+ const caret = caretSource === '' ? ' ' : normalize(caretSource)
1146
+ const remaining = Math.max(0, width - visibleColumns(caret))
1147
+ const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
1148
+ const beforeBudget = Math.max(0, remaining - afterBudget)
1149
+ const before = beforeBudget === 0
1150
+ ? ''
1151
+ : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
1152
+ const after = afterBudget === 0
1153
+ ? ''
1154
+ : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
1155
+ return { before, caret, after }
1156
+ }
1157
+
1158
+ /**
1159
+ * The Ctrl+O transcript inspector: one selected durable entry at a time,
1160
+ * with independent history selection and content scrolling. The complete
1161
+ * retained entry is converted to physical rows, but only one viewport slice
1162
+ * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
1163
+ */
1164
+ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
1165
+ const stdout = useStdout().stdout
1166
+ const columns = stdout?.columns ?? 80
1167
+ const rows = stdout?.rows ?? 30
1168
+ const viewport = inspectorViewport(columns, rows)
1169
+ const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
1170
+ const [scroll, setScroll] = useState(0)
1171
+ const savedScroll = useRef(new Map<number, number>())
1172
+ const cursorRef = useRef(cursor)
1173
+ const previousLength = useRef(entries.length)
1174
+ const entry = entries[cursor]
1175
+ const allLines = useMemo(
1176
+ () => entry === undefined ? [] : transcriptEntryLines(entry, viewport.contentColumns),
1177
+ [entry, viewport.contentColumns],
1178
+ )
1179
+ const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
1180
+
1181
+ useEffect(() => {
1182
+ cursorRef.current = cursor
1183
+ }, [cursor])
1184
+
1185
+ useEffect(() => {
1186
+ const current = cursorRef.current
1187
+ const next = followInspectorCursor(current, previousLength.current, entries.length)
1188
+ if (next !== current) {
1189
+ savedScroll.current.set(current, visibleScroll)
1190
+ setCursor(next)
1191
+ setScroll(savedScroll.current.get(next) ?? 0)
1192
+ }
1193
+ previousLength.current = entries.length
1194
+ }, [entries.length])
1195
+
1196
+ useEffect(() => {
1197
+ const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows)
1198
+ if (clamped !== scroll) setScroll(clamped)
1199
+ savedScroll.current.set(cursor, clamped)
1200
+ }, [cursor, scroll, allLines.length, viewport.bodyRows])
1201
+
1202
+ const selectEntry = (next: number): void => {
1203
+ if (entries.length === 0) return
1204
+ const selected = Math.max(0, Math.min(entries.length - 1, next))
1205
+ if (selected === cursor) return
1206
+ savedScroll.current.set(cursor, visibleScroll)
1207
+ setCursor(selected)
1208
+ setScroll(savedScroll.current.get(selected) ?? 0)
1209
+ }
1210
+
1211
+ const scrollBy = (delta: number): void => {
1212
+ setScroll(current => moveScroll(current, delta, allLines.length, viewport.bodyRows))
1213
+ }
1214
+
1215
+ useInput((input, key) => {
1216
+ if (key.escape || input === 'q' || (key.ctrl && input === 'o')) {
1217
+ onClose()
1218
+ return
1219
+ }
1220
+ if (entries.length === 0) return
1221
+ if (key.leftArrow) {
1222
+ selectEntry(cursor - 1)
1223
+ return
1224
+ }
1225
+ if (key.rightArrow) {
1226
+ selectEntry(cursor + 1)
1227
+ return
1228
+ }
1229
+ if (key.upArrow) {
1230
+ scrollBy(-1)
1231
+ return
1232
+ }
1233
+ if (key.downArrow) {
1234
+ scrollBy(1)
1235
+ return
1236
+ }
1237
+ if (key.pageUp) {
1238
+ scrollBy(-Math.max(1, viewport.bodyRows - 1))
1239
+ return
1240
+ }
1241
+ if (key.pageDown) {
1242
+ scrollBy(Math.max(1, viewport.bodyRows - 1))
1243
+ return
1244
+ }
1245
+ if (input === 'g') {
1246
+ setScroll(0)
1247
+ return
1248
+ }
1249
+ if (input === 'G') {
1250
+ setScroll(Math.max(0, allLines.length - viewport.bodyRows))
1251
+ }
1252
+ })
1253
+
1254
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1255
+ if (viewport.compact) {
1256
+ return createElement(
1257
+ Text,
1258
+ { wrap: 'truncate-end' },
1259
+ truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
1260
+ )
1261
+ }
1262
+
1263
+ const title = entries.length === 0
1264
+ ? 'history details · empty'
1265
+ : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
1266
+ const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
1267
+ return createElement(
1268
+ Box,
1269
+ {
1270
+ flexDirection: 'column',
1271
+ paddingX: 1,
1272
+ borderStyle: 'round',
1273
+ borderColor: inkColor(TUI_RGB.brand),
1274
+ },
1275
+ createElement(
1276
+ Text,
1277
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1278
+ truncateColumns(title, viewport.contentColumns),
1279
+ ),
1280
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1281
+ createElement(
1282
+ Box,
1283
+ { flexDirection: 'column' },
1284
+ entry === undefined
1285
+ ? createElement(Text, { dimColor: true }, ' no durable entries yet')
1286
+ : createElement(StyledRows, { lines: visible }),
1287
+ ),
1288
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1289
+ createElement(
1290
+ Text,
1291
+ { dimColor: true, wrap: 'truncate-end' },
1292
+ dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
1293
+ ),
1294
+ )
1295
+ }
1296
+
1297
+ /** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
1298
+ const MemoVerbosePanel = memo(VerbosePanel)
1299
+
1300
+ /** Stable append-only boundary: modal updates must never revisit Static rows. */
1301
+ function staticRow(item: unknown): ReactElement {
1302
+ return item as ReactElement
1303
+ }
1304
+
1305
+ function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
1306
+ return createElement(Static, { items, children: staticRow })
1307
+ }
1308
+
1309
+ const MemoStaticTranscript = memo(StaticTranscript)
1310
+
615
1311
  /** One completion candidate row. */
616
1312
  interface CompletionCandidate {
617
1313
  /** Insertion text for the command name (with leading slash). */
@@ -619,7 +1315,7 @@ interface CompletionCandidate {
619
1315
  /** Human-readable description shown beside the label. */
620
1316
  description: string
621
1317
  /** Candidate origin; skills land the same literal text but route through the prompt. */
622
- origin: 'command' | 'skill' | 'mention'
1318
+ origin: 'command' | 'skill' | 'mention' | 'path'
623
1319
  }
624
1320
 
625
1321
  /**
@@ -638,7 +1334,13 @@ function completionCandidates(
638
1334
  const local: CompletionCandidate[] = [
639
1335
  { label: '/help', description: 'show commands', origin: 'command' },
640
1336
  { label: '/model', description: 'switch the model', origin: 'command' },
1337
+ { label: '/mode', description: 'select the agent preset', origin: 'command' },
1338
+ { label: '/new', description: 'start a fresh session', origin: 'command' },
1339
+ { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
1340
+ { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
641
1341
  { label: '/clear', description: 'clear the screen', origin: 'command' },
1342
+ { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1343
+ { label: '/title', description: 'rename this session', origin: 'command' },
642
1344
  { label: '/quit', description: 'exit', origin: 'command' },
643
1345
  ]
644
1346
  // Local commands shadow registry names (e.g. the plugin-registered
@@ -665,44 +1367,55 @@ function completionCandidates(
665
1367
  return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
666
1368
  }
667
1369
 
668
- /** The completion menu snapshot the input editor publishes to the app. */
669
- export interface MenuState {
670
- /** Whether the menu is on screen (slash or @mention). */
1370
+ /**
1371
+ * The completion menu, rendered inside the composer's subtree directly above
1372
+ * the framed box attached the way Claude-Code anchors its dropdown. Opening
1373
+ * it grows the stack downward: the composer stays the last element on screen
1374
+ * and everything above (the flushed static transcript, the status line) never
1375
+ * moves. Props-only (no lifted state): the menu is a pure view of the input
1376
+ * editor's live completion state, so no cross-component effect ever resyncs
1377
+ * it (a state lift here previously deadlocked the menu after a resize).
1378
+ */
1379
+ function CompletionMenu({ active, mention, index, rows }: {
671
1380
  active: boolean
672
- /** Whether the menu is driven by an @mention token. */
673
1381
  mention: boolean
674
- /** Highlighted candidate index (wraps by row count). */
675
1382
  index: number
676
- /** Rendered rows in display order. */
677
1383
  rows: readonly CompletionCandidate[]
678
- }
679
-
680
- /**
681
- * The completion menu, rendered after the status line — the very last
682
- * element in the tree. Being last in the layout flow, opening or closing it
683
- * moves nothing above it: the transcript, input box, and status line all
684
- * stay put (the Claude-Code dropdown treatment adapted to Ink, whose
685
- * absolute positioning cannot place children above their parent).
686
- */
687
- function CompletionMenu({ state }: { state: MenuState }): ReactElement | undefined {
688
- if (!state.active) return undefined
689
- const columns = useStdout().stdout?.columns ?? 80
690
- const nameWidth = Math.min(18, Math.max(0, ...state.rows.map(row => visibleColumns(row.label))) + 2)
691
- const descBudget = Math.max(24, columns - nameWidth - 8)
1384
+ }): ReactElement | undefined {
1385
+ // Hook order is unconditional: `active` toggling must not change the hook
1386
+ // count (the early return used to sit above useStdout).
1387
+ const stdout = useStdout().stdout
1388
+ const columns = stdout?.columns ?? 80
1389
+ const terminalRows = stdout?.rows ?? 30
1390
+ if (!active) return undefined
1391
+ const contentColumns = Math.max(1, columns - 4)
1392
+ const nameWidth = Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
1393
+ const descBudget = Math.max(0, contentColumns - nameWidth - 2)
1394
+ const showFooter = terminalRows >= 12
1395
+ const spacious = terminalRows >= 14
1396
+ const verticalPadding = spacious ? 1 : 0
1397
+ const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10) - verticalPadding * 2))
1398
+ const selected = rows.length === 0 ? 0 : index % rows.length
1399
+ const first = selectionWindow(selected, rows.length, limit)
1400
+ const visible = rows.slice(first, first + limit)
692
1401
  return createElement(
693
1402
  Box,
694
- { flexDirection: 'column', marginTop: 1, marginLeft: 2 },
695
- ...(state.rows.length === 0
1403
+ { flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
1404
+ ...(rows.length === 0
696
1405
  ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
697
- : state.rows.map((candidate, index) => createElement(
1406
+ : visible.map((candidate, at) => {
1407
+ const absolute = first + at
1408
+ return createElement(
698
1409
  Text,
699
1410
  {
700
1411
  key: candidate.label,
701
- color: index === state.index % state.rows.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1412
+ color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1413
+ wrap: 'truncate-end',
702
1414
  },
703
- `${index === state.index % state.rows.length ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
704
- ))),
705
- createElement(Text, { dimColor: true }, dim(state.mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')),
1415
+ `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1416
+ )
1417
+ })),
1418
+ showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
706
1419
  )
707
1420
  }
708
1421
 
@@ -712,8 +1425,9 @@ function CompletionMenu({ state }: { state: MenuState }): ReactElement | undefin
712
1425
  * While a modal (approval / question / model panel) owns the keys, the
713
1426
  * box passes every key through untouched.
714
1427
  */
715
- function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }: {
1428
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, openMode, openResume, openPlugin, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }: {
716
1429
  active: boolean
1430
+ frozen: boolean
717
1431
  busy: boolean
718
1432
  descriptors: readonly CommandDescriptor[]
719
1433
  skills: readonly SkillRow[]
@@ -722,18 +1436,32 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
722
1436
  interrupt(): boolean
723
1437
  quit(): void
724
1438
  openModel(): void
725
- notify(text: string): void
1439
+ openHelp(): void
1440
+ openMode(): void
1441
+ openResume(): void
1442
+ openPlugin(query?: string): void
1443
+ createSession(mode?: string): void
1444
+ cancelSessionSwitch(): boolean
1445
+ notify(text: string, tone?: NoticeTone): void
1446
+ hasNotice: boolean
1447
+ dismissNotice(): void
726
1448
  toggleReasoning(): void
1449
+ openVerbose(): void
1450
+ clearView(): void
1451
+ refresh(): void
727
1452
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
728
1453
  cyclePermission(): string
729
- onMenuState(state: MenuState): void
1454
+ exportTranscript(argument: string): Promise<void>
1455
+ renameTitle(argument: string): string
730
1456
  }): ReactElement {
1457
+ const columns = useStdout().stdout?.columns ?? 80
731
1458
  const [value, setValue] = useState('')
732
1459
  const [cursor, setCursor] = useState(0)
733
1460
  const history = useRef<readonly string[]>([])
734
1461
  const historyIndex = useRef<number | null>(null)
735
1462
  const draft = useRef('')
736
1463
  const [completionIndex, setCompletionIndex] = useState(0)
1464
+ const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
737
1465
  const candidates = completionCandidates(value, descriptors, skills)
738
1466
  const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
739
1467
 
@@ -747,8 +1475,37 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
747
1475
  const mentionActive = mentionToken !== undefined
748
1476
  const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
749
1477
 
1478
+ // Bare path token: the last whitespace-delimited run on the cursor's line
1479
+ // when it already looks like a path (Claude-Code bare Tab completion). A
1480
+ // LEADING '/' is the command namespace, never a path — without this guard
1481
+ // typing the bare '/' hijacked the menu into the workspace file scan and
1482
+ // the slash-command candidates never appeared.
1483
+ const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
1484
+ const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
1485
+ const pathActive = !mentionActive
1486
+ && !bareToken.startsWith('/')
1487
+ && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
1488
+ const pathTokenStart = beforeCursor.length - bareToken.length
1489
+ const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
1490
+
750
1491
  useEffect(() => {
751
- if (!mentionActive) {
1492
+ if (!active || !pathActive) {
1493
+ setPathRows([])
1494
+ return
1495
+ }
1496
+ const controller = new AbortController()
1497
+ setPathRows([])
1498
+ loadMentions(bareToken, controller.signal).then(
1499
+ rows => setPathRows(rows.filter(row => row.kind !== 'session')),
1500
+ () => {},
1501
+ )
1502
+ return () => {
1503
+ controller.abort()
1504
+ }
1505
+ }, [active, pathActive, bareToken])
1506
+
1507
+ useEffect(() => {
1508
+ if (!active || !mentionActive) {
752
1509
  setMentionRows([])
753
1510
  return
754
1511
  }
@@ -761,9 +1518,12 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
761
1518
  return () => {
762
1519
  controller.abort()
763
1520
  }
764
- }, [mentionActive, mentionToken?.query])
1521
+ }, [active, mentionActive, mentionToken?.query])
765
1522
 
766
- const menuActive = (slashActive || mentionActive) && !busy
1523
+ // Codex routes keys to the topmost surface first. Completion therefore
1524
+ // remains available while a turn runs, and Esc dismisses it before the
1525
+ // same key is allowed to interrupt the turn.
1526
+ const menuActive = (slashActive || mentionActive || pathActive) && dismissedMenuValue !== value
767
1527
  const menuRows: readonly CompletionCandidate[] = mentionActive
768
1528
  ? mentionRows.map(row => ({
769
1529
  label: row.label.startsWith('@')
@@ -772,31 +1532,25 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
772
1532
  description: row.description,
773
1533
  origin: 'mention',
774
1534
  }))
775
- : candidates
776
-
777
- // The menu renders at the very bottom of the app (after the status line),
778
- // where opening it moves nothing above it — the App needs this snapshot.
779
- // Notify only on change; an unconditional set would re-render in a loop.
780
- const menuStateKey = useRef('')
781
- useEffect(() => {
782
- const key = JSON.stringify([menuActive, mentionActive, completionIndex, menuRows.map(row => row.label)])
783
- if (key === menuStateKey.current) return
784
- menuStateKey.current = key
785
- onMenuState({
786
- active: menuActive,
787
- mention: mentionActive,
788
- index: completionIndex,
789
- rows: menuRows,
790
- })
791
- }, [menuActive, mentionActive, completionIndex, menuRows, onMenuState])
1535
+ : pathActive
1536
+ ? pathRows.map(row => ({
1537
+ label: row.label,
1538
+ description: row.description,
1539
+ origin: 'path',
1540
+ }))
1541
+ : candidates
792
1542
 
793
1543
  useInput((input, key) => {
794
1544
  // Modal ownership: approval/question/model dialogs consume all keys.
795
1545
  if (!active) return
796
1546
  // Shift+Tab cycles the permission preset (Claude-Code convention).
797
1547
  if (key.tab && key.shift) {
798
- const next = cyclePermission()
799
- if (next !== '') notify(`permission → ${next}`)
1548
+ try {
1549
+ const next = cyclePermission()
1550
+ if (next !== '') notify(`permission → ${next}`)
1551
+ } catch (error: unknown) {
1552
+ notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1553
+ }
800
1554
  return
801
1555
  }
802
1556
  // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
@@ -804,6 +1558,13 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
804
1558
  toggleReasoning()
805
1559
  return
806
1560
  }
1561
+ // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
1562
+ // adapted to append-only static rows): one history entry at a time with
1563
+ // tool cards and reasoning expanded, Esc returns.
1564
+ if (key.ctrl && input === 'o') {
1565
+ openVerbose()
1566
+ return
1567
+ }
807
1568
  // Ctrl+C is three-state (community-TUI convention): a running turn is
808
1569
  // cancelled, a non-empty draft is cleared, and only an idle empty input
809
1570
  // exits. Ctrl+D always means exit but refuses mid-turn.
@@ -814,17 +1575,26 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
814
1575
  setValue('')
815
1576
  setCursor(0)
816
1577
  setCompletionIndex(0)
1578
+ setDismissedMenuValue(undefined)
817
1579
  } else {
818
1580
  quit()
819
1581
  }
820
1582
  return
821
1583
  }
822
1584
  if (key.ctrl && input === 'd') {
823
- if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
1585
+ if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
824
1586
  else quit()
825
1587
  return
826
1588
  }
827
1589
  if (key.escape) {
1590
+ if (menuActive) {
1591
+ setDismissedMenuValue(value)
1592
+ return
1593
+ }
1594
+ if (hasNotice) {
1595
+ dismissNotice()
1596
+ return
1597
+ }
828
1598
  if (busy) interrupt()
829
1599
  return
830
1600
  }
@@ -835,13 +1605,16 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
835
1605
  if (key.meta || (key.ctrl && input === 'j')) {
836
1606
  setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
837
1607
  setCursor(cursor + 1)
1608
+ setDismissedMenuValue(undefined)
838
1609
  return
839
1610
  }
840
1611
  const text = value.trim()
841
1612
  setValue('')
842
1613
  setCursor(0)
843
1614
  setCompletionIndex(0)
1615
+ setDismissedMenuValue(undefined)
844
1616
  if (text === '') return
1617
+ dismissNotice()
845
1618
  history.current = [...history.current, text]
846
1619
  historyIndex.current = null
847
1620
  if (text === '/quit') {
@@ -849,17 +1622,60 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
849
1622
  return
850
1623
  }
851
1624
  if (text === '/help') {
852
- notify('/model switch · /clear clear the screen · /quit exit · Ctrl+R toggle thinking · Shift+Tab cycle permission · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
1625
+ openHelp()
853
1626
  return
854
1627
  }
855
1628
  if (text === '/clear') {
856
- console.clear()
1629
+ // Clear the screen AND drop the folded view: the raw ANSI clear + a
1630
+ // Static remount (refresh) so the ledger stays in sync, then the
1631
+ // store resets so the rebuilt transcript starts empty.
1632
+ refresh()
1633
+ clearView()
1634
+ dismissNotice()
1635
+ return
1636
+ }
1637
+ if (text === '/export' || text.startsWith('/export ')) {
1638
+ void exportTranscript(text.slice(8))
1639
+ return
1640
+ }
1641
+ if (text === '/title' || text.startsWith('/title ')) {
1642
+ const outcome = renameTitle(text.slice(7))
1643
+ const tone: NoticeTone = outcome.startsWith('rename failed:')
1644
+ ? 'error'
1645
+ : outcome.startsWith('usage:') || outcome.includes('unavailable')
1646
+ ? 'warning'
1647
+ : 'info'
1648
+ notify(outcome, tone)
857
1649
  return
858
1650
  }
859
1651
  if (text === '/model' || text.startsWith('/model ')) {
860
1652
  openModel()
861
1653
  return
862
1654
  }
1655
+ if (text === '/mode' || text.startsWith('/mode ')) {
1656
+ const mode = text.slice(5).trim()
1657
+ if (mode === '') openMode()
1658
+ else dispatch(text)
1659
+ return
1660
+ }
1661
+ if (text === '/resume cancel') {
1662
+ notify(cancelSessionSwitch() ? 'pending session switch cancelled' : 'no pending session switch', 'info')
1663
+ return
1664
+ }
1665
+ if (text === '/resume' || text.startsWith('/resume ')) {
1666
+ const id = text.slice(7).trim()
1667
+ if (id === '') openResume()
1668
+ else dispatch(text)
1669
+ return
1670
+ }
1671
+ if (text === '/new' || text.startsWith('/new ')) {
1672
+ createSession(text.slice(4).trim() || undefined)
1673
+ return
1674
+ }
1675
+ if (text === '/plugin' || text.startsWith('/plugin ')) {
1676
+ openPlugin(text.slice(7).trim())
1677
+ return
1678
+ }
863
1679
  if (busy && !text.startsWith('/')) {
864
1680
  // A running turn is steered, not blocked: the inbox delivers this
865
1681
  // text at the next step boundary (Esc/Ctrl+C still cancels outright).
@@ -886,6 +1702,7 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
886
1702
  historyIndex.current = next
887
1703
  setValue(entries[next] ?? '')
888
1704
  setCursor((entries[next] ?? '').length)
1705
+ setDismissedMenuValue(undefined)
889
1706
  return
890
1707
  }
891
1708
  if (key.downArrow) {
@@ -896,11 +1713,13 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
896
1713
  historyIndex.current = null
897
1714
  setValue(draft.current)
898
1715
  setCursor(draft.current.length)
1716
+ setDismissedMenuValue(undefined)
899
1717
  return
900
1718
  }
901
1719
  historyIndex.current = next
902
1720
  setValue(entries[next] ?? '')
903
1721
  setCursor((entries[next] ?? '').length)
1722
+ setDismissedMenuValue(undefined)
904
1723
  return
905
1724
  }
906
1725
  if (key.tab && menuActive) {
@@ -915,6 +1734,15 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
915
1734
  setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
916
1735
  setCursor(mentionToken.start + insertion.length)
917
1736
  }
1737
+ } else if (pathActive) {
1738
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
1739
+ if (row !== undefined) {
1740
+ // Bare path completion replaces the typed token with the chosen
1741
+ // workspace path (directories keep their trailing slash).
1742
+ const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
1743
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
1744
+ setCursor(pathTokenStart + insertion.length)
1745
+ }
918
1746
  } else {
919
1747
  const candidate = candidates[completionIndex % candidates.length]
920
1748
  if (candidate !== undefined) {
@@ -923,6 +1751,7 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
923
1751
  }
924
1752
  }
925
1753
  setCompletionIndex(0)
1754
+ setDismissedMenuValue(undefined)
926
1755
  return
927
1756
  }
928
1757
  if (key.backspace || key.delete) {
@@ -930,6 +1759,7 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
930
1759
  setValue(value.slice(0, cursor - 1) + value.slice(cursor))
931
1760
  setCursor(cursor - 1)
932
1761
  setCompletionIndex(0)
1762
+ setDismissedMenuValue(undefined)
933
1763
  }
934
1764
  return
935
1765
  }
@@ -944,6 +1774,20 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
944
1774
  if (key.ctrl && input === 'u') {
945
1775
  setValue('')
946
1776
  setCursor(0)
1777
+ setDismissedMenuValue(undefined)
1778
+ return
1779
+ }
1780
+ // Readline parity: Ctrl+K cuts from the cursor to the end of the line.
1781
+ if (key.ctrl && input === 'k') {
1782
+ setValue(value.slice(0, cursor))
1783
+ setDismissedMenuValue(undefined)
1784
+ return
1785
+ }
1786
+ // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
1787
+ // plus a Static remount so the flushed transcript re-emits (a bare
1788
+ // console.clear() would desync Ink's ledger against the static rows).
1789
+ if (key.ctrl && input === 'l') {
1790
+ refresh()
947
1791
  return
948
1792
  }
949
1793
  if (key.ctrl && input === 'a') {
@@ -958,15 +1802,41 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
958
1802
  setValue(value.slice(0, cursor) + input + value.slice(cursor))
959
1803
  setCursor(cursor + input.length)
960
1804
  setCompletionIndex(0)
1805
+ setDismissedMenuValue(undefined)
961
1806
  }
962
1807
  })
963
1808
 
1809
+ // Every exclusive panel keeps the composer as a stable visual anchor, but
1810
+ // freezes it to one row: no menu, multiline wrap, or animation.
1811
+ if (frozen) {
1812
+ const frozen = value === ''
1813
+ ? 'type a message'
1814
+ : verboseLine(value, Math.max(1, columns - 6))
1815
+ return createElement(
1816
+ Box,
1817
+ { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1818
+ createElement(
1819
+ Text,
1820
+ { wrap: 'truncate-end' },
1821
+ createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1822
+ frozen,
1823
+ ),
1824
+ )
1825
+ }
1826
+
1827
+ const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
1828
+
964
1829
  return createElement(
965
1830
  Box,
966
- { flexDirection: 'column', marginTop: 1 },
967
- busy && value === ''
968
- ? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
969
- : undefined,
1831
+ { flexDirection: 'column' },
1832
+ // The completion dropdown rides directly above the box (Claude-Code
1833
+ // anchor): rendered from the editor's own live state, never lifted.
1834
+ createElement(CompletionMenu, {
1835
+ active: menuActive,
1836
+ mention: mentionActive,
1837
+ index: completionIndex,
1838
+ rows: menuRows,
1839
+ }),
970
1840
  // The framed input box: a visible boundary so the prompt never blends
971
1841
  // into the transcript above it; the cursor block sits immediately after
972
1842
  // the prompt marker (leftmost), with the dim placeholder trailing it —
@@ -974,14 +1844,16 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
974
1844
  createElement(
975
1845
  Box,
976
1846
  { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
977
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
978
- value === ''
979
- ? undefined
980
- : createElement(Text, null, value.slice(0, cursor)),
981
- createElement(CursorBlock, { char: value.slice(cursor, cursor + 1) === '' ? ' ' : value.slice(cursor, cursor + 1) }),
982
- value === '' && !busy
983
- ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
984
- : createElement(Text, null, value.slice(cursor + 1)),
1847
+ createElement(
1848
+ Text,
1849
+ { wrap: 'truncate-end' },
1850
+ createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1851
+ value === '' ? undefined : editor.before,
1852
+ createElement(CursorBlock, { char: editor.caret }),
1853
+ value === '' && !busy
1854
+ ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
1855
+ : editor.after,
1856
+ ),
985
1857
  ),
986
1858
  )
987
1859
  }
@@ -995,19 +1867,24 @@ export function App(props: AppProps): ReactElement {
995
1867
  const [modelOpen, setModelOpen] = useState(false)
996
1868
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
997
1869
  const [modelError, setModelError] = useState<string | undefined>(undefined)
998
- const [notices, setNotices] = useState<readonly string[]>([])
999
- const notify = (text: string): void => {
1000
- setNotices(current => [...current, text])
1001
- }
1870
+ const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
1871
+ const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
1872
+ const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
1873
+ setNotice({ text, tone })
1874
+ }, [])
1002
1875
 
1003
1876
  useEffect(() => {
1004
1877
  props.onBridgeReady({ notify })
1005
1878
  }, [])
1006
1879
  useEffect(() => {
1007
- if (!modelOpen || directory !== undefined) return
1880
+ if (!modelOpen) return
1008
1881
  let cancelled = false
1882
+ setDirectory(undefined)
1009
1883
  setModelError(undefined)
1010
- props.loadModels().then((loaded) => {
1884
+ // Enter the promise chain before invoking the loader so a provider that
1885
+ // throws synchronously becomes an in-panel error instead of escaping the
1886
+ // React effect and tearing down Ink.
1887
+ Promise.resolve().then(() => props.loadModels()).then((loaded) => {
1011
1888
  if (!cancelled) setDirectory(loaded)
1012
1889
  }, (error: unknown) => {
1013
1890
  if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
@@ -1015,101 +1892,314 @@ export function App(props: AppProps): ReactElement {
1015
1892
  return () => {
1016
1893
  cancelled = true
1017
1894
  }
1018
- }, [modelOpen])
1895
+ }, [modelOpen, modelLoadEpoch, props.loadModels])
1019
1896
 
1020
1897
  const busy = view.busy
1021
1898
  const [showReasoning, setShowReasoning] = useState(false)
1022
- const [menuState, setMenuState] = useState<MenuState>({ active: false, mention: false, index: 0, rows: [] })
1899
+ const [verboseOpen, setVerboseOpen] = useState(false)
1900
+ const [helpOpen, setHelpOpen] = useState(false)
1901
+ const [modeOpen, setModeOpen] = useState(false)
1902
+ const [resumeOpen, setResumeOpen] = useState(false)
1903
+ const [pluginOpen, setPluginOpen] = useState(false)
1904
+ const [pluginQuery, setPluginQuery] = useState('')
1905
+ const [refreshEpoch, setRefreshEpoch] = useState(0)
1023
1906
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
1024
1907
  const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
1025
- // While any modal owns the keys, the prompt box passes everything through.
1026
- const inputActive = !modelOpen && approvalSnapshot.pending === undefined && questionSnapshot.pending === undefined
1027
- // Layered ownership: question > approval > model panel; each bar answers
1028
- // only while no higher-priority modal is on screen.
1908
+ const approvalPending = approvalSnapshot.pending !== undefined
1029
1909
  const questionPending = questionSnapshot.pending !== undefined
1910
+ // While any modal owns the keys, the prompt box passes everything through.
1911
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending
1912
+
1913
+ // Human questions outrank local inspectors. Close the lower modal instead
1914
+ // of leaving an approval/question visible but keyboard-locked behind it.
1915
+ useEffect(() => {
1916
+ if (!approvalPending && !questionPending) return
1917
+ setModelOpen(false)
1918
+ setHelpOpen(false)
1919
+ setModeOpen(false)
1920
+ setResumeOpen(false)
1921
+ setPluginOpen(false)
1922
+ setVerboseOpen(false)
1923
+ }, [approvalPending, questionPending])
1924
+
1925
+ // Append-only transcript: everything up to the first still-mutable entry
1926
+ // (a running tool/retry) flushes through Ink's `<Static>` into native
1927
+ // scrollback and is normally never rewritten — the Claude-Code stability
1928
+ // contract
1929
+ // that lets arbitrarily long conversations scroll instead of freezing when
1930
+ // the live tree exceeds the terminal height. The dynamic region below stays
1931
+ // small: the streaming tail, modals, composer, and its status footer.
1932
+ // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
1933
+ // keeps long settled histories out of the per-token render path.
1934
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
1030
1935
  // Claude-Code spacing: one blank row before each user prompt (except the
1031
- // first) separates replies from the next turn.
1032
- const transcriptRows: ReactElement[] = []
1033
- view.entries.forEach((entry, index) => {
1034
- if (entry.kind === 'user' && index > 0) {
1035
- transcriptRows.push(createElement(Text, { key: `gap-${index}` }, ' '))
1936
+ // first) separates replies from the next turn. Settled rows flush once with
1937
+ // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
1938
+ // Ctrl+O browses the frozen history through a bounded selected-entry view.
1939
+ const settledRows = useMemo(() => {
1940
+ const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
1941
+ view.entries.slice(0, settled).forEach((entry, index) => {
1942
+ const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
1943
+ const roomyPrompt = entry.kind === 'user' && !entry.notice
1944
+ if (roomyPrompt) {
1945
+ rows.push(createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
1946
+ }
1947
+ rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
1948
+ if (roomyPrompt) {
1949
+ rows.push(createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
1950
+ }
1951
+ })
1952
+ return rows
1953
+ }, [view.entries, settled, showReasoning, props.resumed])
1954
+
1955
+ // Hook order is unconditional. Its dimensions drive every live-region
1956
+ // budget before any dynamic rows are constructed.
1957
+ const appStdout = useStdout().stdout
1958
+ const [terminalSize, setTerminalSize] = useState(() => ({
1959
+ columns: appStdout?.columns ?? 80,
1960
+ rows: appStdout?.rows ?? 30,
1961
+ }))
1962
+ const terminalSizeRef = useRef(terminalSize)
1963
+ useEffect(() => {
1964
+ if (appStdout === undefined) return
1965
+ let replayTimer: ReturnType<typeof setTimeout> | undefined
1966
+ const handleResize = (): void => {
1967
+ const next = {
1968
+ columns: appStdout.columns ?? 80,
1969
+ rows: appStdout.rows ?? 30,
1970
+ }
1971
+ if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
1972
+ terminalSizeRef.current = next
1973
+
1974
+ // Ink 5 erases by the old logical line count. Once the terminal reflows
1975
+ // a full-width border at a new width, that count is no longer enough and
1976
+ // stale frames remain visible. Follow Codex's source-backed reflow
1977
+ // policy: update live geometry immediately, but wait for the resize
1978
+ // burst to settle before one hard reset and one transcript replay at the
1979
+ // final width. Replaying Static on every event appends duplicate history.
1980
+ setTerminalSize(next)
1981
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
1982
+ replayTimer = setTimeout(() => {
1983
+ appStdout.write(RESIZE_REFLOW_CLEAR)
1984
+ setRefreshEpoch(epoch => epoch + 1)
1985
+ }, RESIZE_REFLOW_DELAY_MS)
1036
1986
  }
1037
- transcriptRows.push(createElement(EntryLine, { key: index, entry, showReasoning }))
1038
- })
1987
+ appStdout.on('resize', handleResize)
1988
+ return () => {
1989
+ appStdout.off('resize', handleResize)
1990
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
1991
+ }
1992
+ }, [appStdout])
1993
+ const terminalRows = terminalSize.rows
1994
+ const terminalColumns = terminalSize.columns
1995
+ const composerGutterRows = layoutGutterRows(terminalRows)
1996
+ const dynamicRows = Math.max(1, terminalRows - 12 - composerGutterRows)
1997
+ const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
1998
+ const deepDivingVisible = busy && !streamingActive
1999
+ const allLiveLines = useMemo(
2000
+ () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
2001
+ [view.entries, settled, terminalColumns],
2002
+ )
2003
+ const liveBudget = streamingActive
2004
+ ? Math.max(1, Math.floor(dynamicRows / 3))
2005
+ : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
2006
+ const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
2007
+
2008
+ // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
2009
+ // screen AND scrollback, home the cursor) then a Static remount via the
2010
+ // key change, which re-flushes the current items from index 0. NEVER
2011
+ // console.clear() — it desyncs Ink's internal line ledger against the
2012
+ // flushed static rows and garbles every frame after.
2013
+ const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
2014
+ const reasoningRows = view.streamingReasoning === ''
2015
+ ? 0
2016
+ : view.streaming === ''
2017
+ ? streamRows
2018
+ : streamRows <= 1
2019
+ ? 0
2020
+ : showReasoning
2021
+ ? Math.max(1, Math.floor(streamRows / 3))
2022
+ : 1
2023
+ const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
2024
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !verboseOpen && !approvalPending && !questionPending
2025
+ const inspectorVisible = verboseOpen && !approvalPending && !questionPending
2026
+ const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || inspectorVisible || approvalPending || questionPending
2027
+ const closeInspector = useCallback((): void => {
2028
+ setVerboseOpen(false)
2029
+ }, [])
2030
+ const refreshScreen = (): void => {
2031
+ if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
2032
+ setRefreshEpoch(epoch => epoch + 1)
2033
+ }
2034
+
1039
2035
  return createElement(
1040
2036
  Box,
1041
2037
  { flexDirection: 'column' },
1042
- createElement(Header, { resumed: props.resumed }),
1043
- createElement(
1044
- Box,
1045
- { flexDirection: 'column', paddingX: 1 },
1046
- ...transcriptRows,
1047
- view.streamingReasoning !== ''
1048
- ? createElement(
1049
- Text,
1050
- { dimColor: true, italic: true },
1051
- showReasoning ? ` ✻ ${displayText(view.streamingReasoning)}` : ' Thinking…',
1052
- )
1053
- : undefined,
1054
- view.streaming !== ''
1055
- ? createElement(Text, null, displayText(view.streaming), busy ? createElement(Caret) : undefined)
1056
- : undefined,
1057
- busy && view.streaming === '' && view.streamingReasoning === '' ? createElement(Text, { dimColor: true }, 'Deep diving...') : undefined,
1058
- ),
1059
- createElement(TodoPanel, { todos: view.todos }),
1060
- createElement(QuestionBar, { store: props.questions, locked: modelOpen }),
1061
- createElement(ApprovalBar, { approval: props.approval, locked: modelOpen || questionPending }),
1062
- modelOpen
2038
+ createElement(MemoStaticTranscript, {
2039
+ key: refreshEpoch,
2040
+ items: settledRows,
2041
+ }),
2042
+ transcriptVisible
2043
+ ? createElement(
2044
+ Box,
2045
+ { flexDirection: 'column', paddingX: 1 },
2046
+ visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
2047
+ view.streamingReasoning !== '' && reasoningRows > 0
2048
+ ? createElement(StreamTail, {
2049
+ text: showReasoning ? view.streamingReasoning : 'Thinking…',
2050
+ prefix: ' ✻ ',
2051
+ dim: true,
2052
+ maxRows: reasoningRows,
2053
+ })
2054
+ : undefined,
2055
+ view.streaming !== '' && answerRows > 0
2056
+ ? createElement(
2057
+ StreamTail,
2058
+ { text: view.streaming, dim: false, maxRows: answerRows },
2059
+ busy ? createElement(Caret) : undefined,
2060
+ )
2061
+ : undefined,
2062
+ deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
2063
+ )
2064
+ : undefined,
2065
+ transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
2066
+ createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
2067
+ createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
2068
+ modelOpen && !approvalPending && !questionPending
1063
2069
  ? createElement(ModelPanel, {
1064
2070
  directory,
1065
2071
  error: modelError,
1066
2072
  onSelect: (row: ModelRow) => {
1067
- setModelLabel(props.selectModel(row))
1068
- notify(`model → next step uses ${row.provider}/${row.model}`)
1069
- setModelOpen(false)
2073
+ try {
2074
+ setModelLabel(props.selectModel(row))
2075
+ notify(`model → next step uses ${row.provider}/${row.model}`)
2076
+ setModelOpen(false)
2077
+ } catch (error: unknown) {
2078
+ notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
2079
+ }
2080
+ },
2081
+ onRetry: () => {
2082
+ setModelLoadEpoch(epoch => epoch + 1)
1070
2083
  },
1071
2084
  onClose: () => {
1072
2085
  setModelOpen(false)
1073
2086
  },
1074
2087
  })
1075
2088
  : undefined,
2089
+ helpOpen && !approvalPending && !questionPending
2090
+ ? createElement(HelpPanel, {
2091
+ descriptors,
2092
+ skills,
2093
+ commandError: props.commands.error,
2094
+ skillError: props.skills.error,
2095
+ onClose: () => {
2096
+ setHelpOpen(false)
2097
+ },
2098
+ })
2099
+ : undefined,
2100
+ verboseOpen && !approvalPending && !questionPending
2101
+ ? createElement(MemoVerbosePanel, {
2102
+ entries: view.entries,
2103
+ onClose: closeInspector,
2104
+ })
2105
+ : undefined,
2106
+ modeOpen && !approvalPending && !questionPending
2107
+ ? createElement(ModePanel, {
2108
+ current: props.mode,
2109
+ load: props.loadPresets,
2110
+ select: (id: string) => {
2111
+ void props.switchMode(id).then(label => {
2112
+ notify(`mode → ${label}`)
2113
+ setModeOpen(false)
2114
+ }, (reason: unknown) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error'))
2115
+ },
2116
+ close: () => setModeOpen(false),
2117
+ })
2118
+ : undefined,
2119
+ resumeOpen && !approvalPending && !questionPending
2120
+ ? createElement(ResumePanel, {
2121
+ currentCwd: props.workspaceRoot,
2122
+ load: props.loadSessions,
2123
+ readTranscript: props.loadSessionTranscript,
2124
+ select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
2125
+ close: () => setResumeOpen(false),
2126
+ })
2127
+ : undefined,
2128
+ pluginOpen && !approvalPending && !questionPending
2129
+ ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
2130
+ : undefined,
2131
+ notice === undefined
2132
+ ? undefined
2133
+ : createElement(NoticeLine, {
2134
+ text: notice.text,
2135
+ tone: notice.tone,
2136
+ columns: terminalColumns,
2137
+ }),
2138
+ // Persistent bottom chrome: every interface owns exactly the same
2139
+ // composer/status geometry. Panels may change above it, but can no longer
2140
+ // reorder the status or introduce mode-specific vertical margins.
1076
2141
  createElement(
1077
2142
  Box,
1078
- { flexDirection: 'column' },
1079
- ...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
2143
+ { flexDirection: 'column', marginTop: composerGutterRows },
2144
+ createElement(Input, {
2145
+ active: inputActive,
2146
+ frozen: modalVisible,
2147
+ busy,
2148
+ descriptors,
2149
+ skills,
2150
+ dispatch: props.dispatch,
2151
+ steer: props.steer,
2152
+ interrupt: props.interrupt,
2153
+ quit: props.quit,
2154
+ openModel: () => {
2155
+ setDirectory(undefined)
2156
+ setModelError(undefined)
2157
+ setModelOpen(true)
2158
+ },
2159
+ openHelp: () => {
2160
+ setHelpOpen(true)
2161
+ },
2162
+ openMode: () => setModeOpen(true),
2163
+ openResume: () => setResumeOpen(true),
2164
+ openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
2165
+ createSession: props.createSession,
2166
+ cancelSessionSwitch: props.cancelSessionSwitch,
2167
+ notify,
2168
+ hasNotice: notice !== undefined,
2169
+ dismissNotice: () => {
2170
+ setNotice(undefined)
2171
+ },
2172
+ openVerbose: () => {
2173
+ setVerboseOpen(true)
2174
+ },
2175
+ clearView: () => {
2176
+ props.store.reset()
2177
+ },
2178
+ refresh: refreshScreen,
2179
+ toggleReasoning: () => {
2180
+ setShowReasoning(current => !current)
2181
+ },
2182
+ loadMentions: props.loadMentions,
2183
+ cyclePermission: props.cyclePermission,
2184
+ exportTranscript: props.exportTranscript,
2185
+ renameTitle: props.renameTitle,
2186
+ }),
2187
+ createElement(StatusLine, {
2188
+ facts: {
2189
+ model: modelLabel,
2190
+ mode: props.mode,
2191
+ cwd: props.cwd,
2192
+ branch: props.branch,
2193
+ sessionId: props.sessionId,
2194
+ title: view.title,
2195
+ plan: view.plan,
2196
+ permission: view.permission,
2197
+ sandbox: view.sandbox,
2198
+ goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
2199
+ },
2200
+ stats: view.stats,
2201
+ busy,
2202
+ }),
1080
2203
  ),
1081
- createElement(Input, {
1082
- active: inputActive,
1083
- busy,
1084
- descriptors,
1085
- skills,
1086
- dispatch: props.dispatch,
1087
- steer: props.steer,
1088
- interrupt: props.interrupt,
1089
- quit: props.quit,
1090
- openModel: () => {
1091
- setModelOpen(true)
1092
- },
1093
- notify,
1094
- toggleReasoning: () => {
1095
- setShowReasoning(current => !current)
1096
- },
1097
- loadMentions: props.loadMentions,
1098
- cyclePermission: props.cyclePermission,
1099
- onMenuState: setMenuState,
1100
- }),
1101
- createElement(StatusLine, {
1102
- facts: {
1103
- model: modelLabel,
1104
- cwd: props.cwd,
1105
- branch: props.branch,
1106
- sessionId: props.sessionId,
1107
- plan: view.plan,
1108
- permission: view.permission,
1109
- },
1110
- stats: view.stats,
1111
- busy,
1112
- }),
1113
- createElement(CompletionMenu, { state: menuState }),
1114
2204
  )
1115
2205
  }