dsh-code 0.2.0 → 0.4.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/src/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { randomUUID } from 'node:crypto'
14
14
  import { readFileSync } from 'node:fs'
15
+ import { writeFile as writeFileAsync } from 'node:fs/promises'
15
16
  import { basename, join } from 'node:path'
16
17
  import { createElement } from 'react'
17
18
  import type { Context } from '@deepseek-ai/cordis'
@@ -20,8 +21,10 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent'
20
21
  import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
21
22
  import type {} from '@deepseek-ai/dsh-agent-default-model'
22
23
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
23
- import { SessionId, type Session, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
24
+ import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
24
25
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
26
+ // Type-only: carries the ctx.sessionTitle service merge for /title.
27
+ import type {} from '@deepseek-ai/dsh-session-title'
25
28
  // Empty type imports carry the loader Context merge for the settlement await
26
29
  // and the cmdline Context merge for the appExit host value.
27
30
  import type {} from '@deepseek-ai/cordis-plugin-loader'
@@ -31,8 +34,12 @@ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
31
34
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
32
35
  import { internals, type TuiMount } from './internals.ts'
33
36
  import { loadModelDirectory, type ModelRow } from './models.ts'
37
+ import { createMentions, type MentionsApi } from './mentions.ts'
38
+ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
34
39
  import { createTranscriptStore } from './store.ts'
35
40
  import { watchSkills, type SkillsView } from './skills.ts'
41
+ import { toolArgumentsPreview } from './render/tool-preview.ts'
42
+ import { buildExportMarkdown } from './render/export.ts'
36
43
  import type { TuiStartup } from './startup.ts'
37
44
 
38
45
  /** Stable Cordis plugin name. */
@@ -136,19 +143,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
136
143
  candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
137
144
  if (entry === undefined) return ''
138
145
  const args = (entry as { arguments?: string }).arguments ?? ''
139
- try {
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
146
+ return toolArgumentsPreview(args, toolName)
152
147
  }
153
148
 
154
149
  /** The runner's connection between the React app and the process side. */
@@ -261,6 +256,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
261
256
  request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
262
257
  )
263
258
 
259
+ // ask_user_question provider: the single UI provider on the shared service,
260
+ // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
261
+ // through this same pipe.
262
+ const questions: QuestionStore = mountQuestionProvider(ctx)
263
+
264
+ // @mention support: workspace file scan plus the opt-in session-reference
265
+ // service (the patch mounts it); submission expands session mentions.
266
+ const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
267
+
264
268
  // The bridge the React app registers on mount: local notices from the
265
269
  // process side (unknown commands, switch confirmations, cancels).
266
270
  const bridge: AppBridge = { notify: () => {} }
@@ -283,36 +287,79 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
283
287
  .then(() => { io.exit(0) })
284
288
  }
285
289
 
286
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
287
- const dispatch = (text: string): void => {
290
+ /** Run one slash line through the command registry (closed namespace). */
291
+ const runSlash = (line: string): void => {
292
+ const registry = ctx.get('commands')
293
+ if (registry === undefined) {
294
+ bridge.notify('no command registry is mounted in this composition')
295
+ return
296
+ }
297
+ const controller = new AbortController()
298
+ void registry.execute(agent, line, controller.signal).then((execution) => {
299
+ if (execution === undefined) {
300
+ // No command owns this line: send it verbatim so a user-invocable
301
+ // skill gesture (`/skill-name`) reaches the host's tool-skill
302
+ // pre-step injection — the web composer's same fall-through.
303
+ agent.followup(createUserMessage({
304
+ content: [{ type: 'text', text: line }],
305
+ source: { kind: 'user' },
306
+ }))
307
+ }
308
+ }, (error: unknown) => {
309
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`)
310
+ })
311
+ }
312
+
313
+ /** Deliver one readable line to the agent, expanding session mentions first. */
314
+ const send = (text: string, mode: 'followup' | 'steer'): void => {
288
315
  const line = text.trim()
289
316
  if (line === '') return
290
- if (isSlashLine(line)) {
291
- const registry = ctx.get('commands')
292
- if (registry === undefined) {
293
- bridge.notify('no command registry is mounted in this composition')
294
- return
295
- }
296
- const controller = new AbortController()
297
- void registry.execute(agent, line, controller.signal).then((execution) => {
298
- if (execution === undefined) {
299
- // No command owns this line: send it verbatim so a user-invocable
300
- // skill gesture (`/skill-name`) reaches the host's tool-skill
301
- // pre-step injection the web composer's same fall-through.
302
- agent.followup(createUserMessage({
303
- content: [{ type: 'text', text: line }],
304
- source: { kind: 'user' },
305
- }))
306
- }
307
- }, (error: unknown) => {
308
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`)
317
+ // The command registry is a closed namespace: slash lines run out of
318
+ // band and never reach the model through this path (steering keeps the
319
+ // registry out of the inbox, so slash lines steer as literal text).
320
+ if (isSlashLine(line) && mode === 'followup') {
321
+ runSlash(line)
322
+ return
323
+ }
324
+ let parsed: ReturnType<typeof mentions.parse>
325
+ try {
326
+ parsed = mentions.parse(line)
327
+ } catch (error: unknown) {
328
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`)
329
+ return
330
+ }
331
+ const deliver = (readable: string, context?: UserMessage): void => {
332
+ // Session snapshots ride the inbox as model-facing context ahead of
333
+ // the readable message (upstream README wiring: inject before the
334
+ // followup/steer that wakes the driver).
335
+ if (context !== undefined) agent.inject(context)
336
+ const message = createUserMessage({
337
+ content: [{ type: 'text', text: readable }],
338
+ source: { kind: 'user' },
309
339
  })
340
+ if (mode === 'steer') {
341
+ agent.steer(message)
342
+ bridge.notify('steering queued — the next step sees it')
343
+ } else {
344
+ agent.followup(message)
345
+ }
346
+ }
347
+ if (parsed.references.length === 0) {
348
+ deliver(parsed.text)
310
349
  return
311
350
  }
312
- agent.followup(createUserMessage({
313
- content: [{ type: 'text', text: line }],
314
- source: { kind: 'user' },
315
- }))
351
+ const controller = new AbortController()
352
+ void mentions.prepare(parsed, controller.signal).then((prepared) => {
353
+ deliver(prepared.text, prepared.additionalContext)
354
+ }, (error: unknown) => {
355
+ if (controller.signal.aborted) return
356
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`)
357
+ })
358
+ }
359
+
360
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
361
+ const dispatch = (text: string): void => {
362
+ send(text, 'followup')
316
363
  }
