dsh-code 0.2.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,22 +15,52 @@
15
15
  */
16
16
 
17
17
  import {
18
- createElement, useEffect, 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 } 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
- import { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
24
+ import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
25
+ import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
25
26
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
26
27
  import type { TranscriptStore } from './store.ts'
27
- import type { TranscriptEntry } from './render/projection.ts'
28
+ import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
29
+ import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
+ import type { ToolDetail } from './render/tool-detail.ts'
31
+ import { caretVisible, pulseFrame } from './render/animations.ts'
28
32
  import type { ApprovalStore } from './approval.ts'
29
33
  import type { CommandsView } from './commands.ts'
30
34
  import type { ModelDirectory, ModelRow } from './models.ts'
35
+ import type { QuestionStore } from './questions.ts'
31
36
  import type { SkillsView, SkillRow } from './skills.ts'
32
- import { buildStatusGroups, type StatusFacts } from './render/status.ts'
33
- import { displayText } from './render/text.ts'
37
+ import type { MentionCandidate } from './mentions.ts'
38
+
39
+ /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
40
+ const RESIZE_REFLOW_DELAY_MS = 75
41
+
42
+ /** Reset region/style, clear the visible screen and scrollback, then home. */
43
+ const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
44
+ import { buildStatusGroups, formatTokens, type StatusFacts } from './render/status.ts'
45
+ import { displayTail, displayText } from './render/text.ts'
46
+ import {
47
+ clampScroll,
48
+ followInspectorCursor,
49
+ inspectorViewport,
50
+ moveScroll,
51
+ panelViewport,
52
+ revealRow,
53
+ selectionWindow,
54
+ } from './render/inspector.ts'
55
+ import {
56
+ lineSegment,
57
+ markdownLines,
58
+ styledLines,
59
+ textLines,
60
+ transcriptEntryLines,
61
+ type LineStyle,
62
+ type StyledLine,
63
+ } from './render/lines.ts'
34
64
 
35
65
  /** Props the runner hands the app; callbacks stay owned by the runner. */
36
66
  export interface AppProps {
@@ -38,6 +68,8 @@ export interface AppProps {
38
68
  store: TranscriptStore
39
69
  /** Approval-question store fed by the answerer listener. */
40
70
  approval: ApprovalStore
71
+ /** ask_user_question store fed by the single UI provider. */
72
+ questions: QuestionStore
41
73
  /** Live slash-command descriptor list (completion candidates). */
42
74
  commands: CommandsView
43
75
  /** Live user-invocable skill catalog (completion candidates). */
@@ -62,8 +94,16 @@ export interface AppProps {
62
94
  quit(): void
63
95
  /** Load the selectable model directory (called when /model opens). */
64
96
  loadModels(): Promise<ModelDirectory>
97
+ /** Load @mention candidates for the typed query (files + sessions). */
98
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
65
99
  /** Apply one /model selection; returns the display label. */
66
100
  selectModel(row: ModelRow): string
101
+ /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
102
+ cyclePermission(): string
103
+ /** Export the transcript to a markdown file (/export [path]); reports via notices. */
104
+ exportTranscript(argument: string): Promise<void>
105
+ /** Rename the session (/title <text>); returns the outcome line for the notice. */
106
+ renameTitle(argument: string): string
67
107
  /** Registers the app's notice channel with the runner (called once on mount). */
68
108
  onBridgeReady(bridge: { notify(text: string): void }): void
69
109
  }
@@ -73,53 +113,393 @@ function inkColor(triple: readonly [number, number, number]): string {
73
113
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
74
114
  }
75
115
 
116
+ /** Truncate text to a visible-column budget, appending … when cut. */
117
+ function truncateColumns(text: string, max: number): string {
118
+ let columns = 0
119
+ let out = ''
120
+ for (const char of text) {
121
+ const code = char.codePointAt(0) ?? 0
122
+ const width = code > 0x2e7f ? 2 : 1
123
+ if (columns + width > max) return `${out}…`
124
+ out += char
125
+ columns += width
126
+ }
127
+ return out
128
+ }
129
+
130
+ /** Pad text with spaces to a visible-column target (menu name column). */
131
+ function padColumns(text: string, width: number): string {
132
+ return text + ' '.repeat(Math.max(0, width - visibleColumns(text)))
133
+ }
134
+
135
+ /** Interval-driven frame counter for one self-contained animated leaf. */
136
+ function useFrames(intervalMs: number): number {
137
+ const [tick, setTick] = useState(0)
138
+ useEffect(() => {
139
+ const id = setInterval(() => setTick(current => current + 1), intervalMs)
140
+ return () => {
141
+ clearInterval(id)
142
+ }
143
+ }, [intervalMs])
144
+ return tick
145
+ }
146
+
147
+ /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
148
+ function Pulse(): ReactElement {
149
+ const tick = useFrames(125)
150
+ return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
151
+ }
152
+
153
+ /** Blinking block caret appended to streaming text. */
154
+ function Caret(): ReactElement {
155
+ const tick = useFrames(530)
156
+ return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
157
+ }
158
+
159
+ /** Blinking input cursor: inverse block while the caret phase is on. */
160
+ function CursorBlock({ char }: { char: string }): ReactElement {
161
+ const tick = useFrames(530)
162
+ return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
163
+ }
164
+
165
+ /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
166
+ function runClock(ms: number): string {
167
+ const total = Math.max(0, Math.floor(ms / 1000))
168
+ const minutes = Math.floor(total / 60)
169
+ const seconds = total % 60
170
+ return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
171
+ }
172
+
173
+ /**
174
+ * The busy line, web TurnStatus contract: the plain `Deep diving...` label,
175
+ * with the elapsed clock appended only once the turn has clearly been running
176
+ * (15s) — anchored to `turn/start` so a resumed mid-turn keeps the real time.
177
+ */
178
+ function DeepDivingLine({ since }: { since: number }): ReactElement {
179
+ useFrames(1000)
180
+ const elapsed = since === 0 ? 0 : Date.now() - since
181
+ return createElement(
182
+ Text,
183
+ { dimColor: true },
184
+ elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
185
+ )
186
+ }
187
+
188
+ /**
189
+ * The streaming buffer rendered with a hard size cap: the live region must
190
+ * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
191
+ * than the screen freezes (cursor-up past the top, garbage, no scroll). The
192
+ * cap counts explicit newlines and terminal wrapping, slicing from the END so
193
+ * the freshest tokens stay visible while a long reply streams; the complete
194
+ * text lands in the flushed scrollback once the turn assembles it.
195
+ */
196
+ function StreamTail({ text, dim, maxRows, prefix, children }: {
197
+ text: string
198
+ dim: boolean
199
+ maxRows: number
200
+ prefix?: string
201
+ children?: ReactElement
202
+ }): ReactElement {
203
+ const columns = useStdout().stdout?.columns ?? 80
204
+ const safeRows = Math.max(1, maxRows)
205
+ // App padding consumes two columns; the final extra column keeps a caret
206
+ // from wrapping onto an unbudgeted row.
207
+ const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
208
+ const initial = displayTail(text, contentColumns, safeRows)
209
+ // Reserve one row for the omission marker only when a marker is needed.
210
+ const tail = initial.truncated && safeRows > 1
211
+ ? displayTail(text, contentColumns, safeRows - 1)
212
+ : initial
213
+ return createElement(
214
+ Box,
215
+ { flexDirection: 'column' },
216
+ tail.truncated && safeRows > 1
217
+ ? createElement(Text, { dimColor: true }, ' …')
218
+ : undefined,
219
+ createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
220
+ )
221
+ }
222
+
223
+ /** Ink props for one markdown style class. */
224
+ function segmentProps(style: MdSegment['style']): {
225
+ color: string | undefined
226
+ bold: boolean | undefined
227
+ italic: boolean | undefined
228
+ strikethrough: boolean | undefined
229
+ } {
230
+ switch (style) {
231
+ case 'accent':
232
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
233
+ case 'code':
234
+ return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
235
+ case 'dim':
236
+ return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
237
+ case 'bold':
238
+ return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
239
+ case 'italic':
240
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
241
+ case 'boldItalic':
242
+ return { color: undefined, bold: true, italic: true, strikethrough: undefined }
243
+ case 'strike':
244
+ return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
245
+ default:
246
+ return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
247
+ }
248
+ }
249
+
250
+ /** Ink props for the richer line model used by bounded scrolling panels. */
251
+ function lineStyleProps(style: LineStyle): {
252
+ color: string | undefined
253
+ bold: boolean | undefined
254
+ italic: boolean | undefined
255
+ strikethrough: boolean | undefined
256
+ dimColor: boolean | undefined
257
+ } {
258
+ switch (style) {
259
+ case 'brand':
260
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
261
+ case 'success':
262
+ return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
263
+ case 'error':
264
+ return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
265
+ case 'warn':
266
+ return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
267
+ case 'dimItalic':
268
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
269
+ default:
270
+ return { ...segmentProps(style), dimColor: undefined }
271
+ }
272
+ }
273
+
274
+ /** Render width-safe rows; every child is exactly one terminal row. */
275
+ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
276
+ return createElement(
277
+ Box,
278
+ { flexDirection: 'column' },
279
+ ...lines.map((line, index) => createElement(
280
+ Text,
281
+ { key: index, wrap: 'truncate-end' },
282
+ line.segments.length === 0
283
+ ? ' '
284
+ : line.segments.map((segment, at) => createElement(
285
+ Text,
286
+ { key: at, ...lineStyleProps(segment.style) },
287
+ segment.text,
288
+ )),
289
+ )),
290
+ )
291
+ }
292
+
293
+ /** One settled markdown document rendered as styled lines at the terminal width. */
294
+ function MarkdownBody({ text }: { text: string }): ReactElement {
295
+ const columns = useStdout().stdout?.columns ?? 80
296
+ // Cached by (text, width): settled replies re-layout only when either moves.
297
+ const lines = useMemo(
298
+ () => renderMarkdown(displayText(text), Math.max(20, columns - 2)),
299
+ [text, columns],
300
+ )
301
+ return createElement(
302
+ Box,
303
+ { flexDirection: 'column' },
304
+ ...lines.map((line, index) => createElement(
305
+ Text,
306
+ { key: index },
307
+ ...line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
308
+ )),
309
+ )
310
+ }
311
+
312
+ /**
313
+ * One expanded tool-card body for the verbose transcript (Ctrl+O): the
314
+ * presentation contract's structured cards — inline diffs, read windows,
315
+ * web sources — rendered as plain terminal rows, degradation-safe against
316
+ * replayed metadata.
317
+ */
318
+ function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
319
+ switch (detail.kind) {
320
+ case 'diff':
321
+ return createElement(
322
+ Box,
323
+ { flexDirection: 'column' },
324
+ ...detail.diffs.map((diff, index) => createElement(
325
+ Box,
326
+ { key: index, flexDirection: 'column' },
327
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
328
+ ...diff.lines.map((line, at) => createElement(
329
+ Text,
330
+ {
331
+ key: at,
332
+ color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
333
+ wrap: 'truncate-end',
334
+ },
335
+ ` ${line.mark}${displayText(line.text)}`,
336
+ )),
337
+ )),
338
+ )
339
+ case 'read':
340
+ return createElement(
341
+ Box,
342
+ { flexDirection: 'column' },
343
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
344
+ ...detail.lines.map((line, at) => createElement(
345
+ Text,
346
+ { key: at, dimColor: true, wrap: 'truncate-end' },
347
+ ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
348
+ )),
349
+ )
350
+ case 'web-search':
351
+ return createElement(
352
+ Box,
353
+ { flexDirection: 'column' },
354
+ ...detail.sources.map((source, at) => createElement(
355
+ Text,
356
+ { key: at, wrap: 'truncate-end' },
357
+ brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
358
+ createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
359
+ )),
360
+ createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
361
+ )
362
+ case 'web-fetch':
363
+ return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
364
+ case 'raw':
365
+ return createElement(
366
+ Box,
367
+ { flexDirection: 'column' },
368
+ ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
369
+ createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
370
+ )
371
+ default:
372
+ return assertNever(detail, 'tool detail kind')
373
+ }
374
+ }
76
375
  /** One settled transcript row. */
77
- function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
376
+ function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
78
377
  switch (entry.kind) {
79
378
  case 'user':
80
- return createElement(Text, null, brand('❯ '), displayText(entry.text))
379
+ // Collapsed injected context reads as a dim ↳ row; only direct human
380
+ // prompts get the brand ❯ (they are different surfaces, not the same).
381
+ return entry.notice
382
+ ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
383
+ : createElement(Text, null, brand('❯ '), displayText(entry.text))
81
384
  case 'assistant':
82
- return createElement(Text, null, displayText(entry.text))
385
+ // Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
386
+ // text dim-italic expanded (Ctrl+R toggles globally). The collapsed
387
+ // row is static — an animated counter inside the text would jitter the
388
+ // line width every frame.
389
+ return createElement(
390
+ Box,
391
+ { flexDirection: 'column' },
392
+ entry.reasoning === ''
393
+ ? undefined
394
+ : showReasoning
395
+ ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
396
+ : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
397
+ createElement(MarkdownBody, { text: entry.text }),
398
+ )
83
399
  case 'tool': {
400
+ // Claude-Code-style tool card: the invocation row plus a nested ⎿
401
+ // result line, so the summary reads under its call instead of inline.
84
402
  const mark = entry.state === 'running'
85
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
403
+ ? createElement(Pulse)
86
404
  : entry.state === 'error'
87
405
  ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
88
406
  : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
89
407
  return createElement(
90
- Text,
91
- null,
92
- mark,
93
- ' ',
94
- brand(entry.name),
95
- entry.summary === '' ? '' : ` ${dim(displayText(entry.summary))}`,
408
+ Box,
409
+ { flexDirection: 'column' },
410
+ createElement(
411
+ Text,
412
+ { wrap: verbose ? 'truncate-end' : undefined },
413
+ mark,
414
+ ' ',
415
+ brand(entry.name),
416
+ entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
417
+ ),
418
+ entry.summary === ''
419
+ ? undefined
420
+ : createElement(
421
+ Text,
422
+ { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
423
+ ` ⎿ ${displayText(entry.summary)}`,
424
+ ),
425
+ verbose && entry.detail !== undefined
426
+ ? createElement(ToolDetailBody, { detail: entry.detail })
427
+ : undefined,
96
428
  )
97
429
  }
98
430
  case 'command': {
99
431
  const mark = entry.state === 'running'
100
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
432
+ ? createElement(Pulse)
101
433
  : entry.state === 'error'
102
434
  ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
103
435
  : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
436
+ return createElement(
437
+ Box,
438
+ { flexDirection: 'column' },
439
+ createElement(
440
+ Text,
441
+ { wrap: verbose ? 'truncate-end' : undefined },
442
+ mark,
443
+ ' ',
444
+ brand(`/${entry.name}`),
445
+ entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
446
+ ),
447
+ entry.summary === ''
448
+ ? undefined
449
+ : createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
450
+ )
451
+ }
452
+ case 'turn-marker':
453
+ // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
454
+ return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
455
+ case 'compaction':
456
+ // Completed compaction lifecycle: what it reclaimed, or why it failed.
457
+ return createElement(
458
+ Text,
459
+ { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
460
+ entry.ok
461
+ ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
462
+ : ` ⧉ compaction failed: ${displayText(entry.error)}`,
463
+ )
464
+ case 'retry':
465
+ // Provider-routed retry: amber while the backoff waits, dim once the
466
+ // next attempt is underway.
104
467
  return createElement(
105
468
  Text,
106
- null,
107
- mark,
108
- ' ',
109
- brand(`/${entry.name}`),
110
- entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
111
- entry.summary === '' ? '' : ` ${dim(displayText(entry.summary))}`,
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`,
112
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}`)
113
477
  }
114
478
  case 'error':
115
- return createElement(Text, null, paintError(displayText(entry.text)))
479
+ return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
116
480
  default:
117
481
  return assertNever(entry, 'transcript entry kind')
118
482
  }
119
483
  }
120
484
 
121
- /** The whale wordmark header in DeepSeek blue, hugging its content width. */
485
+ /**
486
+ * The whale wordmark header in DeepSeek blue, hugging its content width.
487
+ * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
488
+ * short to show it whole (or mid-resize) the clipped pairs garble the
489
+ * screen — below the height floor the header collapses to a single-line
490
+ * wordmark that stays correct at any size.
491
+ */
122
492
  function Header({ resumed }: { resumed: boolean }): ReactElement {
493
+ const rows = useStdout().stdout?.rows ?? 40
494
+ const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
495
+ if (rows < 20) {
496
+ return createElement(
497
+ Box,
498
+ { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
499
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
500
+ createElement(Text, { dimColor: true }, hint),
501
+ )
502
+ }
123
503
  return createElement(
124
504
  Box,
125
505
  // alignSelf shrinks the border to the whale-plus-wordmark content instead
@@ -135,11 +515,7 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
135
515
  Box,
136
516
  { flexDirection: 'column', justifyContent: 'center' },
137
517
  createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
138
- createElement(
139
- Text,
140
- { dimColor: true },
141
- resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit',
142
- ),
518
+ createElement(Text, { dimColor: true }, hint),
143
519
  ),
144
520
  )
145
521
  }
@@ -149,33 +525,23 @@ function todoMark(status: TodoItem['status']): string {
149
525
  return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
150
526
  }
151
527
 
152
- /** Inline todo list (web TodoPanel's compact terminal form). */
528
+ /** One-row todo summary: task count cannot grow the live Ink tree. */
153
529
  function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
154
530
  if (todos.length === 0) return undefined
155
531
  const completed = todos.filter(todo => todo.status === 'completed').length
156
532
  const inProgress = todos.filter(todo => todo.status === 'in_progress').length
157
533
  const pending = todos.length - completed - inProgress
534
+ const current = todos.find(todo => todo.status === 'in_progress')
158
535
  return createElement(
159
536
  Box,
160
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1 },
537
+ { paddingX: 1 },
161
538
  createElement(
162
539
  Text,
163
- { color: inkColor(TUI_RGB.brand), bold: true },
540
+ { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
164
541
  `todos ${completed}/${todos.length}`,
165
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)}`),
166
544
  ),
167
- ...todos.map((todo, index) => createElement(
168
- Text,
169
- {
170
- key: index,
171
- color: todo.status === 'completed'
172
- ? inkColor(TUI_RGB.success)
173
- : todo.status === 'in_progress'
174
- ? inkColor(TUI_RGB.brandBright)
175
- : inkColor(TUI_RGB.dim),
176
- },
177
- `${todoMark(todo.status)} ${displayText(todo.content)}`,
178
- )),
179
545
  )
180
546
  }
181
547
 
@@ -191,32 +557,308 @@ function StatusLine({ facts, stats, busy }: {
191
557
  busy: boolean
192
558
  }): ReactElement {
193
559
  const groups = buildStatusGroups(facts, stats)
194
- const children: ReactElement[] = [
195
- busy
196
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '● ')
197
- : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○ '),
198
- ]
199
- groups.forEach((group, index) => {
200
- if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
201
- children.push(createElement(Text, { dimColor: true }, group))
202
- })
203
- return createElement(Box, { paddingX: 1 }, ...children)
560
+ // One physical row in every mode: a narrow terminal must truncate instead
561
+ // of wrapping the status groups into an unbudgeted second/third row.
562
+ return createElement(
563
+ Box,
564
+ // Match the prompt text inside the bordered composer: one border column
565
+ // plus one padding column. Keeping this row margin-free also makes the
566
+ // composer and status a fixed four-row unit in every interface.
567
+ { paddingLeft: 2 },
568
+ createElement(
569
+ Text,
570
+ { dimColor: true, wrap: 'truncate-end' },
571
+ busy ? '● ' : '○ ',
572
+ groups.join(' | '),
573
+ ),
574
+ )
204
575
  }
205
576
 
206
577
  /** The y/n approval bar rendered while an approval ask is pending. */
207
- function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement | undefined {
578
+ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
208
579
  const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
580
+ const stdout = useStdout().stdout
581
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
582
+ const [scroll, setScroll] = useState(0)
583
+ const pending = snapshot.pending
584
+ const active = !locked && snapshot.pending !== undefined && !snapshot.answered
585
+ const content = useMemo<readonly StyledLine[]>(() => pending === undefined
586
+ ? []
587
+ : [
588
+ ...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
589
+ ...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
590
+ ], [pending, viewport.contentColumns])
591
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
592
+
593
+ useEffect(() => {
594
+ setScroll(0)
595
+ }, [pending])
596
+
597
+ useEffect(() => {
598
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
599
+ }, [visibleScroll, scroll])
600
+
601
+ useInput((input, key) => {
602
+ if (snapshot.pending === undefined) return
603
+ if (key.upArrow) {
604
+ setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
605
+ return
606
+ }
607
+ if (key.downArrow) {
608
+ setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
609
+ return
610
+ }
611
+ if (key.pageUp) {
612
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
613
+ return
614
+ }
615
+ if (key.pageDown) {
616
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
617
+ return
618
+ }
619
+ if (snapshot.answered) return
620
+ if (input === 'y' || input === 'Y') {
621
+ snapshot.pending.answer('allowed-once')
622
+ return
623
+ }
624
+ if (input === 'n' || input === 'N') {
625
+ snapshot.pending.answer('rejected')
626
+ }
627
+ }, { isActive: active })
209
628
  if (snapshot.pending === undefined) return undefined
210
- 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
211
634
  return createElement(
212
635
  Box,
213
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1 },
214
- createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
215
- createElement(Text, null, warn(displayText(pending.headline))),
216
- pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
217
- answered
218
- ? createElement(Text, { dimColor: true }, ' submitted…')
219
- : 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))),
642
+ )
643
+ }
644
+
645
+ /**
646
+ * The ask_user_question bar: walks one request question by question,
647
+ * renders the option menu (Claude-Code style: arrows move, space toggles a
648
+ * multi-select, enter submits, `c` opens the custom-answer box, Esc
649
+ * interrupts the question as aborted). Plan reviews arrive through the same
650
+ * service with a `plan-review` intent — the approve option gets a ✓ mark,
651
+ * the answer encoding stays identical.
652
+ */
653
+ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
654
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
655
+ const stdout = useStdout().stdout
656
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
657
+ const pending = snapshot.pending
658
+ const [index, setIndex] = useState(0)
659
+ const [cursor, setCursor] = useState(0)
660
+ const [selected, setSelected] = useState<readonly number[]>([])
661
+ const [mode, setMode] = useState<'options' | 'custom'>('options')
662
+ const [custom, setCustom] = useState('')
663
+ const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
664
+ const [submitted, setSubmitted] = useState(false)
665
+ const [scroll, setScroll] = useState(0)
666
+
667
+ // A new request resets the walk; questions without options start in the
668
+ // custom-answer box (a free-form question).
669
+ useEffect(() => {
670
+ const question = pending?.request.questions[0]
671
+ setIndex(0)
672
+ setCursor(0)
673
+ setSelected([])
674
+ setMode(question?.options === undefined || question.options.length === 0 ? 'custom' : 'options')
675
+ setCustom('')
676
+ setAnswers([])
677
+ setSubmitted(false)
678
+ setScroll(0)
679
+ }, [pending])
680
+
681
+ const question = pending?.request.questions[index]
682
+ const options = question?.options ?? []
683
+ const isPlan = question?.intent?.kind === 'plan-review'
684
+ const isMulti = question?.multiSelect === true
685
+ const active = !locked && pending !== undefined && question !== undefined && !submitted
686
+ const rendered = useMemo(() => {
687
+ if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
688
+ const lines: StyledLine[] = []
689
+ const optionRows: number[] = []
690
+ if (question.header !== undefined) {
691
+ lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
692
+ }
693
+ lines.push(...textLines(question.question, viewport.contentColumns))
694
+ if (question.detail !== undefined) {
695
+ lines.push(...(isPlan
696
+ ? markdownLines(question.detail, viewport.contentColumns)
697
+ : textLines(question.detail, viewport.contentColumns, 'dim')))
698
+ }
699
+ if (submitted) {
700
+ lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
701
+ } else if (mode === 'custom' || options.length === 0) {
702
+ lines.push(...styledLines([
703
+ lineSegment(' custom: ', 'brand'),
704
+ lineSegment(custom, 'plain'),
705
+ lineSegment('▌', 'brand'),
706
+ ], viewport.contentColumns))
707
+ } else {
708
+ options.forEach((option, at) => {
709
+ optionRows.push(lines.length)
710
+ const chosen = isMulti && selected.includes(at)
711
+ const approve = isPlan && question.intent?.approve === option.label
712
+ const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
713
+ const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
714
+ lines.push(...styledLines([
715
+ lineSegment(mark, style),
716
+ lineSegment(option.label, style),
717
+ lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
718
+ ], viewport.contentColumns))
719
+ })
720
+ }
721
+ return { lines, optionRows }
722
+ }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
723
+ const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows)
724
+
725
+ useEffect(() => {
726
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
727
+ }, [visibleScroll, scroll])
728
+
729
+ useEffect(() => {
730
+ if (mode === 'options' && options.length > 0) {
731
+ const focused = rendered.optionRows[cursor] ?? 0
732
+ setScroll(current => revealRow(current, focused, rendered.lines.length, viewport.bodyRows))
733
+ return
734
+ }
735
+ setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows))
736
+ }, [cursor, mode, custom.length, rendered.lines.length, viewport.bodyRows])
737
+
738
+ const commit = (answer: AskUserQuestionAnswerItem): void => {
739
+ if (pending === undefined) return
740
+ const next = [...answers, answer]
741
+ const total = pending.request.questions.length
742
+ if (index + 1 >= total) {
743
+ setSubmitted(true)
744
+ store.submit(pending, { answers: next })
745
+ return
746
+ }
747
+ setAnswers(next)
748
+ const nextIndex = index + 1
749
+ const nextQuestion = pending.request.questions[nextIndex]
750
+ setIndex(nextIndex)
751
+ setCursor(0)
752
+ setSelected([])
753
+ setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
754
+ setCustom('')
755
+ setScroll(0)
756
+ }
757
+
758
+ const commitOption = (): void => {
759
+ if (pending === undefined || question === undefined) return
760
+ if (isMulti) {
761
+ const labels = selected
762
+ .map(at => options[at]?.label)
763
+ .filter((label): label is string => label !== undefined)
764
+ const customText = custom.trim()
765
+ commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
766
+ return
767
+ }
768
+ const option = options[cursor]
769
+ if (option === undefined) return
770
+ commit({ id: question.id, selected: [option.label] })
771
+ }
772
+
773
+ useInput((input, key) => {
774
+ if (pending === undefined || question === undefined || submitted) return
775
+ if (key.escape) {
776
+ store.cancel(pending)
777
+ return
778
+ }
779
+ if (key.pageUp) {
780
+ setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
781
+ return
782
+ }
783
+ if (key.pageDown) {
784
+ setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
785
+ return
786
+ }
787
+ if (mode === 'custom' || options.length === 0) {
788
+ if (key.upArrow) {
789
+ setScroll(current => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows))
790
+ return
791
+ }
792
+ if (key.downArrow) {
793
+ setScroll(current => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows))
794
+ return
795
+ }
796
+ if (key.return) {
797
+ if (custom.trim() === '' && options.length > 0) {
798
+ commitOption()
799
+ return
800
+ }
801
+ commit({
802
+ id: question.id,
803
+ selected: isMulti
804
+ ? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
805
+ : [],
806
+ ...(custom.trim() === '' ? {} : { custom: custom.trim() }),
807
+ })
808
+ return
809
+ }
810
+ if (key.backspace) {
811
+ setCustom(current => current.slice(0, -1))
812
+ return
813
+ }
814
+ if (input !== '' && !key.ctrl && !key.meta) {
815
+ setCustom(current => current + input)
816
+ }
817
+ return
818
+ }
819
+ if (key.upArrow) {
820
+ setCursor(current => (current + options.length - 1) % options.length)
821
+ return
822
+ }
823
+ if (key.downArrow) {
824
+ setCursor(current => (current + 1) % options.length)
825
+ return
826
+ }
827
+ if (key.return) {
828
+ commitOption()
829
+ return
830
+ }
831
+ if (key.tab || input === 'c' || input === 'C') {
832
+ setMode('custom')
833
+ return
834
+ }
835
+ if (input === ' ' && isMulti) {
836
+ setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
837
+ }
838
+ }, { isActive: active })
839
+
840
+ if (pending === undefined || question === undefined) return undefined
841
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
842
+ if (viewport.compact) {
843
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
844
+ }
845
+ const footer = submitted
846
+ ? 'submitted…'
847
+ : mode === 'custom' || options.length === 0
848
+ ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
849
+ : isMulti
850
+ ? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
851
+ : '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
852
+ return createElement(
853
+ Box,
854
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
855
+ createElement(
856
+ Text,
857
+ { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
858
+ truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
859
+ ),
860
+ createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
861
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
220
862
  )
221
863
  }
222
864
 
@@ -228,12 +870,24 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
228
870
  onClose(): void
229
871
  }): ReactElement {
230
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
+
231
885
  useInput((input, key) => {
232
886
  if (key.escape || input === 'q') {
233
887
  onClose()
234
888
  return
235
889
  }
236
- const rows = directory?.rows ?? []
890
+ if (rows.length === 0) return
237
891
  if (key.upArrow) {
238
892
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
239
893
  return
@@ -242,23 +896,43 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
242
896
  setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
243
897
  return
244
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
+ }
245
915
  if (key.return && rows[cursor] !== undefined) {
246
916
  onSelect(rows[cursor])
247
917
  }
248
918
  })
249
- const rows = directory?.rows ?? []
250
- const window = 8
251
- const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
252
- 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)
253
927
  return createElement(
254
928
  Box,
255
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1 },
256
- 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)),
257
931
  directory === undefined && error === undefined
258
- ? createElement(Text, { dimColor: true }, ' loading models…')
932
+ ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ' loading models…')
259
933
  : undefined,
260
934
  error !== undefined
261
- ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
935
+ ? createElement(Text, { color: inkColor(TUI_RGB.error), wrap: 'truncate-end' }, truncateColumns(` ${displayText(error)}`, viewport.contentColumns))
262
936
  : undefined,
263
937
  ...visible.map((row) => {
264
938
  const index = rows.indexOf(row)
@@ -268,14 +942,272 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
268
942
  {
269
943
  key: `${row.provider}/${row.model}`,
270
944
  color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
945
+ wrap: 'truncate-end',
271
946
  },
272
- `${index === cursor ? '❯ ' : ' '}${label}`,
947
+ truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
273
948
  )
274
949
  }),
275
- 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
+ ),
276
1194
  )
277
1195
  }
278
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
+
279
1211
  /** One completion candidate row. */
280
1212
  interface CompletionCandidate {
281
1213
  /** Insertion text for the command name (with leading slash). */
@@ -283,7 +1215,7 @@ interface CompletionCandidate {
283
1215
  /** Human-readable description shown beside the label. */
284
1216
  description: string
285
1217
  /** Candidate origin; skills land the same literal text but route through the prompt. */
286
- origin: 'command' | 'skill'
1218
+ origin: 'command' | 'skill' | 'mention' | 'path'
287
1219
  }
288
1220
 
289
1221
  /**
@@ -303,13 +1235,21 @@ function completionCandidates(
303
1235
  { label: '/help', description: 'show commands', origin: 'command' },
304
1236
  { label: '/model', description: 'switch the model', origin: 'command' },
305
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' },
306
1240
  { label: '/quit', description: 'exit', origin: 'command' },
307
1241
  ]
308
- const registry = descriptors.map((descriptor): CompletionCandidate => ({
309
- label: `/${descriptor.name}`,
310
- description: descriptor.description,
311
- origin: 'command',
312
- }))
1242
+ // Local commands shadow registry names (e.g. the plugin-registered
1243
+ // /permission is served by the registry itself, never duplicated here),
1244
+ // so collisions cannot render two rows with the same key.
1245
+ const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
1246
+ const registry = descriptors
1247
+ .filter(descriptor => !localNames.has(descriptor.name))
1248
+ .map((descriptor): CompletionCandidate => ({
1249
+ label: `/${descriptor.name}`,
1250
+ description: descriptor.description,
1251
+ origin: 'command',
1252
+ }))
313
1253
  const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
314
1254
  const skillRows = skills
315
1255
  .filter(skill => !taken.has(skill.name))
@@ -323,11 +1263,64 @@ function completionCandidates(
323
1263
  return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
324
1264
  }
325
1265
 
1266
+ /**
1267
+ * The completion menu, rendered inside the composer's subtree directly above
1268
+ * the framed box — attached the way Claude-Code anchors its dropdown. Opening
1269
+ * it grows the stack downward: the composer stays the last element on screen
1270
+ * and everything above (the flushed static transcript, the status line) never
1271
+ * moves. Props-only (no lifted state): the menu is a pure view of the input
1272
+ * editor's live completion state, so no cross-component effect ever resyncs
1273
+ * it (a state lift here previously deadlocked the menu after a resize).
1274
+ */
1275
+ function CompletionMenu({ active, mention, index, rows }: {
1276
+ active: boolean
1277
+ mention: boolean
1278
+ index: number
1279
+ rows: readonly CompletionCandidate[]
1280
+ }): ReactElement | undefined {
1281
+ // Hook order is unconditional: `active` toggling must not change the hook
1282
+ // count (the early return used to sit above useStdout).
1283
+ const stdout = useStdout().stdout
1284
+ const columns = stdout?.columns ?? 80
1285
+ const terminalRows = stdout?.rows ?? 30
1286
+ if (!active) return undefined
1287
+ const nameWidth = Math.min(18, Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
1288
+ const descBudget = Math.max(24, columns - nameWidth - 8)
1289
+ const showFooter = terminalRows >= 12
1290
+ const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)))
1291
+ const selected = rows.length === 0 ? 0 : index % rows.length
1292
+ const first = selectionWindow(selected, rows.length, limit)
1293
+ const visible = rows.slice(first, first + limit)
1294
+ return createElement(
1295
+ Box,
1296
+ { flexDirection: 'column', marginLeft: 2 },
1297
+ ...(rows.length === 0
1298
+ ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
1299
+ : visible.map((candidate, at) => {
1300
+ const absolute = first + at
1301
+ return createElement(
1302
+ Text,
1303
+ {
1304
+ key: candidate.label,
1305
+ color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
1306
+ wrap: 'truncate-end',
1307
+ },
1308
+ `${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
1309
+ )
1310
+ })),
1311
+ showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
1312
+ )
1313
+ }
1314
+
326
1315
  /**
327
1316
  * The prompt box: TUI-local slash commands handled locally, other lines
328
1317
  * dispatched; input editing keeps a cursor with history and completion.
1318
+ * While a modal (approval / question / model panel) owns the keys, the
1319
+ * box passes every key through untouched.
329
1320
  */
