dsh-code 0.1.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.
Files changed (43) hide show
  1. package/README.md +17 -4
  2. package/README.zh.md +17 -4
  3. package/cordis.patch.yml +22 -5
  4. package/lib/index.mjs +1888 -100
  5. package/lib/invariant.mjs +1 -1
  6. package/lib/startup.mjs +70 -0
  7. package/lib/types/app.d.ts +64 -9
  8. package/lib/types/approval.d.ts +57 -0
  9. package/lib/types/commands.d.ts +37 -0
  10. package/lib/types/index.d.ts +19 -7
  11. package/lib/types/invariant.d.ts +2 -2
  12. package/lib/types/mentions.d.ts +70 -0
  13. package/lib/types/models.d.ts +37 -0
  14. package/lib/types/questions.d.ts +48 -0
  15. package/lib/types/render/animations.d.ts +15 -0
  16. package/lib/types/render/markdown.d.ts +27 -0
  17. package/lib/types/render/projection.d.ts +37 -3
  18. package/lib/types/render/status.d.ts +4 -0
  19. package/lib/types/render/text.d.ts +18 -0
  20. package/lib/types/render/tool-preview.d.ts +15 -0
  21. package/lib/types/skills.d.ts +45 -0
  22. package/lib/types/startup.d.ts +44 -0
  23. package/lib/types/store.d.ts +8 -2
  24. package/lib/types/theme.d.ts +4 -0
  25. package/package.json +36 -3
  26. package/src/app.ts +971 -57
  27. package/src/approval.ts +126 -0
  28. package/src/commands.ts +71 -0
  29. package/src/index.ts +353 -40
  30. package/src/invariant.ts +3 -3
  31. package/src/mentions.ts +193 -0
  32. package/src/models.ts +66 -0
  33. package/src/questions.ts +143 -0
  34. package/src/render/animations.ts +22 -0
  35. package/src/render/markdown.ts +235 -0
  36. package/src/render/projection.ts +117 -10
  37. package/src/render/status.ts +14 -2
  38. package/src/render/text.ts +24 -0
  39. package/src/render/tool-preview.ts +34 -0
  40. package/src/skills.ts +104 -0
  41. package/src/startup.ts +91 -0
  42. package/src/store.ts +10 -4
  43. package/src/theme.ts +4 -0
@@ -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,28 @@ 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, type UserMessage } 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'
34
+ import { createMentions, type MentionsApi } from './mentions.ts'
35
+ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
29
36
  import { createTranscriptStore } from './store.ts'
37
+ import { watchSkills, type SkillsView } from './skills.ts'
38
+ import { toolArgumentsPreview } from './render/tool-preview.ts'
39
+ import type { TuiStartup } from './startup.ts'
30
40
 
31
41
  /** Stable Cordis plugin name. */
32
42
  export const name = 'tui-runner'
@@ -34,6 +44,19 @@ export const name = 'tui-runner'
34
44
  /** Core services required before the interactive session can start. */
35
45
  export const inject = ['agentDefaultModel', 'agents', 'sessions']
36
46
 
