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/README.md +13 -2
- package/README.zh.md +11 -2
- package/cordis.patch.yml +6 -0
- package/lib/index.mjs +2793 -394
- package/lib/types/app.d.ts +12 -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/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +30 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +107 -2
- package/lib/types/render/status.d.ts +21 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/store.d.ts +2 -0
- package/lib/types/theme.d.ts +4 -0
- package/package.json +20 -2
- package/src/app.ts +1507 -149
- package/src/index.ts +156 -46
- package/src/mentions.ts +193 -0
- package/src/pictures/1.png +0 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +79 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +235 -0
- package/src/render/projection.ts +322 -18
- package/src/render/status.ts +62 -3
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/render/tool-preview.ts +34 -0
- package/src/store.ts +8 -0
- package/src/theme.ts +4 -0
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
|
-
|
|
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
|
-
/**
|
|
287
|
-
const
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
},
|
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
|
+
}
|
|
Binary file
|
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
|
+
}
|