330
- function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }: {
1321
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }: {
1322
+ active: boolean
1323
+ frozen: boolean
331
1324
  busy: boolean
332
1325
  descriptors: readonly CommandDescriptor[]
333
1326
  skills: readonly SkillRow[]
@@ -336,8 +1329,18 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
336
1329
  interrupt(): boolean
337
1330
  quit(): void
338
1331
  openModel(): void
1332
+ openHelp(): void
339
1333
  notify(text: string): void
1334
+ toggleReasoning(): void
1335
+ openVerbose(): void
1336
+ clearView(): void
1337
+ refresh(): void
1338
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
1339
+ cyclePermission(): string
1340
+ exportTranscript(argument: string): Promise<void>
1341
+ renameTitle(argument: string): string
340
1342
  }): ReactElement {
1343
+ const columns = useStdout().stdout?.columns ?? 80
341
1344
  const [value, setValue] = useState('')
342
1345
  const [cursor, setCursor] = useState(0)
343
1346
  const history = useRef<readonly string[]>([])
@@ -345,9 +1348,101 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
345
1348
  const draft = useRef('')
346
1349
  const [completionIndex, setCompletionIndex] = useState(0)
347
1350
  const candidates = completionCandidates(value, descriptors, skills)
348
- const completionActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
1351
+ const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
1352
+
1353
+ // @mention token: the last `@word` on the cursor's line before the cursor.
1354
+ const beforeCursor = value.slice(0, cursor)
1355
+ const lastLine = beforeCursor.split('\n').at(-1) ?? ''
1356
+ const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
1357
+ const mentionToken = tokenMatch === null
1358
+ ? undefined
1359
+ : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
1360
+ const mentionActive = mentionToken !== undefined
1361
+ const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
1362
+
1363
+ // Bare path token: the last whitespace-delimited run on the cursor's line
1364
+ // when it already looks like a path (Claude-Code bare Tab completion). A
1365
+ // LEADING '/' is the command namespace, never a path — without this guard
1366
+ // typing the bare '/' hijacked the menu into the workspace file scan and
1367
+ // the slash-command candidates never appeared.
1368
+ const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
1369
+ const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
1370
+ const pathActive = !mentionActive
1371
+ && !bareToken.startsWith('/')
1372
+ && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
1373
+ const pathTokenStart = beforeCursor.length - bareToken.length
1374
+ const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
1375
+
1376
+ useEffect(() => {
1377
+ if (!active || !pathActive) {
1378
+ setPathRows([])
1379
+ return
1380
+ }
1381
+ const controller = new AbortController()
1382
+ setPathRows([])
1383
+ loadMentions(bareToken, controller.signal).then(
1384
+ rows => setPathRows(rows.filter(row => row.kind !== 'session')),
1385
+ () => {},
1386
+ )
1387
+ return () => {
1388
+ controller.abort()
1389
+ }
1390
+ }, [active, pathActive, bareToken])
1391
+
1392
+ useEffect(() => {
1393
+ if (!active || !mentionActive) {
1394
+ setMentionRows([])
1395
+ return
1396
+ }
1397
+ const controller = new AbortController()
1398
+ setMentionRows([])
1399
+ loadMentions(mentionToken.query, controller.signal).then(
1400
+ rows => setMentionRows(rows),
1401
+ () => {},
1402
+ )
1403
+ return () => {
1404
+ controller.abort()
1405
+ }
1406
+ }, [active, mentionActive, mentionToken?.query])
1407
+
1408
+ const menuActive = (slashActive || mentionActive || pathActive) && !busy
1409
+ const menuRows: readonly CompletionCandidate[] = mentionActive
1410
+ ? mentionRows.map(row => ({
1411
+ label: row.label.startsWith('@')
1412
+ ? row.label
1413
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
1414
+ description: row.description,
1415
+ origin: 'mention',
1416
+ }))
1417
+ : pathActive
1418
+ ? pathRows.map(row => ({
1419
+ label: row.label,
1420
+ description: row.description,
1421
+ origin: 'path',
1422
+ }))
1423
+ : candidates
349
1424
 
350
1425
  useInput((input, key) => {
1426
+ // Modal ownership: approval/question/model dialogs consume all keys.
1427
+ if (!active) return
1428
+ // Shift+Tab cycles the permission preset (Claude-Code convention).
1429
+ if (key.tab && key.shift) {
1430
+ const next = cyclePermission()
1431
+ if (next !== '') notify(`permission → ${next}`)
1432
+ return
1433
+ }
1434
+ // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
1435
+ if (key.ctrl && input === 'r') {
1436
+ toggleReasoning()
1437
+ return
1438
+ }
1439
+ // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
1440
+ // adapted to append-only static rows): one history entry at a time with
1441
+ // tool cards and reasoning expanded, Esc returns.
1442
+ if (key.ctrl && input === 'o') {
1443
+ openVerbose()
1444
+ return
1445
+ }
351
1446
  // Ctrl+C is three-state (community-TUI convention): a running turn is
352
1447
  // cancelled, a non-empty draft is cleared, and only an idle empty input
353
1448
  // exits. Ctrl+D always means exit but refuses mid-turn.
@@ -393,11 +1488,23 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
393
1488
  return
394
1489
  }
395
1490
  if (text === '/help') {
396
- notify('/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
1491
+ openHelp()
397
1492
  return
398
1493
  }
399
1494
  if (text === '/clear') {
400
- 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)))
401
1508
  return
402
1509
  }
