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/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
|
20
20
|
import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
21
21
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
22
22
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
|
-
import { SessionId, type Session, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
23
|
+
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
24
24
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
25
25
|
// Empty type imports carry the loader Context merge for the settlement await
|
|
26
26
|
// and the cmdline Context merge for the appExit host value.
|
|
@@ -31,8 +31,11 @@ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
|
31
31
|
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
32
32
|
import { internals, type TuiMount } from './internals.ts'
|
|
33
33
|
import { loadModelDirectory, type ModelRow } from './models.ts'
|
|
34
|
+
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
35
|
+
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
34
36
|
import { createTranscriptStore } from './store.ts'
|
|
35
37
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
38
|
+
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
36
39
|
import type { TuiStartup } from './startup.ts'
|
|
37
40
|
|
|
38
41
|
/** Stable Cordis plugin name. */
|
|
@@ -136,19 +139,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
|
|
|
136
139
|
candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
|
|
137
140
|
if (entry === undefined) return ''
|
|
138
141
|
const args = (entry as { arguments?: string }).arguments ?? ''
|
|
139
|
-
|
|
140
|
-
const parsed: unknown = JSON.parse(args)
|
|
141
|
-
if (parsed !== null && typeof parsed === 'object') {
|
|
142
|
-
const record = parsed as Record<string, unknown>
|
|
143
|
-
for (const key of ['command', 'cmd', 'description', 'path', 'pattern', 'query']) {
|
|
144
|
-
const value = record[key]
|
|
145
|
-
if (typeof value === 'string' && value !== '') return value
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
} catch {
|
|
149
|
-
// Raw JSON parse failed: fall through to the bounded raw arguments.
|
|
150
|
-
}
|
|
151
|
-
return args.length > 80 ? `${args.slice(0, 77)}...` : args === '' ? toolName : args
|
|
142
|
+
return toolArgumentsPreview(args, toolName)
|
|
152
143
|
}
|
|
153
144
|
|
|
154
145
|
/** The runner's connection between the React app and the process side. */
|
|
@@ -261,6 +252,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
261
252
|
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
262
253
|
)
|
|
263
254
|
|
|
255
|
+
// ask_user_question provider: the single UI provider on the shared service,
|
|
256
|
+
// one request on screen at a time. Plan reviews (exit_plan_mode) arrive
|
|
257
|
+
// through this same pipe.
|
|
258
|
+
const questions: QuestionStore = mountQuestionProvider(ctx)
|
|
259
|
+
|
|
260
|
+
// @mention support: workspace file scan plus the opt-in session-reference
|
|
261
|
+
// service (the patch mounts it); submission expands session mentions.
|
|
262
|
+
const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
|
|
263
|
+
|
|
264
264
|
// The bridge the React app registers on mount: local notices from the
|
|
265
265
|
// process side (unknown commands, switch confirmations, cancels).
|
|
266
266
|
const bridge: AppBridge = { notify: () => {} }
|
|
@@ -283,36 +283,79 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
283
283
|
.then(() => { io.exit(0) })
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
/**
|
|
287
|
-
const
|
|
286
|
+
/** Run one slash line through the command registry (closed namespace). */
|
|
287
|
+
const runSlash = (line: string): void => {
|
|
288
|
+
const registry = ctx.get('commands')
|
|
289
|
+
if (registry === undefined) {
|
|
290
|
+
bridge.notify('no command registry is mounted in this composition')
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
const controller = new AbortController()
|
|
294
|
+
void registry.execute(agent, line, controller.signal).then((execution) => {
|
|
295
|
+
if (execution === undefined) {
|
|
296
|
+
// No command owns this line: send it verbatim so a user-invocable
|
|
297
|
+
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
298
|
+
// pre-step injection — the web composer's same fall-through.
|
|
299
|
+
agent.followup(createUserMessage({
|
|
300
|
+
content: [{ type: 'text', text: line }],
|
|
301
|
+
source: { kind: 'user' },
|
|
302
|
+
}))
|
|
303
|
+
}
|
|
304
|
+
}, (error: unknown) => {
|
|
305
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
310
|
+
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
288
311
|
const line = text.trim()
|
|
289
312
|
if (line === '') return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
313
|
+
// The command registry is a closed namespace: slash lines run out of
|
|
314
|
+
// band and never reach the model through this path (steering keeps the
|
|
315
|
+
// registry out of the inbox, so slash lines steer as literal text).
|
|
316
|
+
if (isSlashLine(line) && mode === 'followup') {
|
|
317
|
+
runSlash(line)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
let parsed: ReturnType<typeof mentions.parse>
|
|
321
|
+
try {
|
|
322
|
+
parsed = mentions.parse(line)
|
|
323
|
+
} catch (error: unknown) {
|
|
324
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
const deliver = (readable: string, context?: UserMessage): void => {
|
|
328
|
+
// Session snapshots ride the inbox as model-facing context ahead of
|
|
329
|
+
// the readable message (upstream README wiring: inject before the
|
|
330
|
+
// followup/steer that wakes the driver).
|
|
331
|
+
if (context !== undefined) agent.inject(context)
|
|
332
|
+
const message = createUserMessage({
|
|
333
|
+
content: [{ type: 'text', text: readable }],
|
|
334
|
+
source: { kind: 'user' },
|
|
309
335
|
})
|
|
336
|
+
if (mode === 'steer') {
|
|
337
|
+
agent.steer(message)
|
|
338
|
+
bridge.notify('steering queued — the next step sees it')
|
|
339
|
+
} else {
|
|
340
|
+
agent.followup(message)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (parsed.references.length === 0) {
|
|
344
|
+
deliver(parsed.text)
|
|
310
345
|
return
|
|
311
346
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
})
|
|
347
|
+
const controller = new AbortController()
|
|
348
|
+
void mentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
349
|
+
deliver(prepared.text, prepared.additionalContext)
|
|
350
|
+
}, (error: unknown) => {
|
|
351
|
+
if (controller.signal.aborted) return
|
|
352
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
357
|
+
const dispatch = (text: string): void => {
|
|
358
|
+
send(text, 'followup')
|
|
316
359
|
}
|
|
317
360
|
|
|
318
361
|
/**
|
|
@@ -321,13 +364,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
321
364
|
* a turn, so this doubles as the busy-state submit path.
|
|
322
365
|
*/
|
|
323
366
|
const steer = (text: string): void => {
|
|
324
|
-
|
|
325
|
-
if (line === '') return
|
|
326
|
-
agent.steer(createUserMessage({
|
|
327
|
-
content: [{ type: 'text', text: line }],
|
|
328
|
-
source: { kind: 'user' },
|
|
329
|
-
}))
|
|
330
|
-
bridge.notify('steering queued — the next step sees it')
|
|
367
|
+
send(text, 'steer')
|
|
331
368
|
}
|
|
332
369
|
|
|
333
370
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
@@ -338,6 +375,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
338
375
|
return true
|
|
339
376
|
}
|
|
340
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Cycle to the next permission preset (Shift+Tab, the Claude-Code
|
|
380
|
+
* permission-mode convention mapped onto dsh presets). A session in a
|
|
381
|
+
* custom knob state wraps to the first declared preset.
|
|
382
|
+
*/
|
|
383
|
+
const cyclePermission = (): string => {
|
|
384
|
+
const service = ctx.get('permissionPresets') as
|
|
385
|
+
| {
|
|
386
|
+
names: readonly string[]
|
|
387
|
+
current(events: readonly SessionEvent[]): string
|
|
388
|
+
set(target: Session, preset: string): void
|
|
389
|
+
}
|
|
390
|
+
| undefined
|
|
391
|
+
if (service === undefined || service.names.length === 0) {
|
|
392
|
+
bridge.notify('permission presets are not mounted in this composition')
|
|
393
|
+
return ''
|
|
394
|
+
}
|
|
395
|
+
const at = service.names.indexOf(service.current(session.events))
|
|
396
|
+
const next = service.names[(at + 1) % service.names.length] ?? ''
|
|
397
|
+
if (next === '') return ''
|
|
398
|
+
service.set(session, next)
|
|
399
|
+
return next
|
|
400
|
+
}
|
|
401
|
+
|
|
341
402
|
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
342
403
|
const selectModel = (row: ModelRow): string => {
|
|
343
404
|
picked = { provider: row.provider, model: row.model }
|
|
@@ -351,6 +412,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
351
412
|
mountRef.current = io.mount(createElement(App, {
|
|
352
413
|
store,
|
|
353
414
|
approval,
|
|
415
|
+
questions,
|
|
354
416
|
commands,
|
|
355
417
|
skills,
|
|
356
418
|
model: initialModel,
|
|
@@ -363,6 +425,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
363
425
|
interrupt,
|
|
364
426
|
quit,
|
|
365
427
|
loadModels: () => loadModelDirectory(ctx),
|
|
428
|
+
loadMentions: mentions.candidates,
|
|
429
|
+
cyclePermission,
|
|
366
430
|
selectModel,
|
|
367
431
|
onBridgeReady: (instance: AppBridge) => {
|
|
368
432
|
bridge.notify = instance.notify
|
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/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
|
+
}
|