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/invariant.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Package-owned invariant companion for
|
|
3
|
-
* @module
|
|
2
|
+
* Package-owned invariant companion for `dsh-code`.
|
|
3
|
+
* @module dsh-code/invariant
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Context } from '@deepseek-ai/cordis'
|
|
7
7
|
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
|
8
8
|
|
|
9
|
-
const PACKAGE_NAME = '
|
|
9
|
+
const PACKAGE_NAME = 'dsh-code'
|
|
10
10
|
|
|
11
11
|
/** Cordis companion plugin name. */
|
|
12
12
|
export const name = 'tui-invariant'
|
package/src/mentions.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace @mention support: file candidates from a bounded async scan of
|
|
3
|
+
* the session cwd, session candidates from the opt-in `sessionReferenceResolver`
|
|
4
|
+
* service, and submission preparation through its `prepare()` API. Picked
|
|
5
|
+
* session mentions land as canonical `@[label](dsh-session:…)` tokens; on
|
|
6
|
+
* submit the text is parsed back into readable `@label` text plus structured
|
|
7
|
+
* references, snapshots are injected via `agent.inject()` before the readable
|
|
8
|
+
* message wakes the driver (`followup` idle, `steer` running) — exactly the
|
|
9
|
+
* upstream README's wiring.
|
|
10
|
+
*
|
|
11
|
+
* @module @deepseek-ai/dsh-code/mentions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdir } from 'node:fs/promises'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
18
|
+
import {
|
|
19
|
+
formatSessionReferenceMention,
|
|
20
|
+
parseSessionReferenceText,
|
|
21
|
+
type SessionReferenceCandidate,
|
|
22
|
+
type SessionReferenceInput,
|
|
23
|
+
} from '@deepseek-ai/dsh-session-reference'
|
|
24
|
+
|
|
25
|
+
/** Parsed submission text: readable text plus structured references. */
|
|
26
|
+
type ParsedSessionReferenceText = ReturnType<typeof parseSessionReferenceText>
|
|
27
|
+
|
|
28
|
+
/** One filesystem entry the @ menu can complete. */
|
|
29
|
+
export interface FileCandidate {
|
|
30
|
+
/** Workspace-relative path with forward slashes. */
|
|
31
|
+
path: string
|
|
32
|
+
/** Entry kind; directories insert with a trailing slash. */
|
|
33
|
+
kind: 'file' | 'directory'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One merged menu candidate (files and sessions, already ranked). */
|
|
37
|
+
export interface MentionCandidate {
|
|
38
|
+
/** Text inserted after the `@` (directories carry a trailing slash). */
|
|
39
|
+
label: string
|
|
40
|
+
/** Human-readable origin shown beside the label. */
|
|
41
|
+
description: string
|
|
42
|
+
/** Origin kind for icon/coloring decisions. */
|
|
43
|
+
kind: 'file' | 'directory' | 'session'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Prepared submission: readable content plus optional injected context. */
|
|
47
|
+
export interface PreparedMention {
|
|
48
|
+
/** Readable text with mention tokens normalized to `@label`. */
|
|
49
|
+
text: string
|
|
50
|
+
/** Structured source sessions in appearance order (empty when none). */
|
|
51
|
+
references: SessionReferenceInput[]
|
|
52
|
+
/** Aggregated snapshot for `agent.inject()`, undefined without references. */
|
|
53
|
+
additionalContext?: import('@deepseek-ai/dsh-session').UserMessage
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Directories never entered and files never listed during the scan. */
|
|
57
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'lib', 'dist', 'out', '.omc', 'coverage'])
|
|
58
|
+
const MAX_FILES = 4000
|
|
59
|
+
const MAX_DEPTH = 12
|
|
60
|
+
|
|
61
|
+
/** Bounded async BFS scan of a workspace; unreadable entries are skipped. */
|
|
62
|
+
export async function scanWorkspaceFiles(root: string, signal?: AbortSignal): Promise<readonly FileCandidate[]> {
|
|
63
|
+
const found: FileCandidate[] = []
|
|
64
|
+
const pending: Array<{ absolute: string; relative: string; depth: number }> = [{ absolute: root, relative: '', depth: 0 }]
|
|
65
|
+
const aborted = (): boolean => signal?.aborted === true
|
|
66
|
+
while (pending.length > 0 && found.length < MAX_FILES && !aborted()) {
|
|
67
|
+
const current = pending.shift()
|
|
68
|
+
if (current === undefined) break
|
|
69
|
+
let entries
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(current.absolute, { withFileTypes: true })
|
|
72
|
+
} catch {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (found.length >= MAX_FILES || aborted()) return found
|
|
77
|
+
if (entry.name.startsWith('.')) continue
|
|
78
|
+
const relative = current.relative === '' ? entry.name : `${current.relative}/${entry.name}`
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue
|
|
81
|
+
pending.push({ absolute: join(current.absolute, entry.name), relative, depth: current.depth + 1 })
|
|
82
|
+
} else if (entry.isFile()) {
|
|
83
|
+
found.push({ path: relative, kind: 'file' })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return found.sort((left, right) => left.path < right.path ? -1 : 1)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when every query character appears in order in the haystack. */
|
|
91
|
+
function isSubsequence(query: string, haystack: string): boolean {
|
|
92
|
+
let at = 0
|
|
93
|
+
for (const char of haystack) {
|
|
94
|
+
if (char === query[at]) at += 1
|
|
95
|
+
if (at >= query.length) return true
|
|
96
|
+
}
|
|
97
|
+
return at >= query.length
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Rank one file path against the typed query (community-TUI scoring shape). */
|
|
101
|
+
function scoreFile(path: string, query: string): number {
|
|
102
|
+
const name = path.slice(path.lastIndexOf('/') + 1)
|
|
103
|
+
if (query === '') return 0
|
|
104
|
+
if (name === query) return 1000
|
|
105
|
+
if (name.startsWith(query)) return 900
|
|
106
|
+
if (name.includes(query)) return 700
|
|
107
|
+
if (path.includes(query)) return 500
|
|
108
|
+
if (isSubsequence(query, name)) return 300
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The mention API the input editor and the runner share. */
|
|
113
|
+
export interface MentionsApi {
|
|
114
|
+
/** Scanned workspace files, cached across one session. */
|
|
115
|
+
files(): Promise<readonly FileCandidate[]>
|
|
116
|
+
/** Ranked menu candidates for the typed `@` query. */
|
|
117
|
+
candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
118
|
+
/** Parse submission text into readable text plus structured references. */
|
|
119
|
+
parse(text: string): ParsedSessionReferenceText
|
|
120
|
+
/**
|
|
121
|
+
* Snapshot references and build the injected context. Throws the service's
|
|
122
|
+
* typed error on failure — the caller restores the draft and notifies.
|
|
123
|
+
*/
|
|
124
|
+
prepare(parsed: ParsedSessionReferenceText, signal?: AbortSignal): Promise<PreparedMention>
|
|
125
|
+
/** Canonical mention token for a picked session candidate. */
|
|
126
|
+
sessionMention(candidate: SessionReferenceCandidate): string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create the mention API for one agent's workspace. A missing
|
|
131
|
+
* session-reference service degrades to file mentions only (the scan still
|
|
132
|
+
* works); `prepare` then passes text through untouched.
|
|
133
|
+
* @param ctx - context carrying the optional `sessionReferenceResolver`.
|
|
134
|
+
* @param agent - the session owner; excluded from its own candidates.
|
|
135
|
+
* @param cwd - workspace root to scan.
|
|
136
|
+
*/
|
|
137
|
+
export function createMentions(ctx: Context, agent: Agent, cwd: string): MentionsApi {
|
|
138
|
+
const resolver = ctx.get('sessionReferenceResolver')
|
|
139
|
+
let filesPromise: Promise<readonly FileCandidate[]> | undefined
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
files(): Promise<readonly FileCandidate[]> {
|
|
143
|
+
filesPromise ??= scanWorkspaceFiles(cwd)
|
|
144
|
+
return filesPromise
|
|
145
|
+
},
|
|
146
|
+
async candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]> {
|
|
147
|
+
const needle = query.trim()
|
|
148
|
+
const [files, sessions] = await Promise.all([
|
|
149
|
+
this.files(),
|
|
150
|
+
resolver === undefined
|
|
151
|
+
? Promise.resolve([])
|
|
152
|
+
: resolver.listCandidates(agent, needle, 10, signal).catch(() => []),
|
|
153
|
+
])
|
|
154
|
+
const fileRows: MentionCandidate[] = files
|
|
155
|
+
.filter(candidate => scoreFile(candidate.path, needle) > 0)
|
|
156
|
+
.sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle))
|
|
157
|
+
.slice(0, 20)
|
|
158
|
+
.map(candidate => ({
|
|
159
|
+
label: candidate.path,
|
|
160
|
+
description: candidate.kind === 'directory' ? 'Folder' : 'File',
|
|
161
|
+
kind: candidate.kind,
|
|
162
|
+
}))
|
|
163
|
+
const sessionRows: MentionCandidate[] = sessions.map(candidate => ({
|
|
164
|
+
label: formatSessionReferenceMention(candidate),
|
|
165
|
+
description: `Session · ${candidate.cwd ?? '(no cwd)'}`,
|
|
166
|
+
kind: 'session',
|
|
167
|
+
}))
|
|
168
|
+
return [...sessionRows, ...fileRows]
|
|
169
|
+
},
|
|
170
|
+
parse(text: string): ParsedSessionReferenceText {
|
|
171
|
+
return parseSessionReferenceText(text)
|
|
172
|
+
},
|
|
173
|
+
async prepare(parsed: ParsedSessionReferenceText, signal?: AbortSignal): Promise<PreparedMention> {
|
|
174
|
+
if (parsed.references.length === 0 || resolver === undefined) {
|
|
175
|
+
return { text: parsed.text, references: parsed.references }
|
|
176
|
+
}
|
|
177
|
+
const prepared = await resolver.prepare(
|
|
178
|
+
agent,
|
|
179
|
+
[{ type: 'text', text: parsed.text }],
|
|
180
|
+
parsed.references,
|
|
181
|
+
signal,
|
|
182
|
+
)
|
|
183
|
+
return {
|
|
184
|
+
text: prepared.content.filter(block => block.type === 'text').map(block => block.text).join(''),
|
|
185
|
+
references: parsed.references,
|
|
186
|
+
additionalContext: prepared.additionalContext,
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
sessionMention(candidate: SessionReferenceCandidate): string {
|
|
190
|
+
return formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label })
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model directory for the `/model` panel: the in-process equivalent of the
|
|
3
|
+
* web host's model catalog (`buildModelCatalog`), reading the advisory
|
|
4
|
+
* `ctx.llm` registry directly. Catalog membership is advisory — a route
|
|
5
|
+
* serving a model it stopped advertising stays usable — so selection never
|
|
6
|
+
* fails on catalog absence alone.
|
|
7
|
+
*
|
|
8
|
+
* @module @deepseek-ai/dsh-tui/models
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { LlmModelInfo } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
|
|
14
|
+
/** One selectable row in the `/model` panel. */
|
|
15
|
+
export interface ModelRow {
|
|
16
|
+
/** Registered provider route. */
|
|
17
|
+
provider: string
|
|
18
|
+
/** Display name of the provider route. */
|
|
19
|
+
providerName: string
|
|
20
|
+
/** Provider-owned model id. */
|
|
21
|
+
model: string
|
|
22
|
+
/** Human-readable model name. */
|
|
23
|
+
modelName: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The resolved directory: rows plus per-provider discovery failures. */
|
|
27
|
+
export interface ModelDirectory {
|
|
28
|
+
/** Advisory rows, provider-major in registry order. */
|
|
29
|
+
rows: readonly ModelRow[]
|
|
30
|
+
/** Provider ids whose model listing failed; those providers contribute no rows. */
|
|
31
|
+
failures: readonly string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load the selectable model directory from the live `ctx.llm` registry.
|
|
36
|
+
* Providers are listed synchronously; each provider's models are discovered
|
|
37
|
+
* with a bounded parallel fan-out whose failures degrade to that provider
|
|
38
|
+
* contributing no rows (mirrors the web catalog's per-provider failures).
|
|
39
|
+
* @param ctx - context carrying the `llm` service.
|
|
40
|
+
* @returns the resolved directory; empty rows when `llm` is unavailable.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadModelDirectory(ctx: Context): Promise<ModelDirectory> {
|
|
43
|
+
const llm = ctx.get('llm')
|
|
44
|
+
if (llm === undefined) return { rows: [], failures: [] }
|
|
45
|
+
const providers = llm.listProviders()
|
|
46
|
+
const listed = await Promise.all(providers.map(async (provider) => {
|
|
47
|
+
try {
|
|
48
|
+
const models: readonly LlmModelInfo[] = await llm.listModels(provider.id)
|
|
49
|
+
return {
|
|
50
|
+
provider: provider.id,
|
|
51
|
+
providerName: provider.name,
|
|
52
|
+
models: models.map(model => ({
|
|
53
|
+
provider: provider.id,
|
|
54
|
+
providerName: provider.name,
|
|
55
|
+
model: model.id,
|
|
56
|
+
modelName: model.name,
|
|
57
|
+
})),
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
return { provider: provider.id, providerName: provider.name, models: [] as ModelRow[], failed: true }
|
|
61
|
+
}
|
|
62
|
+
}))
|
|
63
|
+
const rows = listed.flatMap(entry => entry.models)
|
|
64
|
+
const failures = listed.filter(entry => 'failed' in entry && entry.failed === true).map(entry => entry.provider)
|
|
65
|
+
return { rows, failures }
|
|
66
|
+
}
|
package/src/questions.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal ask_user_question provider: registers the single UI provider
|
|
3
|
+
* on `ctx.userQuestions` and drives it with a FIFO queue — one question
|
|
4
|
+
* request on screen at a time, everything else waiting — then resolves the
|
|
5
|
+
* collected answers back into the tool's promise. The community TUI proved
|
|
6
|
+
* this exact pipeline shape; here the dialog is an Ink bar instead of a
|
|
7
|
+
* pi-tui inline modal.
|
|
8
|
+
*
|
|
9
|
+
* Plan reviews (`exit_plan_mode`) arrive through the same service with an
|
|
10
|
+
* `intent: { kind: 'plan-review' }` — the renderer highlights the approve
|
|
11
|
+
* option; the answer encoding is identical either way.
|
|
12
|
+
*
|
|
13
|
+
* @module @deepseek-ai/dsh-code/questions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import {
|
|
18
|
+
UserQuestionError,
|
|
19
|
+
type AskUserQuestionAnswer,
|
|
20
|
+
type AskUserQuestionRequest,
|
|
21
|
+
} from '@deepseek-ai/dsh-user-questions'
|
|
22
|
+
|
|
23
|
+
/** One question request waiting on the human, with its settle channels. */
|
|
24
|
+
export interface PendingQuestion {
|
|
25
|
+
/** The request the renderer walks through question by question. */
|
|
26
|
+
request: AskUserQuestionRequest
|
|
27
|
+
/** Resolve the provider promise with the collected answers. */
|
|
28
|
+
resolve(answers: AskUserQuestionAnswer): void
|
|
29
|
+
/** Reject the provider promise as aborted (also used for Esc cancel). */
|
|
30
|
+
reject(error: Error): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The pending-question snapshot the renderer subscribes to. */
|
|
34
|
+
export interface QuestionSnapshot {
|
|
35
|
+
/** The active request, or undefined when nothing is being asked. */
|
|
36
|
+
pending: PendingQuestion | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Store the pending question lands in; the renderer reads, the provider writes. */
|
|
40
|
+
export interface QuestionStore {
|
|
41
|
+
/** Subscribe to pending-state changes; returns the unsubscribe function. */
|
|
42
|
+
subscribe(listener: () => void): () => void
|
|
43
|
+
/** Read the current snapshot (identity-stable between changes). */
|
|
44
|
+
getSnapshot(): QuestionSnapshot
|
|
45
|
+
/** Submit the collected answers for the active request and advance the queue. */
|
|
46
|
+
submit(pending: PendingQuestion, answers: AskUserQuestionAnswer): void
|
|
47
|
+
/** Cancel the active request (Esc) — rejects ASK_ABORTED and advances the queue. */
|
|
48
|
+
cancel(pending: PendingQuestion): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ABORT_ERROR = new UserQuestionError(
|
|
52
|
+
'ask_user_question was interrupted before the user answered',
|
|
53
|
+
'ASK_ABORTED',
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
|
|
58
|
+
* @param ctx - context carrying the `userQuestions` service (dsh-base).
|
|
59
|
+
* @returns the store the renderer subscribes to; a context without the
|
|
60
|
+
* service yields a permanently empty store.
|
|
61
|
+
*/
|
|
62
|
+
export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
63
|
+
const service = ctx.get('userQuestions')
|
|
64
|
+
let snapshot: QuestionSnapshot = { pending: undefined }
|
|
65
|
+
let active: PendingQuestion | undefined
|
|
66
|
+
const queue: PendingQuestion[] = []
|
|
67
|
+
const listeners = new Set<() => void>()
|
|
68
|
+
const set = (next: QuestionSnapshot): void => {
|
|
69
|
+
snapshot = next
|
|
70
|
+
for (const listener of listeners) listener()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Settle the active request and show the next queued one, if any. */
|
|
74
|
+
const advance = (): void => {
|
|
75
|
+
const next = queue.shift()
|
|
76
|
+
active = next
|
|
77
|
+
set({ pending: next })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (service !== undefined) {
|
|
81
|
+
service.registerProvider({
|
|
82
|
+
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const pending: PendingQuestion = {
|
|
85
|
+
request,
|
|
86
|
+
resolve,
|
|
87
|
+
reject,
|
|
88
|
+
}
|
|
89
|
+
// Abort settles through the same channel as an Esc cancel: the
|
|
90
|
+
// owning tool/step died, so the answer must not linger.
|
|
91
|
+
const onAbort = (): void => {
|
|
92
|
+
if (active === pending) {
|
|
93
|
+
active = undefined
|
|
94
|
+
set({ pending: undefined })
|
|
95
|
+
advance()
|
|
96
|
+
} else {
|
|
97
|
+
const at = queue.indexOf(pending)
|
|
98
|
+
if (at >= 0) queue.splice(at, 1)
|
|
99
|
+
}
|
|
100
|
+
reject(ABORT_ERROR)
|
|
101
|
+
}
|
|
102
|
+
if (request.signal?.aborted === true) {
|
|
103
|
+
reject(ABORT_ERROR)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
107
|
+
if (active === undefined) {
|
|
108
|
+
active = pending
|
|
109
|
+
set({ pending })
|
|
110
|
+
} else {
|
|
111
|
+
queue.push(pending)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
subscribe(listener: () => void): () => void {
|
|
120
|
+
listeners.add(listener)
|
|
121
|
+
return () => {
|
|
122
|
+
listeners.delete(listener)
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
getSnapshot(): QuestionSnapshot {
|
|
126
|
+
return snapshot
|
|
127
|
+
},
|
|
128
|
+
submit(pending: PendingQuestion, answers: AskUserQuestionAnswer): void {
|
|
129
|
+
if (active !== pending) return
|
|
130
|
+
active = undefined
|
|
131
|
+
set({ pending: undefined })
|
|
132
|
+
pending.resolve(answers)
|
|
133
|
+
advance()
|
|
134
|
+
},
|
|
135
|
+
cancel(pending: PendingQuestion): void {
|
|
136
|
+
if (active !== pending) return
|
|
137
|
+
active = undefined
|
|
138
|
+
set({ pending: undefined })
|
|
139
|
+
pending.reject(ABORT_ERROR)
|
|
140
|
+
advance()
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal animation frame tables derived from the web design language:
|
|
3
|
+
* the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
|
|
4
|
+
* steps, 1s cycle) becomes the single-cell stepped pulse below, and the
|
|
5
|
+
* streaming caret blink is the Claude-Code convention. Pure functions only —
|
|
6
|
+
* the Ink layer owns timers and colors.
|
|
7
|
+
*
|
|
8
|
+
* @module @deepseek-ai/dsh-code/render/animations
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Single-cell stepped pulse: flat holds mirroring the web's 125ms keyframes. */
|
|
12
|
+
export const PULSE_FRAMES = ['█', '█', '▆', '▃', '▁', '▃', '▆', '█'] as const
|
|
13
|
+
|
|
14
|
+
/** Pulse frame for a monotonic tick. */
|
|
15
|
+
export function pulseFrame(tick: number): string {
|
|
16
|
+
return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Caret visibility: half the ticks on, half off (530ms blink). */
|
|
20
|
+
export function caretVisible(tick: number): boolean {
|
|
21
|
+
return tick % 2 === 0
|
|
22
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal markdown renderer for assistant replies: a pure GFM-subset
|
|
3
|
+
* block/inline parser producing styled line segments the Ink renderer maps
|
|
4
|
+
* to colored text. No ANSI here — the app owns color mapping, tests own the
|
|
5
|
+
* structure. The subset mirrors what agent replies actually emit: headings,
|
|
6
|
+
* emphasis, inline/fenced code, flat lists, blockquotes, links, rules, and
|
|
7
|
+
* wrapped paragraphs. Unknown syntax degrades to plain text (never throws).
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/render/markdown
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Style classes the renderer emits; the app maps them to colors/props. */
|
|
13
|
+
export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'dim' | 'strike'
|
|
14
|
+
|
|
15
|
+
/** One styled run of text. */
|
|
16
|
+
export interface MdSegment {
|
|
17
|
+
/** Visible text (no ANSI). */
|
|
18
|
+
text: string
|
|
19
|
+
/** Presentation class for the app's color map. */
|
|
20
|
+
style: MdStyle
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One rendered line: a sequence of styled runs. */
|
|
24
|
+
export interface MdLine {
|
|
25
|
+
segments: readonly MdSegment[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Plain segment helper. */
|
|
29
|
+
function seg(text: string, style: MdStyle = 'plain'): MdSegment {
|
|
30
|
+
return { text, style }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Visible width of a run in columns (CJK counts double). */
|
|
34
|
+
export function visibleColumns(text: string): number {
|
|
35
|
+
let columns = 0
|
|
36
|
+
for (const char of text) {
|
|
37
|
+
const code = char.codePointAt(0) ?? 0
|
|
38
|
+
columns += code > 0x2e7f ? 2 : 1
|
|
39
|
+
}
|
|
40
|
+
return columns
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Walk a segment list, breaking it into lines that fit `width` columns. */
|
|
44
|
+
function wrapSegments(segments: readonly MdSegment[], width: number): readonly MdSegment[][] {
|
|
45
|
+
const lines: MdSegment[][] = []
|
|
46
|
+
let current: MdSegment[] = []
|
|
47
|
+
let used = 0
|
|
48
|
+
for (const segment of segments) {
|
|
49
|
+
// Break the segment at spaces into words so long runs wrap mid-text.
|
|
50
|
+
const words = segment.text.split(/( )/u)
|
|
51
|
+
for (const word of words) {
|
|
52
|
+
if (word === '') continue
|
|
53
|
+
const columns = visibleColumns(word)
|
|
54
|
+
if (used + columns > width && used > 0) {
|
|
55
|
+
lines.push(current)
|
|
56
|
+
current = []
|
|
57
|
+
used = 0
|
|
58
|
+
}
|
|
59
|
+
// A single word wider than the line still goes on its own line.
|
|
60
|
+
current.push({ text: word, style: segment.style })
|
|
61
|
+
used += columns
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (current.length > 0) lines.push(current)
|
|
65
|
+
// Drop the trailing space a wrapped line picked up before the break.
|
|
66
|
+
return lines.map(line => {
|
|
67
|
+
const last = line[line.length - 1]
|
|
68
|
+
if (last !== undefined && last.text === ' ' && line.length > 1) return line.slice(0, -1)
|
|
69
|
+
return line
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Join adjacent same-style runs so the app renders fewer elements. */
|
|
74
|
+
function merge(segments: readonly MdSegment[]): readonly MdSegment[] {
|
|
75
|
+
const merged: MdSegment[] = []
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
const last = merged[merged.length - 1]
|
|
78
|
+
if (last !== undefined && last.style === segment.style) {
|
|
79
|
+
merged[merged.length - 1] = { text: last.text + segment.text, style: last.style }
|
|
80
|
+
} else {
|
|
81
|
+
merged.push({ ...segment })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return merged
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One parsed inline run before wrapping. */
|
|
88
|
+
interface InlineRun {
|
|
89
|
+
text: string
|
|
90
|
+
style: MdStyle
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse inline markdown in one line of text. Link destinations render as a
|
|
95
|
+
* dim `(url)` suffix — the visible text keeps the accent.
|
|
96
|
+
*/
|
|
97
|
+
function parseInline(text: string): readonly InlineRun[] {
|
|
98
|
+
const runs: InlineRun[] = []
|
|
99
|
+
let rest = text
|
|
100
|
+
while (rest !== '') {
|
|
101
|
+
const code = /^`([^`]+)`/u.exec(rest)
|
|
102
|
+
if (code !== null) {
|
|
103
|
+
runs.push({ text: code[1] ?? '', style: 'code' })
|
|
104
|
+
rest = rest.slice(code[0].length)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const boldItalic = /^\*\*\*([^*]+)\*\*\*/u.exec(rest)
|
|
108
|
+
if (boldItalic !== null) {
|
|
109
|
+
runs.push({ text: boldItalic[1] ?? '', style: 'boldItalic' })
|
|
110
|
+
rest = rest.slice(boldItalic[0].length)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const bold = /^\*\*([^*]+)\*\*/u.exec(rest)
|
|
114
|
+
if (bold !== null) {
|
|
115
|
+
runs.push({ text: bold[1] ?? '', style: 'bold' })
|
|
116
|
+
rest = rest.slice(bold[0].length)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const italic = /^\*([^*]+)\*/u.exec(rest) ?? /^_([^_]+)_/u.exec(rest)
|
|
120
|
+
if (italic !== null) {
|
|
121
|
+
runs.push({ text: italic[1] ?? '', style: 'italic' })
|
|
122
|
+
rest = rest.slice(italic[0].length)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const strike = /^~~([^~]+)~~/u.exec(rest)
|
|
126
|
+
if (strike !== null) {
|
|
127
|
+
runs.push({ text: strike[1] ?? '', style: 'strike' })
|
|
128
|
+
rest = rest.slice(strike[0].length)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const link = /^\[([^\]]+)\]\(([^)\s]+)\)/u.exec(rest)
|
|
132
|
+
if (link !== null) {
|
|
133
|
+
const label = link[1] ?? ''
|
|
134
|
+
const url = link[2] ?? ''
|
|
135
|
+
runs.push({ text: label, style: 'accent' })
|
|
136
|
+
runs.push({ text: ` (${url})`, style: 'dim' })
|
|
137
|
+
rest = rest.slice(link[0].length)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
// Plain run up to the next special opener.
|
|
141
|
+
const next = rest.search(/[*_`~[]/u)
|
|
142
|
+
if (next === -1) {
|
|
143
|
+
runs.push({ text: rest, style: 'plain' })
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
if (next > 0) {
|
|
147
|
+
runs.push({ text: rest.slice(0, next), style: 'plain' })
|
|
148
|
+
rest = rest.slice(next)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
// A special opener at position 0 that no pattern consumed: emit it
|
|
152
|
+
// literally and advance, so unbalanced syntax never loops.
|
|
153
|
+
runs.push({ text: rest.slice(0, 1), style: 'plain' })
|
|
154
|
+
rest = rest.slice(1)
|
|
155
|
+
}
|
|
156
|
+
return runs
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const HEADING = /^(#{1,6})\s+(.*)$/u
|
|
160
|
+
const FENCE = /^```([^\s`]*)\s*$/u
|
|
161
|
+
const RULE = /^(?:---|\*\*\*|___)\s*$/u
|
|
162
|
+
const QUOTE = /^>\s?(.*)$/u
|
|
163
|
+
const UNORDERED = /^\s*[-*+]\s+(.*)$/u
|
|
164
|
+
const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
|
|
165
|
+
|
|
166
|
+
/** Render markdown text into styled lines of at most `width` columns. */
|
|
167
|
+
export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
168
|
+
const lines: MdLine[] = []
|
|
169
|
+
const push = (segments: readonly MdSegment[]): void => {
|
|
170
|
+
for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
|
|
171
|
+
lines.push({ segments: merge(wrapped) })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const raw = text.replaceAll('\r', '')
|
|
175
|
+
const source = raw.split('\n')
|
|
176
|
+
let index = 0
|
|
177
|
+
while (index < source.length) {
|
|
178
|
+
const line = source[index] ?? ''
|
|
179
|
+
index += 1
|
|
180
|
+
|
|
181
|
+
// Fenced code block: verbatim lines in code style, language label first.
|
|
182
|
+
const fence = FENCE.exec(line)
|
|
183
|
+
if (fence !== null) {
|
|
184
|
+
const language = fence[1] ?? ''
|
|
185
|
+
if (language !== '') push([seg(` ${language}`, 'dim')])
|
|
186
|
+
while (index < source.length && !FENCE.test(source[index] ?? '')) {
|
|
187
|
+
push([seg(` ${source[index] ?? ''}`, 'code')])
|
|
188
|
+
index += 1
|
|
189
|
+
}
|
|
190
|
+
index += 1 // closing fence
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (line.trim() === '') continue
|
|
195
|
+
if (RULE.test(line.trim())) {
|
|
196
|
+
push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const heading = HEADING.exec(line)
|
|
200
|
+
if (heading !== null) {
|
|
201
|
+
push([seg(heading[2] ?? '', 'accent')])
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
const quote = QUOTE.exec(line)
|
|
205
|
+
if (quote !== null) {
|
|
206
|
+
push([seg(' │ ', 'accent'), ...parseInline(quote[1] ?? '').map(run => seg(run.text, run.style === 'plain' ? 'dim' : run.style))])
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
const ordered = ORDERED.exec(line)
|
|
210
|
+
if (ordered !== null) {
|
|
211
|
+
push([seg(` ${ordered[1] ?? ''}. `, 'accent'), ...parseInline(ordered[2] ?? '').map(run => seg(run.text, run.style))])
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const unordered = UNORDERED.exec(line)
|
|
215
|
+
if (unordered !== null) {
|
|
216
|
+
push([seg(' • ', 'accent'), ...parseInline(unordered[1] ?? '').map(run => seg(run.text, run.style))])
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Paragraph: gather until a blank line, then wrap as one flow. Line
|
|
221
|
+
// breaks inside a paragraph join as a single space (GFM soft breaks).
|
|
222
|
+
const paragraph = [line]
|
|
223
|
+
while (index < source.length && (source[index] ?? '').trim() !== '') {
|
|
224
|
+
paragraph.push(source[index] ?? '')
|
|
225
|
+
index += 1
|
|
226
|
+
}
|
|
227
|
+
const runs: InlineRun[] = []
|
|
228
|
+
for (let at = 0; at < paragraph.length; at += 1) {
|
|
229
|
+
if (at > 0) runs.push({ text: ' ', style: 'plain' })
|
|
230
|
+
runs.push(...parseInline(paragraph[at] ?? ''))
|
|
231
|
+
}
|
|
232
|
+
push(runs.map(run => seg(run.text, run.style)))
|
|
233
|
+
}
|
|
234
|
+
return lines
|
|
235
|
+
}
|