403
1510
  if (text === '/model' || text.startsWith('/model ')) {
@@ -414,12 +1521,12 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
414
1521
  dispatch(text)
415
1522
  return
416
1523
  }
417
- if (completionActive && key.upArrow) {
418
- setCompletionIndex(index => (index + candidates.length - 1) % candidates.length)
1524
+ if (menuActive && key.upArrow) {
1525
+ setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
419
1526
  return
420
1527
  }
421
- if (completionActive && key.downArrow) {
422
- setCompletionIndex(index => (index + 1) % candidates.length)
1528
+ if (menuActive && key.downArrow) {
1529
+ setCompletionIndex(index => (index + 1) % menuRows.length)
423
1530
  return
424
1531
  }
425
1532
  if (key.upArrow) {
@@ -447,13 +1554,35 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
447
1554
  setCursor((entries[next] ?? '').length)
448
1555
  return
449
1556
  }
450
- if (key.tab && completionActive) {
451
- const candidate = candidates[completionIndex % candidates.length]
452
- if (candidate !== undefined) {
453
- setValue(`${candidate.label} `)
454
- setCursor(candidate.label.length + 1)
455
- setCompletionIndex(0)
1557
+ if (key.tab && menuActive) {
1558
+ if (mentionActive && mentionToken !== undefined) {
1559
+ const row = mentionRows[completionIndex % mentionRows.length]
1560
+ if (row !== undefined) {
1561
+ // Session rows carry the canonical @[label](dsh-session:…) token;
1562
+ // file rows insert `@path` (directories keep their trailing slash).
1563
+ const insertion = row.label.startsWith('@')
1564
+ ? row.label
1565
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
1566
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
1567
+ setCursor(mentionToken.start + insertion.length)
1568
+ }
1569
+ } else if (pathActive) {
1570
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
1571
+ if (row !== undefined) {
1572
+ // Bare path completion replaces the typed token with the chosen
1573
+ // workspace path (directories keep their trailing slash).
1574
+ const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
1575
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
1576
+ setCursor(pathTokenStart + insertion.length)
1577
+ }
1578
+ } else {
1579
+ const candidate = candidates[completionIndex % candidates.length]
1580
+ if (candidate !== undefined) {
1581
+ setValue(`${candidate.label} `)
1582
+ setCursor(candidate.label.length + 1)
1583
+ }
456
1584
  }
1585
+ setCompletionIndex(0)
457
1586
  return
458
1587
  }
459
1588
  if (key.backspace || key.delete) {
@@ -477,6 +1606,18 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
477
1606
  setCursor(0)
478
1607
  return
479
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
+ }
480
1621
  if (key.ctrl && input === 'a') {
481
1622
  setCursor(0)
482
1623
  return
@@ -492,35 +1633,54 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
492
1633
  }
493
1634
  })
494
1635
 
495
- const shown = completionActive && !busy
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
+
496
1656
  return createElement(
497
1657
  Box,
498
1658
  { flexDirection: 'column' },
499
- shown
500
- ? createElement(
501
- Box,
502
- { flexDirection: 'column', marginLeft: 1 },
503
- ...candidates.map((candidate, index) => createElement(
504
- Text,
505
- {
506
- key: candidate.label,
507
- color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
508
- },
509
- `${index === completionIndex % candidates.length ? '❯ ' : ' '}${candidate.label} ${dim(displayText(candidate.description))}`,
510
- )),
511
- createElement(Text, { dimColor: true }, dim(' ↑↓ choose · tab complete')),
512
- )
513
- : undefined,
514
- busy && value === ''
515
- ? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
516
- : undefined,
1659
+ // The completion dropdown rides directly above the box (Claude-Code
1660
+ // anchor): rendered from the editor's own live state, never lifted.
1661
+ createElement(CompletionMenu, {
1662
+ active: menuActive,
1663
+ mention: mentionActive,
1664
+ index: completionIndex,
1665
+ rows: menuRows,
1666
+ }),
1667
+ // The framed input box: a visible boundary so the prompt never blends
1668
+ // into the transcript above it; the cursor block sits immediately after
1669
+ // the prompt marker (leftmost), with the dim placeholder trailing it —
1670
+ // no extra space, so the empty state reads `❯ ▮type a message…`.
517
1671
  createElement(
518
1672
  Box,
519
- null,
520
- createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
521
- createElement(Text, null, value.slice(0, cursor)),
522
- createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === '' ? ' ' : value.slice(cursor, cursor + 1)),
523
- createElement(Text, null, value.slice(cursor + 1)),
1673
+ { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
1674
+ createElement(
1675
+ Text,
1676
+ { wrap: 'truncate-end' },
1677
+ createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
1678
+ value === '' ? undefined : editor.before,
1679
+ createElement(CursorBlock, { char: editor.caret }),
1680
+ value === '' && !busy
1681
+ ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
1682
+ : editor.after,
1683
+ ),
524
1684
  ),
525
1685
  )
526
1686
  }
@@ -557,20 +1717,165 @@ export function App(props: AppProps): ReactElement {
557
1717
  }, [modelOpen])
