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