dsh-code 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -15,18 +15,19 @@
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
32
  import type { ApprovalStore } from './approval.ts'
32
33
  import type { CommandsView } from './commands.ts'
@@ -34,8 +35,32 @@ import type { ModelDirectory, ModelRow } from './models.ts'
34
35
  import type { 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
+
39
+ /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
40
+ const RESIZE_REFLOW_DELAY_MS = 75
41
+
42
+ /** Reset region/style, clear the visible screen and scrollback, then home. */
43
+ const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
44
+ import { buildStatusGroups, formatTokens, type StatusFacts } from './render/status.ts'
45
+ import { displayTail, displayText } from './render/text.ts'
46
+ import {
47
+ clampScroll,
48
+ followInspectorCursor,
49
+ inspectorViewport,
50
+ moveScroll,
51
+ panelViewport,
52
+ revealRow,
53
+ selectionWindow,
54
+ } from './render/inspector.ts'
55
+ import {
56
+ lineSegment,
57
+ markdownLines,
58
+ styledLines,
59
+ textLines,
60
+ transcriptEntryLines,
61
+ type LineStyle,
62
+ type StyledLine,
63
+ } from './render/lines.ts'
39
64
 
40
65
  /** Props the runner hands the app; callbacks stay owned by the runner. */
41
66
  export interface AppProps {
@@ -75,6 +100,10 @@ export interface AppProps {
75
100
  selectModel(row: ModelRow): string
76
101
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
77
102
  cyclePermission(): string
103
+ /** Export the transcript to a markdown file (/export [path]); reports via notices. */
104
+ exportTranscript(argument: string): Promise<void>
105
+ /** Rename the session (/title <text>); returns the outcome line for the notice. */
106
+ renameTitle(argument: string): string
78
107
  /** Registers the app's notice channel with the runner (called once on mount). */
79
108
  onBridgeReady(bridge: { notify(text: string): void }): void
80
109
  }
@@ -133,6 +162,64 @@ function CursorBlock({ char }: { char: string }): ReactElement {
133
162
  return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
134
163
  }
135
164
 
165
+ /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
166
+ function runClock(ms: number): string {
167
+ const total = Math.max(0, Math.floor(ms / 1000))
168
+ const minutes = Math.floor(total / 60)
169
+ const seconds = total % 60
170
+ return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
171
+ }
172
+
173
+ /**
174
+ * The busy line, web TurnStatus contract: the plain `Deep diving...` label,
175
+ * with the elapsed clock appended only once the turn has clearly been running
176
+ * (15s) — anchored to `turn/start` so a resumed mid-turn keeps the real time.
177
+ */
178
+ function DeepDivingLine({ since }: { since: number }): ReactElement {
179
+ useFrames(1000)
180
+ const elapsed = since === 0 ? 0 : Date.now() - since
181
+ return createElement(
182
+ Text,
183
+ { dimColor: true },
184
+ elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
185
+ )
186
+ }
187
+
188
+ /**
189
+ * The streaming buffer rendered with a hard size cap: the live region must
190
+ * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
191
+ * than the screen freezes (cursor-up past the top, garbage, no scroll). The
192
+ * cap counts explicit newlines and terminal wrapping, slicing from the END so
193
+ * the freshest tokens stay visible while a long reply streams; the complete
194
+ * text lands in the flushed scrollback once the turn assembles it.
195
+ */
196
+ function StreamTail({ text, dim, maxRows, prefix, children }: {
197
+ text: string
198
+ dim: boolean
199
+ maxRows: number
200
+ prefix?: string
201
+ children?: ReactElement
202
+ }): ReactElement {
203
+ const columns = useStdout().stdout?.columns ?? 80
204
+ const safeRows = Math.max(1, maxRows)
205
+ // App padding consumes two columns; the final extra column keeps a caret
206
+ // from wrapping onto an unbudgeted row.
207
+ const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
208
+ const initial = displayTail(text, contentColumns, safeRows)
209
+ // Reserve one row for the omission marker only when a marker is needed.
210
+ const tail = initial.truncated && safeRows > 1
211
+ ? displayTail(text, contentColumns, safeRows - 1)
212
+ : initial
213
+ return createElement(
214
+ Box,
215
+ { flexDirection: 'column' },
216
+ tail.truncated && safeRows > 1
217
+ ? createElement(Text, { dimColor: true }, ' …')
218
+ : undefined,
219
+ createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
220
+ )
221
+ }
222
+
136
223
  /** Ink props for one markdown style class. */
137
224
  function segmentProps(style: MdSegment['style']): {
138
225
  color: string | undefined
@@ -160,6 +247,49 @@ function segmentProps(style: MdSegment['style']): {
160
247
  }
161
248
  }
162
249
 
250
+ /** Ink props for the richer line model used by bounded scrolling panels. */
251
+ function lineStyleProps(style: LineStyle): {
252
+ color: string | undefined
253
+ bold: boolean | undefined
254
+ italic: boolean | undefined
255
+ strikethrough: boolean | undefined
256
+ dimColor: boolean | undefined
257
+ } {
258
+ switch (style) {
259
+ case 'brand':
260
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
261
+ case 'success':
262
+ return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
263
+ case 'error':
264
+ return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
265
+ case 'warn':
266
+ return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
267
+ case 'dimItalic':
268
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
269
+ default:
270
+ return { ...segmentProps(style), dimColor: undefined }
271
+ }
272
+ }
273
+
274
+ /** Render width-safe rows; every child is exactly one terminal row. */
275
+ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
276
+ return createElement(
277
+ Box,
278
+ { flexDirection: 'column' },
279
+ ...lines.map((line, index) => createElement(
280
+ Text,
281
+ { key: index, wrap: 'truncate-end' },
282
+ line.segments.length === 0
283
+ ? ' '
284
+ : line.segments.map((segment, at) => createElement(
285
+ Text,
286
+ { key: at, ...lineStyleProps(segment.style) },
287
+ segment.text,
288
+ )),
289
+ )),
290
+ )
291
+ }
292
+
163
293
  /** One settled markdown document rendered as styled lines at the terminal width. */
164
294
  function MarkdownBody({ text }: { text: string }): ReactElement {
165
295
  const columns = useStdout().stdout?.columns ?? 80
@@ -179,8 +309,71 @@ function MarkdownBody({ text }: { text: string }): ReactElement {
179
309
  )
180
310
  }
181
311
 
312
+ /**
313
+ * One expanded tool-card body for the verbose transcript (Ctrl+O): the
314
+ * presentation contract's structured cards — inline diffs, read windows,
315
+ * web sources — rendered as plain terminal rows, degradation-safe against
316
+ * replayed metadata.
317
+ */
318
+ function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
319
+ switch (detail.kind) {
320
+ case 'diff':
321
+ return createElement(
322
+ Box,
323
+ { flexDirection: 'column' },
324
+ ...detail.diffs.map((diff, index) => createElement(
325
+ Box,
326
+ { key: index, flexDirection: 'column' },
327
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
328
+ ...diff.lines.map((line, at) => createElement(
329
+ Text,
330
+ {
331
+ key: at,
332
+ color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
333
+ wrap: 'truncate-end',
334
+ },
335
+ ` ${line.mark}${displayText(line.text)}`,
336
+ )),
337
+ )),
338
+ )
339
+ case 'read':
340
+ return createElement(
341
+ Box,
342
+ { flexDirection: 'column' },
343
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
344
+ ...detail.lines.map((line, at) => createElement(
345
+ Text,
346
+ { key: at, dimColor: true, wrap: 'truncate-end' },
347
+ ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
348
+ )),
349
+ )
350
+ case 'web-search':
351
+ return createElement(
352
+ Box,
353
+ { flexDirection: 'column' },
354
+ ...detail.sources.map((source, at) => createElement(
355
+ Text,
356
+ { key: at, wrap: 'truncate-end' },
357
+ brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
358
+ createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
359
+ )),
360
+ createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
361
+ )
362
+ case 'web-fetch':
363
+ return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
364
+ case 'raw':
365
+ return createElement(
366
+ Box,
367
+ { flexDirection: 'column' },
368
+ ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
369
+ createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
370
+ )
371
+ default:
372
+ return assertNever(detail, 'tool detail kind')
373
+ }
374
+ }
182
375
  /** One settled transcript row. */
