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