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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The terminal approval answerer: one `approval/request` waterfall listener
3
+ * that renders the pending question as a y/n bar and resolves the decision
4
+ * back into the waterfall. Mirrors the web host's composer takeover — the
5
+ * service (audit pair, policy gate, fail-closed defaults) all live in
6
+ * dsh-base; this module only answers for agents this TUI owns.
7
+ *
8
+ * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
+ * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
+ * `'unavailable'` to the fail-closed waterfall default.
11
+ *
12
+ * @module @deepseek-ai/dsh-code/approval
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
+
19
+ /** The answer values a client answerer may resolve with. */
20
+ export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
+
22
+ /** One pending approval question, derived from the request for rendering. */
23
+ export interface PendingApproval {
24
+ /** The asker's human-readable explanation, or a generic fallback. */
25
+ headline: string
26
+ /** The tool the question is about. */
27
+ toolName: string
28
+ /** Command-line preview resolved from the paired streaming tool call. */
29
+ command: string
30
+ /** Resolve the ask; calling twice is inert (one-shot latch). */
31
+ answer(outcome: ApprovalAnswer): void
32
+ }
33
+
34
+ /** The pending-question snapshot the renderer subscribes to. */
35
+ export interface ApprovalSnapshot {
36
+ /** The pending question, or undefined when none is being asked. */
37
+ pending: PendingApproval | undefined
38
+ /** Presentational: an answer was submitted, the ask has not settled yet. */
39
+ answered: boolean
40
+ }
41
+
42
+ /** Store the pending question lands in; the renderer reads, the answerer writes. */
43
+ export interface ApprovalStore {
44
+ /** Subscribe to pending-state changes; returns the unsubscribe function. */
45
+ subscribe(listener: () => void): () => void
46
+ /** Read the current snapshot (identity-stable between changes). */
47
+ getSnapshot(): ApprovalSnapshot
48
+ }
49
+
50
+ /**
51
+ * Create the approval store and mount the answerer listener on the context.
52
+ * The listener claims only requests for `owns`-owned agents and defers every
53
+ * other request back into the waterfall (`next()`), so sibling answerers stay
54
+ * usable. An aborted ask never reaches the human. Plugin teardown removes the
55
+ * listener; the service then fails its own question closed.
56
+ * @param ctx - plugin context whose event bus carries `approval/request`.
57
+ * @param owns - agents this terminal answers for.
58
+ * @param preview - resolves a tool-call preview for a pending request (the
59
+ * request contract carries no arguments; the UI self-serves from the
60
+ * transcript projection via `callId`).
61
+ * @returns the store the renderer subscribes to.
62
+ */
63
+ export function mountApprovalAnswerer(
64
+ ctx: Context,
65
+ owns: (agent: Agent) => boolean,
66
+ preview: (request: ApprovalRequest) => string,
67
+ ): ApprovalStore {
68
+ let snapshot: ApprovalSnapshot = { pending: undefined, answered: false }
69
+ const listeners = new Set<() => void>()
70
+ const set = (next: ApprovalSnapshot): void => {
71
+ snapshot = next
72
+ for (const listener of listeners) listener()
73
+ }
74
+
75
+ ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
76
+ if (!owns(request.agent)) return next()
77
+ // An already-aborted ask never reaches the human (mirrors the host bridge).
78
+ if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
79
+
80
+ let resolved = false
81
+ let settle!: (outcome: ApprovalOutcome) => void
82
+ const withdraw = (): void => {
83
+ if (resolved) return
84
+ resolved = true
85
+ set({ pending: undefined, answered: false })
86
+ // The service's signal race would conclude 'cancelled' anyway; settle
87
+ // the same way so this listener never dangles a pending promise.
88
+ settle('cancelled')
89
+ }
90
+ if (request.signal !== undefined) {
91
+ request.signal.addEventListener('abort', withdraw, { once: true })
92
+ }
93
+ const pending: PendingApproval = {
94
+ headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
95
+ toolName: request.toolName,
96
+ command: preview(request),
97
+ answer: (outcome: ApprovalAnswer): void => {
98
+ // One-shot latch: a second keypress after submission is inert.
99
+ if (resolved) return
100
+ resolved = true
101
+ set({ pending, answered: true })
102
+ settle(outcome)
103
+ },
104
+ }
105
+ set({ pending, answered: false })
106
+
107
+ return new Promise<ApprovalOutcome>((resolve) => {
108
+ settle = resolve
109
+ }).then((outcome) => {
110
+ if (outcome !== 'cancelled') set({ pending: undefined, answered: false })
111
+ return outcome
112
+ })
113
+ })
114
+
115
+ return {
116
+ subscribe(listener: () => void): () => void {
117
+ listeners.add(listener)
118
+ return () => {
119
+ listeners.delete(listener)
120
+ }
121
+ },
122
+ getSnapshot(): ApprovalSnapshot {
123
+ return snapshot
124
+ },
125
+ }
126
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Slash-command bridge: forwards terminal command lines into the shared
3
+ * `ctx.commands` registry (the same surface the web composer dispatches
4
+ * through) and exposes the live descriptor list as completion candidates.
5
+ * The runner keeps only its own TUI-local commands (`/help`, `/quit`,
6
+ * `/clear`, `/model`) ahead of the registry dispatch.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/commands
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { Agent } from '@deepseek-ai/dsh-agent'
13
+ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
14
+
15
+ /** Descriptor list snapshot the completion menu renders from. */
16
+ export interface CommandsView {
17
+ /** Name-sorted descriptors after scoped shadowing. */
18
+ readonly descriptors: readonly CommandDescriptor[]
19
+ /** Subscribe to list changes (`commands/change`); returns the unsubscribe function. */
20
+ subscribe(listener: () => void): () => void
21
+ /** Retarget the agent whose scoped view the list is read through. */
22
+ setAgent(agent: Agent): void
23
+ }
24
+
25
+ /**
26
+ * Watch the live command registry. Reads the current list immediately and
27
+ * re-reads on every registry mutation or agent retarget; notification
28
+ * failures are contained by the registry itself, so this watcher only ever
29
+ * re-reads. Without a `commands` service the view stays empty and all lines
30
+ * fall through to normal prompts.
31
+ * @param ctx - context carrying the `commands` service (optional).
32
+ * @returns the view the completion menu subscribes to.
33
+ */
34
+ export function watchCommands(ctx: Context): CommandsView {
35
+ const commands = ctx.get('commands')
36
+ let agent: Agent | undefined
37
+ let descriptors: readonly CommandDescriptor[] = []
38
+ const listeners = new Set<() => void>()
39
+ const refresh = (): void => {
40
+ if (commands === undefined || agent === undefined) return
41
+ descriptors = commands.list(agent)
42
+ for (const listener of listeners) listener()
43
+ }
44
+ if (commands !== undefined) {
45
+ ctx.on('commands/change', () => refresh())
46
+ }
47
+ return {
48
+ get descriptors(): readonly CommandDescriptor[] {
49
+ return descriptors
50
+ },
51
+ subscribe(listener: () => void): () => void {
52
+ listeners.add(listener)
53
+ return () => {
54
+ listeners.delete(listener)
55
+ }
56
+ },
57
+ setAgent(next: Agent): void {
58
+ agent = next
59
+ refresh()
60
+ },
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Whether one command line is a syntactically valid slash command.
66
+ * @param line - the complete candidate line.
67
+ * @returns true when the line parses as `/name` or `/name input`.
68
+ */
69
+ export function isSlashLine(line: string): boolean {
70
+ return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line)
71
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
- * @deepseek-ai/dsh-tui — the interactive terminal driver. The bundle patch
2
+ * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3
3
  * rides over dsh-base without Host, HTTP, or browser plugins; this runner
4
- * creates one Agent through the core registry, mounts the Ink app (DeepSeek
5
- * blue, whale wordmark), folds submitted prompts into the same durable
6
- * session, streams `session/event` into the transcript, and on quit flushes
7
- * and requests process exit.
4
+ * creates (or resumes) one Agent through the core registry, mounts the Ink
5
+ * app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
6
+ * durable session, answers approval asks with a y/n bar, dispatches slash
7
+ * commands through the shared registry, and on quit flushes and requests
8
+ * process exit.
8
9
  *
9
- * @module @deepseek-ai/dsh-tui
10
+ * @module @deepseek-ai/dsh-code
10
11
  */
11
12
 
12
13
  import { randomUUID } from 'node:crypto'
@@ -14,19 +15,25 @@ import { readFileSync } from 'node:fs'
14
15
  import { basename, join } from 'node:path'
15
16
  import { createElement } from 'react'
16
17
  import type { Context } from '@deepseek-ai/cordis'
18
+ import z from '@deepseek-ai/schemastery'
17
19
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
18
- import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent'
20
+ import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
19
21
  import type {} from '@deepseek-ai/dsh-agent-default-model'
20
22
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
- import { SessionId } from '@deepseek-ai/dsh-session'
22
- import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
23
+ import { SessionId, type Session, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
24
+ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
23
25
  // Empty type imports carry the loader Context merge for the settlement await
24
26
  // and the cmdline Context merge for the appExit host value.
25
27
  import type {} from '@deepseek-ai/cordis-plugin-loader'
26
28
  import type {} from '@deepseek-ai/dsh-cmdline'
27
29
  import { App } from './app.ts'
30
+ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
31
+ import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
28
32
  import { internals, type TuiMount } from './internals.ts'
33
+ import { loadModelDirectory, type ModelRow } from './models.ts'
29
34
  import { createTranscriptStore } from './store.ts'
35
+ import { watchSkills, type SkillsView } from './skills.ts'
36
+ import type { TuiStartup } from './startup.ts'
30
37
 
31
38
  /** Stable Cordis plugin name. */
32
39
  export const name = 'tui-runner'
@@ -34,6 +41,19 @@ export const name = 'tui-runner'
34
41
  /** Core services required before the interactive session can start. */
35
42
  export const inject = ['agentDefaultModel', 'agents', 'sessions']
36
43
 
44
+ /** Plugin config: the startup resolved from this app's injected provider service. */
45
+ export interface Config {
46
+ /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
47
+ startup: { kind: string; sessionId?: string }
48
+ }
49
+
50
+ export const Config: z<Config> = z.object({
51
+ startup: z.object({
52
+ kind: z.string().required(),
53
+ sessionId: z.string(),
54
+ }),
55
+ })
56
+
37
57
  /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
38
58
  interface TuiIo {
39
59
  mount: typeof internals.mount
@@ -62,40 +82,189 @@ function gitBranch(cwd: string): string {
62
82
  }
63
83
  }
64
84
 
85
+ /** The session identity this invocation will run, plus whether it is resumed. */
86
+ interface Target {
87
+ sessionId: string
88
+ resume: boolean
89
+ }
90
+
91
+ /**
92
+ * Resolve the invocation's target session against the persisted headers.
93
+ * @param startup - the parsed startup flags.
94
+ * @param persistence - the persistence service; required for resume/latest.
95
+ * @param cwd - the working directory `--continue` filters by.
96
+ * @returns the target identity.
97
+ * @throws with a user-facing message when the flags name nothing resolvable.
98
+ */
99
+ async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
100
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
101
+ if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false }
102
+ if (persistence === undefined) {
103
+ throw new Error('cannot resolve the requested session: session persistence is not configured')
104
+ }
105
+ const headers: readonly SessionHeader[] = await persistence.list()
106
+ if (startup.kind === 'resume') {
107
+ const wanted = startup.sessionId
108
+ const exact = headers.filter(header => header.id === wanted)
109
+ const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
110
+ if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
111
+ if (matches.length > 1) {
112
+ throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
113
+ }
114
+ return { sessionId: matches[0]!.id, resume: true }
115
+ }
116
+ // --continue: the newest persisted session whose header pins this cwd.
117
+ const local = headers
118
+ .filter(header => header.cwd === cwd)
119
+ .sort((left, right) => right.createdAt - left.createdAt)
120
+ if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
121
+ return { sessionId: local[0]!.id, resume: true }
122
+ }
123
+
124
+ /**
125
+ * Resolve a bounded command preview for one pending approval: the request
126
+ * contract carries no arguments, so the bar self-serves from the transcript
127
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
128
+ * @param events - the transcript entries to search.
129
+ * @param callId - the tool call the question is about, when the asker had one.
130
+ * @param toolName - the tool the question is about.
131
+ * @returns a bounded preview line, '' when nothing useful resolves.
132
+ */
133
+ function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
134
+ if (callId === undefined) return ''
135
+ const entry = events.find(candidate =>
136
+ candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
137
+ if (entry === undefined) return ''
138
+ 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
152
+ }
153
+
154
+ /** The runner's connection between the React app and the process side. */
155
+ interface AppBridge {
156
+ /** Post one local notice line (feedback the transcript does not carry). */
157
+ notify(text: string): void
158
+ }
159
+
65
160
  /**
66
- * Run the interactive terminal session: create one Agent, mount the app, and
67
- * keep the process alive until the user quits.
161
+ * Run the interactive terminal session: resolve the target session, create or
162
+ * resume one Agent, mount the app, and keep the process alive until the user
163
+ * quits.
68
164
  * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
165
+ * @param startup - the parsed invocation flags.
69
166
  * @param io - process-facing effects.
70
167
  */
71
- async function run(ctx: Context, io: TuiIo): Promise<void> {
168
+ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
72
169
  // Loader siblings mount concurrently. Await the complete application before
73
170
  // creating an Agent so its scoped tools and adapters are not half-composed.
74
171
  await ctx.get('loader')?.await()
75
172
  const agents = ctx.get('agents')
76
173
  const defaultModel = ctx.get('agentDefaultModel')
77
174
  const sessions = ctx.get('sessions')
175
+ const persistence = ctx.get('sessionPersistence')
78
176
  // Early process shutdown can dispose the tree while settlement is pending.
79
177
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
80
178
 
81
- const selection = defaultModel.currentSelection()
82
- // This bundle composes no preset roster, so the model-facing rows sit in the
83
- // host plane and the agent reads them from the global layer (mirrors dsh-headless).
84
- const { agent } = await agents.create({
85
- sessionId: SessionId(`session-${randomUUID()}`),
86
- meta: { cwd: process.cwd() },
87
- agentOptions: { provider: selection.provider, model: selection.model },
88
- setup: (agentCtx) => {
89
- const selected: ModelSelectionRef = { current: selection, assembled: undefined }
90
- installModelSelection(agentCtx, selected)
91
- },
92
- })
179
+ const cwd = process.cwd()
180
+ const target = await resolveTarget(startup, persistence, cwd)
181
+
182
+ const defaults = defaultModel.currentSelection()
183
+ let picked: ModelSelection | undefined
184
+ let session: Session
185
+ let agent: Agent
186
+ if (target.resume) {
187
+ const resumed = await agents.resume({
188
+ resumeSessionId: SessionId(target.sessionId),
189
+ agentOptions: { provider: defaults.provider, model: defaults.model },
190
+ setup: (agentCtx) => {
191
+ // The getter order mirrors the web host's resume selection: an
192
+ // in-process switch wins, then the session's own last logged request
193
+ // header, then the deployment default. Without this, a resumed
194
+ // session's first request would silently fall back to the default.
195
+ const selection: ModelSelectionRef = {
196
+ get current(): ModelSelection | undefined {
197
+ if (picked !== undefined) return picked
198
+ const logged = agentCtx.agent?.session.requestHeader()?.config
199
+ if (logged !== undefined) {
200
+ return {
201
+ provider: logged.provider,
202
+ model: logged.model,
203
+ ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
204
+ }
205
+ }
206
+ return defaults
207
+ },
208
+ set current(next: ModelSelection | undefined) {
209
+ picked = next
210
+ },
211
+ assembled: undefined,
212
+ }
213
+ installModelSelection(agentCtx, selection)
214
+ },
215
+ })
216
+ agent = resumed.agent
217
+ session = agent.session
218
+ } else {
219
+ const created = await agents.create({
220
+ sessionId: SessionId(target.sessionId),
221
+ meta: { cwd },
222
+ agentOptions: { provider: defaults.provider, model: defaults.model },
223
+ setup: (agentCtx) => {
224
+ // Same getter shape as resume, minus the logged-header arm (a fresh
225
+ // session has no request header yet).
226
+ const selection: ModelSelectionRef = {
227
+ get current(): ModelSelection | undefined {
228
+ return picked ?? defaults
229
+ },
230
+ set current(next: ModelSelection | undefined) {
231
+ picked = next
232
+ },
233
+ assembled: undefined,
234
+ }
235
+ installModelSelection(agentCtx, selection)
236
+ },
237
+ })
238
+ agent = created.agent
239
+ session = agent.session
240
+ }
93
241
 
94
- const store = createTranscriptStore()
95
- const off = ctx.on('session/event', (session: Session, event: SessionEvent) => {
96
- if (session.id === agent.session.id) store.apply(event)
242
+ // Seed the transcript from the full session log: constructor seeds never
243
+ // fire on `session/event`, so a resumed session paints its history once,
244
+ // here, before the first render.
245
+ const store = createTranscriptStore(session.events)
246
+ const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
247
+ if (subject.id === session.id) store.apply(event)
97
248
  })
98
249
 
250
+ const commands: CommandsView = watchCommands(ctx)
251
+ commands.setAgent(agent)
252
+
253
+ const skills: SkillsView = watchSkills(ctx)
254
+ skills.setAgent(agent)
255
+
256
+ // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
257
+ // claimed, every other ask falls through to the fail-closed waterfall.
258
+ const approval: ApprovalStore = mountApprovalAnswerer(
259
+ ctx,
260
+ candidate => candidate.id === agent.id,
261
+ request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
262
+ )
263
+
264
+ // The bridge the React app registers on mount: local notices from the
265
+ // process side (unknown commands, switch confirmations, cancels).
266
+ const bridge: AppBridge = { notify: () => {} }
267
+
99
268
  // The mount handle lives in a box: quit closes over it, while the mount
100
269
  // itself is created after quit (the App element needs quit as a prop).
101
270
  const mountRef: { current?: TuiMount } = {}
@@ -105,7 +274,7 @@ async function run(ctx: Context, io: TuiIo): Promise<void> {
105
274
  quitting = true
106
275
  off()
107
276
  mountRef.current?.unmount()
108
- void sessions.flush(agent.session)
277
+ void sessions.flush(session)
109
278
  .catch((flushError: unknown) => {
110
279
  // The session log already carries every durable event; a failed flush
111
280
  // must not trap the user in a dead terminal, so report and still exit.
@@ -114,27 +283,107 @@ async function run(ctx: Context, io: TuiIo): Promise<void> {
114
283
  .then(() => { io.exit(0) })
115
284
  }
116
285
 
286
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
287
+ const dispatch = (text: string): void => {
288
+ const line = text.trim()
289
+ 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)}`)
309
+ })
310
+ return
311
+ }
312
+ agent.followup(createUserMessage({
313
+ content: [{ type: 'text', text: line }],
314
+ source: { kind: 'user' },
315
+ }))
316
+ }
317
+
318
+ /**
319
+ * Submit steering: a running driver consumes the text at its next step
320
+ * boundary (the inbox delivers between steps); an idle driver just starts
321
+ * a turn, so this doubles as the busy-state submit path.
322
+ */
323
+ 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')
331
+ }
332
+
333
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
334
+ const interrupt = (): boolean => {
335
+ if (agent.status !== 'running') return false
336
+ agent.cancel({ kind: 'user' })
337
+ bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
338
+ return true
339
+ }
340
+
341
+ /** Apply one /model selection: takes effect from the next assembled step. */
342
+ const selectModel = (row: ModelRow): string => {
343
+ picked = { provider: row.provider, model: row.model }
344
+ return `${row.provider}/${row.model}`
345
+ }
346
+
347
+ const initialModel = store.getView().model !== ''
348
+ ? store.getView().model
349
+ : `${defaults.provider}/${defaults.model}`
350
+
117
351
  mountRef.current = io.mount(createElement(App, {
118
352
  store,
119
- model: `${selection.provider}/${selection.model}`,
120
- cwd: basename(process.cwd()),
121
- branch: gitBranch(process.cwd()),
122
- sessionId: agent.session.id.slice(-8),
123
- onSubmit: (text: string) => {
124
- agent.followup(createUserMessage({
125
- content: [{ type: 'text', text }],
126
- source: { kind: 'user' },
127
- }))
353
+ approval,
354
+ commands,
355
+ skills,
356
+ model: initialModel,
357
+ cwd: basename(cwd),
358
+ branch: gitBranch(cwd),
359
+ sessionId: session.id.slice(-8),
360
+ resumed: target.resume,
361
+ dispatch,
362
+ steer,
363
+ interrupt,
364
+ quit,
365
+ loadModels: () => loadModelDirectory(ctx),
366
+ selectModel,
367
+ onBridgeReady: (instance: AppBridge) => {
368
+ bridge.notify = instance.notify
128
369
  },
129
- onQuit: quit,
130
370
  }))
131
371
  }
132
372
 
133
373
  /**
134
374
  * Mount the interactive terminal driver.
135
375
  * @param ctx - plugin context carrying core services and the launcher-provided exit request.
376
+ * @param config - validated startup config resolved from the tuiStartup provider.
136
377
  */
137
- export function apply(ctx: Context): void {
378
+ export function apply(ctx: Context, config: Config): void {
379
+ const startup: TuiStartup =
380
+ config.startup.kind === 'resume' && config.startup.sessionId !== undefined
381
+ ? { kind: 'resume', sessionId: config.startup.sessionId }
382
+ : config.startup.kind === 'latest'
383
+ ? { kind: 'latest' }
384
+ : config.startup.kind === 'named' && config.startup.sessionId !== undefined
385
+ ? { kind: 'named', sessionId: config.startup.sessionId }
386
+ : { kind: 'fresh' }
138
387
  // Read through the global service store, not the property proxy: appExit is
139
388
  // an optional host value, never an injected dependency.
140
389
  const exit = ctx.get('appExit')
@@ -142,5 +391,5 @@ export function apply(ctx: Context): void {
142
391
  throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
143
392
  }
144
393
  const io: TuiIo = { mount: internals.mount, exit }
145
- void run(ctx, io).catch((error: unknown) => { fail(io, error) })
394
+ void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
146
395
  }
package/src/invariant.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
3
- * @module @deepseek-ai/dsh-tui/invariant
2
+ * Package-owned invariant companion for `dsh-code`.
3
+ * @module dsh-code/invariant
4
4
  */
5
5
 
6
6
  import type { Context } from '@deepseek-ai/cordis'
7
7
  import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
8
8
 
9
- const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
9
+ const PACKAGE_NAME = 'dsh-code'
10
10
 
11
11
  /** Cordis companion plugin name. */
12
12
  export const name = 'tui-invariant'