183
- function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReasoning: boolean }): ReactElement {
376
+ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
184
377
  switch (entry.kind) {
185
378
  case 'user':
186
379
  // Collapsed injected context reads as a dim ↳ row; only direct human
@@ -216,7 +409,7 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
216
409
  { flexDirection: 'column' },
217
410
  createElement(
218
411
  Text,
219
- null,
412
+ { wrap: verbose ? 'truncate-end' : undefined },
220
413
  mark,
221
414
  ' ',
222
415
  brand(entry.name),
@@ -226,9 +419,12 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
226
419
  ? undefined
227
420
  : createElement(
228
421
  Text,
229
- { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim) },
422
+ { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
230
423
  ` ⎿ ${displayText(entry.summary)}`,
231
424
  ),
425
+ verbose && entry.detail !== undefined
426
+ ? createElement(ToolDetailBody, { detail: entry.detail })
427
+ : undefined,
232
428
  )
233
429
  }
234
430
  case 'command': {
@@ -242,7 +438,7 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
242
438
  { flexDirection: 'column' },
243
439
  createElement(
244
440
  Text,
245
- null,
441
+ { wrap: verbose ? 'truncate-end' : undefined },
246
442
  mark,
247
443
  ' ',
248
444
  brand(`/${entry.name}`),
@@ -250,11 +446,37 @@ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReaso
250
446
  ),
251
447
  entry.summary === ''
252
448
  ? undefined
253
- : createElement(Text, { color: inkColor(TUI_RGB.dim) }, ` ⎿ ${displayText(entry.summary)}`),
449
+ : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
450
+ )
451
+ }
452
+ case 'turn-marker':
453
+ // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
454
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
455
+ case 'compaction':
456
+ // Completed compaction lifecycle: what it reclaimed, or why it failed.
457
+ return createElement(
458
+ Text,
459
+ { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
460
+ entry.ok
461
+ ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
462
+ : ` ⧉ compaction failed: ${displayText(entry.error)}`,
463
+ )
464
+ case 'retry':
465
+ // Provider-routed retry: amber while the backoff waits, dim once the
466
+ // next attempt is underway.
467
+ return createElement(
468
+ Text,
469
+ { color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
470
+ ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
254
471
  )
472
+ case 'files': {
473
+ // Turn-tail deliverables: the turn's mutated files (web turnTail chips).
474
+ const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
475
+ const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
476
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⎄ ${shown}${more}`)
255
477
  }
256
478
  case 'error':
257
- return createElement(Text, null, paintError(displayText(entry.text)))
479
+ return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
258
480
  default:
259
481
  return assertNever(entry, 'transcript entry kind')
260
482
  }
@@ -303,33 +525,23 @@ function todoMark(status: TodoItem['status']): string {
303
525
  return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
304
526
  }
305
527
 
306
- /** Inline todo list (web TodoPanel's compact terminal form). */
528
+ /** One-row todo summary: task count cannot grow the live Ink tree. */
307
529
  function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
308
530
  if (todos.length === 0) return undefined
309
531
  const completed = todos.filter(todo => todo.status === 'completed').length
310
532
  const inProgress = todos.filter(todo => todo.status === 'in_progress').length
311
533
  const pending = todos.length - completed - inProgress
534
+ const current = todos.find(todo => todo.status === 'in_progress')
312
535
  return createElement(
313
536
  Box,
314
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
537
+ { paddingX: 1 },
315
538
  createElement(
316
539
  Text,
317
- { color: inkColor(TUI_RGB.brand), bold: true },
540
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
318
541
  `todos ${completed}/${todos.length}`,
319
542
  createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
543
+ current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
320
544
  ),
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
545
  )
334
546
  }
335
547
 
@@ -345,29 +557,66 @@ function StatusLine({ facts, stats, busy }: {
345
557
  busy: boolean
346
558
  }): ReactElement {
347
559
  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.
560
+ // One physical row in every mode: a narrow terminal must truncate instead
561
+ // of wrapping the status groups into an unbudgeted second/third row.
359
562
  return createElement(
360
563
  Box,
361
- { paddingX: 1, marginTop: 1 },
362
- ...children,
564
+ // Match the prompt text inside the bordered composer: one border column
565
+ // plus one padding column. Keeping this row margin-free also makes the
566
+ // composer and status a fixed four-row unit in every interface.
567
+ { paddingLeft: 2 },
568
+ createElement(
569
+ Text,
570
+ { dimColor: true, wrap: 'truncate-end' },
571
+ busy ? '● ' : '○ ',
572
+ groups.join(' | '),
573
+ ),
363
574
  )
364
575
  }
365
576
 
366
577
  /** The y/n approval bar rendered while an approval ask is pending. */
367
578
  function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
368
579
  const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
369
- useInput((input) => {
370
- if (locked || snapshot.pending === undefined || snapshot.answered) return
580
+ const stdout = useStdout().stdout
581
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
582
+ const [scroll, setScroll] = useState(0)
583
+ const pending = snapshot.pending
584
+ const active = !locked && snapshot.pending !== undefined && !snapshot.answered
585
+ const content = useMemo<readonly StyledLine[]>(() => pending === undefined
586
+ ? []
587
+ : [
588
+ ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
589
+ ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
590
+ ], [pending, viewport.contentColumns])
591
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
592
+
593
+ useEffect(() => {
594
+ setScroll(0)
595
+ }, [pending])
596
+
597
+ useEffect(() => {
598
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
599
+ }, [visibleScroll, scroll])
600
+
601
+ useInput((input, key) => {
602
+ if (snapshot.pending === undefined) return
603
+ if (key.upArrow) {
604
+ setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
605
+ return
606
+ }
607
+ if (key.downArrow) {
608
+ setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
609
+ return
610
+ }
611
+ if (key.pageUp) {
612
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
613
+ return
614
+ }
615
+ if (key.pageDown) {
616
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
617
+ return
618
+ }
619
+ if (snapshot.answered) return
371
620
  if (input === 'y' || input === 'Y') {
372
621
  snapshot.pending.answer('allowed-once')
373
622
  return
@@ -375,18 +624,21 @@ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: bo
375
624
  if (input === 'n' || input === 'N') {
376
625
  snapshot.pending.answer('rejected')
377
626
  }
378
- })
627
+ }, { isActive: active })
379
628
  if (snapshot.pending === undefined) return undefined
380
- const { pending, answered } = snapshot
629
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
630
+ if (viewport.compact) {
631
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
632
+ }
633
+ const { answered } = snapshot
381
634
  return createElement(
382
635
  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')),
636
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
637
+ createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
638
+ createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
639
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
640
+ ? 'submitted…'
641
+ : '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
390
642
  )
391
643
  }
392
644
 
@@ -400,6 +652,8 @@ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: bo
400
652
  */
401
653
  function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
402
654
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
655
+ const stdout = useStdout().stdout
656
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
403
657
  const pending = snapshot.pending
404
658
  const [index, setIndex] = useState(0)
405
659
  const [cursor, setCursor] = useState(0)
@@ -408,6 +662,7 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
408
662
  const [custom, setCustom] = useState('')
409
663
  const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
410
664
  const [submitted, setSubmitted] = useState(false)
665
+ const [scroll, setScroll] = useState(0)
411
666
 
412
667
  // A new request resets the walk; questions without options start in the
413
668
  // custom-answer box (a free-form question).
@@ -420,12 +675,65 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
420
675
  setCustom('')
421
676
  setAnswers([])
422
677
  setSubmitted(false)
678
+ setScroll(0)
423
679
  }, [pending])
424
680
 
425
681
  const question = pending?.request.questions[index]
426
682
  const options = question?.options ?? []
427
683
  const isPlan = question?.intent?.kind === 'plan-review'
428
684
  const isMulti = question?.multiSelect === true
685
+ const active = !locked && pending !== undefined && question !== undefined && !submitted
686
+ const rendered = useMemo(() => {
687
+ if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
688
+ const lines: StyledLine[] = []
689
+ const optionRows: number[] = []
690
+ if (question.header !== undefined) {
691
+ lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
692
+ }
693
+ lines.push(...textLines(question.question, viewport.contentColumns))
694
+ if (question.detail !== undefined) {
695
+ lines.push(...(isPlan
696
+ ? markdownLines(question.detail, viewport.contentColumns)
697
+ : textLines(question.detail, viewport.contentColumns, 'dim')))
698
+ }
699
+ if (submitted) {
700
+ lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
701
+ } else if (mode === 'custom' || options.length === 0) {
702
+ lines.push(...styledLines([
703
+ lineSegment(' custom: ', 'brand'),
704
+ lineSegment(custom, 'plain'),
705
+ lineSegment('▌', 'brand'),
706
+ ], viewport.contentColumns))
707
+ } else {
708
+ options.forEach((option, at) => {
709
+ optionRows.push(lines.length)
710
+ const chosen = isMulti && selected.includes(at)
711
+ const approve = isPlan && question.intent?.approve === option.label
712
+ const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
713
+ const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
714
+ lines.push(...styledLines([
715
+ lineSegment(mark, style),
716
+ lineSegment(option.label, style),
717
+ lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
718
+ ], viewport.contentColumns))
719
+ })
720
+ }
721
+ return { lines, optionRows }
722
+ }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
723
+ const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows)
724
+
725
+ useEffect(() => {
726
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
727
+ }, [visibleScroll, scroll])
728
+
729
+ useEffect(() => {
730
+ if (mode === 'options' && options.length > 0) {
731
+ const focused = rendered.optionRows[cursor] ?? 0
732
+ setScroll(current => revealRow(current, focused, rendered.lines.length, viewport.bodyRows))
733
+ return
734
+ }
735
+ setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows))
736
+ }, [cursor, mode, custom.length, rendered.lines.length, viewport.bodyRows])
429
737
 
430
738
  const commit = (answer: AskUserQuestionAnswerItem): void => {
431
739
  if (pending === undefined) return
@@ -437,11 +745,14 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
437
745
  return
438
746
  }
439
747
  setAnswers(next)
440
- setIndex(index + 1)
748
+ const nextIndex = index + 1
749
+ const nextQuestion = pending.request.questions[nextIndex]
750
+ setIndex(nextIndex)
441
751
  setCursor(0)
442
752
  setSelected([])
443
- setMode('options')
753
+ setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
444
754
  setCustom('')
755
+ setScroll(0)
445
756
  }
446
757
 
447
758
  const commitOption = (): void => {
@@ -460,12 +771,28 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
460
771
  }
461
772
 
462
773
  useInput((input, key) => {
463
- if (locked || pending === undefined || question === undefined || submitted) return
774
+ if (pending === undefined || question === undefined || submitted) return
464
775
  if (key.escape) {
465
776
  store.cancel(pending)
466
777
  return
467
778
  }
779
+ if (key.pageUp) {
780
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
781
+ return
782
+ }
783
+ if (key.pageDown) {
784
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
785
+ return
786
+ }
468
787
  if (mode === 'custom' || options.length === 0) {
788
+ if (key.upArrow) {
789
+ setScroll(current => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows))
790
+ return
791
+ }
792
+ if (key.downArrow) {
793
+ setScroll(current => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows))
794
+ return
795
+ }
469
796
  if (key.return) {
470
797
  if (custom.trim() === '' && options.length > 0) {
471
798
  commitOption()
@@ -508,51 +835,30 @@ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean
508
835
  if (input === ' ' && isMulti) {
509
836
  setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
510
837
  }
511
- })
838
+ }, { isActive: active })
512
839
 
513
840
  if (pending === undefined || question === undefined) return undefined
841
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
842
+ if (viewport.compact) {
843
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
844
+ }
845
+ const footer = submitted
846
+ ? 'submitted…'
847
+ : mode === 'custom' || options.length === 0
848
+ ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
849
+ : isMulti
850
+ ? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
851
+ : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
514
852
  return createElement(
515
853
  Box,
516
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
854
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
517
855
  createElement(
518
856
  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}`,
857
+ { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
858
+ truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
521
859
  ),
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
- ),
860
+ createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
861
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
556
862
  )
557
863
  }