558
1718
 
559
1719
  const busy = view.busy
1720
+ const [showReasoning, setShowReasoning] = useState(false)
1721
+ const [verboseOpen, setVerboseOpen] = useState(false)
1722
+ const [helpOpen, setHelpOpen] = useState(false)
1723
+ const [refreshEpoch, setRefreshEpoch] = useState(0)
1724
+ const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
1725
+ const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
1726
+ const approvalPending = approvalSnapshot.pending !== undefined
1727
+ const questionPending = questionSnapshot.pending !== undefined
1728
+ // While any modal owns the keys, the prompt box passes everything through.
1729
+ const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
1730
+
1731
+ // Human questions outrank local inspectors. Close the lower modal instead
1732
+ // of leaving an approval/question visible but keyboard-locked behind it.
1733
+ useEffect(() => {
1734
+ if (!approvalPending && !questionPending) return
1735
+ setModelOpen(false)
1736
+ setHelpOpen(false)
1737
+ setVerboseOpen(false)
1738
+ }, [approvalPending, questionPending])
1739
+
1740
+ // Append-only transcript: everything up to the first still-mutable entry
1741
+ // (a running tool/retry) flushes through Ink's `<Static>` into native
1742
+ // scrollback and is normally never rewritten — the Claude-Code stability
1743
+ // contract
1744
+ // that lets arbitrarily long conversations scroll instead of freezing when
1745
+ // the live tree exceeds the terminal height. The dynamic region below stays
1746
+ // small: the streaming tail, modals, composer, and its status footer.
1747
+ // `assistant/chunk` preserves `entries` identity. Memoizing on that identity
1748
+ // keeps long settled histories out of the per-token render path.
1749
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
1750
+ // Claude-Code spacing: one blank row before each user prompt (except the
1751
+ // first) separates replies from the next turn. Settled rows flush once with
1752
+ // the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
1753
+ // Ctrl+O browses the frozen history through a bounded selected-entry view.
1754
+ const settledRows = useMemo(() => {
1755
+ const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
1756
+ view.entries.slice(0, settled).forEach((entry, index) => {
1757
+ const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
1758
+ if (entry.kind === 'user' && index > 0) {
1759
+ rows.push(createElement(Box, { key: `gap-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
1760
+ }
1761
+ rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
1762
+ })
1763
+ return rows
1764
+ }, [view.entries, settled, showReasoning, props.resumed])
1765
+
1766
+ // Hook order is unconditional. Its dimensions drive every live-region
1767
+ // budget before any dynamic rows are constructed.
1768
+ const appStdout = useStdout().stdout
1769
+ const [terminalSize, setTerminalSize] = useState(() => ({
1770
+ columns: appStdout?.columns ?? 80,
1771
+ rows: appStdout?.rows ?? 30,
1772
+ }))
1773
+ const terminalSizeRef = useRef(terminalSize)
1774
+ useEffect(() => {
1775
+ if (appStdout === undefined) return
1776
+ let replayTimer: ReturnType<typeof setTimeout> | undefined
1777
+ const handleResize = (): void => {
1778
+ const next = {
1779
+ columns: appStdout.columns ?? 80,
1780
+ rows: appStdout.rows ?? 30,
1781
+ }
1782
+ if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
1783
+ terminalSizeRef.current = next
1784
+
1785
+ // Ink 5 erases by the old logical line count. Once the terminal reflows
1786
+ // a full-width border at a new width, that count is no longer enough and
1787
+ // stale frames remain visible. Follow Codex's source-backed reflow
1788
+ // policy: update live geometry immediately, but wait for the resize
1789
+ // burst to settle before one hard reset and one transcript replay at the
1790
+ // final width. Replaying Static on every event appends duplicate history.
1791
+ setTerminalSize(next)
1792
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
1793
+ replayTimer = setTimeout(() => {
1794
+ appStdout.write(RESIZE_REFLOW_CLEAR)
1795
+ setRefreshEpoch(epoch => epoch + 1)
1796
+ }, RESIZE_REFLOW_DELAY_MS)
1797
+ }
1798
+ appStdout.on('resize', handleResize)
1799
+ return () => {
1800
+ appStdout.off('resize', handleResize)
1801
+ if (replayTimer !== undefined) clearTimeout(replayTimer)
1802
+ }
1803
+ }, [appStdout])
1804
+ const terminalRows = terminalSize.rows
1805
+ const terminalColumns = terminalSize.columns
1806
+ const dynamicRows = Math.max(1, terminalRows - 12)
1807
+ const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
1808
+ const deepDivingVisible = busy && !streamingActive
1809
+ const allLiveLines = useMemo(
1810
+ () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
1811
+ [view.entries, settled, terminalColumns],
1812
+ )
1813
+ const liveBudget = streamingActive
1814
+ ? Math.max(1, Math.floor(dynamicRows / 3))
1815
+ : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
1816
+ const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
1817
+
1818
+ // The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
1819
+ // screen AND scrollback, home the cursor) then a Static remount via the
1820
+ // key change, which re-flushes the current items from index 0. NEVER
1821
+ // console.clear() — it desyncs Ink's internal line ledger against the
1822
+ // flushed static rows and garbles every frame after.
1823
+ const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
1824
+ const reasoningRows = view.streamingReasoning === ''
1825
+ ? 0
1826
+ : view.streaming === ''
1827
+ ? streamRows
1828
+ : streamRows <= 1
1829
+ ? 0
1830
+ : showReasoning
1831
+ ? Math.max(1, Math.floor(streamRows / 3))
1832
+ : 1
1833
+ const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
1834
+ const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending
1835
+ const inspectorVisible = verboseOpen && !approvalPending && !questionPending
1836
+ const modalVisible = modelOpen || helpOpen || inspectorVisible || approvalPending || questionPending
1837
+ const closeInspector = useCallback((): void => {
1838
+ setVerboseOpen(false)
1839
+ }, [])
1840
+ const refreshScreen = (): void => {
1841
+ if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
1842
+ setRefreshEpoch(epoch => epoch + 1)
1843
+ }
1844
+
560
1845
  return createElement(
561
1846
  Box,
562
1847
  { flexDirection: 'column' },
563
- createElement(Header, { resumed: props.resumed }),
564
- createElement(
565
- Box,
566
- { flexDirection: 'column', paddingX: 1 },
567
- ...view.entries.map((entry, index) => createElement(EntryLine, { key: index, entry })),
568
- view.streaming !== '' ? createElement(Text, null, displayText(view.streaming)) : undefined,
569
- busy && view.streaming === '' ? createElement(Text, { dimColor: true }, 'thinking…') : undefined,
570
- ),
571
- createElement(TodoPanel, { todos: view.todos }),
572
- createElement(ApprovalBar, { approval: props.approval }),
573
- 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
574
1879
  ? createElement(ModelPanel, {
575
1880
  directory,
576
1881
  error: modelError,
@@ -584,28 +1889,81 @@ export function App(props: AppProps): ReactElement {
584
1889
  },
585
1890
  })
586
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.
587
1917
  createElement(
588
1918
  Box,
589
1919
  { flexDirection: 'column' },
590
- ...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
+ }),
591
1967
  ),
592
- createElement(Input, {
593
- busy,
594
- descriptors,
595
- skills,
596
- dispatch: props.dispatch,
597
- steer: props.steer,
598
- interrupt: props.interrupt,
599
- quit: props.quit,
600
- openModel: () => {
601
- setModelOpen(true)
602
- },
603
- notify,
604
- }),
605
- createElement(StatusLine, {
606
- facts: { model: modelLabel, cwd: props.cwd, branch: props.branch, sessionId: props.sessionId },
607
- stats: view.stats,
608
- busy,
609
- }),
610
1968
  )
611
1969
  }