dsh-code 0.1.0 → 0.2.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/models.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Model directory for the `/model` panel: the in-process equivalent of the
3
+ * web host's model catalog (`buildModelCatalog`), reading the advisory
4
+ * `ctx.llm` registry directly. Catalog membership is advisory — a route
5
+ * serving a model it stopped advertising stays usable — so selection never
6
+ * fails on catalog absence alone.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/models
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { LlmModelInfo } from '@deepseek-ai/dsh-llm'
13
+
14
+ /** One selectable row in the `/model` panel. */
15
+ export interface ModelRow {
16
+ /** Registered provider route. */
17
+ provider: string
18
+ /** Display name of the provider route. */
19
+ providerName: string
20
+ /** Provider-owned model id. */
21
+ model: string
22
+ /** Human-readable model name. */
23
+ modelName: string
24
+ }
25
+
26
+ /** The resolved directory: rows plus per-provider discovery failures. */
27
+ export interface ModelDirectory {
28
+ /** Advisory rows, provider-major in registry order. */
29
+ rows: readonly ModelRow[]
30
+ /** Provider ids whose model listing failed; those providers contribute no rows. */
31
+ failures: readonly string[]
32
+ }
33
+
34
+ /**
35
+ * Load the selectable model directory from the live `ctx.llm` registry.
36
+ * Providers are listed synchronously; each provider's models are discovered
37
+ * with a bounded parallel fan-out whose failures degrade to that provider
38
+ * contributing no rows (mirrors the web catalog's per-provider failures).
39
+ * @param ctx - context carrying the `llm` service.
40
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
41
+ */
42
+ export async function loadModelDirectory(ctx: Context): Promise<ModelDirectory> {
43
+ const llm = ctx.get('llm')
44
+ if (llm === undefined) return { rows: [], failures: [] }
45
+ const providers = llm.listProviders()
46
+ const listed = await Promise.all(providers.map(async (provider) => {
47
+ try {
48
+ const models: readonly LlmModelInfo[] = await llm.listModels(provider.id)
49
+ return {
50
+ provider: provider.id,
51
+ providerName: provider.name,
52
+ models: models.map(model => ({
53
+ provider: provider.id,
54
+ providerName: provider.name,
55
+ model: model.id,
56
+ modelName: model.name,
57
+ })),
58
+ }
59
+ } catch {
60
+ return { provider: provider.id, providerName: provider.name, models: [] as ModelRow[], failed: true }
61
+ }
62
+ }))
63
+ const rows = listed.flatMap(entry => entry.models)
64
+ const failures = listed.filter(entry => 'failed' in entry && entry.failed === true).map(entry => entry.provider)
65
+ return { rows, failures }
66
+ }
@@ -8,7 +8,10 @@
8
8
  */
9
9
 
10
10
  import { boundContextSummary, type ContentBlock } from '@deepseek-ai/dsh-llm'
11
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
11
+ import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
+ // Type-only imports merge the plugin-owned SessionEventMap variants (command/*
13
+ // from dsh-commands) into the union this reducer switches on.
14
+ import type {} from '@deepseek-ai/dsh-commands'
12
15
 
13
16
  /** One user prompt line. */
