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/render/projection.ts
CHANGED
|
@@ -8,13 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { boundContextSummary, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
11
|
-
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
11
|
+
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
|
|
12
|
+
// Type-only imports merge the plugin-owned SessionEventMap variants (command/*
|
|
13
|
+
// from dsh-commands, plan/mode, permission/preset) into the union this reducer
|
|
14
|
+
// switches on.
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-plan-mode'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-permission-presets'
|
|
18
|
+
import { toolArgumentsPreview } from './tool-preview.ts'
|
|
12
19
|
|
|
13
20
|
/** One user prompt line. */
|
|
14
21
|
export interface UserEntry {
|
|
15
22
|
kind: 'user'
|
|
16
23
|
/** Joined text blocks of the user message. */
|
|
17
24
|
text: string
|
|
25
|
+
/** True for collapsed injected context (plugin/continuation notices), which
|
|
26
|
+
* the renderer marks with a dim ↳ instead of the user ❯ prompt. */
|
|
27
|
+
notice: boolean
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
/** One assembled assistant reply. */
|
|
@@ -22,6 +32,8 @@ export interface AssistantEntry {
|
|
|
22
32
|
kind: 'assistant'
|
|
23
33
|
/** Joined text blocks of the assistant message. */
|
|
24
34
|
text: string
|
|
35
|
+
/** Joined reasoning blocks of the same message, empty when the model thought out loud. */
|
|
36
|
+
reasoning: string
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
/** One model-requested tool invocation and its settled state. */
|
|
@@ -33,12 +45,29 @@ export interface ToolEntry {
|
|
|
33
45
|
name: string
|
|
34
46
|
/** Raw arguments JSON string exactly as the model produced it. */
|
|
35
47
|
arguments: string
|
|
48
|
+
/** Bounded human-meaningful arguments preview for the tool card. */
|
|
49
|
+
preview: string
|
|
36
50
|
/** Execution state; `running` until the paired result lands. */
|
|
37
51
|
state: 'running' | 'done' | 'error'
|
|
38
52
|
/** Bounded first text block of the result, empty until it lands. */
|
|
39
53
|
summary: string
|
|
40
54
|
}
|
|
41
55
|
|
|
56
|
+
/** One slash-command execution dispatched through `ctx.commands`. */
|
|
57
|
+
export interface CommandEntry {
|
|
58
|
+
kind: 'command'
|
|
59
|
+
/** Pairing id shared with the matching `command/done`. */
|
|
60
|
+
commandId: string
|
|
61
|
+
/** Lowercase command name without the leading slash. */
|
|
62
|
+
name: string
|
|
63
|
+
/** Verbatim text following the command name. */
|
|
64
|
+
args: string
|
|
65
|
+
/** Execution state; `running` until the paired lifecycle event lands. */
|
|
66
|
+
state: 'running' | 'done' | 'error'
|
|
67
|
+
/** Handler outcome text, empty until it lands. */
|
|
68
|
+
summary: string
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
/** One turn-level failure surfaced from `turn/end`. */
|
|
43
72
|
export interface ErrorEntry {
|
|
44
73
|
kind: 'error'
|
|
@@ -47,7 +76,7 @@ export interface ErrorEntry {
|
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
/** Ordered transcript items the renderer draws. */
|
|
50
|
-
export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | ErrorEntry
|
|
79
|
+
export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry
|
|
51
80
|
|
|
52
81
|
/** Cumulative token accounting folded from `assistant/message` usage reports. */
|
|
53
82
|
export interface UsageTotals {
|
|
@@ -79,12 +108,25 @@ export interface TranscriptView {
|
|
|
79
108
|
entries: readonly TranscriptEntry[]
|
|
80
109
|
/** Text accumulated from `assistant/chunk` deltas since the last flush. */
|
|
81
110
|
streaming: string
|
|
111
|
+
/** Thinking accumulated from `assistant/chunk` reasoning deltas since the last flush. */
|
|
112
|
+
streamingReasoning: string
|
|
82
113
|
/** Latest whole-list todo snapshot from `todo/write`, empty when none. */
|
|
83
|
-
todos:
|
|
114
|
+
todos: readonly TodoItem[]
|
|
84
115
|
/** True while a durable turn is open (`turn/start` … `turn/end`). */
|
|
85
116
|
busy: boolean
|
|
86
117
|
/** Figures the status line renders. */
|
|
87
118
|
stats: TranscriptStats
|
|
119
|
+
/**
|
|
120
|
+
* The `provider/model` pair of the last `request/header` snapshot — the
|
|
121
|
+
* session's own model record, which a resumed TUI prefers over the
|
|
122
|
+
* deployment default (mirrors the web host's resume selection order).
|
|
123
|
+
* Empty before the session's first request.
|
|
124
|
+
*/
|
|
125
|
+
model: string
|
|
126
|
+
/** Plan mode state folded from the last `plan/mode` event. */
|
|
127
|
+
plan: boolean
|
|
128
|
+
/** Active permission preset folded from the last `permission/preset` event, empty before one. */
|
|
129
|
+
permission: string
|
|
88
130
|
/**
|
|
89
131
|
* Fold-internal timing anchors, never rendered: open step and tool-call
|
|
90
132
|
* start timestamps the next `assistant/message` / `tool/result` resolves
|
|
@@ -98,13 +140,22 @@ function textOf(content: readonly ContentBlock[]): string {
|
|
|
98
140
|
return content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
99
141
|
}
|
|
100
142
|
|
|
143
|
+
/** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
|
|
144
|
+
function reasoningOf(content: readonly ContentBlock[]): string {
|
|
145
|
+
return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
|
|
146
|
+
}
|
|
147
|
+
|
|
101
148
|
/** A fresh, empty transcript view. */
|
|
102
149
|
export function createTranscriptView(): TranscriptView {
|
|
103
150
|
return {
|
|
104
151
|
entries: [],
|
|
105
152
|
streaming: '',
|
|
153
|
+
streamingReasoning: '',
|
|
106
154
|
todos: [],
|
|
107
155
|
busy: false,
|
|
156
|
+
model: '',
|
|
157
|
+
plan: false,
|
|
158
|
+
permission: '',
|
|
108
159
|
stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
|
|
109
160
|
anchors: { stepStart: new Map(), toolStart: new Map() },
|
|
110
161
|
}
|
|
@@ -124,20 +175,25 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
124
175
|
// elsewhere in the product; only direct human prompts render in full.
|
|
125
176
|
const message = event.data
|
|
126
177
|
if (message.source.kind === 'user') {
|
|
127
|
-
return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content) }] }
|
|
178
|
+
return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content), notice: false }] }
|
|
128
179
|
}
|
|
129
180
|
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
130
181
|
? message.source.summary
|
|
131
182
|
: message.source.kind
|
|
132
|
-
return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice) }] }
|
|
183
|
+
return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice), notice: true }] }
|
|
133
184
|
}
|
|
134
185
|
case 'assistant/chunk': {
|
|
135
186
|
const chunk = event.data.chunk
|
|
136
|
-
if (chunk.type
|
|
137
|
-
|
|
187
|
+
if (chunk.type === 'text-delta') {
|
|
188
|
+
return { ...view, streaming: view.streaming + chunk.text }
|
|
189
|
+
}
|
|
190
|
+
if (chunk.type === 'reasoning-delta') {
|
|
191
|
+
return { ...view, streamingReasoning: view.streamingReasoning + chunk.text }
|
|
192
|
+
}
|
|
193
|
+
return view
|
|
138
194
|
}
|
|
139
195
|
case 'assistant/message': {
|
|
140
|
-
// The assembled message is authoritative; drop the streamed
|
|
196
|
+
// The assembled message is authoritative; drop the streamed buffers.
|
|
141
197
|
const key = `${event.data.turn}:${event.data.step}`
|
|
142
198
|
const started = view.anchors.stepStart.get(key)
|
|
143
199
|
view.anchors.stepStart.delete(key)
|
|
@@ -146,7 +202,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
146
202
|
return {
|
|
147
203
|
...view,
|
|
148
204
|
streaming: '',
|
|
149
|
-
|
|
205
|
+
streamingReasoning: '',
|
|
206
|
+
entries: [...view.entries, {
|
|
207
|
+
kind: 'assistant',
|
|
208
|
+
text: textOf(event.data.message.content),
|
|
209
|
+
reasoning: reasoningOf(event.data.message.content),
|
|
210
|
+
}],
|
|
150
211
|
stats: {
|
|
151
212
|
...view.stats,
|
|
152
213
|
llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
@@ -168,6 +229,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
168
229
|
callId: data.callId,
|
|
169
230
|
name: data.name,
|
|
170
231
|
arguments: data.arguments,
|
|
232
|
+
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
171
233
|
state: 'running',
|
|
172
234
|
summary: '',
|
|
173
235
|
}],
|
|
@@ -194,7 +256,15 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
194
256
|
case 'todo/write':
|
|
195
257
|
return { ...view, todos: event.data.todos }
|
|
196
258
|
case 'turn/start':
|
|
197
|
-
|
|
259
|
+
// The web todo projection clears on turn/start: a fresh turn's first
|
|
260
|
+
// write is the authoritative list, and a stale snapshot must not linger
|
|
261
|
+
// through a turn that has not written one yet.
|
|
262
|
+
return {
|
|
263
|
+
...view,
|
|
264
|
+
busy: true,
|
|
265
|
+
todos: [],
|
|
266
|
+
stats: { ...view.stats, turns: view.stats.turns + 1 },
|
|
267
|
+
}
|
|
198
268
|
case 'step/start':
|
|
199
269
|
view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time)
|
|
200
270
|
return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
|
|
@@ -207,6 +277,43 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
207
277
|
entries: [...view.entries, { kind: 'error', text: `${reason.error.code}: ${reason.error.message}` }],
|
|
208
278
|
}
|
|
209
279
|
}
|
|
280
|
+
case 'request/header': {
|
|
281
|
+
// The session's own model record: the latest snapshot's provider/model
|
|
282
|
+
// pair, exactly what a resumed TUI restores as the selection.
|
|
283
|
+
const config = event.data.header.config
|
|
284
|
+
return { ...view, model: `${config.provider}/${config.model}` }
|
|
285
|
+
}
|
|
286
|
+
case 'plan/mode':
|
|
287
|
+
// Whole-value replace; the last one wins (upstream fold semantics).
|
|
288
|
+
return { ...view, plan: event.data.active }
|
|
289
|
+
case 'permission/preset':
|
|
290
|
+
return { ...view, permission: event.data.preset }
|
|
291
|
+
case 'command/run': {
|
|
292
|
+
const data = event.data
|
|
293
|
+
return {
|
|
294
|
+
...view,
|
|
295
|
+
entries: [...view.entries, {
|
|
296
|
+
kind: 'command',
|
|
297
|
+
commandId: data.commandId,
|
|
298
|
+
name: data.name,
|
|
299
|
+
args: data.args ?? '',
|
|
300
|
+
state: 'running',
|
|
301
|
+
summary: '',
|
|
302
|
+
}],
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
case 'command/done': {
|
|
306
|
+
const data = event.data
|
|
307
|
+
const entries = view.entries.map((entry) => {
|
|
308
|
+
if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
|
|
309
|
+
return {
|
|
310
|
+
...entry,
|
|
311
|
+
state: data.kind === 'success' ? 'done' as const : 'error' as const,
|
|
312
|
+
summary: boundContextSummary(data.text ?? ''),
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
return { ...view, entries }
|
|
316
|
+
}
|
|
210
317
|
default:
|
|
211
318
|
return view
|
|
212
319
|
}
|
package/src/render/status.ts
CHANGED
|
@@ -57,6 +57,10 @@ export interface StatusFacts {
|
|
|
57
57
|
branch: string
|
|
58
58
|
/** Short session identifier (last dash-separated segment or tail). */
|
|
59
59
|
sessionId: string
|
|
60
|
+
/** Whether plan mode is active (folded from `plan/mode`). */
|
|
61
|
+
plan: boolean
|
|
62
|
+
/** Active permission preset (folded from `permission/preset`), empty when unknown. */
|
|
63
|
+
permission: string
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -67,8 +71,12 @@ export interface StatusFacts {
|
|
|
67
71
|
*/
|
|
68
72
|
export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): string[] {
|
|
69
73
|
const groups: string[] = []
|
|
70
|
-
const identity = [
|
|
71
|
-
.
|
|
74
|
+
const identity = [
|
|
75
|
+
facts.model,
|
|
76
|
+
facts.cwd,
|
|
77
|
+
facts.branch === '' ? undefined : `⑂ ${facts.branch}`,
|
|
78
|
+
facts.plan ? '⧉ plan' : undefined,
|
|
79
|
+
].filter(part => part !== undefined && part !== '')
|
|
72
80
|
if (identity.length > 0) groups.push(identity.join(' · '))
|
|
73
81
|
if (stats.turns > 0 || stats.steps > 0) {
|
|
74
82
|
groups.push(`T${stats.turns} · S${stats.steps}`)
|
|
@@ -83,5 +91,9 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
|
|
|
83
91
|
groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
|
|
84
92
|
}
|
|
85
93
|
if (facts.sessionId !== '') groups.push(facts.sessionId)
|
|
94
|
+
// The permission preset trails the line: switching it changes only the
|
|
95
|
+
// tail, so the left-aligned bar never shifts its other groups. Plain text,
|
|
96
|
+
// the Claude-Code permission-mode display (no glyphs).
|
|
97
|
+
if (facts.permission !== undefined && facts.permission !== '') groups.push(facts.permission)
|
|
86
98
|
return groups
|
|
87
99
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-boundary sanitization for externally sourced text (model output,
|
|
3
|
+
* tool payloads, skill descriptions). Control characters — including ANSI
|
|
4
|
+
* CSI/OSC escape sequences — would otherwise pass through Ink into the
|
|
5
|
+
* terminal, letting output rewrite the screen or inject prompts. Newlines
|
|
6
|
+
* and tabs survive; everything else in C0/C1 plus DEL becomes a visible
|
|
7
|
+
* `\xNN` escape.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/render/text
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
|
|
13
|
+
const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Escape control characters so externally sourced text cannot drive the
|
|
17
|
+
* terminal.
|
|
18
|
+
* @param text - raw text from a session event, tool payload, or catalog.
|
|
19
|
+
* @returns text with every control character (except `\n`, `\t`) rendered
|
|
20
|
+
* as a literal `\xNN` escape.
|
|
21
|
+
*/
|
|
22
|
+
export function displayText(text: string): string {
|
|
23
|
+
return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
3
|
+
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
4
|
+
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
5
|
+
* transcript and the approval bar's command preview.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-code/render/tool-preview
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Keys searched in declaration order when building a preview. */
|
|
11
|
+
const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve one bounded preview for raw tool arguments.
|
|
15
|
+
* @param args - raw JSON arguments string as the model produced it.
|
|
16
|
+
* @param toolName - the tool the arguments belong to (fallback label).
|
|
17
|
+
* @returns the preview line; empty when nothing useful resolves.
|
|
18
|
+
*/
|
|
19
|
+
export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
20
|
+
if (args === '') return toolName
|
|
21
|
+
try {
|
|
22
|
+
const parsed: unknown = JSON.parse(args)
|
|
23
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
24
|
+
const record = parsed as Record<string, unknown>
|
|
25
|
+
for (const key of PREVIEW_KEYS) {
|
|
26
|
+
const value = record[key]
|
|
27
|
+
if (typeof value === 'string' && value !== '') return value
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Raw JSON parse failed: fall through to the bounded raw arguments.
|
|
32
|
+
}
|
|
33
|
+
return args.length > 80 ? `${args.slice(0, 77)}...` : args
|
|
34
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-invocable skill watch for the `/` completion menu: the in-process
|
|
3
|
+
* equivalent of the web ui-skill trigger source. Skills are NOT commands —
|
|
4
|
+
* picking one lands the literal `/name ` text in the input, and submitting
|
|
5
|
+
* it as a normal prompt lets the host's tool-skill pre-step inject the body
|
|
6
|
+
* (the only entry point for model-disabled skills). Command descriptors win
|
|
7
|
+
* on a name collision; see the runner's dispatch.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/skills
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
14
|
+
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
|
|
15
|
+
import type { SkillSummary } from '@deepseek-ai/dsh-skill'
|
|
16
|
+
|
|
17
|
+
/** One completion-menu row derived from a user-invocable skill. */
|
|
18
|
+
export interface SkillRow {
|
|
19
|
+
/** Skill name; the literal `/name` text is what a pick lands. */
|
|
20
|
+
name: string
|
|
21
|
+
/** Human-readable description (suffixed when model-invocation is off). */
|
|
22
|
+
description: string
|
|
23
|
+
/** Whether the model may also invoke this skill by name. */
|
|
24
|
+
modelInvocable: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The skill-catalog snapshot the completion menu subscribes to. */
|
|
28
|
+
export interface SkillsView {
|
|
29
|
+
/** Name-sorted user-invocable rows; empty until the first load lands. */
|
|
30
|
+
readonly rows: readonly SkillRow[]
|
|
31
|
+
/** Subscribe to catalog changes; returns the unsubscribe function. */
|
|
32
|
+
subscribe(listener: () => void): () => void
|
|
33
|
+
/** Retarget the agent whose workspace the catalog is read for. */
|
|
34
|
+
setAgent(agent: Agent): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Internal shape shared by {@link watchSkills} and its test doubles. */
|
|
38
|
+
interface SkillsWatch extends SkillsView {
|
|
39
|
+
setAgent(agent: Agent): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toRows(skills: readonly SkillSummary[]): readonly SkillRow[] {
|
|
43
|
+
return skills
|
|
44
|
+
.filter(skill => isUserInvocable(skill))
|
|
45
|
+
.map(skill => ({
|
|
46
|
+
name: skill.name,
|
|
47
|
+
description: skill.description,
|
|
48
|
+
modelInvocable: skill.invocation.modelInvocable === true,
|
|
49
|
+
}))
|
|
50
|
+
.sort((left, right) => left.name < right.name ? -1 : 1)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Watch the user-invocable skill catalog for one agent's workspace. The first
|
|
55
|
+
* load starts when the owning agent is known (`setAgent`); `skills/change`
|
|
56
|
+
* and agent retargets re-read. Read failures keep the last good rows (the
|
|
57
|
+
* next change notification is the retry surface) — a missing `skills`
|
|
58
|
+
* service leaves the view permanently empty.
|
|
59
|
+
* @param ctx - context carrying the `skills` service (optional).
|
|
60
|
+
* @returns the view the completion menu subscribes to.
|
|
61
|
+
*/
|
|
62
|
+
export function watchSkills(ctx: Context): SkillsWatch {
|
|
63
|
+
const skills = ctx.get('skills')
|
|
64
|
+
let agent: Agent | undefined
|
|
65
|
+
let rows: readonly SkillRow[] = []
|
|
66
|
+
const listeners = new Set<() => void>()
|
|
67
|
+
|
|
68
|
+
const reload = (): void => {
|
|
69
|
+
if (skills === undefined || agent === undefined) return
|
|
70
|
+
skills.list({
|
|
71
|
+
cwd: agent.session.header.cwd,
|
|
72
|
+
scope: agent,
|
|
73
|
+
}).then((summaries: readonly SkillSummary[]) => {
|
|
74
|
+
const next = toRows(summaries)
|
|
75
|
+
if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return
|
|
76
|
+
rows = next
|
|
77
|
+
for (const listener of listeners) listener()
|
|
78
|
+
}, () => {
|
|
79
|
+
// Discovery failure keeps the last good rows; the next skills/change
|
|
80
|
+
// notification is the retry surface (mirrors the web directory).
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (skills !== undefined) {
|
|
85
|
+
ctx.on('skills/change', reload)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const view: SkillsWatch = {
|
|
89
|
+
get rows(): readonly SkillRow[] {
|
|
90
|
+
return rows
|
|
91
|
+
},
|
|
92
|
+
subscribe(listener: () => void): () => void {
|
|
93
|
+
listeners.add(listener)
|
|
94
|
+
return () => {
|
|
95
|
+
listeners.delete(listener)
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
setAgent(next: Agent): void {
|
|
99
|
+
agent = next
|
|
100
|
+
reload()
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
return view
|
|
104
|
+
}
|
package/src/startup.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive terminal app's command-line provider: parses `--resume`,
|
|
3
|
+
* `--continue`, `--session`, and `--help`, then publishes
|
|
4
|
+
* {@link TUI_STARTUP_SERVICE} for the runner to consume lazily. Follows the
|
|
5
|
+
* headless bundle's startup shape (a commander action publishing a service
|
|
6
|
+
* through {@link parseCmdline}).
|
|
7
|
+
*
|
|
8
|
+
* Semantics:
|
|
9
|
+
* - `--resume <id|prefix>` — continue the persisted session whose id or unique
|
|
10
|
+
* id-prefix matches; the TUI replays its transcript and appends to the same
|
|
11
|
+
* durable log.
|
|
12
|
+
* - `--continue` / `-c` — resume the most recently modified persisted session
|
|
13
|
+
* whose project directory matches the current working directory.
|
|
14
|
+
* - `--session <id>` — create a new session under an explicit identity (the
|
|
15
|
+
* id must not exist yet).
|
|
16
|
+
* - no flags — a fresh session with a minted id.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepseek-ai/dsh-tui/startup
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Command } from 'commander'
|
|
22
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
23
|
+
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
|
|
24
|
+
|
|
25
|
+
/** Stable Cordis plugin name. */
|
|
26
|
+
export const name = 'tui-startup'
|
|
27
|
+
|
|
28
|
+
/** Services required before the invocation can be resolved. */
|
|
29
|
+
export const inject = ['cmdlineArgs']
|
|
30
|
+
|
|
31
|
+
/** Service provided by this plugin and injected by the terminal runner. */
|
|
32
|
+
export const TUI_STARTUP_SERVICE = 'tuiStartup'
|
|
33
|
+
|
|
34
|
+
/** How the runner obtains its session identity. */
|
|
35
|
+
export type TuiStartup =
|
|
36
|
+
| { readonly kind: 'fresh' }
|
|
37
|
+
| { readonly kind: 'named'; readonly sessionId: string }
|
|
38
|
+
| { readonly kind: 'resume'; readonly sessionId: string }
|
|
39
|
+
| { readonly kind: 'latest' }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* This app's command: the launcher's flags this app owns, its description,
|
|
43
|
+
* and its help text.
|
|
44
|
+
* @returns a fresh program, so one process can parse more than once (tests).
|
|
45
|
+
*/
|
|
46
|
+
function tuiCommand(): Command {
|
|
47
|
+
return new Command()
|
|
48
|
+
.name('dsh --profile cli')
|
|
49
|
+
.description('Claude-Code-style interactive terminal for DeepSeek Harness.')
|
|
50
|
+
.helpOption('-h, --help', 'show this help')
|
|
51
|
+
.option('-r, --resume <session>', 'resume the persisted session with this id (or unique id prefix)')
|
|
52
|
+
.option('-c, --continue', 'resume the most recent persisted session for this working directory')
|
|
53
|
+
.option('--session <id>', 'create a new session under this explicit id')
|
|
54
|
+
.addHelpText('after', `
|
|
55
|
+
Examples:
|
|
56
|
+
dsh --profile cli fresh session, minted id
|
|
57
|
+
dsh --profile cli --resume abc123 resume session by id prefix
|
|
58
|
+
dsh --profile cli --continue resume the latest local session
|
|
59
|
+
`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse the invocation and publish the startup service. Mutual exclusions are
|
|
64
|
+
* usage errors rejected from the action before anything is provided.
|
|
65
|
+
* @param ctx - plugin context carrying the command line and exit request.
|
|
66
|
+
*/
|
|
67
|
+
export function apply(ctx: Context): void {
|
|
68
|
+
const program = tuiCommand()
|
|
69
|
+
program.action(() => {
|
|
70
|
+
const options = program.opts<{ resume?: string; continue?: boolean; session?: string }>()
|
|
71
|
+
const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
|
|
72
|
+
if (selected.filter(Boolean).length > 1) {
|
|
73
|
+
program.error('error: --resume, --continue, and --session are mutually exclusive')
|
|
74
|
+
}
|
|
75
|
+
if (options.session !== undefined && options.session === '') {
|
|
76
|
+
program.error('error: --session needs an id')
|
|
77
|
+
}
|
|
78
|
+
if (options.resume !== undefined && options.resume === '') {
|
|
79
|
+
program.error('error: --resume needs a session id or id prefix')
|
|
80
|
+
}
|
|
81
|
+
const startup: TuiStartup = options.resume !== undefined
|
|
82
|
+
? { kind: 'resume', sessionId: options.resume }
|
|
83
|
+
: options.continue === true
|
|
84
|
+
? { kind: 'latest' }
|
|
85
|
+
: options.session !== undefined
|
|
86
|
+
? { kind: 'named', sessionId: options.session }
|
|
87
|
+
: { kind: 'fresh' }
|
|
88
|
+
ctx.provide(TUI_STARTUP_SERVICE, { startup } satisfies { startup: TuiStartup })
|
|
89
|
+
})
|
|
90
|
+
parseCmdline(ctx, program)
|
|
91
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
11
|
-
import { createTranscriptView, projectEvent, type TranscriptView } from './render/projection.ts'
|
|
11
|
+
import { createTranscriptView, projectEvent, projectEvents, type TranscriptView } from './render/projection.ts'
|
|
12
12
|
|
|
13
13
|
/** The externally readable, event-fed transcript store for one session. */
|
|
14
14
|
export interface TranscriptStore {
|
|
@@ -21,11 +21,17 @@ export interface TranscriptStore {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Create one transcript store.
|
|
24
|
+
* Create one transcript store, optionally seeded with replayed history. The
|
|
25
|
+
* seed folds synchronously BEFORE the first render, so a resumed session
|
|
26
|
+
* paints its full transcript on mount (no live `session/event` fires for
|
|
27
|
+
* constructor seeds — the store's `session/event` feed only carries new
|
|
28
|
+
* appends).
|
|
29
|
+
* @param replay - persisted events in `seq` order (e.g. a resumed session's
|
|
30
|
+
* constructor seed); folded once and never re-notified.
|
|
25
31
|
* @returns the store the runner feeds and the renderer subscribes to.
|
|
26
32
|
*/
|
|
27
|
-
export function createTranscriptStore(): TranscriptStore {
|
|
28
|
-
let view = createTranscriptView()
|
|
33
|
+
export function createTranscriptStore(replay?: readonly SessionEvent[]): TranscriptStore {
|
|
34
|
+
let view = replay === undefined ? createTranscriptView() : projectEvents(replay)
|
|
29
35
|
const listeners = new Set<() => void>()
|
|
30
36
|
return {
|
|
31
37
|
getView: () => view,
|
package/src/theme.ts
CHANGED
|
@@ -28,6 +28,10 @@ export const TUI_RGB = {
|
|
|
28
28
|
error: [239, 68, 68],
|
|
29
29
|
/** Warning amber — `--dsw-static-amber-500`. */
|
|
30
30
|
warn: [245, 158, 11],
|
|
31
|
+
/** Default foreground text — `--dsw-static-neutral-50`. */
|
|
32
|
+
text: [236, 240, 246],
|
|
33
|
+
/** Inline/fenced code — soft sky blue, distinct from brand accents. */
|
|
34
|
+
code: [125, 211, 252],
|
|
31
35
|
} as const satisfies Record<string, readonly [number, number, number]>
|
|
32
36
|
|
|
33
37
|
/** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
|