558
864
 
@@ -564,12 +870,24 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
564
870
  onClose(): void
565
871
  }): ReactElement {
566
872
  const [cursor, setCursor] = useState(0)
873
+ const stdout = useStdout().stdout
874
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
875
+ const rows = directory?.rows ?? []
876
+
877
+ useEffect(() => {
878
+ if (rows.length === 0) {
879
+ if (cursor !== 0) setCursor(0)
880
+ return
881
+ }
882
+ if (cursor >= rows.length) setCursor(rows.length - 1)
883
+ }, [rows.length, cursor])
884
+
567
885
  useInput((input, key) => {
568
886
  if (key.escape || input === 'q') {
569
887
  onClose()
570
888
  return
571
889
  }
572
- const rows = directory?.rows ?? []
890
+ if (rows.length === 0) return
573
891
  if (key.upArrow) {
574
892
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
575
893
  return
@@ -578,23 +896,43 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
578
896
  setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
579
897
  return
580
898
  }
899
+ if (key.pageUp) {
900
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
901
+ return
902
+ }
903
+ if (key.pageDown) {
904
+ setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
905
+ return
906
+ }
907
+ if (input === 'g') {
908
+ setCursor(0)
909
+ return
910
+ }
911
+ if (input === 'G') {
912
+ setCursor(rows.length - 1)
913
+ return
914
+ }
581
915
  if (key.return && rows[cursor] !== undefined) {
582
916
  onSelect(rows[cursor])
583
917
  }
584
918
  })
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)
919
+
920
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
921
+ if (viewport.compact) {
922
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · esc/q close', viewport.contentColumns))
923
+ }
924
+
925
+ const first = selectionWindow(cursor, rows.length, viewport.bodyRows)
926
+ const visible = rows.slice(first, first + viewport.bodyRows)
589
927
  return createElement(
590
928
  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'),
929
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
930
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
593
931
  directory === undefined && error === undefined
594
- ? createElement(Text, { dimColor: true }, ' loading models…')
932
+ ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ' loading models…')
595
933
  : undefined,
