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/README.md +7 -1
- package/README.zh.md +5 -1
- package/lib/index.mjs +3116 -1703
- package/lib/types/app.d.ts +4 -21
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +30 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +17 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/store.d.ts +2 -0
- package/package.json +11 -1
- package/src/app.ts +1087 -233
- package/src/index.ts +46 -0
- package/src/pictures/1.png +0 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +79 -0
- package/src/render/lines.ts +207 -0
- package/src/render/projection.ts +279 -16
- package/src/render/status.ts +48 -1
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/store.ts +8 -0
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
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
{
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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)
|
|
384
|
-
createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true },
|
|
385
|
-
createElement(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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 ?
|
|
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
|
-
|
|
523
|
-
createElement(Text,
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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)
|
|
592
|
-
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true },
|
|
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('
|
|
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
|
-
/**
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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',
|
|
695
|
-
...(
|
|
1296
|
+
{ flexDirection: 'column', marginLeft: 2 },
|
|
1297
|
+
...(rows.length === 0
|
|
696
1298
|
? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
|
|
697
|
-
:
|
|
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:
|
|
1305
|
+
color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
1306
|
+
wrap: 'truncate-end',
|
|
702
1307
|
},
|
|
703
|
-
`${
|
|
704
|
-
|
|
705
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
:
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
1491
|
+
openHelp()
|
|
853
1492
|
return
|
|
854
1493
|
}
|
|
855
1494
|
if (text === '/clear') {
|
|
856
|
-
|
|
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'
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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(
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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(
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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
|
}
|