47
+ /** Plugin config: the startup resolved from this app's injected provider service. */
48
+ export interface Config {
49
+ /** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
50
+ startup: { kind: string; sessionId?: string }
51
+ }
52
+
53
+ export const Config: z<Config> = z.object({
54
+ startup: z.object({
55
+ kind: z.string().required(),
56
+ sessionId: z.string(),
57
+ }),
58
+ })
59
+
37
60
  /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
38
61
  interface TuiIo {
39
62
  mount: typeof internals.mount
@@ -62,40 +85,186 @@ function gitBranch(cwd: string): string {
62
85
  }
63
86
  }
64
87
 
88
+ /** The session identity this invocation will run, plus whether it is resumed. */
89
+ interface Target {
90
+ sessionId: string
91
+ resume: boolean
92
+ }
93
+
94
+ /**
95
+ * Resolve the invocation's target session against the persisted headers.
96
+ * @param startup - the parsed startup flags.
97
+ * @param persistence - the persistence service; required for resume/latest.
98
+ * @param cwd - the working directory `--continue` filters by.
99
+ * @returns the target identity.
100
+ * @throws with a user-facing message when the flags name nothing resolvable.
101
+ */
102
+ async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
103
+ if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
104
+ if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false }
105
+ if (persistence === undefined) {
106
+ throw new Error('cannot resolve the requested session: session persistence is not configured')
107
+ }
108
+ const headers: readonly SessionHeader[] = await persistence.list()
109
+ if (startup.kind === 'resume') {
110
+ const wanted = startup.sessionId
111
+ const exact = headers.filter(header => header.id === wanted)
112
+ const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
113
+ if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
114
+ if (matches.length > 1) {
115
+ throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
116
+ }
117
+ return { sessionId: matches[0]!.id, resume: true }
118
+ }
119
+ // --continue: the newest persisted session whose header pins this cwd.
120
+ const local = headers
121
+ .filter(header => header.cwd === cwd)
122
+ .sort((left, right) => right.createdAt - left.createdAt)
123
+ if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
124
+ return { sessionId: local[0]!.id, resume: true }
125
+ }
126
+
65
127
  /**
66
- * Run the interactive terminal session: create one Agent, mount the app, and
67
- * keep the process alive until the user quits.
128
+ * Resolve a bounded command preview for one pending approval: the request
129
+ * contract carries no arguments, so the bar self-serves from the transcript
130
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
131
+ * @param events - the transcript entries to search.
132
+ * @param callId - the tool call the question is about, when the asker had one.
133
+ * @param toolName - the tool the question is about.
134
+ * @returns a bounded preview line, '' when nothing useful resolves.
135
+ */
136
+ function approvalCommandPreview(events: readonly { kind: string }[], callId: string | undefined, toolName: string): string {
137
+ if (callId === undefined) return ''
138
+ const entry = events.find(candidate =>
139
+ candidate.kind === 'tool' && (candidate as { callId?: string }).callId === callId)
140
+ if (entry === undefined) return ''
141
+ const args = (entry as { arguments?: string }).arguments ?? ''
142
+ return toolArgumentsPreview(args, toolName)
143
+ }
144
+
145
+ /** The runner's connection between the React app and the process side. */
146
+ interface AppBridge {
147
+ /** Post one local notice line (feedback the transcript does not carry). */
148
+ notify(text: string): void
149
+ }
150
+
151
+ /**
152
+ * Run the interactive terminal session: resolve the target session, create or
153
+ * resume one Agent, mount the app, and keep the process alive until the user
154
+ * quits.
68
155
  * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
156
+ * @param startup - the parsed invocation flags.
69
157
  * @param io - process-facing effects.
70
158
  */
71
- async function run(ctx: Context, io: TuiIo): Promise<void> {
159
+ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void> {
72
160
  // Loader siblings mount concurrently. Await the complete application before
73
161
  // creating an Agent so its scoped tools and adapters are not half-composed.
74
162
  await ctx.get('loader')?.await()
75
163
  const agents = ctx.get('agents')
76
164
  const defaultModel = ctx.get('agentDefaultModel')
77
165
  const sessions = ctx.get('sessions')
166
+ const persistence = ctx.get('sessionPersistence')
78
167
  // Early process shutdown can dispose the tree while settlement is pending.
79
168
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
80
169
 
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
- })
170
+ const cwd = process.cwd()
171
+ const target = await resolveTarget(startup, persistence, cwd)
172
+
173
+ const defaults = defaultModel.currentSelection()
174
+ let picked: ModelSelection | undefined
175
+ let session: Session
176
+ let agent: Agent
177
+ if (target.resume) {
178
+ const resumed = await agents.resume({
179
+ resumeSessionId: SessionId(target.sessionId),
180
+ agentOptions: { provider: defaults.provider, model: defaults.model },
181
+ setup: (agentCtx) => {
182
+ // The getter order mirrors the web host's resume selection: an
183
+ // in-process switch wins, then the session's own last logged request
184
+ // header, then the deployment default. Without this, a resumed
185
+ // session's first request would silently fall back to the default.
186
+ const selection: ModelSelectionRef = {
187
+ get current(): ModelSelection | undefined {
188
+ if (picked !== undefined) return picked
189
+ const logged = agentCtx.agent?.session.requestHeader()?.config
190
+ if (logged !== undefined) {
191
+ return {
192
+ provider: logged.provider,
193
+ model: logged.model,
194
+ ...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
195
+ }
196
+ }
197
+ return defaults
198
+ },
199
+ set current(next: ModelSelection | undefined) {
200
+ picked = next
201
+ },
202
+ assembled: undefined,
203
+ }
204
+ installModelSelection(agentCtx, selection)
205
+ },
206
+ })
207
+ agent = resumed.agent
208
+ session = agent.session
209
+ } else {
210
+ const created = await agents.create({
211
+ sessionId: SessionId(target.sessionId),
212
+ meta: { cwd },
213
+ agentOptions: { provider: defaults.provider, model: defaults.model },
214
+ setup: (agentCtx) => {
215
+ // Same getter shape as resume, minus the logged-header arm (a fresh
216
+ // session has no request header yet).
217
+ const selection: ModelSelectionRef = {
218
+ get current(): ModelSelection | undefined {
219
+ return picked ?? defaults
220
+ },
221
+ set current(next: ModelSelection | undefined) {
222
+ picked = next
223
+ },
224
+ assembled: undefined,
225
+ }
226
+ installModelSelection(agentCtx, selection)
227
+ },
228
+ })
229
+ agent = created.agent
230
+ session = agent.session
231
+ }
93
232
 
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)
233
+ // Seed the transcript from the full session log: constructor seeds never
234
+ // fire on `session/event`, so a resumed session paints its history once,
235
+ // here, before the first render.
236
+ const store = createTranscriptStore(session.events)
237
+ const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
238
+ if (subject.id === session.id) store.apply(event)
97
239
  })
