dsh-code 0.1.0 → 0.3.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 +17 -4
- package/README.zh.md +17 -4
- package/cordis.patch.yml +22 -5
- package/lib/index.mjs +1888 -100
- package/lib/invariant.mjs +1 -1
- package/lib/startup.mjs +70 -0
- package/lib/types/app.d.ts +64 -9
- package/lib/types/approval.d.ts +57 -0
- package/lib/types/commands.d.ts +37 -0
- package/lib/types/index.d.ts +19 -7
- package/lib/types/invariant.d.ts +2 -2
- package/lib/types/mentions.d.ts +70 -0
- package/lib/types/models.d.ts +37 -0
- package/lib/types/questions.d.ts +48 -0
- package/lib/types/render/animations.d.ts +15 -0
- package/lib/types/render/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +37 -3
- package/lib/types/render/status.d.ts +4 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/skills.d.ts +45 -0
- package/lib/types/startup.d.ts +44 -0
- package/lib/types/store.d.ts +8 -2
- package/lib/types/theme.d.ts +4 -0
- package/package.json +36 -3
- package/src/app.ts +971 -57
- package/src/approval.ts +126 -0
- package/src/commands.ts +71 -0
- package/src/index.ts +353 -40
- package/src/invariant.ts +3 -3
- package/src/mentions.ts +193 -0
- package/src/models.ts +66 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -0
- package/src/render/markdown.ts +235 -0
- package/src/render/projection.ts +117 -10
- package/src/render/status.ts +14 -2
- package/src/render/text.ts +24 -0
- package/src/render/tool-preview.ts +34 -0
- package/src/skills.ts +104 -0
- package/src/startup.ts +91 -0
- package/src/store.ts +10 -4
- package/src/theme.ts +4 -0
package/src/app.ts
CHANGED
|
@@ -1,31 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
3
|
-
* transcript, the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* transcript, the todo panel, the streaming line, the approval bar, the model
|
|
4
|
+
* panel, local notices, and the input box with history and slash-command
|
|
5
|
+
* completion. All state arrives through the transcript store (derived from
|
|
6
|
+
* the durable session log) plus local input state; the app owns no session
|
|
7
|
+
* mutation of its own.
|
|
6
8
|
*
|
|
7
9
|
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
8
10
|
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
9
11
|
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
10
12
|
* would demand a React global.
|
|
11
13
|
*
|
|
12
|
-
* @module @deepseek-ai/dsh-
|
|
14
|
+
* @module @deepseek-ai/dsh-code/app
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
+
import {
|
|
18
|
+
createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
19
|
+
} from 'react'
|
|
20
|
+
import { Box, Text, useInput, useStdout } from 'ink'
|
|
17
21
|
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
18
|
-
import {
|
|
22
|
+
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
|
|
23
|
+
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
|
24
|
+
import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
|
|
25
|
+
import { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
|
|
19
26
|
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
20
27
|
import type { TranscriptStore } from './store.ts'
|
|
21
28
|
import type { TranscriptEntry } from './render/projection.ts'
|
|
29
|
+
import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
30
|
+
import { caretVisible, pulseFrame } from './render/animations.ts'
|
|
31
|
+
import type { ApprovalStore } from './approval.ts'
|
|
32
|
+
import type { CommandsView } from './commands.ts'
|
|
33
|
+
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
34
|
+
import type { QuestionStore } from './questions.ts'
|
|
35
|
+
import type { SkillsView, SkillRow } from './skills.ts'
|
|
36
|
+
import type { MentionCandidate } from './mentions.ts'
|
|
22
37
|
import { buildStatusGroups, type StatusFacts } from './render/status.ts'
|
|
38
|
+
import { displayText } from './render/text.ts'
|
|
23
39
|
|
|
24
40
|
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
25
41
|
export interface AppProps {
|
|
26
42
|
/** Event-fed transcript store for the live session. */
|
|
27
43
|
store: TranscriptStore
|
|
28
|
-
/**
|
|
44
|
+
/** Approval-question store fed by the answerer listener. */
|
|
45
|
+
approval: ApprovalStore
|
|
46
|
+
/** ask_user_question store fed by the single UI provider. */
|
|
47
|
+
questions: QuestionStore
|
|
48
|
+
/** Live slash-command descriptor list (completion candidates). */
|
|
49
|
+
commands: CommandsView
|
|
50
|
+
/** Live user-invocable skill catalog (completion candidates). */
|
|
51
|
+
skills: SkillsView
|
|
52
|
+
/** `provider/model` selection serving this session (updated on /model). */
|
|
29
53
|
model: string
|
|
30
54
|
/** Working-directory basename the session serves. */
|
|
31
55
|
cwd: string
|
|
@@ -33,10 +57,26 @@ export interface AppProps {
|
|
|
33
57
|
branch: string
|
|
34
58
|
/** Short session identifier. */
|
|
35
59
|
sessionId: string
|
|
36
|
-
/**
|
|
37
|
-
|
|
60
|
+
/** Whether this session was resumed from persistence. */
|
|
61
|
+
resumed: boolean
|
|
62
|
+
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
63
|
+
dispatch(text: string): void
|
|
64
|
+
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
65
|
+
steer(text: string): void
|
|
66
|
+
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
67
|
+
interrupt(): boolean
|
|
38
68
|
/** Quit: unmount, flush, and request process exit. */
|
|
39
|
-
|
|
69
|
+
quit(): void
|
|
70
|
+
/** Load the selectable model directory (called when /model opens). */
|
|
71
|
+
loadModels(): Promise<ModelDirectory>
|
|
72
|
+
/** Load @mention candidates for the typed query (files + sessions). */
|
|
73
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
74
|
+
/** Apply one /model selection; returns the display label. */
|
|
75
|
+
selectModel(row: ModelRow): string
|
|
76
|
+
/** Cycle to the next permission preset (Shift+Tab); returns the new label. */
|
|
77
|
+
cyclePermission(): string
|
|
78
|
+
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
79
|
+
onBridgeReady(bridge: { notify(text: string): void }): void
|
|
40
80
|
}
|
|
41
81
|
|
|
42
82
|
/** Ink `color` string for one palette triple. */
|
|
@@ -44,37 +84,200 @@ function inkColor(triple: readonly [number, number, number]): string {
|
|
|
44
84
|
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
|
|
45
85
|
}
|
|
46
86
|
|
|
87
|
+
/** Truncate text to a visible-column budget, appending … when cut. */
|
|
88
|
+
function truncateColumns(text: string, max: number): string {
|
|
89
|
+
let columns = 0
|
|
90
|
+
let out = ''
|
|
91
|
+
for (const char of text) {
|
|
92
|
+
const code = char.codePointAt(0) ?? 0
|
|
93
|
+
const width = code > 0x2e7f ? 2 : 1
|
|
94
|
+
if (columns + width > max) return `${out}…`
|
|
95
|
+
out += char
|
|
96
|
+
columns += width
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Pad text with spaces to a visible-column target (menu name column). */
|
|
102
|
+
function padColumns(text: string, width: number): string {
|
|
103
|
+
return text + ' '.repeat(Math.max(0, width - visibleColumns(text)))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Interval-driven frame counter for one self-contained animated leaf. */
|
|
107
|
+
function useFrames(intervalMs: number): number {
|
|
108
|
+
const [tick, setTick] = useState(0)
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const id = setInterval(() => setTick(current => current + 1), intervalMs)
|
|
111
|
+
return () => {
|
|
112
|
+
clearInterval(id)
|
|
113
|
+
}
|
|
114
|
+
}, [intervalMs])
|
|
115
|
+
return tick
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
|
|
119
|
+
function Pulse(): ReactElement {
|
|
120
|
+
const tick = useFrames(125)
|
|
121
|
+
return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Blinking block caret appended to streaming text. */
|
|
125
|
+
function Caret(): ReactElement {
|
|
126
|
+
const tick = useFrames(530)
|
|
127
|
+
return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Blinking input cursor: inverse block while the caret phase is on. */
|
|
131
|
+
function CursorBlock({ char }: { char: string }): ReactElement {
|
|
132
|
+
const tick = useFrames(530)
|
|
133
|
+
return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Ink props for one markdown style class. */
|
|
137
|
+
function segmentProps(style: MdSegment['style']): {
|
|
138
|
+
color: string | undefined
|
|
139
|
+
bold: boolean | undefined
|
|
140
|
+
italic: boolean | undefined
|
|
141
|
+
strikethrough: boolean | undefined
|
|
142
|
+
} {
|
|
143
|
+
switch (style) {
|
|
144
|
+
case 'accent':
|
|
145
|
+
return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
146
|
+
case 'code':
|
|
147
|
+
return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
148
|
+
case 'dim':
|
|
149
|
+
return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
150
|
+
case 'bold':
|
|
151
|
+
return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
|
|
152
|
+
case 'italic':
|
|
153
|
+
return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
|
|
154
|
+
case 'boldItalic':
|
|
155
|
+
return { color: undefined, bold: true, italic: true, strikethrough: undefined }
|
|
156
|
+
case 'strike':
|
|
157
|
+
return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
|
|
158
|
+
default:
|
|
159
|
+
return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One settled markdown document rendered as styled lines at the terminal width. */
|
|
164
|
+
function MarkdownBody({ text }: { text: string }): ReactElement {
|
|
165
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
166
|
+
// Cached by (text, width): settled replies re-layout only when either moves.
|
|
167
|
+
const lines = useMemo(
|
|
168
|
+
() => renderMarkdown(displayText(text), Math.max(20, columns - 2)),
|
|
169
|
+
[text, columns],
|
|
170
|
+
)
|
|
171
|
+
return createElement(
|
|
172
|
+
Box,
|
|
173
|
+
{ flexDirection: 'column' },
|
|
174
|
+
...lines.map((line, index) => createElement(
|
|
175
|
+
Text,
|
|
176
|
+
{ key: index },
|
|
177
|
+
...line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
|
|
178
|
+
)),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
47
182
|
/** One settled transcript row. */
|
|
48
|
-
function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
|
|
183
|
+
function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReasoning: boolean }): ReactElement {
|
|
49
184
|
switch (entry.kind) {
|
|
50
185
|
case 'user':
|
|
51
|
-
|
|
186
|
+
// Collapsed injected context reads as a dim ↳ row; only direct human
|
|
187
|
+
// prompts get the brand ❯ (they are different surfaces, not the same).
|
|
188
|
+
return entry.notice
|
|
189
|
+
? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
|
|
190
|
+
: createElement(Text, null, brand('❯ '), displayText(entry.text))
|
|
52
191
|
case 'assistant':
|
|
53
|
-
|
|
192
|
+
// Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
|
|
193
|
+
// text dim-italic expanded (Ctrl+R toggles globally). The collapsed
|
|
194
|
+
// row is static — an animated counter inside the text would jitter the
|
|
195
|
+
// line width every frame.
|
|
196
|
+
return createElement(
|
|
197
|
+
Box,
|
|
198
|
+
{ flexDirection: 'column' },
|
|
199
|
+
entry.reasoning === ''
|
|
200
|
+
? undefined
|
|
201
|
+
: showReasoning
|
|
202
|
+
? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
|
|
203
|
+
: createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
|
|
204
|
+
createElement(MarkdownBody, { text: entry.text }),
|
|
205
|
+
)
|
|
54
206
|
case 'tool': {
|
|
207
|
+
// Claude-Code-style tool card: the invocation row plus a nested ⎿
|
|
208
|
+
// result line, so the summary reads under its call instead of inline.
|
|
55
209
|
const mark = entry.state === 'running'
|
|
56
|
-
? createElement(
|
|
210
|
+
? createElement(Pulse)
|
|
57
211
|
: entry.state === 'error'
|
|
58
212
|
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
59
213
|
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
60
214
|
return createElement(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
215
|
+
Box,
|
|
216
|
+
{ flexDirection: 'column' },
|
|
217
|
+
createElement(
|
|
218
|
+
Text,
|
|
219
|
+
null,
|
|
220
|
+
mark,
|
|
221
|
+
' ',
|
|
222
|
+
brand(entry.name),
|
|
223
|
+
entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
|
|
224
|
+
),
|
|
225
|
+
entry.summary === ''
|
|
226
|
+
? undefined
|
|
227
|
+
: createElement(
|
|
228
|
+
Text,
|
|
229
|
+
{ color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim) },
|
|
230
|
+
` ⎿ ${displayText(entry.summary)}`,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
case 'command': {
|
|
235
|
+
const mark = entry.state === 'running'
|
|
236
|
+
? createElement(Pulse)
|
|
237
|
+
: entry.state === 'error'
|
|
238
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
239
|
+
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
240
|
+
return createElement(
|
|
241
|
+
Box,
|
|
242
|
+
{ flexDirection: 'column' },
|
|
243
|
+
createElement(
|
|
244
|
+
Text,
|
|
245
|
+
null,
|
|
246
|
+
mark,
|
|
247
|
+
' ',
|
|
248
|
+
brand(`/${entry.name}`),
|
|
249
|
+
entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
|
|
250
|
+
),
|
|
251
|
+
entry.summary === ''
|
|
252
|
+
? undefined
|
|
253
|
+
: createElement(Text, { color: inkColor(TUI_RGB.dim) }, ` ⎿ ${displayText(entry.summary)}`),
|
|
67
254
|
)
|
|
68
255
|
}
|
|
69
256
|
case 'error':
|
|
70
|
-
return createElement(Text, null, paintError(entry.text))
|
|
257
|
+
return createElement(Text, null, paintError(displayText(entry.text)))
|
|
71
258
|
default:
|
|
72
259
|
return assertNever(entry, 'transcript entry kind')
|
|
73
260
|
}
|
|
74
261
|
}
|
|
75
262
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
263
|
+
/**
|
|
264
|
+
* The whale wordmark header in DeepSeek blue, hugging its content width.
|
|
265
|
+
* The 8-row half-block glyph pairs adjacent lines, so on a terminal too
|
|
266
|
+
* short to show it whole (or mid-resize) the clipped pairs garble the
|
|
267
|
+
* screen — below the height floor the header collapses to a single-line
|
|
268
|
+
* wordmark that stays correct at any size.
|
|
269
|
+
*/
|
|
270
|
+
function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
271
|
+
const rows = useStdout().stdout?.rows ?? 40
|
|
272
|
+
const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
|
|
273
|
+
if (rows < 20) {
|
|
274
|
+
return createElement(
|
|
275
|
+
Box,
|
|
276
|
+
{ flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
|
|
277
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
|
|
278
|
+
createElement(Text, { dimColor: true }, hint),
|
|
279
|
+
)
|
|
280
|
+
}
|
|
78
281
|
return createElement(
|
|
79
282
|
Box,
|
|
80
283
|
// alignSelf shrinks the border to the whale-plus-wordmark content instead
|
|
@@ -90,8 +293,43 @@ function Header(): ReactElement {
|
|
|
90
293
|
Box,
|
|
91
294
|
{ flexDirection: 'column', justifyContent: 'center' },
|
|
92
295
|
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
|
|
93
|
-
createElement(Text, { dimColor: true },
|
|
296
|
+
createElement(Text, { dimColor: true }, hint),
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Todo status glyph: web TodoPanel's three-state marker. */
|
|
302
|
+
function todoMark(status: TodoItem['status']): string {
|
|
303
|
+
return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Inline todo list (web TodoPanel's compact terminal form). */
|
|
307
|
+
function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
|
|
308
|
+
if (todos.length === 0) return undefined
|
|
309
|
+
const completed = todos.filter(todo => todo.status === 'completed').length
|
|
310
|
+
const inProgress = todos.filter(todo => todo.status === 'in_progress').length
|
|
311
|
+
const pending = todos.length - completed - inProgress
|
|
312
|
+
return createElement(
|
|
313
|
+
Box,
|
|
314
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
315
|
+
createElement(
|
|
316
|
+
Text,
|
|
317
|
+
{ color: inkColor(TUI_RGB.brand), bold: true },
|
|
318
|
+
`todos ${completed}/${todos.length}`,
|
|
319
|
+
createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
|
|
94
320
|
),
|
|
321
|
+
...todos.map((todo, index) => createElement(
|
|
322
|
+
Text,
|
|
323
|
+
{
|
|
324
|
+
key: index,
|
|
325
|
+
color: todo.status === 'completed'
|
|
326
|
+
? inkColor(TUI_RGB.success)
|
|
327
|
+
: todo.status === 'in_progress'
|
|
328
|
+
? inkColor(TUI_RGB.brandBright)
|
|
329
|
+
: inkColor(TUI_RGB.dim),
|
|
330
|
+
},
|
|
331
|
+
`${todoMark(todo.status)} ${displayText(todo.content)}`,
|
|
332
|
+
)),
|
|
95
333
|
)
|
|
96
334
|
}
|
|
97
335
|
|
|
@@ -109,93 +347,769 @@ function StatusLine({ facts, stats, busy }: {
|
|
|
109
347
|
const groups = buildStatusGroups(facts, stats)
|
|
110
348
|
const children: ReactElement[] = [
|
|
111
349
|
busy
|
|
112
|
-
? createElement(
|
|
113
|
-
: createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○
|
|
350
|
+
? createElement(Pulse)
|
|
351
|
+
: createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○'),
|
|
352
|
+
createElement(Text, null, ' '),
|
|
114
353
|
]
|
|
115
354
|
groups.forEach((group, index) => {
|
|
116
355
|
if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
|
|
117
356
|
children.push(createElement(Text, { dimColor: true }, group))
|
|
118
357
|
})
|
|
119
|
-
|
|
358
|
+
// Left-aligned status bar; the top margin keeps it clear of the input box.
|
|
359
|
+
return createElement(
|
|
360
|
+
Box,
|
|
361
|
+
{ paddingX: 1, marginTop: 1 },
|
|
362
|
+
...children,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
367
|
+
function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
|
|
368
|
+
const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
|
|
369
|
+
useInput((input) => {
|
|
370
|
+
if (locked || snapshot.pending === undefined || snapshot.answered) return
|
|
371
|
+
if (input === 'y' || input === 'Y') {
|
|
372
|
+
snapshot.pending.answer('allowed-once')
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
if (input === 'n' || input === 'N') {
|
|
376
|
+
snapshot.pending.answer('rejected')
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
if (snapshot.pending === undefined) return undefined
|
|
380
|
+
const { pending, answered } = snapshot
|
|
381
|
+
return createElement(
|
|
382
|
+
Box,
|
|
383
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
384
|
+
createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
|
|
385
|
+
createElement(Text, null, warn(displayText(pending.headline))),
|
|
386
|
+
pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
|
|
387
|
+
answered
|
|
388
|
+
? createElement(Text, { dimColor: true }, ' submitted…')
|
|
389
|
+
: createElement(Text, { dimColor: true }, dim(' y allow once · n reject')),
|
|
390
|
+
)
|
|
120
391
|
}
|
|
121
392
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
393
|
+
/**
|
|
394
|
+
* The ask_user_question bar: walks one request question by question,
|
|
395
|
+
* renders the option menu (Claude-Code style: arrows move, space toggles a
|
|
396
|
+
* multi-select, enter submits, `c` opens the custom-answer box, Esc
|
|
397
|
+
* interrupts the question as aborted). Plan reviews arrive through the same
|
|
398
|
+
* service with a `plan-review` intent — the approve option gets a ✓ mark,
|
|
399
|
+
* the answer encoding stays identical.
|
|
400
|
+
*/
|
|
401
|
+
function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
|
|
402
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
403
|
+
const pending = snapshot.pending
|
|
404
|
+
const [index, setIndex] = useState(0)
|
|
405
|
+
const [cursor, setCursor] = useState(0)
|
|
406
|
+
const [selected, setSelected] = useState<readonly number[]>([])
|
|
407
|
+
const [mode, setMode] = useState<'options' | 'custom'>('options')
|
|
408
|
+
const [custom, setCustom] = useState('')
|
|
409
|
+
const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
|
|
410
|
+
const [submitted, setSubmitted] = useState(false)
|
|
411
|
+
|
|
412
|
+
// A new request resets the walk; questions without options start in the
|
|
413
|
+
// custom-answer box (a free-form question).
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
const question = pending?.request.questions[0]
|
|
416
|
+
setIndex(0)
|
|
417
|
+
setCursor(0)
|
|
418
|
+
setSelected([])
|
|
419
|
+
setMode(question?.options === undefined || question.options.length === 0 ? 'custom' : 'options')
|
|
420
|
+
setCustom('')
|
|
421
|
+
setAnswers([])
|
|
422
|
+
setSubmitted(false)
|
|
423
|
+
}, [pending])
|
|
424
|
+
|
|
425
|
+
const question = pending?.request.questions[index]
|
|
426
|
+
const options = question?.options ?? []
|
|
427
|
+
const isPlan = question?.intent?.kind === 'plan-review'
|
|
428
|
+
const isMulti = question?.multiSelect === true
|
|
429
|
+
|
|
430
|
+
const commit = (answer: AskUserQuestionAnswerItem): void => {
|
|
431
|
+
if (pending === undefined) return
|
|
432
|
+
const next = [...answers, answer]
|
|
433
|
+
const total = pending.request.questions.length
|
|
434
|
+
if (index + 1 >= total) {
|
|
435
|
+
setSubmitted(true)
|
|
436
|
+
store.submit(pending, { answers: next })
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
setAnswers(next)
|
|
440
|
+
setIndex(index + 1)
|
|
441
|
+
setCursor(0)
|
|
442
|
+
setSelected([])
|
|
443
|
+
setMode('options')
|
|
444
|
+
setCustom('')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const commitOption = (): void => {
|
|
448
|
+
if (pending === undefined || question === undefined) return
|
|
449
|
+
if (isMulti) {
|
|
450
|
+
const labels = selected
|
|
451
|
+
.map(at => options[at]?.label)
|
|
452
|
+
.filter((label): label is string => label !== undefined)
|
|
453
|
+
const customText = custom.trim()
|
|
454
|
+
commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
const option = options[cursor]
|
|
458
|
+
if (option === undefined) return
|
|
459
|
+
commit({ id: question.id, selected: [option.label] })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
useInput((input, key) => {
|
|
463
|
+
if (locked || pending === undefined || question === undefined || submitted) return
|
|
464
|
+
if (key.escape) {
|
|
465
|
+
store.cancel(pending)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
if (mode === 'custom' || options.length === 0) {
|
|
469
|
+
if (key.return) {
|
|
470
|
+
if (custom.trim() === '' && options.length > 0) {
|
|
471
|
+
commitOption()
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
commit({
|
|
475
|
+
id: question.id,
|
|
476
|
+
selected: isMulti
|
|
477
|
+
? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
|
|
478
|
+
: [],
|
|
479
|
+
...(custom.trim() === '' ? {} : { custom: custom.trim() }),
|
|
480
|
+
})
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (key.backspace) {
|
|
484
|
+
setCustom(current => current.slice(0, -1))
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
488
|
+
setCustom(current => current + input)
|
|
489
|
+
}
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
if (key.upArrow) {
|
|
493
|
+
setCursor(current => (current + options.length - 1) % options.length)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
if (key.downArrow) {
|
|
497
|
+
setCursor(current => (current + 1) % options.length)
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
if (key.return) {
|
|
501
|
+
commitOption()
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
if (key.tab || input === 'c' || input === 'C') {
|
|
505
|
+
setMode('custom')
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
if (input === ' ' && isMulti) {
|
|
509
|
+
setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
if (pending === undefined || question === undefined) return undefined
|
|
514
|
+
return createElement(
|
|
515
|
+
Box,
|
|
516
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
517
|
+
createElement(
|
|
518
|
+
Text,
|
|
519
|
+
{ color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true },
|
|
520
|
+
isPlan ? `📋 plan review (${index + 1}/${pending.request.questions.length})` : `❓ question ${index + 1}/${pending.request.questions.length}`,
|
|
521
|
+
),
|
|
522
|
+
question.header === undefined ? undefined : createElement(Text, { bold: true }, displayText(question.header)),
|
|
523
|
+
createElement(Text, null, displayText(question.question)),
|
|
524
|
+
question.detail === undefined
|
|
525
|
+
? undefined
|
|
526
|
+
: isPlan
|
|
527
|
+
? createElement(MarkdownBody, { text: question.detail })
|
|
528
|
+
: createElement(Text, { dimColor: true }, displayText(question.detail)),
|
|
529
|
+
submitted
|
|
530
|
+
? createElement(Text, { dimColor: true }, ' submitted…')
|
|
531
|
+
: createElement(
|
|
532
|
+
Box,
|
|
533
|
+
{ flexDirection: 'column', marginLeft: 1 },
|
|
534
|
+
...(mode === 'custom' || options.length === 0
|
|
535
|
+
? [
|
|
536
|
+
createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` custom: ${custom}${submitted ? '' : '▌'}`),
|
|
537
|
+
createElement(Text, { dimColor: true }, dim(' type your answer · enter submit · esc interrupt')),
|
|
538
|
+
]
|
|
539
|
+
: options.map((option, at) => {
|
|
540
|
+
const chosen = isMulti && selected.includes(at)
|
|
541
|
+
const approve = isPlan && question.intent?.approve === option.label
|
|
542
|
+
const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
|
|
543
|
+
return createElement(
|
|
544
|
+
Text,
|
|
545
|
+
{
|
|
546
|
+
key: at,
|
|
547
|
+
color: at === cursor ? inkColor(TUI_RGB.brandBright) : chosen || approve ? inkColor(TUI_RGB.success) : inkColor(TUI_RGB.text),
|
|
548
|
+
},
|
|
549
|
+
`${mark}${displayText(option.label)}${option.description === undefined ? '' : dim(` — ${displayText(option.description)}`)}`,
|
|
550
|
+
)
|
|
551
|
+
})),
|
|
552
|
+
createElement(Text, { dimColor: true }, dim(isMulti
|
|
553
|
+
? ' ↑↓ move · space toggle · enter submit · c custom · esc interrupt'
|
|
554
|
+
: ' ↑↓ move · enter submit · c custom · esc interrupt')),
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
560
|
+
function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
561
|
+
directory: ModelDirectory | undefined
|
|
562
|
+
error: string | undefined
|
|
563
|
+
onSelect(row: ModelRow): void
|
|
564
|
+
onClose(): void
|
|
565
|
+
}): ReactElement {
|
|
566
|
+
const [cursor, setCursor] = useState(0)
|
|
567
|
+
useInput((input, key) => {
|
|
568
|
+
if (key.escape || input === 'q') {
|
|
569
|
+
onClose()
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
const rows = directory?.rows ?? []
|
|
573
|
+
if (key.upArrow) {
|
|
574
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
if (key.downArrow) {
|
|
578
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
if (key.return && rows[cursor] !== undefined) {
|
|
582
|
+
onSelect(rows[cursor])
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
const rows = directory?.rows ?? []
|
|
586
|
+
const window = 8
|
|
587
|
+
const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
|
|
588
|
+
const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
|
|
589
|
+
return createElement(
|
|
590
|
+
Box,
|
|
591
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
592
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
|
|
593
|
+
directory === undefined && error === undefined
|
|
594
|
+
? createElement(Text, { dimColor: true }, ' loading models…')
|
|
595
|
+
: undefined,
|
|
596
|
+
error !== undefined
|
|
597
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
|
|
598
|
+
: undefined,
|
|
599
|
+
...visible.map((row) => {
|
|
600
|
+
const index = rows.indexOf(row)
|
|
601
|
+
const label = displayText(`${row.providerName} · ${row.modelName}`)
|
|
602
|
+
return createElement(
|
|
603
|
+
Text,
|
|
604
|
+
{
|
|
605
|
+
key: `${row.provider}/${row.model}`,
|
|
606
|
+
color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
607
|
+
},
|
|
608
|
+
`${index === cursor ? '❯ ' : ' '}${label}`,
|
|
609
|
+
)
|
|
610
|
+
}),
|
|
611
|
+
createElement(Text, { dimColor: true }, dim(' ↑↓ move · enter select · esc close')),
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** One completion candidate row. */
|
|
616
|
+
interface CompletionCandidate {
|
|
617
|
+
/** Insertion text for the command name (with leading slash). */
|
|
618
|
+
label: string
|
|
619
|
+
/** Human-readable description shown beside the label. */
|
|
620
|
+
description: string
|
|
621
|
+
/** Candidate origin; skills land the same literal text but route through the prompt. */
|
|
622
|
+
origin: 'command' | 'skill' | 'mention'
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Resolve completion candidates for the current input: TUI-local commands,
|
|
627
|
+
* the live registry descriptors, and user-invocable skills, filtered by the
|
|
628
|
+
* typed prefix. Command names win collisions (the dispatch tries the
|
|
629
|
+
* registry first and only then falls through to the skill gesture).
|
|
630
|
+
*/
|
|
631
|
+
function completionCandidates(
|
|
632
|
+
value: string,
|
|
633
|
+
descriptors: readonly CommandDescriptor[],
|
|
634
|
+
skills: readonly SkillRow[],
|
|
635
|
+
): readonly CompletionCandidate[] {
|
|
636
|
+
if (!value.startsWith('/')) return []
|
|
637
|
+
const prefix = value.slice(1).split(' ')[0] ?? ''
|
|
638
|
+
const local: CompletionCandidate[] = [
|
|
639
|
+
{ label: '/help', description: 'show commands', origin: 'command' },
|
|
640
|
+
{ label: '/model', description: 'switch the model', origin: 'command' },
|
|
641
|
+
{ label: '/clear', description: 'clear the screen', origin: 'command' },
|
|
642
|
+
{ label: '/quit', description: 'exit', origin: 'command' },
|
|
643
|
+
]
|
|
644
|
+
// Local commands shadow registry names (e.g. the plugin-registered
|
|
645
|
+
// /permission is served by the registry itself, never duplicated here),
|
|
646
|
+
// so collisions cannot render two rows with the same key.
|
|
647
|
+
const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
|
|
648
|
+
const registry = descriptors
|
|
649
|
+
.filter(descriptor => !localNames.has(descriptor.name))
|
|
650
|
+
.map((descriptor): CompletionCandidate => ({
|
|
651
|
+
label: `/${descriptor.name}`,
|
|
652
|
+
description: descriptor.description,
|
|
653
|
+
origin: 'command',
|
|
654
|
+
}))
|
|
655
|
+
const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
|
|
656
|
+
const skillRows = skills
|
|
657
|
+
.filter(skill => !taken.has(skill.name))
|
|
658
|
+
.map((skill): CompletionCandidate => ({
|
|
659
|
+
label: `/${skill.name}`,
|
|
660
|
+
description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
|
|
661
|
+
origin: 'skill',
|
|
662
|
+
}))
|
|
663
|
+
const all = [...local, ...registry, ...skillRows]
|
|
664
|
+
if (prefix === '') return all.slice(0, 10)
|
|
665
|
+
return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The completion menu snapshot the input editor publishes to the app. */
|
|
669
|
+
export interface MenuState {
|
|
670
|
+
/** Whether the menu is on screen (slash or @mention). */
|
|
671
|
+
active: boolean
|
|
672
|
+
/** Whether the menu is driven by an @mention token. */
|
|
673
|
+
mention: boolean
|
|
674
|
+
/** Highlighted candidate index (wraps by row count). */
|
|
675
|
+
index: number
|
|
676
|
+
/** Rendered rows in display order. */
|
|
677
|
+
rows: readonly CompletionCandidate[]
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The completion menu, rendered after the status line — the very last
|
|
682
|
+
* element in the tree. Being last in the layout flow, opening or closing it
|
|
683
|
+
* moves nothing above it: the transcript, input box, and status line all
|
|
684
|
+
* stay put (the Claude-Code dropdown treatment adapted to Ink, whose
|
|
685
|
+
* absolute positioning cannot place children above their parent).
|
|
686
|
+
*/
|
|
687
|
+
function CompletionMenu({ state }: { state: MenuState }): ReactElement | undefined {
|
|
688
|
+
if (!state.active) return undefined
|
|
689
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
690
|
+
const nameWidth = Math.min(18, Math.max(0, ...state.rows.map(row => visibleColumns(row.label))) + 2)
|
|
691
|
+
const descBudget = Math.max(24, columns - nameWidth - 8)
|
|
692
|
+
return createElement(
|
|
693
|
+
Box,
|
|
694
|
+
{ flexDirection: 'column', marginTop: 1, marginLeft: 2 },
|
|
695
|
+
...(state.rows.length === 0
|
|
696
|
+
? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
|
|
697
|
+
: state.rows.map((candidate, index) => createElement(
|
|
698
|
+
Text,
|
|
699
|
+
{
|
|
700
|
+
key: candidate.label,
|
|
701
|
+
color: index === state.index % state.rows.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
702
|
+
},
|
|
703
|
+
`${index === state.index % state.rows.length ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
|
|
704
|
+
))),
|
|
705
|
+
createElement(Text, { dimColor: true }, dim(state.mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')),
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
711
|
+
* dispatched; input editing keeps a cursor with history and completion.
|
|
712
|
+
* While a modal (approval / question / model panel) owns the keys, the
|
|
713
|
+
* box passes every key through untouched.
|
|
714
|
+
*/
|
|
715
|
+
function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }: {
|
|
716
|
+
active: boolean
|
|
124
717
|
busy: boolean
|
|
125
|
-
|
|
126
|
-
|
|
718
|
+
descriptors: readonly CommandDescriptor[]
|
|
719
|
+
skills: readonly SkillRow[]
|
|
720
|
+
dispatch(text: string): void
|
|
721
|
+
steer(text: string): void
|
|
722
|
+
interrupt(): boolean
|
|
723
|
+
quit(): void
|
|
724
|
+
openModel(): void
|
|
725
|
+
notify(text: string): void
|
|
726
|
+
toggleReasoning(): void
|
|
727
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
728
|
+
cyclePermission(): string
|
|
729
|
+
onMenuState(state: MenuState): void
|
|
127
730
|
}): ReactElement {
|
|
128
731
|
const [value, setValue] = useState('')
|
|
129
|
-
const [
|
|
732
|
+
const [cursor, setCursor] = useState(0)
|
|
733
|
+
const history = useRef<readonly string[]>([])
|
|
734
|
+
const historyIndex = useRef<number | null>(null)
|
|
735
|
+
const draft = useRef('')
|
|
736
|
+
const [completionIndex, setCompletionIndex] = useState(0)
|
|
737
|
+
const candidates = completionCandidates(value, descriptors, skills)
|
|
738
|
+
const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
|
|
739
|
+
|
|
740
|
+
// @mention token: the last `@word` on the cursor's line before the cursor.
|
|
741
|
+
const beforeCursor = value.slice(0, cursor)
|
|
742
|
+
const lastLine = beforeCursor.split('\n').at(-1) ?? ''
|
|
743
|
+
const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
|
|
744
|
+
const mentionToken = tokenMatch === null
|
|
745
|
+
? undefined
|
|
746
|
+
: { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
|
|
747
|
+
const mentionActive = mentionToken !== undefined
|
|
748
|
+
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
749
|
+
|
|
750
|
+
useEffect(() => {
|
|
751
|
+
if (!mentionActive) {
|
|
752
|
+
setMentionRows([])
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
const controller = new AbortController()
|
|
756
|
+
setMentionRows([])
|
|
757
|
+
loadMentions(mentionToken.query, controller.signal).then(
|
|
758
|
+
rows => setMentionRows(rows),
|
|
759
|
+
() => {},
|
|
760
|
+
)
|
|
761
|
+
return () => {
|
|
762
|
+
controller.abort()
|
|
763
|
+
}
|
|
764
|
+
}, [mentionActive, mentionToken?.query])
|
|
765
|
+
|
|
766
|
+
const menuActive = (slashActive || mentionActive) && !busy
|
|
767
|
+
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
768
|
+
? mentionRows.map(row => ({
|
|
769
|
+
label: row.label.startsWith('@')
|
|
770
|
+
? row.label
|
|
771
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
|
|
772
|
+
description: row.description,
|
|
773
|
+
origin: 'mention',
|
|
774
|
+
}))
|
|
775
|
+
: candidates
|
|
776
|
+
|
|
777
|
+
// The menu renders at the very bottom of the app (after the status line),
|
|
778
|
+
// where opening it moves nothing above it — the App needs this snapshot.
|
|
779
|
+
// Notify only on change; an unconditional set would re-render in a loop.
|
|
780
|
+
const menuStateKey = useRef('')
|
|
781
|
+
useEffect(() => {
|
|
782
|
+
const key = JSON.stringify([menuActive, mentionActive, completionIndex, menuRows.map(row => row.label)])
|
|
783
|
+
if (key === menuStateKey.current) return
|
|
784
|
+
menuStateKey.current = key
|
|
785
|
+
onMenuState({
|
|
786
|
+
active: menuActive,
|
|
787
|
+
mention: mentionActive,
|
|
788
|
+
index: completionIndex,
|
|
789
|
+
rows: menuRows,
|
|
790
|
+
})
|
|
791
|
+
}, [menuActive, mentionActive, completionIndex, menuRows, onMenuState])
|
|
792
|
+
|
|
130
793
|
useInput((input, key) => {
|
|
131
|
-
|
|
132
|
-
|
|
794
|
+
// Modal ownership: approval/question/model dialogs consume all keys.
|
|
795
|
+
if (!active) return
|
|
796
|
+
// Shift+Tab cycles the permission preset (Claude-Code convention).
|
|
797
|
+
if (key.tab && key.shift) {
|
|
798
|
+
const next = cyclePermission()
|
|
799
|
+
if (next !== '') notify(`permission → ${next}`)
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
// Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
|
|
803
|
+
if (key.ctrl && input === 'r') {
|
|
804
|
+
toggleReasoning()
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
// Ctrl+C is three-state (community-TUI convention): a running turn is
|
|
808
|
+
// cancelled, a non-empty draft is cleared, and only an idle empty input
|
|
809
|
+
// exits. Ctrl+D always means exit but refuses mid-turn.
|
|
810
|
+
if (key.ctrl && input === 'c') {
|
|
811
|
+
if (busy) {
|
|
812
|
+
interrupt()
|
|
813
|
+
} else if (value !== '') {
|
|
814
|
+
setValue('')
|
|
815
|
+
setCursor(0)
|
|
816
|
+
setCompletionIndex(0)
|
|
817
|
+
} else {
|
|
818
|
+
quit()
|
|
819
|
+
}
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
if (key.ctrl && input === 'd') {
|
|
823
|
+
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
|
|
824
|
+
else quit()
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
if (key.escape) {
|
|
828
|
+
if (busy) interrupt()
|
|
133
829
|
return
|
|
134
830
|
}
|
|
135
831
|
if (key.return) {
|
|
832
|
+
// Multi-line editing: most terminals send the same byte for
|
|
833
|
+
// shift+enter as enter, so newline insertion rides alt/meta+enter
|
|
834
|
+
// and ctrl+j (the two distinguishable bindings); a bare return submits.
|
|
835
|
+
if (key.meta || (key.ctrl && input === 'j')) {
|
|
836
|
+
setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
|
|
837
|
+
setCursor(cursor + 1)
|
|
838
|
+
return
|
|
839
|
+
}
|
|
136
840
|
const text = value.trim()
|
|
137
841
|
setValue('')
|
|
842
|
+
setCursor(0)
|
|
843
|
+
setCompletionIndex(0)
|
|
138
844
|
if (text === '') return
|
|
845
|
+
history.current = [...history.current, text]
|
|
846
|
+
historyIndex.current = null
|
|
139
847
|
if (text === '/quit') {
|
|
140
|
-
|
|
848
|
+
quit()
|
|
141
849
|
return
|
|
142
850
|
}
|
|
143
851
|
if (text === '/help') {
|
|
144
|
-
|
|
852
|
+
notify('/model switch · /clear clear the screen · /quit exit · Ctrl+R toggle thinking · Shift+Tab cycle permission · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
|
|
145
853
|
return
|
|
146
854
|
}
|
|
147
855
|
if (text === '/clear') {
|
|
148
|
-
setNotices([])
|
|
149
856
|
console.clear()
|
|
150
857
|
return
|
|
151
858
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
859
|
+
if (text === '/model' || text.startsWith('/model ')) {
|
|
860
|
+
openModel()
|
|
154
861
|
return
|
|
155
862
|
}
|
|
156
|
-
|
|
863
|
+
if (busy && !text.startsWith('/')) {
|
|
864
|
+
// A running turn is steered, not blocked: the inbox delivers this
|
|
865
|
+
// text at the next step boundary (Esc/Ctrl+C still cancels outright).
|
|
866
|
+
// Slash lines keep the registry path — commands run out of band.
|
|
867
|
+
steer(text)
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
dispatch(text)
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
if (menuActive && key.upArrow) {
|
|
874
|
+
setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
if (menuActive && key.downArrow) {
|
|
878
|
+
setCompletionIndex(index => (index + 1) % menuRows.length)
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
if (key.upArrow) {
|
|
882
|
+
const entries = history.current
|
|
883
|
+
if (entries.length === 0) return
|
|
884
|
+
const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1)
|
|
885
|
+
if (historyIndex.current === null) draft.current = value
|
|
886
|
+
historyIndex.current = next
|
|
887
|
+
setValue(entries[next] ?? '')
|
|
888
|
+
setCursor((entries[next] ?? '').length)
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
if (key.downArrow) {
|
|
892
|
+
const entries = history.current
|
|
893
|
+
if (historyIndex.current === null) return
|
|
894
|
+
const next = historyIndex.current + 1
|
|
895
|
+
if (next >= entries.length) {
|
|
896
|
+
historyIndex.current = null
|
|
897
|
+
setValue(draft.current)
|
|
898
|
+
setCursor(draft.current.length)
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
historyIndex.current = next
|
|
902
|
+
setValue(entries[next] ?? '')
|
|
903
|
+
setCursor((entries[next] ?? '').length)
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
if (key.tab && menuActive) {
|
|
907
|
+
if (mentionActive && mentionToken !== undefined) {
|
|
908
|
+
const row = mentionRows[completionIndex % mentionRows.length]
|
|
909
|
+
if (row !== undefined) {
|
|
910
|
+
// Session rows carry the canonical @[label](dsh-session:…) token;
|
|
911
|
+
// file rows insert `@path` (directories keep their trailing slash).
|
|
912
|
+
const insertion = row.label.startsWith('@')
|
|
913
|
+
? row.label
|
|
914
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
915
|
+
setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
|
|
916
|
+
setCursor(mentionToken.start + insertion.length)
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
const candidate = candidates[completionIndex % candidates.length]
|
|
920
|
+
if (candidate !== undefined) {
|
|
921
|
+
setValue(`${candidate.label} `)
|
|
922
|
+
setCursor(candidate.label.length + 1)
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
setCompletionIndex(0)
|
|
157
926
|
return
|
|
158
927
|
}
|
|
159
928
|
if (key.backspace || key.delete) {
|
|
160
|
-
|
|
929
|
+
if (cursor > 0) {
|
|
930
|
+
setValue(value.slice(0, cursor - 1) + value.slice(cursor))
|
|
931
|
+
setCursor(cursor - 1)
|
|
932
|
+
setCompletionIndex(0)
|
|
933
|
+
}
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
if (key.leftArrow) {
|
|
937
|
+
setCursor(Math.max(0, cursor - 1))
|
|
161
938
|
return
|
|
162
939
|
}
|
|
163
|
-
if (
|
|
164
|
-
|
|
940
|
+
if (key.rightArrow) {
|
|
941
|
+
setCursor(Math.min(value.length, cursor + 1))
|
|
942
|
+
return
|
|
943
|
+
}
|
|
944
|
+
if (key.ctrl && input === 'u') {
|
|
945
|
+
setValue('')
|
|
946
|
+
setCursor(0)
|
|
947
|
+
return
|
|
948
|
+
}
|
|
949
|
+
if (key.ctrl && input === 'a') {
|
|
950
|
+
setCursor(0)
|
|
951
|
+
return
|
|
952
|
+
}
|
|
953
|
+
if (key.ctrl && input === 'e') {
|
|
954
|
+
setCursor(value.length)
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
958
|
+
setValue(value.slice(0, cursor) + input + value.slice(cursor))
|
|
959
|
+
setCursor(cursor + input.length)
|
|
960
|
+
setCompletionIndex(0)
|
|
165
961
|
}
|
|
166
962
|
})
|
|
963
|
+
|
|
167
964
|
return createElement(
|
|
168
965
|
Box,
|
|
169
|
-
{ flexDirection: 'column' },
|
|
170
|
-
|
|
966
|
+
{ flexDirection: 'column', marginTop: 1 },
|
|
967
|
+
busy && value === ''
|
|
968
|
+
? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
|
|
969
|
+
: undefined,
|
|
970
|
+
// The framed input box: a visible boundary so the prompt never blends
|
|
971
|
+
// into the transcript above it; the cursor block sits immediately after
|
|
972
|
+
// the prompt marker (leftmost), with the dim placeholder trailing it —
|
|
973
|
+
// no extra space, so the empty state reads `❯ ▮type a message…`.
|
|
171
974
|
createElement(
|
|
172
975
|
Box,
|
|
173
|
-
|
|
976
|
+
{ borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
|
|
174
977
|
createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
|
|
175
|
-
|
|
978
|
+
value === ''
|
|
979
|
+
? undefined
|
|
980
|
+
: createElement(Text, null, value.slice(0, cursor)),
|
|
981
|
+
createElement(CursorBlock, { char: value.slice(cursor, cursor + 1) === '' ? ' ' : value.slice(cursor, cursor + 1) }),
|
|
982
|
+
value === '' && !busy
|
|
983
|
+
? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
|
|
984
|
+
: createElement(Text, null, value.slice(cursor + 1)),
|
|
176
985
|
),
|
|
177
986
|
)
|
|
178
987
|
}
|
|
179
988
|
|
|
180
989
|
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
181
|
-
export function App(
|
|
182
|
-
const view = useSyncExternalStore(store.subscribe, store.getView)
|
|
990
|
+
export function App(props: AppProps): ReactElement {
|
|
991
|
+
const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
|
|
992
|
+
const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
|
|
993
|
+
const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
|
|
994
|
+
const [modelLabel, setModelLabel] = useState(props.model)
|
|
995
|
+
const [modelOpen, setModelOpen] = useState(false)
|
|
996
|
+
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
997
|
+
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
998
|
+
const [notices, setNotices] = useState<readonly string[]>([])
|
|
999
|
+
const notify = (text: string): void => {
|
|
1000
|
+
setNotices(current => [...current, text])
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
useEffect(() => {
|
|
1004
|
+
props.onBridgeReady({ notify })
|
|
1005
|
+
}, [])
|
|
1006
|
+
useEffect(() => {
|
|
1007
|
+
if (!modelOpen || directory !== undefined) return
|
|
1008
|
+
let cancelled = false
|
|
1009
|
+
setModelError(undefined)
|
|
1010
|
+
props.loadModels().then((loaded) => {
|
|
1011
|
+
if (!cancelled) setDirectory(loaded)
|
|
1012
|
+
}, (error: unknown) => {
|
|
1013
|
+
if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
|
|
1014
|
+
})
|
|
1015
|
+
return () => {
|
|
1016
|
+
cancelled = true
|
|
1017
|
+
}
|
|
1018
|
+
}, [modelOpen])
|
|
1019
|
+
|
|
1020
|
+
const busy = view.busy
|
|
1021
|
+
const [showReasoning, setShowReasoning] = useState(false)
|
|
1022
|
+
const [menuState, setMenuState] = useState<MenuState>({ active: false, mention: false, index: 0, rows: [] })
|
|
1023
|
+
const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
|
|
1024
|
+
const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
|
|
1025
|
+
// While any modal owns the keys, the prompt box passes everything through.
|
|
1026
|
+
const inputActive = !modelOpen && approvalSnapshot.pending === undefined && questionSnapshot.pending === undefined
|
|
1027
|
+
// Layered ownership: question > approval > model panel; each bar answers
|
|
1028
|
+
// only while no higher-priority modal is on screen.
|
|
1029
|
+
const questionPending = questionSnapshot.pending !== undefined
|
|
1030
|
+
// Claude-Code spacing: one blank row before each user prompt (except the
|
|
1031
|
+
// first) separates replies from the next turn.
|
|
1032
|
+
const transcriptRows: ReactElement[] = []
|
|
1033
|
+
view.entries.forEach((entry, index) => {
|
|
1034
|
+
if (entry.kind === 'user' && index > 0) {
|
|
1035
|
+
transcriptRows.push(createElement(Text, { key: `gap-${index}` }, ' '))
|
|
1036
|
+
}
|
|
1037
|
+
transcriptRows.push(createElement(EntryLine, { key: index, entry, showReasoning }))
|
|
1038
|
+
})
|
|
183
1039
|
return createElement(
|
|
184
1040
|
Box,
|
|
185
1041
|
{ flexDirection: 'column' },
|
|
186
|
-
createElement(Header),
|
|
1042
|
+
createElement(Header, { resumed: props.resumed }),
|
|
187
1043
|
createElement(
|
|
188
1044
|
Box,
|
|
189
1045
|
{ flexDirection: 'column', paddingX: 1 },
|
|
190
|
-
...
|
|
191
|
-
view.
|
|
192
|
-
|
|
1046
|
+
...transcriptRows,
|
|
1047
|
+
view.streamingReasoning !== ''
|
|
1048
|
+
? createElement(
|
|
1049
|
+
Text,
|
|
1050
|
+
{ dimColor: true, italic: true },
|
|
1051
|
+
showReasoning ? ` ✻ ${displayText(view.streamingReasoning)}` : ' ✻ Thinking…',
|
|
1052
|
+
)
|
|
1053
|
+
: undefined,
|
|
1054
|
+
view.streaming !== ''
|
|
1055
|
+
? createElement(Text, null, displayText(view.streaming), busy ? createElement(Caret) : undefined)
|
|
1056
|
+
: undefined,
|
|
1057
|
+
busy && view.streaming === '' && view.streamingReasoning === '' ? createElement(Text, { dimColor: true }, 'Deep diving...') : undefined,
|
|
1058
|
+
),
|
|
1059
|
+
createElement(TodoPanel, { todos: view.todos }),
|
|
1060
|
+
createElement(QuestionBar, { store: props.questions, locked: modelOpen }),
|
|
1061
|
+
createElement(ApprovalBar, { approval: props.approval, locked: modelOpen || questionPending }),
|
|
1062
|
+
modelOpen
|
|
1063
|
+
? createElement(ModelPanel, {
|
|
1064
|
+
directory,
|
|
1065
|
+
error: modelError,
|
|
1066
|
+
onSelect: (row: ModelRow) => {
|
|
1067
|
+
setModelLabel(props.selectModel(row))
|
|
1068
|
+
notify(`model → next step uses ${row.provider}/${row.model}`)
|
|
1069
|
+
setModelOpen(false)
|
|
1070
|
+
},
|
|
1071
|
+
onClose: () => {
|
|
1072
|
+
setModelOpen(false)
|
|
1073
|
+
},
|
|
1074
|
+
})
|
|
1075
|
+
: undefined,
|
|
1076
|
+
createElement(
|
|
1077
|
+
Box,
|
|
1078
|
+
{ flexDirection: 'column' },
|
|
1079
|
+
...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
|
|
193
1080
|
),
|
|
194
|
-
createElement(Input, {
|
|
1081
|
+
createElement(Input, {
|
|
1082
|
+
active: inputActive,
|
|
1083
|
+
busy,
|
|
1084
|
+
descriptors,
|
|
1085
|
+
skills,
|
|
1086
|
+
dispatch: props.dispatch,
|
|
1087
|
+
steer: props.steer,
|
|
1088
|
+
interrupt: props.interrupt,
|
|
1089
|
+
quit: props.quit,
|
|
1090
|
+
openModel: () => {
|
|
1091
|
+
setModelOpen(true)
|
|
1092
|
+
},
|
|
1093
|
+
notify,
|
|
1094
|
+
toggleReasoning: () => {
|
|
1095
|
+
setShowReasoning(current => !current)
|
|
1096
|
+
},
|
|
1097
|
+
loadMentions: props.loadMentions,
|
|
1098
|
+
cyclePermission: props.cyclePermission,
|
|
1099
|
+
onMenuState: setMenuState,
|
|
1100
|
+
}),
|
|
195
1101
|
createElement(StatusLine, {
|
|
196
|
-
facts: {
|
|
1102
|
+
facts: {
|
|
1103
|
+
model: modelLabel,
|
|
1104
|
+
cwd: props.cwd,
|
|
1105
|
+
branch: props.branch,
|
|
1106
|
+
sessionId: props.sessionId,
|
|
1107
|
+
plan: view.plan,
|
|
1108
|
+
permission: view.permission,
|
|
1109
|
+
},
|
|
197
1110
|
stats: view.stats,
|
|
198
|
-
busy
|
|
1111
|
+
busy,
|
|
199
1112
|
}),
|
|
1113
|
+
createElement(CompletionMenu, { state: menuState }),
|
|
200
1114
|
)
|
|
201
1115
|
}
|