dsh-code 0.1.0 → 0.2.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 +11 -3
- package/README.zh.md +11 -3
- package/cordis.patch.yml +16 -5
- package/lib/index.mjs +890 -88
- package/lib/invariant.mjs +1 -1
- package/lib/startup.mjs +70 -0
- package/lib/types/app.d.ts +35 -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/models.d.ts +37 -0
- package/lib/types/render/projection.d.ts +24 -3
- package/lib/types/render/text.d.ts +18 -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/package.json +28 -3
- package/src/app.ts +452 -42
- package/src/approval.ts +126 -0
- package/src/commands.ts +71 -0
- package/src/index.ts +289 -40
- package/src/invariant.ts +3 -3
- package/src/models.ts +66 -0
- package/src/render/projection.ts +70 -4
- package/src/render/text.ts +24 -0
- package/src/skills.ts +104 -0
- package/src/startup.ts +91 -0
- package/src/store.ts +10 -4
package/src/app.ts
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
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 {
|
|
17
|
+
import {
|
|
18
|
+
createElement, useEffect, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
19
|
+
} from 'react'
|
|
16
20
|
import { Box, Text, useInput } 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 { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
|
|
19
25
|
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
20
26
|
import type { TranscriptStore } from './store.ts'
|
|
21
27
|
import type { TranscriptEntry } from './render/projection.ts'
|
|
28
|
+
import type { ApprovalStore } from './approval.ts'
|
|
29
|
+
import type { CommandsView } from './commands.ts'
|
|
30
|
+
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
31
|
+
import type { SkillsView, SkillRow } from './skills.ts'
|
|
22
32
|
import { buildStatusGroups, type StatusFacts } from './render/status.ts'
|
|
33
|
+
import { displayText } from './render/text.ts'
|
|
23
34
|
|
|
24
35
|
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
25
36
|
export interface AppProps {
|
|
26
37
|
/** Event-fed transcript store for the live session. */
|
|
27
38
|
store: TranscriptStore
|
|
28
|
-
/**
|
|
39
|
+
/** Approval-question store fed by the answerer listener. */
|
|
40
|
+
approval: ApprovalStore
|
|
41
|
+
/** Live slash-command descriptor list (completion candidates). */
|
|
42
|
+
commands: CommandsView
|
|
43
|
+
/** Live user-invocable skill catalog (completion candidates). */
|
|
44
|
+
skills: SkillsView
|
|
45
|
+
/** `provider/model` selection serving this session (updated on /model). */
|
|
29
46
|
model: string
|
|
30
47
|
/** Working-directory basename the session serves. */
|
|
31
48
|
cwd: string
|
|
@@ -33,10 +50,22 @@ export interface AppProps {
|
|
|
33
50
|
branch: string
|
|
34
51
|
/** Short session identifier. */
|
|
35
52
|
sessionId: string
|
|
36
|
-
/**
|
|
37
|
-
|
|
53
|
+
/** Whether this session was resumed from persistence. */
|
|
54
|
+
resumed: boolean
|
|
55
|
+
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
56
|
+
dispatch(text: string): void
|
|
57
|
+
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
58
|
+
steer(text: string): void
|
|
59
|
+
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
60
|
+
interrupt(): boolean
|
|
38
61
|
/** Quit: unmount, flush, and request process exit. */
|
|
39
|
-
|
|
62
|
+
quit(): void
|
|
63
|
+
/** Load the selectable model directory (called when /model opens). */
|
|
64
|
+
loadModels(): Promise<ModelDirectory>
|
|
65
|
+
/** Apply one /model selection; returns the display label. */
|
|
66
|
+
selectModel(row: ModelRow): string
|
|
67
|
+
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
68
|
+
onBridgeReady(bridge: { notify(text: string): void }): void
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
/** Ink `color` string for one palette triple. */
|
|
@@ -48,9 +77,9 @@ function inkColor(triple: readonly [number, number, number]): string {
|
|
|
48
77
|
function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
|
|
49
78
|
switch (entry.kind) {
|
|
50
79
|
case 'user':
|
|
51
|
-
return createElement(Text, null, brand('❯ '), entry.text)
|
|
80
|
+
return createElement(Text, null, brand('❯ '), displayText(entry.text))
|
|
52
81
|
case 'assistant':
|
|
53
|
-
return createElement(Text, null, entry.text)
|
|
82
|
+
return createElement(Text, null, displayText(entry.text))
|
|
54
83
|
case 'tool': {
|
|
55
84
|
const mark = entry.state === 'running'
|
|
56
85
|
? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
|
|
@@ -63,18 +92,34 @@ function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
|
|
|
63
92
|
mark,
|
|
64
93
|
' ',
|
|
65
94
|
brand(entry.name),
|
|
66
|
-
entry.summary === '' ? '' : ` ${dim(entry.summary)}`,
|
|
95
|
+
entry.summary === '' ? '' : ` ${dim(displayText(entry.summary))}`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
case 'command': {
|
|
99
|
+
const mark = entry.state === 'running'
|
|
100
|
+
? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
|
|
101
|
+
: entry.state === 'error'
|
|
102
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
103
|
+
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
104
|
+
return createElement(
|
|
105
|
+
Text,
|
|
106
|
+
null,
|
|
107
|
+
mark,
|
|
108
|
+
' ',
|
|
109
|
+
brand(`/${entry.name}`),
|
|
110
|
+
entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
|
|
111
|
+
entry.summary === '' ? '' : ` ${dim(displayText(entry.summary))}`,
|
|
67
112
|
)
|
|
68
113
|
}
|
|
69
114
|
case 'error':
|
|
70
|
-
return createElement(Text, null, paintError(entry.text))
|
|
115
|
+
return createElement(Text, null, paintError(displayText(entry.text)))
|
|
71
116
|
default:
|
|
72
117
|
return assertNever(entry, 'transcript entry kind')
|
|
73
118
|
}
|
|
74
119
|
}
|
|
75
120
|
|
|
76
121
|
/** The whale wordmark header in DeepSeek blue, hugging its content width. */
|
|
77
|
-
function Header(): ReactElement {
|
|
122
|
+
function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
78
123
|
return createElement(
|
|
79
124
|
Box,
|
|
80
125
|
// alignSelf shrinks the border to the whale-plus-wordmark content instead
|
|
@@ -90,11 +135,50 @@ function Header(): ReactElement {
|
|
|
90
135
|
Box,
|
|
91
136
|
{ flexDirection: 'column', justifyContent: 'center' },
|
|
92
137
|
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
|
|
93
|
-
createElement(
|
|
138
|
+
createElement(
|
|
139
|
+
Text,
|
|
140
|
+
{ dimColor: true },
|
|
141
|
+
resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit',
|
|
142
|
+
),
|
|
94
143
|
),
|
|
95
144
|
)
|
|
96
145
|
}
|
|
97
146
|
|
|
147
|
+
/** Todo status glyph: web TodoPanel's three-state marker. */
|
|
148
|
+
function todoMark(status: TodoItem['status']): string {
|
|
149
|
+
return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Inline todo list (web TodoPanel's compact terminal form). */
|
|
153
|
+
function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
|
|
154
|
+
if (todos.length === 0) return undefined
|
|
155
|
+
const completed = todos.filter(todo => todo.status === 'completed').length
|
|
156
|
+
const inProgress = todos.filter(todo => todo.status === 'in_progress').length
|
|
157
|
+
const pending = todos.length - completed - inProgress
|
|
158
|
+
return createElement(
|
|
159
|
+
Box,
|
|
160
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1 },
|
|
161
|
+
createElement(
|
|
162
|
+
Text,
|
|
163
|
+
{ color: inkColor(TUI_RGB.brand), bold: true },
|
|
164
|
+
`todos ${completed}/${todos.length}`,
|
|
165
|
+
createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
|
|
166
|
+
),
|
|
167
|
+
...todos.map((todo, index) => createElement(
|
|
168
|
+
Text,
|
|
169
|
+
{
|
|
170
|
+
key: index,
|
|
171
|
+
color: todo.status === 'completed'
|
|
172
|
+
? inkColor(TUI_RGB.success)
|
|
173
|
+
: todo.status === 'in_progress'
|
|
174
|
+
? inkColor(TUI_RGB.brandBright)
|
|
175
|
+
: inkColor(TUI_RGB.dim),
|
|
176
|
+
},
|
|
177
|
+
`${todoMark(todo.status)} ${displayText(todo.content)}`,
|
|
178
|
+
)),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
98
182
|
/**
|
|
99
183
|
* The footer status line: Claude-Code-style identity facts (model, working
|
|
100
184
|
* directory, git branch, session) beside the web composer's session figures
|
|
@@ -119,83 +203,409 @@ function StatusLine({ facts, stats, busy }: {
|
|
|
119
203
|
return createElement(Box, { paddingX: 1 }, ...children)
|
|
120
204
|
}
|
|
121
205
|
|
|
122
|
-
/** The
|
|
123
|
-
function
|
|
206
|
+
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
207
|
+
function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement | undefined {
|
|
208
|
+
const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
|
|
209
|
+
if (snapshot.pending === undefined) return undefined
|
|
210
|
+
const { pending, answered } = snapshot
|
|
211
|
+
return createElement(
|
|
212
|
+
Box,
|
|
213
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1 },
|
|
214
|
+
createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
|
|
215
|
+
createElement(Text, null, warn(displayText(pending.headline))),
|
|
216
|
+
pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
|
|
217
|
+
answered
|
|
218
|
+
? createElement(Text, { dimColor: true }, ' submitted…')
|
|
219
|
+
: createElement(Text, { dimColor: true }, dim(' y allow once · n reject')),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
224
|
+
function ModelPanel({ directory, error, onSelect, onClose }: {
|
|
225
|
+
directory: ModelDirectory | undefined
|
|
226
|
+
error: string | undefined
|
|
227
|
+
onSelect(row: ModelRow): void
|
|
228
|
+
onClose(): void
|
|
229
|
+
}): ReactElement {
|
|
230
|
+
const [cursor, setCursor] = useState(0)
|
|
231
|
+
useInput((input, key) => {
|
|
232
|
+
if (key.escape || input === 'q') {
|
|
233
|
+
onClose()
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const rows = directory?.rows ?? []
|
|
237
|
+
if (key.upArrow) {
|
|
238
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (key.downArrow) {
|
|
242
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (key.return && rows[cursor] !== undefined) {
|
|
246
|
+
onSelect(rows[cursor])
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
const rows = directory?.rows ?? []
|
|
250
|
+
const window = 8
|
|
251
|
+
const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
|
|
252
|
+
const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
|
|
253
|
+
return createElement(
|
|
254
|
+
Box,
|
|
255
|
+
{ flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1 },
|
|
256
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
|
|
257
|
+
directory === undefined && error === undefined
|
|
258
|
+
? createElement(Text, { dimColor: true }, ' loading models…')
|
|
259
|
+
: undefined,
|
|
260
|
+
error !== undefined
|
|
261
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
|
|
262
|
+
: undefined,
|
|
263
|
+
...visible.map((row) => {
|
|
264
|
+
const index = rows.indexOf(row)
|
|
265
|
+
const label = displayText(`${row.providerName} · ${row.modelName}`)
|
|
266
|
+
return createElement(
|
|
267
|
+
Text,
|
|
268
|
+
{
|
|
269
|
+
key: `${row.provider}/${row.model}`,
|
|
270
|
+
color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
271
|
+
},
|
|
272
|
+
`${index === cursor ? '❯ ' : ' '}${label}`,
|
|
273
|
+
)
|
|
274
|
+
}),
|
|
275
|
+
createElement(Text, { dimColor: true }, dim(' ↑↓ move · enter select · esc close')),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One completion candidate row. */
|
|
280
|
+
interface CompletionCandidate {
|
|
281
|
+
/** Insertion text for the command name (with leading slash). */
|
|
282
|
+
label: string
|
|
283
|
+
/** Human-readable description shown beside the label. */
|
|
284
|
+
description: string
|
|
285
|
+
/** Candidate origin; skills land the same literal text but route through the prompt. */
|
|
286
|
+
origin: 'command' | 'skill'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Resolve completion candidates for the current input: TUI-local commands,
|
|
291
|
+
* the live registry descriptors, and user-invocable skills, filtered by the
|
|
292
|
+
* typed prefix. Command names win collisions (the dispatch tries the
|
|
293
|
+
* registry first and only then falls through to the skill gesture).
|
|
294
|
+
*/
|
|
295
|
+
function completionCandidates(
|
|
296
|
+
value: string,
|
|
297
|
+
descriptors: readonly CommandDescriptor[],
|
|
298
|
+
skills: readonly SkillRow[],
|
|
299
|
+
): readonly CompletionCandidate[] {
|
|
300
|
+
if (!value.startsWith('/')) return []
|
|
301
|
+
const prefix = value.slice(1).split(' ')[0] ?? ''
|
|
302
|
+
const local: CompletionCandidate[] = [
|
|
303
|
+
{ label: '/help', description: 'show commands', origin: 'command' },
|
|
304
|
+
{ label: '/model', description: 'switch the model', origin: 'command' },
|
|
305
|
+
{ label: '/clear', description: 'clear the screen', origin: 'command' },
|
|
306
|
+
{ label: '/quit', description: 'exit', origin: 'command' },
|
|
307
|
+
]
|
|
308
|
+
const registry = descriptors.map((descriptor): CompletionCandidate => ({
|
|
309
|
+
label: `/${descriptor.name}`,
|
|
310
|
+
description: descriptor.description,
|
|
311
|
+
origin: 'command',
|
|
312
|
+
}))
|
|
313
|
+
const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
|
|
314
|
+
const skillRows = skills
|
|
315
|
+
.filter(skill => !taken.has(skill.name))
|
|
316
|
+
.map((skill): CompletionCandidate => ({
|
|
317
|
+
label: `/${skill.name}`,
|
|
318
|
+
description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
|
|
319
|
+
origin: 'skill',
|
|
320
|
+
}))
|
|
321
|
+
const all = [...local, ...registry, ...skillRows]
|
|
322
|
+
if (prefix === '') return all.slice(0, 10)
|
|
323
|
+
return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
328
|
+
* dispatched; input editing keeps a cursor with history and completion.
|
|
329
|
+
*/
|
|
330
|
+
function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }: {
|
|
124
331
|
busy: boolean
|
|
125
|
-
|
|
126
|
-
|
|
332
|
+
descriptors: readonly CommandDescriptor[]
|
|
333
|
+
skills: readonly SkillRow[]
|
|
334
|
+
dispatch(text: string): void
|
|
335
|
+
steer(text: string): void
|
|
336
|
+
interrupt(): boolean
|
|
337
|
+
quit(): void
|
|
338
|
+
openModel(): void
|
|
339
|
+
notify(text: string): void
|
|
127
340
|
}): ReactElement {
|
|
128
341
|
const [value, setValue] = useState('')
|
|
129
|
-
const [
|
|
342
|
+
const [cursor, setCursor] = useState(0)
|
|
343
|
+
const history = useRef<readonly string[]>([])
|
|
344
|
+
const historyIndex = useRef<number | null>(null)
|
|
345
|
+
const draft = useRef('')
|
|
346
|
+
const [completionIndex, setCompletionIndex] = useState(0)
|
|
347
|
+
const candidates = completionCandidates(value, descriptors, skills)
|
|
348
|
+
const completionActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
|
|
349
|
+
|
|
130
350
|
useInput((input, key) => {
|
|
131
|
-
|
|
132
|
-
|
|
351
|
+
// Ctrl+C is three-state (community-TUI convention): a running turn is
|
|
352
|
+
// cancelled, a non-empty draft is cleared, and only an idle empty input
|
|
353
|
+
// exits. Ctrl+D always means exit but refuses mid-turn.
|
|
354
|
+
if (key.ctrl && input === 'c') {
|
|
355
|
+
if (busy) {
|
|
356
|
+
interrupt()
|
|
357
|
+
} else if (value !== '') {
|
|
358
|
+
setValue('')
|
|
359
|
+
setCursor(0)
|
|
360
|
+
setCompletionIndex(0)
|
|
361
|
+
} else {
|
|
362
|
+
quit()
|
|
363
|
+
}
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (key.ctrl && input === 'd') {
|
|
367
|
+
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
|
|
368
|
+
else quit()
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (key.escape) {
|
|
372
|
+
if (busy) interrupt()
|
|
133
373
|
return
|
|
134
374
|
}
|
|
135
375
|
if (key.return) {
|
|
376
|
+
// Multi-line editing: most terminals send the same byte for
|
|
377
|
+
// shift+enter as enter, so newline insertion rides alt/meta+enter
|
|
378
|
+
// and ctrl+j (the two distinguishable bindings); a bare return submits.
|
|
379
|
+
if (key.meta || (key.ctrl && input === 'j')) {
|
|
380
|
+
setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
|
|
381
|
+
setCursor(cursor + 1)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
136
384
|
const text = value.trim()
|
|
137
385
|
setValue('')
|
|
386
|
+
setCursor(0)
|
|
387
|
+
setCompletionIndex(0)
|
|
138
388
|
if (text === '') return
|
|
389
|
+
history.current = [...history.current, text]
|
|
390
|
+
historyIndex.current = null
|
|
139
391
|
if (text === '/quit') {
|
|
140
|
-
|
|
392
|
+
quit()
|
|
141
393
|
return
|
|
142
394
|
}
|
|
143
395
|
if (text === '/help') {
|
|
144
|
-
|
|
396
|
+
notify('/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
|
|
145
397
|
return
|
|
146
398
|
}
|
|
147
399
|
if (text === '/clear') {
|
|
148
|
-
setNotices([])
|
|
149
400
|
console.clear()
|
|
150
401
|
return
|
|
151
402
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
403
|
+
if (text === '/model' || text.startsWith('/model ')) {
|
|
404
|
+
openModel()
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (busy && !text.startsWith('/')) {
|
|
408
|
+
// A running turn is steered, not blocked: the inbox delivers this
|
|
409
|
+
// text at the next step boundary (Esc/Ctrl+C still cancels outright).
|
|
410
|
+
// Slash lines keep the registry path — commands run out of band.
|
|
411
|
+
steer(text)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
dispatch(text)
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
if (completionActive && key.upArrow) {
|
|
418
|
+
setCompletionIndex(index => (index + candidates.length - 1) % candidates.length)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (completionActive && key.downArrow) {
|
|
422
|
+
setCompletionIndex(index => (index + 1) % candidates.length)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (key.upArrow) {
|
|
426
|
+
const entries = history.current
|
|
427
|
+
if (entries.length === 0) return
|
|
428
|
+
const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1)
|
|
429
|
+
if (historyIndex.current === null) draft.current = value
|
|
430
|
+
historyIndex.current = next
|
|
431
|
+
setValue(entries[next] ?? '')
|
|
432
|
+
setCursor((entries[next] ?? '').length)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (key.downArrow) {
|
|
436
|
+
const entries = history.current
|
|
437
|
+
if (historyIndex.current === null) return
|
|
438
|
+
const next = historyIndex.current + 1
|
|
439
|
+
if (next >= entries.length) {
|
|
440
|
+
historyIndex.current = null
|
|
441
|
+
setValue(draft.current)
|
|
442
|
+
setCursor(draft.current.length)
|
|
154
443
|
return
|
|
155
444
|
}
|
|
156
|
-
|
|
445
|
+
historyIndex.current = next
|
|
446
|
+
setValue(entries[next] ?? '')
|
|
447
|
+
setCursor((entries[next] ?? '').length)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (key.tab && completionActive) {
|
|
451
|
+
const candidate = candidates[completionIndex % candidates.length]
|
|
452
|
+
if (candidate !== undefined) {
|
|
453
|
+
setValue(`${candidate.label} `)
|
|
454
|
+
setCursor(candidate.label.length + 1)
|
|
455
|
+
setCompletionIndex(0)
|
|
456
|
+
}
|
|
157
457
|
return
|
|
158
458
|
}
|
|
159
459
|
if (key.backspace || key.delete) {
|
|
160
|
-
|
|
460
|
+
if (cursor > 0) {
|
|
461
|
+
setValue(value.slice(0, cursor - 1) + value.slice(cursor))
|
|
462
|
+
setCursor(cursor - 1)
|
|
463
|
+
setCompletionIndex(0)
|
|
464
|
+
}
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
if (key.leftArrow) {
|
|
468
|
+
setCursor(Math.max(0, cursor - 1))
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
if (key.rightArrow) {
|
|
472
|
+
setCursor(Math.min(value.length, cursor + 1))
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
if (key.ctrl && input === 'u') {
|
|
476
|
+
setValue('')
|
|
477
|
+
setCursor(0)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (key.ctrl && input === 'a') {
|
|
481
|
+
setCursor(0)
|
|
161
482
|
return
|
|
162
483
|
}
|
|
163
|
-
if (input
|
|
164
|
-
|
|
484
|
+
if (key.ctrl && input === 'e') {
|
|
485
|
+
setCursor(value.length)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
489
|
+
setValue(value.slice(0, cursor) + input + value.slice(cursor))
|
|
490
|
+
setCursor(cursor + input.length)
|
|
491
|
+
setCompletionIndex(0)
|
|
165
492
|
}
|
|
166
493
|
})
|
|
494
|
+
|
|
495
|
+
const shown = completionActive && !busy
|
|
167
496
|
return createElement(
|
|
168
497
|
Box,
|
|
169
498
|
{ flexDirection: 'column' },
|
|
170
|
-
|
|
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,
|
|
514
|
+
busy && value === ''
|
|
515
|
+
? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
|
|
516
|
+
: undefined,
|
|
171
517
|
createElement(
|
|
172
518
|
Box,
|
|
173
519
|
null,
|
|
174
520
|
createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
|
|
175
|
-
createElement(Text, null, value),
|
|
521
|
+
createElement(Text, null, value.slice(0, cursor)),
|
|
522
|
+
createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === '' ? ' ' : value.slice(cursor, cursor + 1)),
|
|
523
|
+
createElement(Text, null, value.slice(cursor + 1)),
|
|
176
524
|
),
|
|
177
525
|
)
|
|
178
526
|
}
|
|
179
527
|
|
|
180
528
|
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
181
|
-
export function App(
|
|
182
|
-
const view = useSyncExternalStore(store.subscribe, store.getView)
|
|
529
|
+
export function App(props: AppProps): ReactElement {
|
|
530
|
+
const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
|
|
531
|
+
const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
|
|
532
|
+
const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
|
|
533
|
+
const [modelLabel, setModelLabel] = useState(props.model)
|
|
534
|
+
const [modelOpen, setModelOpen] = useState(false)
|
|
535
|
+
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
536
|
+
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
537
|
+
const [notices, setNotices] = useState<readonly string[]>([])
|
|
538
|
+
const notify = (text: string): void => {
|
|
539
|
+
setNotices(current => [...current, text])
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
props.onBridgeReady({ notify })
|
|
544
|
+
}, [])
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!modelOpen || directory !== undefined) return
|
|
547
|
+
let cancelled = false
|
|
548
|
+
setModelError(undefined)
|
|
549
|
+
props.loadModels().then((loaded) => {
|
|
550
|
+
if (!cancelled) setDirectory(loaded)
|
|
551
|
+
}, (error: unknown) => {
|
|
552
|
+
if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
|
|
553
|
+
})
|
|
554
|
+
return () => {
|
|
555
|
+
cancelled = true
|
|
556
|
+
}
|
|
557
|
+
}, [modelOpen])
|
|
558
|
+
|
|
559
|
+
const busy = view.busy
|
|
183
560
|
return createElement(
|
|
184
561
|
Box,
|
|
185
562
|
{ flexDirection: 'column' },
|
|
186
|
-
createElement(Header),
|
|
563
|
+
createElement(Header, { resumed: props.resumed }),
|
|
187
564
|
createElement(
|
|
188
565
|
Box,
|
|
189
566
|
{ flexDirection: 'column', paddingX: 1 },
|
|
190
567
|
...view.entries.map((entry, index) => createElement(EntryLine, { key: index, entry })),
|
|
191
|
-
view.streaming !== '' ? createElement(Text, null, view.streaming) : undefined,
|
|
192
|
-
|
|
568
|
+
view.streaming !== '' ? createElement(Text, null, displayText(view.streaming)) : undefined,
|
|
569
|
+
busy && view.streaming === '' ? createElement(Text, { dimColor: true }, 'thinking…') : undefined,
|
|
570
|
+
),
|
|
571
|
+
createElement(TodoPanel, { todos: view.todos }),
|
|
572
|
+
createElement(ApprovalBar, { approval: props.approval }),
|
|
573
|
+
modelOpen
|
|
574
|
+
? createElement(ModelPanel, {
|
|
575
|
+
directory,
|
|
576
|
+
error: modelError,
|
|
577
|
+
onSelect: (row: ModelRow) => {
|
|
578
|
+
setModelLabel(props.selectModel(row))
|
|
579
|
+
notify(`model → next step uses ${row.provider}/${row.model}`)
|
|
580
|
+
setModelOpen(false)
|
|
581
|
+
},
|
|
582
|
+
onClose: () => {
|
|
583
|
+
setModelOpen(false)
|
|
584
|
+
},
|
|
585
|
+
})
|
|
586
|
+
: undefined,
|
|
587
|
+
createElement(
|
|
588
|
+
Box,
|
|
589
|
+
{ flexDirection: 'column' },
|
|
590
|
+
...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
|
|
193
591
|
),
|
|
194
|
-
createElement(Input, {
|
|
592
|
+
createElement(Input, {
|
|
593
|
+
busy,
|
|
594
|
+
descriptors,
|
|
595
|
+
skills,
|
|
596
|
+
dispatch: props.dispatch,
|
|
597
|
+
steer: props.steer,
|
|
598
|
+
interrupt: props.interrupt,
|
|
599
|
+
quit: props.quit,
|
|
600
|
+
openModel: () => {
|
|
601
|
+
setModelOpen(true)
|
|
602
|
+
},
|
|
603
|
+
notify,
|
|
604
|
+
}),
|
|
195
605
|
createElement(StatusLine, {
|
|
196
|
-
facts: { model, cwd, branch, sessionId },
|
|
606
|
+
facts: { model: modelLabel, cwd: props.cwd, branch: props.branch, sessionId: props.sessionId },
|
|
197
607
|
stats: view.stats,
|
|
198
|
-
busy
|
|
608
|
+
busy,
|
|
199
609
|
}),
|
|
200
610
|
)
|
|
201
611
|
}
|