98
240
 
241
+ const commands: CommandsView = watchCommands(ctx)
242
+ commands.setAgent(agent)
243
+
244
+ const skills: SkillsView = watchSkills(ctx)
245
+ skills.setAgent(agent)
246
+
247
+ // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
248
+ // claimed, every other ask falls through to the fail-closed waterfall.
249
+ const approval: ApprovalStore = mountApprovalAnswerer(
250
+ ctx,
251
+ candidate => candidate.id === agent.id,
252
+ request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
253
+ )
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
+ // 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,171 @@ async function run(ctx: Context, io: TuiIo): Promise<void> {
114
283
  .then(() => { io.exit(0) })
115
284
  }
116
285
 
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 => {
311
+ const line = text.trim()
312
+ if (line === '') return
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' },
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)
345
+ return
346
+ }
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')
359
+ }
360
+
361
+ /**
362
+ * Submit steering: a running driver consumes the text at its next step
363
+ * boundary (the inbox delivers between steps); an idle driver just starts
364
+ * a turn, so this doubles as the busy-state submit path.
365
+ */
366
+ const steer = (text: string): void => {
367
+ send(text, 'steer')
368
+ }
369
+
370
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
371
+ const interrupt = (): boolean => {
372
+ if (agent.status !== 'running') return false
373
+ agent.cancel({ kind: 'user' })
374
+ bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
375
+ return true
376
+ }
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
+
402
+ /** Apply one /model selection: takes effect from the next assembled step. */
403
+ const selectModel = (row: ModelRow): string => {
404
+ picked = { provider: row.provider, model: row.model }
405
+ return `${row.provider}/${row.model}`
406
+ }
407
+
408
+ const initialModel = store.getView().model !== ''
409
+ ? store.getView().model
410
+ : `${defaults.provider}/${defaults.model}`
411
+
117
412
  mountRef.current = io.mount(createElement(App, {
118
413
  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
- }))
414
+ approval,
415
+ questions,
416
+ commands,
417
+ skills,
418
+ model: initialModel,
419
+ cwd: basename(cwd),
420
+ branch: gitBranch(cwd),
421
+ sessionId: session.id.slice(-8),
422
+ resumed: target.resume,
423
+ dispatch,
424
+ steer,
425
+ interrupt,
426
+ quit,
427
+ loadModels: () => loadModelDirectory(ctx),
428
+ loadMentions: mentions.candidates,
429
+ cyclePermission,
430
+ selectModel,
431
+ onBridgeReady: (instance: AppBridge) => {
432
+ bridge.notify = instance.notify
128
433
  },
129
- onQuit: quit,
130
434
  }))
131
435
  }
132
436
 
133
437
  /**
134
438
  * Mount the interactive terminal driver.
135
439
  * @param ctx - plugin context carrying core services and the launcher-provided exit request.
440
+ * @param config - validated startup config resolved from the tuiStartup provider.
136
441
  */
137
- export function apply(ctx: Context): void {
442
+ export function apply(ctx: Context, config: Config): void {
443
+ const startup: TuiStartup =
444
+ config.startup.kind === 'resume' && config.startup.sessionId !== undefined
445
+ ? { kind: 'resume', sessionId: config.startup.sessionId }
446
+ : config.startup.kind === 'latest'
447
+ ? { kind: 'latest' }
448
+ : config.startup.kind === 'named' && config.startup.sessionId !== undefined
449
+ ? { kind: 'named', sessionId: config.startup.sessionId }
450
+ : { kind: 'fresh' }
138
451
  // Read through the global service store, not the property proxy: appExit is
139
452
  // an optional host value, never an injected dependency.
140
453
  const exit = ctx.get('appExit')
@@ -142,5 +455,5 @@ export function apply(ctx: Context): void {
142
455
  throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts')
143
456
  }
144
457
  const io: TuiIo = { mount: internals.mount, exit }
145
- void run(ctx, io).catch((error: unknown) => { fail(io, error) })
458
+ void run(ctx, startup, io).catch((error: unknown) => { fail(io, error) })
146
459
  }