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/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
- 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
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
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
287
- const dispatch = (text: string): void => {
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
- 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)}`)
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
- agent.followup(createUserMessage({
313
- content: [{ type: 'text', text: line }],
314
- source: { kind: 'user' },
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
- 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')
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
@@ -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
+ }
@@ -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
+ }