14
17
  export interface UserEntry {
@@ -39,6 +42,21 @@ export interface ToolEntry {
39
42
  summary: string
40
43
  }
41
44
 
45
+ /** One slash-command execution dispatched through `ctx.commands`. */
46
+ export interface CommandEntry {
47
+ kind: 'command'
48
+ /** Pairing id shared with the matching `command/done`. */
49
+ commandId: string
50
+ /** Lowercase command name without the leading slash. */
51
+ name: string
52
+ /** Verbatim text following the command name. */
53
+ args: string
54
+ /** Execution state; `running` until the paired lifecycle event lands. */
55
+ state: 'running' | 'done' | 'error'
56
+ /** Handler outcome text, empty until it lands. */
57
+ summary: string
58
+ }
59
+
42
60
  /** One turn-level failure surfaced from `turn/end`. */
43
61
  export interface ErrorEntry {
44
62
  kind: 'error'
@@ -47,7 +65,7 @@ export interface ErrorEntry {
47
65
  }
48
66
 
49
67
  /** Ordered transcript items the renderer draws. */
50
- export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | ErrorEntry
68
+ export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry
51
69
 
52
70
  /** Cumulative token accounting folded from `assistant/message` usage reports. */
53
71
  export interface UsageTotals {
@@ -80,11 +98,18 @@ export interface TranscriptView {
80
98
  /** Text accumulated from `assistant/chunk` deltas since the last flush. */
81
99
  streaming: string
82
100
  /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
83
- todos: SessionEvent<'todo/write'>['data']['todos']
101
+ todos: readonly TodoItem[]
84
102
  /** True while a durable turn is open (`turn/start` … `turn/end`). */
85
103
  busy: boolean
86
104
  /** Figures the status line renders. */
87
105
  stats: TranscriptStats
106
+ /**
107
+ * The `provider/model` pair of the last `request/header` snapshot — the
108
+ * session's own model record, which a resumed TUI prefers over the
109
+ * deployment default (mirrors the web host's resume selection order).
110
+ * Empty before the session's first request.
111
+ */
112
+ model: string
88
113
  /**
89
114
  * Fold-internal timing anchors, never rendered: open step and tool-call
90
115
  * start timestamps the next `assistant/message` / `tool/result` resolves
@@ -105,6 +130,7 @@ export function createTranscriptView(): TranscriptView {
105
130
  streaming: '',
106
131
  todos: [],
107
132
  busy: false,
133
+ model: '',
108
134
  stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
109
135
  anchors: { stepStart: new Map(), toolStart: new Map() },
110
136
  }
@@ -194,7 +220,15 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
194
220
  case 'todo/write':
195
221
  return { ...view, todos: event.data.todos }
196
222
  case 'turn/start':
197
- return { ...view, busy: true, stats: { ...view.stats, turns: view.stats.turns + 1 } }
223
+ // The web todo projection clears on turn/start: a fresh turn's first
224
+ // write is the authoritative list, and a stale snapshot must not linger
225
+ // through a turn that has not written one yet.
226
+ return {
227
+ ...view,
228
+ busy: true,
229
+ todos: [],
230
+ stats: { ...view.stats, turns: view.stats.turns + 1 },
231
+ }
198
232
  case 'step/start':
199
233
  view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time)
200
234
  return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
@@ -207,6 +241,38 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
207
241
  entries: [...view.entries, { kind: 'error', text: `${reason.error.code}: ${reason.error.message}` }],
208
242
  }
209
243
  }
244
+ case 'request/header': {
245
+ // The session's own model record: the latest snapshot's provider/model
246
+ // pair, exactly what a resumed TUI restores as the selection.
247
+ const config = event.data.header.config
248
+ return { ...view, model: `${config.provider}/${config.model}` }
249
+ }
250
+ case 'command/run': {
251
+ const data = event.data
252
+ return {
253
+ ...view,
254
+ entries: [...view.entries, {
255
+ kind: 'command',
256
+ commandId: data.commandId,
257
+ name: data.name,
258
+ args: data.args ?? '',
259
+ state: 'running',
260
+ summary: '',
261
+ }],
262
+ }
263
+ }
264
+ case 'command/done': {
265
+ const data = event.data
266
+ const entries = view.entries.map((entry) => {
267
+ if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
268
+ return {
269
+ ...entry,
270
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
271
+ summary: boundContextSummary(data.text ?? ''),
272
+ }
273
+ })
274
+ return { ...view, entries }
275
+ }
210
276
  default:
211
277
  return view
212
278
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Display-boundary sanitization for externally sourced text (model output,
3
+ * tool payloads, skill descriptions). Control characters — including ANSI
4
+ * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
+ * terminal, letting output rewrite the screen or inject prompts. Newlines
6
+ * and tabs survive; everything else in C0/C1 plus DEL becomes a visible
7
+ * `\xNN` escape.
8
+ *
9
+ * @module @deepseek-ai/dsh-code/render/text
10
+ */
11
+
12
+ /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
13
+ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
14
+
15
+ /**
16
+ * Escape control characters so externally sourced text cannot drive the
17
+ * terminal.
18
+ * @param text - raw text from a session event, tool payload, or catalog.
19
+ * @returns text with every control character (except `\n`, `\t`) rendered
20
+ * as a literal `\xNN` escape.
21
+ */
22
+ export function displayText(text: string): string {
23
+ return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
24
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * User-invocable skill watch for the `/` completion menu: the in-process
3
+ * equivalent of the web ui-skill trigger source. Skills are NOT commands —
4
+ * picking one lands the literal `/name ` text in the input, and submitting
5
+ * it as a normal prompt lets the host's tool-skill pre-step inject the body
6
+ * (the only entry point for model-disabled skills). Command descriptors win
7
+ * on a name collision; see the runner's dispatch.
8
+ *
9
+ * @module @deepseek-ai/dsh-code/skills
10
+ */
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type { Agent } from '@deepseek-ai/dsh-agent'
14
+ import { isUserInvocable } from '@deepseek-ai/dsh-skill'
15
+ import type { SkillSummary } from '@deepseek-ai/dsh-skill'
16
+
17
+ /** One completion-menu row derived from a user-invocable skill. */
18
+ export interface SkillRow {
19
+ /** Skill name; the literal `/name` text is what a pick lands. */
20
+ name: string
21
+ /** Human-readable description (suffixed when model-invocation is off). */
22
+ description: string
23
+ /** Whether the model may also invoke this skill by name. */
24
+ modelInvocable: boolean
25
+ }
26
+
27
+ /** The skill-catalog snapshot the completion menu subscribes to. */
28
+ export interface SkillsView {
29
+ /** Name-sorted user-invocable rows; empty until the first load lands. */
30
+ readonly rows: readonly SkillRow[]
31
+ /** Subscribe to catalog changes; returns the unsubscribe function. */
32
+ subscribe(listener: () => void): () => void
33
+ /** Retarget the agent whose workspace the catalog is read for. */
34
+ setAgent(agent: Agent): void
35
+ }
36
+
37
+ /** Internal shape shared by {@link watchSkills} and its test doubles. */
38
+ interface SkillsWatch extends SkillsView {
39
+ setAgent(agent: Agent): void
40
+ }
41
+
42
+ function toRows(skills: readonly SkillSummary[]): readonly SkillRow[] {
43
+ return skills
44
+ .filter(skill => isUserInvocable(skill))
45
+ .map(skill => ({
46
+ name: skill.name,
47
+ description: skill.description,
48
+ modelInvocable: skill.invocation.modelInvocable === true,
49
+ }))
50
+ .sort((left, right) => left.name < right.name ? -1 : 1)
51
+ }
52
+
53
+ /**
54
+ * Watch the user-invocable skill catalog for one agent's workspace. The first
55
+ * load starts when the owning agent is known (`setAgent`); `skills/change`
56
+ * and agent retargets re-read. Read failures keep the last good rows (the
57
+ * next change notification is the retry surface) — a missing `skills`
58
+ * service leaves the view permanently empty.
59
+ * @param ctx - context carrying the `skills` service (optional).
60
+ * @returns the view the completion menu subscribes to.
61
+ */
62
+ export function watchSkills(ctx: Context): SkillsWatch {
63
+ const skills = ctx.get('skills')
64
+ let agent: Agent | undefined
65
+ let rows: readonly SkillRow[] = []
66
+ const listeners = new Set<() => void>()
67
+
68
+ const reload = (): void => {
69
+ if (skills === undefined || agent === undefined) return
70
+ skills.list({
71
+ cwd: agent.session.header.cwd,
72
+ scope: agent,
73
+ }).then((summaries: readonly SkillSummary[]) => {
74
+ const next = toRows(summaries)
75
+ if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return
76
+ rows = next
77
+ for (const listener of listeners) listener()
78
+ }, () => {
79
+ // Discovery failure keeps the last good rows; the next skills/change
80
+ // notification is the retry surface (mirrors the web directory).
81
+ })
82
+ }
83
+
84
+ if (skills !== undefined) {
85
+ ctx.on('skills/change', reload)
86
+ }
87
+
88
+ const view: SkillsWatch = {
89
+ get rows(): readonly SkillRow[] {
90
+ return rows
91
+ },
92
+ subscribe(listener: () => void): () => void {
93
+ listeners.add(listener)
94
+ return () => {
95
+ listeners.delete(listener)
96
+ }
97
+ },
98
+ setAgent(next: Agent): void {
99
+ agent = next
100
+ reload()
101
+ },
102
+ }
103
+ return view
104
+ }
package/src/startup.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The interactive terminal app's command-line provider: parses `--resume`,
3
+ * `--continue`, `--session`, and `--help`, then publishes
4
+ * {@link TUI_STARTUP_SERVICE} for the runner to consume lazily. Follows the
5
+ * headless bundle's startup shape (a commander action publishing a service
6
+ * through {@link parseCmdline}).
7
+ *
8
+ * Semantics:
9
+ * - `--resume <id|prefix>` — continue the persisted session whose id or unique
10
+ * id-prefix matches; the TUI replays its transcript and appends to the same
11
+ * durable log.
12
+ * - `--continue` / `-c` — resume the most recently modified persisted session
13
+ * whose project directory matches the current working directory.
14
+ * - `--session <id>` — create a new session under an explicit identity (the
15
+ * id must not exist yet).
16
+ * - no flags — a fresh session with a minted id.
17
+ *
18
+ * @module @deepseek-ai/dsh-tui/startup
19
+ */
20
+
21
+ import { Command } from 'commander'
22
+ import type { Context } from '@deepseek-ai/cordis'
23
+ import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
24
+
25
+ /** Stable Cordis plugin name. */
26
+ export const name = 'tui-startup'
27
+
28
+ /** Services required before the invocation can be resolved. */
29
+ export const inject = ['cmdlineArgs']
30
+
31
+ /** Service provided by this plugin and injected by the terminal runner. */
32
+ export const TUI_STARTUP_SERVICE = 'tuiStartup'
33
+
34
+ /** How the runner obtains its session identity. */
35
+ export type TuiStartup =
36
+ | { readonly kind: 'fresh' }
37
+ | { readonly kind: 'named'; readonly sessionId: string }
38
+ | { readonly kind: 'resume'; readonly sessionId: string }
39
+ | { readonly kind: 'latest' }
40
+
41
+ /**
42
+ * This app's command: the launcher's flags this app owns, its description,
43
+ * and its help text.
44
+ * @returns a fresh program, so one process can parse more than once (tests).
45
+ */
46
+ function tuiCommand(): Command {
47
+ return new Command()
48
+ .name('dsh --profile cli')
49
+ .description('Claude-Code-style interactive terminal for DeepSeek Harness.')
50
+ .helpOption('-h, --help', 'show this help')
51
+ .option('-r, --resume <session>', 'resume the persisted session with this id (or unique id prefix)')
52
+ .option('-c, --continue', 'resume the most recent persisted session for this working directory')
53
+ .option('--session <id>', 'create a new session under this explicit id')
54
+ .addHelpText('after', `
55
+ Examples:
56
+ dsh --profile cli fresh session, minted id
57
+ dsh --profile cli --resume abc123 resume session by id prefix
58
+ dsh --profile cli --continue resume the latest local session
59
+ `)
60
+ }
61
+
62
+ /**
63
+ * Parse the invocation and publish the startup service. Mutual exclusions are
64
+ * usage errors rejected from the action before anything is provided.
65
+ * @param ctx - plugin context carrying the command line and exit request.
66
+ */
67
+ export function apply(ctx: Context): void {
68
+ const program = tuiCommand()
69
+ program.action(() => {
70
+ const options = program.opts<{ resume?: string; continue?: boolean; session?: string }>()
71
+ const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
72
+ if (selected.filter(Boolean).length > 1) {
73
+ program.error('error: --resume, --continue, and --session are mutually exclusive')
74
+ }
75
+ if (options.session !== undefined && options.session === '') {
76
+ program.error('error: --session needs an id')
77
+ }
78
+ if (options.resume !== undefined && options.resume === '') {
79
+ program.error('error: --resume needs a session id or id prefix')
80
+ }
81
+ const startup: TuiStartup = options.resume !== undefined
82
+ ? { kind: 'resume', sessionId: options.resume }
83
+ : options.continue === true
84
+ ? { kind: 'latest' }
85
+ : options.session !== undefined
86
+ ? { kind: 'named', sessionId: options.session }
87
+ : { kind: 'fresh' }
88
+ ctx.provide(TUI_STARTUP_SERVICE, { startup } satisfies { startup: TuiStartup })
89
+ })
90
+ parseCmdline(ctx, program)
91
+ }
package/src/store.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
11
- import { createTranscriptView, projectEvent, type TranscriptView } from './render/projection.ts'
11
+ import { createTranscriptView, projectEvent, projectEvents, type TranscriptView } from './render/projection.ts'
12
12
 
13
13
  /** The externally readable, event-fed transcript store for one session. */
14
14
  export interface TranscriptStore {
@@ -21,11 +21,17 @@ export interface TranscriptStore {
21
21
  }
22
22
 
23
23
  /**
24
- * Create one transcript store.
24
+ * Create one transcript store, optionally seeded with replayed history. The
25
+ * seed folds synchronously BEFORE the first render, so a resumed session
26
+ * paints its full transcript on mount (no live `session/event` fires for
27
+ * constructor seeds — the store's `session/event` feed only carries new
28
+ * appends).
29
+ * @param replay - persisted events in `seq` order (e.g. a resumed session's
30
+ * constructor seed); folded once and never re-notified.
25
31
  * @returns the store the runner feeds and the renderer subscribes to.
26
32
  */
27
- export function createTranscriptStore(): TranscriptStore {
28
- let view = createTranscriptView()
33
+ export function createTranscriptStore(replay?: readonly SessionEvent[]): TranscriptStore {
34
+ let view = replay === undefined ? createTranscriptView() : projectEvents(replay)
29
35
  const listeners = new Set<() => void>()
30
36
  return {
31
37
  getView: () => view,