596
934
  error !== undefined
597
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
935
+ ? createElement(Text, { color: inkColor(TUI_RGB.error), wrap: 'truncate-end' }, truncateColumns(` ${displayText(error)}`, viewport.contentColumns))
598
936
  : undefined,
599
937
  ...visible.map((row) => {
600
938
  const index = rows.indexOf(row)
@@ -604,14 +942,272 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
604
942
  {
605
943
  key: `${row.provider}/${row.model}`,
606
944
  color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
945
+ wrap: 'truncate-end',
607
946
  },
608
- `${index === cursor ? '❯ ' : ' '}${label}`,
947
+ truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
609
948
  )
610
949
  }),
611
- createElement(Text, { dimColor: true }, dim(' ↑↓ move · enter select · esc close')),
950
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · esc/q close', viewport.contentColumns))),
951
+ )
952
+ }
953
+
954
+ /**
955
+ * The /help overlay: one scrolling card with the keyboard map, the TUI-local
956
+ * commands, the live registry commands, and the user-invocable skills — the
957
+ * real command surface, replacing the one-line notice.
958
+ */
959
+ function HelpPanel({ descriptors, skills, onClose }: {
960
+ descriptors: readonly CommandDescriptor[]
961
+ skills: readonly SkillRow[]
962
+ onClose(): void
963
+ }): ReactElement {
964
+ const stdout = useStdout().stdout
965
+ const columns = stdout?.columns ?? 80
966
+ const viewport = panelViewport(columns, stdout?.rows ?? 30)
967
+ const [scroll, setScroll] = useState(0)
968
+ const nameWidth = 18
969
+ const descBudget = Math.max(1, viewport.contentColumns - nameWidth - 2)
970
+ const row = (label: string, description: string): ReactElement => createElement(
971
+ Text,
972
+ { dimColor: true, wrap: 'truncate-end' },
973
+ ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
974
+ )
975
+ const content: ReactElement[] = [
976
+ createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
977
+ createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
978
+ createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
979
+ createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
980
+ createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
981
+ createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
982
+ createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
983
+ createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
984
+ createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
985
+ createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
986
+ createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
987
+ createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
988
+ createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
989
+ ...descriptors.map(descriptor => createElement(
990
+ Text,
991
+ { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
992
+ ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
993
+ )),
994
+ ...(skills.length === 0 ? [] : [createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills')]),
995
+ ...skills.map(skill => createElement(
996
+ Text,
997
+ { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
998
+ ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
999
+ )),
1000
+ ]
1001
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
1002
+ const scrollBy = (delta: number): void => {
1003
+ setScroll(current => moveScroll(current, delta, content.length, viewport.bodyRows))
1004
+ }
1005
+
1006
+ useEffect(() => {
1007
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
1008
+ }, [visibleScroll, scroll])
1009
+
1010
+ useInput((input, key) => {
1011
+ if (key.escape || input === 'q') {
1012
+ onClose()
1013
+ return
1014
+ }
1015
+ if (key.upArrow) scrollBy(-1)
1016
+ else if (key.downArrow) scrollBy(1)
1017
+ else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
1018
+ else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
1019
+ else if (input === 'g') setScroll(0)
1020
+ else if (input === 'G') setScroll(Math.max(0, content.length - viewport.bodyRows))
1021
+ })
1022
+
1023
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1024
+ if (viewport.compact) {
1025
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
1026
+ }
1027
+
1028
+ return createElement(
1029
+ Box,
1030
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
1031
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
1032
+ ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1033
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
1034
+ )
1035
+ }
1036
+
1037
+ /** Collapse arbitrary metadata to one terminal row before verbose rendering. */
1038
+ function verboseLine(text: string, columns: number): string {
1039
+ return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
1040
+ }
1041
+
1042
+ /** One-row editor window keeping the logical cursor visible in long drafts. */
1043
+ function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
1044
+ const width = Math.max(1, columns)
1045
+ const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
1046
+ const caretSource = value.slice(cursor, cursor + 1)
1047
+ const caret = caretSource === '' ? ' ' : normalize(caretSource)
1048
+ const remaining = Math.max(0, width - visibleColumns(caret))
1049
+ const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
1050
+ const beforeBudget = Math.max(0, remaining - afterBudget)
1051
+ const before = beforeBudget === 0
1052
+ ? ''
1053
+ : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
1054
+ const after = afterBudget === 0
1055
+ ? ''
1056
+ : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
1057
+ return { before, caret, after }
1058
+ }
1059
+
1060
+ /**
1061
+ * The Ctrl+O transcript inspector: one selected durable entry at a time,
1062
+ * with independent history selection and content scrolling. The complete
1063
+ * retained entry is converted to physical rows, but only one viewport slice
1064
+ * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
1065
+ */
1066
+ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
1067
+ const stdout = useStdout().stdout
1068
+ const columns = stdout?.columns ?? 80
1069
+ const rows = stdout?.rows ?? 30
1070
+ const viewport = inspectorViewport(columns, rows)
1071
+ const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
1072
+ const [scroll, setScroll] = useState(0)
1073
+ const savedScroll = useRef(new Map<number, number>())
1074
+ const cursorRef = useRef(cursor)
1075
+ const previousLength = useRef(entries.length)
1076
+ const entry = entries[cursor]
1077
+ const allLines = useMemo(
1078
+ () => entry === undefined ? [] : transcriptEntryLines(entry, viewport.contentColumns),
1079
+ [entry, viewport.contentColumns],
1080
+ )
1081
+ const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
1082
+
1083
+ useEffect(() => {
1084
+ cursorRef.current = cursor
1085
+ }, [cursor])
1086
+
1087
+ useEffect(() => {
1088
+ const current = cursorRef.current
1089
+ const next = followInspectorCursor(current, previousLength.current, entries.length)
1090
+ if (next !== current) {
1091
+ savedScroll.current.set(current, visibleScroll)
1092
+ setCursor(next)
1093
+ setScroll(savedScroll.current.get(next) ?? 0)
1094
+ }
1095
+ previousLength.current = entries.length
1096
+ }, [entries.length])
1097
+
1098
+ useEffect(() => {
1099
+ const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows)
1100
+ if (clamped !== scroll) setScroll(clamped)
1101
+ savedScroll.current.set(cursor, clamped)
1102
+ }, [cursor, scroll, allLines.length, viewport.bodyRows])
1103
+
1104
+ const selectEntry = (next: number): void => {
1105
+ if (entries.length === 0) return
1106
+ const selected = Math.max(0, Math.min(entries.length - 1, next))
1107
+ if (selected === cursor) return
1108
+ savedScroll.current.set(cursor, visibleScroll)
1109
+ setCursor(selected)
1110
+ setScroll(savedScroll.current.get(selected) ?? 0)
1111
+ }
1112
+
1113
+ const scrollBy = (delta: number): void => {
1114
+ setScroll(current => moveScroll(current, delta, allLines.length, viewport.bodyRows))
1115
+ }
1116
+
1117
+ useInput((input, key) => {
1118
+ if (key.escape || input === 'q' || (key.ctrl && input === 'o')) {
1119
+ onClose()
1120
+ return
1121
+ }
1122
+ if (entries.length === 0) return
1123
+ if (key.leftArrow) {
1124
+ selectEntry(cursor - 1)
1125
+ return
1126
+ }
1127
+ if (key.rightArrow) {
1128
+ selectEntry(cursor + 1)
1129
+ return
1130
+ }
1131
+ if (key.upArrow) {
1132
+ scrollBy(-1)
1133
+ return
1134
+ }
1135
+ if (key.downArrow) {
1136
+ scrollBy(1)
1137
+ return
1138
+ }
1139
+ if (key.pageUp) {
1140
+ scrollBy(-Math.max(1, viewport.bodyRows - 1))
1141
+ return
1142
+ }
1143
+ if (key.pageDown) {
1144
+ scrollBy(Math.max(1, viewport.bodyRows - 1))
1145
+ return
1146
+ }
1147
+ if (input === 'g') {
1148
+ setScroll(0)
1149
+ return
1150
+ }
1151
+ if (input === 'G') {
1152
+ setScroll(Math.max(0, allLines.length - viewport.bodyRows))
1153
+ }
1154
+ })
1155
+
1156
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1157
+ if (viewport.compact) {
1158
+ return createElement(
1159
+ Text,
1160
+ { wrap: 'truncate-end' },
1161
+ truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
1162
+ )
1163
+ }
1164
+
1165
+ const title = entries.length === 0
1166
+ ? 'history details · empty'
1167
+ : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
1168
+ const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
1169
+ return createElement(
1170
+ Box,
1171
+ {
1172
+ flexDirection: 'column',
1173
+ paddingX: 1,
1174
+ borderStyle: 'round',
1175
+ borderColor: inkColor(TUI_RGB.brand),
1176
+ },
1177
+ createElement(
1178
+ Text,
1179
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
1180
+ truncateColumns(title, viewport.contentColumns),
1181
+ ),
1182
+ createElement(
1183
+ Box,
1184
+ { flexDirection: 'column' },
1185
+ entry === undefined
1186
+ ? createElement(Text, { dimColor: true }, ' no durable entries yet')
1187
+ : createElement(StyledRows, { lines: visible }),
1188
+ ),
1189
+ createElement(
1190
+ Text,
1191
+ { dimColor: true, wrap: 'truncate-end' },
1192
+ dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
1193
+ ),
612
1194
  )
613
1195
  }
614
1196
 
1197
+ /** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
1198
+ const MemoVerbosePanel = memo(VerbosePanel)
1199
+
1200
+ /** Stable append-only boundary: modal updates must never revisit Static rows. */
1201
+ function staticRow(item: unknown): ReactElement {
1202
+ return item as ReactElement
1203
+ }
1204
+
1205
+ function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
1206
+ return createElement(Static, { items, children: staticRow })
1207
+ }
1208
+
1209
+ const MemoStaticTranscript = memo(StaticTranscript)
1210
+
615
1211
  /** One completion candidate row. */
