dsh-code 0.2.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 +7 -2
- package/README.zh.md +7 -2
- package/cordis.patch.yml +6 -0
- package/lib/index.mjs +1091 -105
- package/lib/types/app.d.ts +29 -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/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +13 -0
- package/lib/types/render/status.d.ts +4 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/theme.d.ts +4 -0
- package/package.json +10 -2
- package/src/app.ts +582 -78
- package/src/index.ts +110 -46
- package/src/mentions.ts +193 -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 +48 -7
- package/src/render/status.ts +14 -2
- package/src/render/tool-preview.ts +34 -0
- package/src/theme.ts +4 -0
package/src/app.ts
CHANGED
|
@@ -15,20 +15,25 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
|
-
createElement, useEffect, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
18
|
+
createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
19
19
|
} from 'react'
|
|
20
|
-
import { Box, Text, useInput } from 'ink'
|
|
20
|
+
import { Box, 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 type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
|
|
24
25
|
import { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
|
|
25
26
|
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
26
27
|
import type { TranscriptStore } from './store.ts'
|
|
27
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'
|
|
28
31
|
import type { ApprovalStore } from './approval.ts'
|
|
29
32
|
import type { CommandsView } from './commands.ts'
|
|
30
33
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
34
|
+
import type { QuestionStore } from './questions.ts'
|
|
31
35
|
import type { SkillsView, SkillRow } from './skills.ts'
|
|
36
|
+
import type { MentionCandidate } from './mentions.ts'
|
|
32
37
|
import { buildStatusGroups, type StatusFacts } from './render/status.ts'
|
|
33
38
|
import { displayText } from './render/text.ts'
|
|
34
39
|
|
|
@@ -38,6 +43,8 @@ export interface AppProps {
|
|
|
38
43
|
store: TranscriptStore
|
|
39
44
|
/** Approval-question store fed by the answerer listener. */
|
|
40
45
|
approval: ApprovalStore
|
|
46
|
+
/** ask_user_question store fed by the single UI provider. */
|
|
47
|
+
questions: QuestionStore
|
|
41
48
|
/** Live slash-command descriptor list (completion candidates). */
|
|
42
49
|
commands: CommandsView
|
|
43
50
|
/** Live user-invocable skill catalog (completion candidates). */
|
|
@@ -62,8 +69,12 @@ export interface AppProps {
|
|
|
62
69
|
quit(): void
|
|
63
70
|
/** Load the selectable model directory (called when /model opens). */
|
|
64
71
|
loadModels(): Promise<ModelDirectory>
|
|
72
|
+
/** Load @mention candidates for the typed query (files + sessions). */
|
|
73
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
65
74
|
/** Apply one /model selection; returns the display label. */
|
|
66
75
|
selectModel(row: ModelRow): string
|
|
76
|
+
/** Cycle to the next permission preset (Shift+Tab); returns the new label. */
|
|
77
|
+
cyclePermission(): string
|
|
67
78
|
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
68
79
|
onBridgeReady(bridge: { notify(text: string): void }): void
|
|
69
80
|
}
|
|
@@ -73,42 +84,173 @@ function inkColor(triple: readonly [number, number, number]): string {
|
|
|
73
84
|
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
|
|
74
85
|
}
|
|
75
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
|
+
|
|
76
182
|
/** One settled transcript row. */
|
|
77
|
-
function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
|
|
183
|
+
function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReasoning: boolean }): ReactElement {
|
|
78
184
|
switch (entry.kind) {
|
|
79
185
|
case 'user':
|
|
80
|
-
|
|
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))
|
|
81
191
|
case 'assistant':
|
|
82
|
-
|
|
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
|
+
)
|
|
83
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.
|
|
84
209
|
const mark = entry.state === 'running'
|
|
85
|
-
? createElement(
|
|
210
|
+
? createElement(Pulse)
|
|
86
211
|
: entry.state === 'error'
|
|
87
212
|
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
88
213
|
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
89
214
|
return createElement(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
),
|
|
96
232
|
)
|
|
97
233
|
}
|
|
98
234
|
case 'command': {
|
|
99
235
|
const mark = entry.state === 'running'
|
|
100
|
-
? createElement(
|
|
236
|
+
? createElement(Pulse)
|
|
101
237
|
: entry.state === 'error'
|
|
102
238
|
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
103
239
|
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
104
240
|
return createElement(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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)}`),
|
|
112
254
|
)
|
|
113
255
|
}
|
|
114
256
|
case 'error':
|
|
@@ -118,8 +260,24 @@ function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
|
|
|
118
260
|
}
|
|
119
261
|
}
|
|
120
262
|
|
|
121
|
-
/**
|
|
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
|
+
*/
|
|
122
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
|
+
}
|
|
123
281
|
return createElement(
|
|
124
282
|
Box,
|
|
125
283
|
// alignSelf shrinks the border to the whale-plus-wordmark content instead
|
|
@@ -135,11 +293,7 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
|
135
293
|
Box,
|
|
136
294
|
{ flexDirection: 'column', justifyContent: 'center' },
|
|
137
295
|
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
|
-
),
|
|
296
|
+
createElement(Text, { dimColor: true }, hint),
|
|
143
297
|
),
|
|
144
298
|
)
|
|
145
299
|
}
|
|
@@ -157,7 +311,7 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
|
|
|
157
311
|
const pending = todos.length - completed - inProgress
|
|
158
312
|
return createElement(
|
|
159
313
|
Box,
|
|
160
|
-
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1 },
|
|
314
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
161
315
|
createElement(
|
|
162
316
|
Text,
|
|
163
317
|
{ color: inkColor(TUI_RGB.brand), bold: true },
|
|
@@ -193,24 +347,40 @@ function StatusLine({ facts, stats, busy }: {
|
|
|
193
347
|
const groups = buildStatusGroups(facts, stats)
|
|
194
348
|
const children: ReactElement[] = [
|
|
195
349
|
busy
|
|
196
|
-
? createElement(
|
|
197
|
-
: createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○
|
|
350
|
+
? createElement(Pulse)
|
|
351
|
+
: createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○'),
|
|
352
|
+
createElement(Text, null, ' '),
|
|
198
353
|
]
|
|
199
354
|
groups.forEach((group, index) => {
|
|
200
355
|
if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
|
|
201
356
|
children.push(createElement(Text, { dimColor: true }, group))
|
|
202
357
|
})
|
|
203
|
-
|
|
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
|
+
)
|
|
204
364
|
}
|
|
205
365
|
|
|
206
366
|
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
207
|
-
function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement | undefined {
|
|
367
|
+
function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
|
|
208
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
|
+
})
|
|
209
379
|
if (snapshot.pending === undefined) return undefined
|
|
210
380
|
const { pending, answered } = snapshot
|
|
211
381
|
return createElement(
|
|
212
382
|
Box,
|
|
213
|
-
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1 },
|
|
383
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
214
384
|
createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
|
|
215
385
|
createElement(Text, null, warn(displayText(pending.headline))),
|
|
216
386
|
pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
|
|
@@ -220,6 +390,172 @@ function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement |
|
|
|
220
390
|
)
|
|
221
391
|
}
|
|
222
392
|
|
|
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
|
+
|
|
223
559
|
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
224
560
|
function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
225
561
|
directory: ModelDirectory | undefined
|
|
@@ -252,7 +588,7 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
|
252
588
|
const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
|
|
253
589
|
return createElement(
|
|
254
590
|
Box,
|
|
255
|
-
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1 },
|
|
591
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
|
|
256
592
|
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
|
|
257
593
|
directory === undefined && error === undefined
|
|
258
594
|
? createElement(Text, { dimColor: true }, ' loading models…')
|
|
@@ -283,7 +619,7 @@ interface CompletionCandidate {
|
|
|
283
619
|
/** Human-readable description shown beside the label. */
|
|
284
620
|
description: string
|
|
285
621
|
/** Candidate origin; skills land the same literal text but route through the prompt. */
|
|
286
|
-
origin: 'command' | 'skill'
|
|
622
|
+
origin: 'command' | 'skill' | 'mention'
|
|
287
623
|
}
|
|
288
624
|
|
|
289
625
|
/**
|
|
@@ -305,11 +641,17 @@ function completionCandidates(
|
|
|
305
641
|
{ label: '/clear', description: 'clear the screen', origin: 'command' },
|
|
306
642
|
{ label: '/quit', description: 'exit', origin: 'command' },
|
|
307
643
|
]
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
}))
|
|
313
655
|
const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
|
|
314
656
|
const skillRows = skills
|
|
315
657
|
.filter(skill => !taken.has(skill.name))
|
|
@@ -323,11 +665,55 @@ function completionCandidates(
|
|
|
323
665
|
return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
|
|
324
666
|
}
|
|
325
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
|
+
|
|
326
709
|
/**
|
|
327
710
|
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
328
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.
|
|
329
714
|
*/
|
|
330
|
-
function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }: {
|
|
715
|
+
function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }: {
|
|
716
|
+
active: boolean
|
|
331
717
|
busy: boolean
|
|
332
718
|
descriptors: readonly CommandDescriptor[]
|
|
333
719
|
skills: readonly SkillRow[]
|
|
@@ -337,6 +723,10 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
337
723
|
quit(): void
|
|
338
724
|
openModel(): void
|
|
339
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
|
|
340
730
|
}): ReactElement {
|
|
341
731
|
const [value, setValue] = useState('')
|
|
342
732
|
const [cursor, setCursor] = useState(0)
|
|
@@ -345,9 +735,75 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
345
735
|
const draft = useRef('')
|
|
346
736
|
const [completionIndex, setCompletionIndex] = useState(0)
|
|
347
737
|
const candidates = completionCandidates(value, descriptors, skills)
|
|
348
|
-
const
|
|
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])
|
|
349
792
|
|
|
350
793
|
useInput((input, key) => {
|
|
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
|
+
}
|
|
351
807
|
// Ctrl+C is three-state (community-TUI convention): a running turn is
|
|
352
808
|
// cancelled, a non-empty draft is cleared, and only an idle empty input
|
|
353
809
|
// exits. Ctrl+D always means exit but refuses mid-turn.
|
|
@@ -393,7 +849,7 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
393
849
|
return
|
|
394
850
|
}
|
|
395
851
|
if (text === '/help') {
|
|
396
|
-
notify('/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
|
|
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')
|
|
397
853
|
return
|
|
398
854
|
}
|
|
399
855
|
if (text === '/clear') {
|
|
@@ -414,12 +870,12 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
414
870
|
dispatch(text)
|
|
415
871
|
return
|
|
416
872
|
}
|
|
417
|
-
if (
|
|
418
|
-
setCompletionIndex(index => (index +
|
|
873
|
+
if (menuActive && key.upArrow) {
|
|
874
|
+
setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
|
|
419
875
|
return
|
|
420
876
|
}
|
|
421
|
-
if (
|
|
422
|
-
setCompletionIndex(index => (index + 1) %
|
|
877
|
+
if (menuActive && key.downArrow) {
|
|
878
|
+
setCompletionIndex(index => (index + 1) % menuRows.length)
|
|
423
879
|
return
|
|
424
880
|
}
|
|
425
881
|
if (key.upArrow) {
|
|
@@ -447,13 +903,26 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
447
903
|
setCursor((entries[next] ?? '').length)
|
|
448
904
|
return
|
|
449
905
|
}
|
|
450
|
-
if (key.tab &&
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
+
}
|
|
456
924
|
}
|
|
925
|
+
setCompletionIndex(0)
|
|
457
926
|
return
|
|
458
927
|
}
|
|
459
928
|
if (key.backspace || key.delete) {
|
|
@@ -492,35 +961,27 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
492
961
|
}
|
|
493
962
|
})
|
|
494
963
|
|
|
495
|
-
const shown = completionActive && !busy
|
|
496
964
|
return createElement(
|
|
497
965
|
Box,
|
|
498
|
-
{ flexDirection: 'column' },
|
|
499
|
-
shown
|
|
500
|
-
? createElement(
|
|
501
|
-
Box,
|
|
502
|
-
{ flexDirection: 'column', marginLeft: 1 },
|
|
503
|
-
...candidates.map((candidate, index) => createElement(
|
|
504
|
-
Text,
|
|
505
|
-
{
|
|
506
|
-
key: candidate.label,
|
|
507
|
-
color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
508
|
-
},
|
|
509
|
-
`${index === completionIndex % candidates.length ? '❯ ' : ' '}${candidate.label} ${dim(displayText(candidate.description))}`,
|
|
510
|
-
)),
|
|
511
|
-
createElement(Text, { dimColor: true }, dim(' ↑↓ choose · tab complete')),
|
|
512
|
-
)
|
|
513
|
-
: undefined,
|
|
966
|
+
{ flexDirection: 'column', marginTop: 1 },
|
|
514
967
|
busy && value === ''
|
|
515
968
|
? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
|
|
516
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…`.
|
|
517
974
|
createElement(
|
|
518
975
|
Box,
|
|
519
|
-
|
|
976
|
+
{ borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
|
|
520
977
|
createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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)),
|
|
524
985
|
),
|
|
525
986
|
)
|
|
526
987
|
}
|
|
@@ -557,6 +1018,24 @@ export function App(props: AppProps): ReactElement {
|
|
|
557
1018
|
}, [modelOpen])
|
|
558
1019
|
|
|
559
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
|
+
})
|
|
560
1039
|
return createElement(
|
|
561
1040
|
Box,
|
|
562
1041
|
{ flexDirection: 'column' },
|
|
@@ -564,12 +1043,22 @@ export function App(props: AppProps): ReactElement {
|
|
|
564
1043
|
createElement(
|
|
565
1044
|
Box,
|
|
566
1045
|
{ flexDirection: 'column', paddingX: 1 },
|
|
567
|
-
...
|
|
568
|
-
view.
|
|
569
|
-
|
|
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,
|
|
570
1058
|
),
|
|
571
1059
|
createElement(TodoPanel, { todos: view.todos }),
|
|
572
|
-
createElement(
|
|
1060
|
+
createElement(QuestionBar, { store: props.questions, locked: modelOpen }),
|
|
1061
|
+
createElement(ApprovalBar, { approval: props.approval, locked: modelOpen || questionPending }),
|
|
573
1062
|
modelOpen
|
|
574
1063
|
? createElement(ModelPanel, {
|
|
575
1064
|
directory,
|
|
@@ -590,6 +1079,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
590
1079
|
...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
|
|
591
1080
|
),
|
|
592
1081
|
createElement(Input, {
|
|
1082
|
+
active: inputActive,
|
|
593
1083
|
busy,
|
|
594
1084
|
descriptors,
|
|
595
1085
|
skills,
|
|
@@ -601,11 +1091,25 @@ export function App(props: AppProps): ReactElement {
|
|
|
601
1091
|
setModelOpen(true)
|
|
602
1092
|
},
|
|
603
1093
|
notify,
|
|
1094
|
+
toggleReasoning: () => {
|
|
1095
|
+
setShowReasoning(current => !current)
|
|
1096
|
+
},
|
|
1097
|
+
loadMentions: props.loadMentions,
|
|
1098
|
+
cyclePermission: props.cyclePermission,
|
|
1099
|
+
onMenuState: setMenuState,
|
|
604
1100
|
}),
|
|
605
1101
|
createElement(StatusLine, {
|
|
606
|
-
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
|
+
},
|
|
607
1110
|
stats: view.stats,
|
|
608
1111
|
busy,
|
|
609
1112
|
}),
|
|
1113
|
+
createElement(CompletionMenu, { state: menuState }),
|
|
610
1114
|
)
|
|
611
1115
|
}
|