317
364
 
318
365
  /**
@@ -321,13 +368,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
321
368
  * a turn, so this doubles as the busy-state submit path.
322
369
  */
323
370
  const steer = (text: string): void => {
324
- const line = text.trim()
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')
371
+ send(text, 'steer')
331
372
  }
332
373
 
333
374
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
@@ -338,12 +379,76 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
338
379
  return true
339
380
  }
340
381
 
382
+ /**
383
+ * Cycle to the next permission preset (Shift+Tab, the Claude-Code
384
+ * permission-mode convention mapped onto dsh presets). A session in a
385
+ * custom knob state wraps to the first declared preset.
386
+ */
387
+ const cyclePermission = (): string => {
388
+ const service = ctx.get('permissionPresets') as
389
+ | {
390
+ names: readonly string[]
391
+ current(events: readonly SessionEvent[]): string
392
+ set(target: Session, preset: string): void
393
+ }
394
+ | undefined
395
+ if (service === undefined || service.names.length === 0) {
396
+ bridge.notify('permission presets are not mounted in this composition')
397
+ return ''
398
+ }
399
+ const at = service.names.indexOf(service.current(session.events))
400
+ const next = service.names[(at + 1) % service.names.length] ?? ''
401
+ if (next === '') return ''
402
+ service.set(session, next)
403
+ return next
404
+ }
405
+
341
406
  /** Apply one /model selection: takes effect from the next assembled step. */
342
407
  const selectModel = (row: ModelRow): string => {
343
408
  picked = { provider: row.provider, model: row.model }
344
409
  return `${row.provider}/${row.model}`
345
410
  }
346
411
 
412
+ /**
413
+ * Export the folded transcript to a markdown file (/export). The default
414
+ * target sits beside the session's cwd so the file lands in the user's
415
+ * workspace; an absolute or cwd-relative argument overrides it.
416
+ */
417
+ const exportTranscript = async (argument: string): Promise<void> => {
418
+ const wanted = argument.trim()
419
+ const defaultName = `dsh-session-${session.id.slice(-8)}.md`
420
+ const target = wanted === ''
421
+ ? join(cwd, defaultName)
422
+ : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
423
+ ? wanted
424
+ : join(cwd, wanted)
425
+ const markdown = buildExportMarkdown(store.getView(), session.id)
426
+ try {
427
+ await writeFileAsync(target, `${markdown}\n`, 'utf8')
428
+ bridge.notify(`exported to ${target}`)
429
+ } catch (error: unknown) {
430
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`)
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Rename the session (/title): a user title pins the session and stops
436
+ * automatic generation (the service's own contract). The appended
437
+ * `session/title` event flows back through the store into the status line.
438
+ */
439
+ const renameTitle = (argument: string): string => {
440
+ const title = argument.trim()
441
+ if (title === '') return 'usage: /title <text>'
442
+ const service = ctx.get('sessionTitle')
443
+ if (service === undefined) return 'session titles are unavailable in this profile'
444
+ try {
445
+ service.rename(session, title)
446
+ return `title → ${title}`
447
+ } catch (error: unknown) {
448
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`
449
+ }
450
+ }
451
+
347
452
  const initialModel = store.getView().model !== ''
348
453
  ? store.getView().model
349
454
  : `${defaults.provider}/${defaults.model}`
@@ -351,6 +456,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
351
456
  mountRef.current = io.mount(createElement(App, {
352
457
  store,
353
458
  approval,
459
+ questions,
354
460
  commands,
355
461
  skills,
356
462
  model: initialModel,
@@ -363,7 +469,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
363
469
  interrupt,
364
470
  quit,
365
471
  loadModels: () => loadModelDirectory(ctx),
472
+ loadMentions: mentions.candidates,
473
+ cyclePermission,
366
474
  selectModel,
475
+ exportTranscript,
476
+ renameTitle,
367
477
  onBridgeReady: (instance: AppBridge) => {
368
478
  bridge.notify = instance.notify
369
479
  },
@@ -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
+ }
Binary file
@@ -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
+ }