616
1212
  interface CompletionCandidate {
617
1213
  /** Insertion text for the command name (with leading slash). */
@@ -619,7 +1215,7 @@ interface CompletionCandidate {
619
1215
  /** Human-readable description shown beside the label. */
620
1216
  description: string
621
1217
  /** Candidate origin; skills land the same literal text but route through the prompt. */
622
- origin: 'command' | 'skill' | 'mention'
1218
+ origin: 'command' | 'skill' | 'mention' | 'path'
623
1219
  }
624
1220
 
625
1221
  /**
@@ -639,6 +1235,8 @@ function completionCandidates(
639
1235
  { label: '/help', description: 'show commands', origin: 'command' },
640
1236
  { label: '/model', description: 'switch the model', origin: 'command' },
641
1237
  { label: '/clear', description: 'clear the screen', origin: 'command' },
1238
+ { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
1239
+ { label: '/title', description: 'rename this session', origin: 'command' },
642
1240
  { label: '/quit', description: 'exit', origin: 'command' },
643
1241
  ]
644
1242
  // Local commands shadow registry names (e.g. the plugin-registered
@@ -665,44 +1263,52 @@ function completionCandidates(
665
1263
  return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
666
1264
  }
667
1265
 
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). */
1266
+ /**
1267
+ * The completion menu, rendered inside the composer's subtree directly above
1268
+ * the framed box attached the way Claude-Code anchors its dropdown. Opening
1269
+ * it grows the stack downward: the composer stays the last element on screen
1270
+ * and everything above (the flushed static transcript, the status line) never
1271
+ * moves. Props-only (no lifted state): the menu is a pure view of the input
1272
+ * editor's live completion state, so no cross-component effect ever resyncs
1273
+ * it (a state lift here previously deadlocked the menu after a resize).
1274
+ */
1275
+ function CompletionMenu({ active, mention, index, rows }: {
671
1276
  active: boolean
672
- /** Whether the menu is driven by an @mention token. */
673
1277
  mention: boolean
674
- /** Highlighted candidate index (wraps by row count). */
675
1278
  index: number
676
- /** Rendered rows in display order. */
677
1279
  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)
1280
+ }): ReactElement | undefined {
1281
+ // Hook order is unconditional: `active` toggling must not change the hook
1282
+ // count (the early return used to sit above useStdout).
1283
+ const stdout = useStdout().stdout
1284
+ const columns = stdout?.columns ?? 80
1285
+ const terminalRows = stdout?.rows ?? 30
1286
+ if (!active) return undefined
1287
+ const nameWidth = Math.min(18, Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
691
1288
  const descBudget = Math.max(24, columns - nameWidth - 8)
1289
+ const showFooter = terminalRows >= 12
1290
+ const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)))
1291
+ const selected = rows.length === 0 ? 0 : index % rows.length
1292
+ const first = selectionWindow(selected, rows.length, limit)
1293
+ const visible = rows.slice(first, first + limit)
692
1294
  return createElement(
693
1295
  Box,
694
- { flexDirection: 'column', marginTop: 1, marginLeft: 2 },
695
- ...(state.rows.length === 0
1296
+ { flexDirection: 'column', marginLeft: 2 },
1297
+ ...(rows.length === 0
696
1298
  ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
697
- : state.rows.map((candidate, index) => createElement(
1299
+ : visible.map((candidate, at) => {
1300
+ const absolute = first + at
1301
+ return createElement(
698
1302
  Text,
699
1303
  {
700
1304
  key: candidate.label,
701
- color: index === state.index % state.rows.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1305
+ color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1306
+ wrap: 'truncate-end',
702
1307
  },
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')),
1308
+ `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1309
+ )
1310
+ })),
1311
+ showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
706
1312
  )
707
1313
  }
708
1314
 
@@ -712,8 +1318,9 @@ function CompletionMenu({ state }: { state: MenuState }): ReactElement | undefin
712
1318
  * While a modal (approval / question / model panel) owns the keys, the
713
1319
  * box passes every key through untouched.
714
1320
  */
715
- function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }: {
1321
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }: {
716
1322
  active: boolean
1323
+ frozen: boolean
717
1324
  busy: boolean
718
1325
  descriptors: readonly CommandDescriptor[]
719
1326
  skills: readonly SkillRow[]
@@ -722,12 +1329,18 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
722
1329
  interrupt(): boolean
723
1330
  quit(): void
724
1331
  openModel(): void
1332
+ openHelp(): void
725
1333
  notify(text: string): void
726
1334
  toggleReasoning(): void
1335
+ openVerbose(): void
1336
+ clearView(): void
1337
+ refresh(): void
727
1338
  loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
728
1339
  cyclePermission(): string
729
- onMenuState(state: MenuState): void
1340
+ exportTranscript(argument: string): Promise<void>
1341
+ renameTitle(argument: string): string
730
1342
  }): ReactElement {
1343
+ const columns = useStdout().stdout?.columns ?? 80
731
1344
  const [value, setValue] = useState('')
732
1345
  const [cursor, setCursor] = useState(0)
733
1346
  const history = useRef<readonly string[]>([])
@@ -747,8 +1360,37 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
747
1360
  const mentionActive = mentionToken !== undefined
748
1361
  const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
749
1362
 
1363
+ // Bare path token: the last whitespace-delimited run on the cursor's line
1364
+ // when it already looks like a path (Claude-Code bare Tab completion). A
1365
+ // LEADING '/' is the command namespace, never a path — without this guard
1366
+ // typing the bare '/' hijacked the menu into the workspace file scan and
1367
+ // the slash-command candidates never appeared.
1368
+ const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
1369
+ const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
1370
+ const pathActive = !mentionActive
1371
+ && !bareToken.startsWith('/')
1372
+ && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
1373
+ const pathTokenStart = beforeCursor.length - bareToken.length
1374
+ const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
1375
+
1376
+ useEffect(() => {
1377
+ if (!active || !pathActive) {
1378
+ setPathRows([])
1379
+ return
1380
+ }
1381
+ const controller = new AbortController()
1382
+ setPathRows([])
1383
+ loadMentions(bareToken, controller.signal).then(
1384
+ rows => setPathRows(rows.filter(row => row.kind !== 'session')),
1385
+ () => {},
1386
+ )
1387
+ return () => {
1388
+ controller.abort()
1389
+ }
1390
+ }, [active, pathActive, bareToken])
1391
+
750
1392
  useEffect(() => {
751
- if (!mentionActive) {
1393
+ if (!active || !mentionActive) {
752
1394
  setMentionRows([])
753
1395
  return
754
1396
  }
@@ -761,9 +1403,9 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
761
1403
  return () => {
762
1404
  controller.abort()
763
1405
  }
764
- }, [mentionActive, mentionToken?.query])
1406
+ }, [active, mentionActive, mentionToken?.query])
765
1407
 
766
- const menuActive = (slashActive || mentionActive) && !busy
1408
+ const menuActive = (slashActive || mentionActive || pathActive) && !busy
767
1409
  const menuRows: readonly CompletionCandidate[] = mentionActive
768
1410
  ? mentionRows.map(row => ({
769
1411
  label: row.label.startsWith('@')
@@ -772,23 +1414,13 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
772
1414
  description: row.description,
773
1415
  origin: 'mention',
774
1416
  }))
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])
1417
+ : pathActive
1418
+ ? pathRows.map(row => ({
1419
+ label: row.label,
1420
+ description: row.description,
1421
+ origin: 'path',
1422
+ }))
1423
+ : candidates
792
1424
 
793
1425
  useInput((input, key) => {
794
1426
  // Modal ownership: approval/question/model dialogs consume all keys.
@@ -804,6 +1436,13 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
804
1436
  toggleReasoning()
805
1437
  return
806
1438
  }
1439
+ // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
1440
+ // adapted to append-only static rows): one history entry at a time with
1441
+ // tool cards and reasoning expanded, Esc returns.
1442
+ if (key.ctrl && input === 'o') {
1443
+ openVerbose()
1444
+ return
1445
+ }
807
1446
  // Ctrl+C is three-state (community-TUI convention): a running turn is
808
1447
  // cancelled, a non-empty draft is cleared, and only an idle empty input
809
1448
  // exits. Ctrl+D always means exit but refuses mid-turn.
@@ -849,11 +1488,23 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
849
1488
  return
850
1489
  }
851
1490
  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')
1491
+ openHelp()
853
1492
  return
854
1493
  }
855
1494
  if (text === '/clear') {
856
- console.clear()
1495
+ // Clear the screen AND drop the folded view: the raw ANSI clear + a
1496
+ // Static remount (refresh) so the ledger stays in sync, then the
1497
+ // store resets so the rebuilt transcript starts empty.
1498
+ refresh()
1499
+ clearView()
1500
+ return
1501
+ }
1502
+ if (text === '/export' || text.startsWith('/export ')) {
1503
+ void exportTranscript(text.slice(8))
1504
+ return
1505
+ }
1506
+ if (text === '/title' || text.startsWith('/title ')) {
1507
+ notify(renameTitle(text.slice(7)))
857
1508
  return
858
1509
  }
859
1510
  if (text === '/model' || text.startsWith('/model ')) {
@@ -915,6 +1566,15 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
915
1566
  setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
916
1567
  setCursor(mentionToken.start + insertion.length)
917
1568
  }
1569
+ } else if (pathActive) {
1570
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
1571
+ if (row !== undefined) {
1572
+ // Bare path completion replaces the typed token with the chosen
1573
+ // workspace path (directories keep their trailing slash).
1574
+ const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
1575
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
1576
+ setCursor(pathTokenStart + insertion.length)
1577
+ }
918
1578
  } else {
919
1579
  const candidate = candidates[completionIndex % candidates.length]
920
1580
  if (candidate !== undefined) {
@@ -946,6 +1606,18 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
946
1606
  setCursor(0)
947
1607
  return
948
1608
  }
1609
+ // Readline parity: Ctrl+K cuts from the cursor to the end of the line.
1610
+ if (key.ctrl && input === 'k') {
1611
+ setValue(value.slice(0, cursor))
1612
+ return
1613
+ }
1614
+ // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
1615
+ // plus a Static remount so the flushed transcript re-emits (a bare
1616
+ // console.clear() would desync Ink's ledger against the static rows).
1617
+ if (key.ctrl && input === 'l') {
1618
+ refresh()
1619
+ return
1620
+ }
949
1621
  if (key.ctrl && input === 'a') {
950
1622
  setCursor(0)
951
1623
  return
@@ -961,12 +1633,37 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
961
1633
  }
962
1634
  })
963
1635
 
1636
+ // Every exclusive panel keeps the composer as a stable visual anchor, but
1637
+ // freezes it to one row: no menu, multiline wrap, or animation.
1638
+ if (frozen) {
1639
+ const frozen = value === ''
1640
+ ? 'type a message'
1641
+ : verboseLine(value, Math.max(1, columns - 6))
1642
+ return createElement(
1643
+ Box,
1644
+ { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1645
+ createElement(
1646
+ Text,
1647
+ { wrap: 'truncate-end' },
1648
+ createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1649
+ frozen,
1650
+ ),
1651
+ )
1652
+ }
1653
+
1654
+ const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
1655
+
964
1656
  return createElement(
965
1657
  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,
1658
+ { flexDirection: 'column' },
1659
+ // The completion dropdown rides directly above the box (Claude-Code
1660
+ // anchor): rendered from the editor's own live state, never lifted.
1661
+ createElement(CompletionMenu, {
1662
+ active: menuActive,
1663
+ mention: mentionActive,
1664
+ index: completionIndex,
1665
+ rows: menuRows,
1666
+ }),
970
1667
  // The framed input box: a visible boundary so the prompt never blends
971
1668
  // into the transcript above it; the cursor block sits immediately after
972
1669
  // the prompt marker (leftmost), with the dim placeholder trailing it —
@@ -974,14 +1671,16 @@ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt,
974
1671
  createElement(
975
1672
  Box,
976
1673
  { 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)),
1674
+ createElement(
1675
+ Text,
1676
+ { wrap: 'truncate-end' },
1677
+ createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1678
+ value === '' ? undefined : editor.before,
1679
+ createElement(CursorBlock, { char: editor.caret }),
1680
+ value === '' && !busy
1681
+ ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
1682
+ : editor.after,
1683
+ ),
985
1684
  ),
986
1685
  )
987
1686
  }
@@ -1019,47 +1718,164 @@ export function App(props: AppProps): ReactElement {
1019
1718
 
1020
1719
  const busy = view.busy
1021
1720
  const [showReasoning, setShowReasoning] = useState(false)
1022
- const [menuState, setMenuState] = useState<MenuState>({ active: false, mention: false, index: 0, rows: [] })
1721
+ const [verboseOpen, setVerboseOpen] = useState(false)
1722
+ const [helpOpen, setHelpOpen] = useState(false)
1723
+ const [refreshEpoch, setRefreshEpoch] = useState(0)
1023
1724
  const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
1024
1725
  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.
1726
+ const approvalPending = approvalSnapshot.pending !== undefined
1029
1727
  const questionPending = questionSnapshot.pending !== undefined
1728
+ // While any modal owns the keys, the prompt box passes everything through.
1729
+ const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
1730
+
1731
+ // Human questions outrank local inspectors. Close the lower modal instead
1732
+ // of leaving an approval/question visible but keyboard-locked behind it.
1733
+ useEffect(() => {
1734
+ if (!approvalPending && !questionPending) return
1735
+ setModelOpen(false)
1736
+ setHelpOpen(false)
1737
+ setVerboseOpen(false)
1738
+ }, [approvalPending, questionPending])
1739
+
1740
+ // Append-only transcript: everything up to the first still-mutable entry
1741
+ // (a running tool/retry) flushes through Ink's `<Static>` into native
1742
+ // scrollback and is normally never rewritten — the Claude-Code stability
1743
+ // contract
1744
+ // that lets arbitrarily long conversations scroll instead of freezing when
1745
+ // the live tree exceeds the terminal height. The dynamic region below stays
1746
+ // small: the streaming tail, modals, composer, and its status footer.
1747
+ // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
1748
+ // keeps long settled histories out of the per-token render path.
1749
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
1030
1750
  // 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}` }, ' '))
1751
+ // first) separates replies from the next turn. Settled rows flush once with
1752
+ // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
1753
+ // Ctrl+O browses the frozen history through a bounded selected-entry view.
1754
+ const settledRows = useMemo(() => {
1755
+ const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
1756
+ view.entries.slice(0, settled).forEach((entry, index) => {
1757
+ const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
1758
+ if (entry.kind === 'user' && index > 0) {
1759
+ rows.push(createElement(Box, { key: `gap-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
1760
+ }
1761
+ rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
1762
+ })
1763
+ return rows
1764
+ }, [view.entries, settled, showReasoning, props.resumed])
1765
+
1766
+ // Hook order is unconditional. Its dimensions drive every live-region
1767
+ // budget before any dynamic rows are constructed.
1768
+ const appStdout = useStdout().stdout
1769
+ const [terminalSize, setTerminalSize] = useState(() => ({
1770
+ columns: appStdout?.columns ?? 80,
1771
+ rows: appStdout?.rows ?? 30,
1772
+ }))
1773
+ const terminalSizeRef = useRef(terminalSize)
1774
+ useEffect(() => {
1775
+ if (appStdout === undefined) return
1776
+ let replayTimer: ReturnType<typeof setTimeout> | undefined
1777
+ const handleResize = (): void => {
1778
+ const next = {
1779
+ columns: appStdout.columns ?? 80,
1780
+ rows: appStdout.rows ?? 30,
1781
+ }
1782
+ if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
1783
+ terminalSizeRef.current = next
1784
+
1785
+ // Ink 5 erases by the old logical line count. Once the terminal reflows
1786
+ // a full-width border at a new width, that count is no longer enough and
1787
+ // stale frames remain visible. Follow Codex's source-backed reflow
1788
+ // policy: update live geometry immediately, but wait for the resize
1789
+ // burst to settle before one hard reset and one transcript replay at the
1790
+ // final width. Replaying Static on every event appends duplicate history.
1791
+ setTerminalSize(next)
1792
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
1793
+ replayTimer = setTimeout(() => {
1794
+ appStdout.write(RESIZE_REFLOW_CLEAR)
1795
+ setRefreshEpoch(epoch => epoch + 1)
1796
+ }, RESIZE_REFLOW_DELAY_MS)
1036
1797
  }
1037
- transcriptRows.push(createElement(EntryLine, { key: index, entry, showReasoning }))
1038
- })
1798
+ appStdout.on('resize', handleResize)
1799
+ return () => {
1800
+ appStdout.off('resize', handleResize)
1801
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
1802
+ }
1803
+ }, [appStdout])
1804
+ const terminalRows = terminalSize.rows
1805
+ const terminalColumns = terminalSize.columns
1806
+ const dynamicRows = Math.max(1, terminalRows - 12)
1807
+ const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
1808
+ const deepDivingVisible = busy && !streamingActive
1809
+ const allLiveLines = useMemo(
1810
+ () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
1811
+ [view.entries, settled, terminalColumns],
1812
+ )
1813
+ const liveBudget = streamingActive
1814
+ ? Math.max(1, Math.floor(dynamicRows / 3))
1815
+ : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
1816
+ const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
1817
+
1818
+ // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
1819
+ // screen AND scrollback, home the cursor) then a Static remount via the
1820
+ // key change, which re-flushes the current items from index 0. NEVER
1821
+ // console.clear() — it desyncs Ink's internal line ledger against the
1822
+ // flushed static rows and garbles every frame after.
1823
+ const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
1824
+ const reasoningRows = view.streamingReasoning === ''
1825
+ ? 0
1826
+ : view.streaming === ''
1827
+ ? streamRows
1828
+ : streamRows <= 1
1829
+ ? 0
1830
+ : showReasoning
1831
+ ? Math.max(1, Math.floor(streamRows / 3))
1832
+ : 1
1833
+ const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
1834
+ const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
1835
+ const inspectorVisible = verboseOpen && !approvalPending && !questionPending
1836
+ const modalVisible = modelOpen || helpOpen || inspectorVisible || approvalPending || questionPending
1837
+ const closeInspector = useCallback((): void => {
1838
+ setVerboseOpen(false)
1839
+ }, [])
1840
+ const refreshScreen = (): void => {
1841
+ if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
1842
+ setRefreshEpoch(epoch => epoch + 1)
1843
+ }
1844
+
1039
1845
  return createElement(
1040
1846
  Box,
1041
1847
  { 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
1848
+ createElement(MemoStaticTranscript, {
1849
+ key: refreshEpoch,
1850
+ items: settledRows,
1851
+ }),
1852
+ transcriptVisible
1853
+ ? createElement(
1854
+ Box,
1855
+ { flexDirection: 'column', paddingX: 1 },
1856
+ visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
1857
+ view.streamingReasoning !== '' && reasoningRows > 0
1858
+ ? createElement(StreamTail, {
1859
+ text: showReasoning ? view.streamingReasoning : 'Thinking…',
1860
+ prefix: ' ✻ ',
1861
+ dim: true,
1862
+ maxRows: reasoningRows,
1863
+ })
1864
+ : undefined,
1865
+ view.streaming !== '' && answerRows > 0
1866
+ ? createElement(
1867
+ StreamTail,
1868
+ { text: view.streaming, dim: false, maxRows: answerRows },
1869
+ busy ? createElement(Caret) : undefined,
1870
+ )
1871
+ : undefined,
1872
+ deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
1873
+ )
1874
+ : undefined,
1875
+ transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
1876
+ createElement(QuestionBar, { store: props.questions, locked: false }),
1877
+ createElement(ApprovalBar, { approval: props.approval, locked: questionPending }),
1878
+ modelOpen && !approvalPending && !questionPending
1063
1879
  ? createElement(ModelPanel, {
1064
1880
  directory,
1065
1881
  error: modelError,
@@ -1073,43 +1889,81 @@ export function App(props: AppProps): ReactElement {
1073
1889
  },
1074
1890
  })
1075
1891
  : undefined,
1892
+ helpOpen && !approvalPending && !questionPending
1893
+ ? createElement(HelpPanel, {
1894
+ descriptors,
1895
+ skills,
1896
+ onClose: () => {
1897
+ setHelpOpen(false)
1898
+ },
1899
+ })
1900
+ : undefined,
1901
+ verboseOpen && !approvalPending && !questionPending
1902
+ ? createElement(MemoVerbosePanel, {
1903
+ entries: view.entries,
1904
+ onClose: closeInspector,
1905
+ })
1906
+ : undefined,
1907
+ transcriptVisible
1908
+ ? createElement(
1909
+ Box,
1910
+ { flexDirection: 'column' },
1911
+ ...notices.slice(-1).map((notice, index) => createElement(Text, { key: index, dimColor: true, wrap: 'truncate-end' }, displayText(notice))),
1912
+ )
1913
+ : undefined,
1914
+ // Persistent bottom chrome: every interface owns exactly the same
1915
+ // composer/status geometry. Panels may change above it, but can no longer
1916
+ // reorder the status or introduce mode-specific vertical margins.
1076
1917
  createElement(
1077
1918
  Box,
1078
1919
  { flexDirection: 'column' },
1079
- ...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
1920
+ createElement(Input, {
1921
+ active: inputActive,
1922
+ frozen: modalVisible,
1923
+ busy,
1924
+ descriptors,
1925
+ skills,
1926
+ dispatch: props.dispatch,
1927
+ steer: props.steer,
1928
+ interrupt: props.interrupt,
1929
+ quit: props.quit,
1930
+ openModel: () => {
1931
+ setModelOpen(true)
1932
+ },
1933
+ openHelp: () => {
1934
+ setHelpOpen(true)
1935
+ },
1936
+ notify,
1937
+ openVerbose: () => {
1938
+ setVerboseOpen(true)
1939
+ },
1940
+ clearView: () => {
1941
+ props.store.reset()
1942
+ },
1943
+ refresh: refreshScreen,
1944
+ toggleReasoning: () => {
1945
+ setShowReasoning(current => !current)
1946
+ },
1947
+ loadMentions: props.loadMentions,
1948
+ cyclePermission: props.cyclePermission,
1949
+ exportTranscript: props.exportTranscript,
1950
+ renameTitle: props.renameTitle,
1951
+ }),
1952
+ createElement(StatusLine, {
1953
+ facts: {
1954
+ model: modelLabel,
1955
+ cwd: props.cwd,
1956
+ branch: props.branch,
1957
+ sessionId: props.sessionId,
1958
+ title: view.title,
1959
+ plan: view.plan,
1960
+ permission: view.permission,
1961
+ sandbox: view.sandbox,
1962
+ goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
1963
+ },
1964
+ stats: view.stats,
1965
+ busy,
1966
+ }),
1080
1967
  ),
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
1968
  )
1115
1969
  }