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.
- package/README.md +17 -4
- package/README.zh.md +17 -4
- package/cordis.patch.yml +22 -5
- package/lib/index.mjs +1888 -100
- package/lib/invariant.mjs +1 -1
- package/lib/startup.mjs +70 -0
- package/lib/types/app.d.ts +64 -9
- package/lib/types/approval.d.ts +57 -0
- package/lib/types/commands.d.ts +37 -0
- package/lib/types/index.d.ts +19 -7
- package/lib/types/invariant.d.ts +2 -2
- package/lib/types/mentions.d.ts +70 -0
- package/lib/types/models.d.ts +37 -0
- package/lib/types/questions.d.ts +48 -0
- package/lib/types/render/animations.d.ts +15 -0
- package/lib/types/render/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +37 -3
- package/lib/types/render/status.d.ts +4 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/skills.d.ts +45 -0
- package/lib/types/startup.d.ts +44 -0
- package/lib/types/store.d.ts +8 -2
- package/lib/types/theme.d.ts +4 -0
- package/package.json +36 -3
- package/src/app.ts +971 -57
- package/src/approval.ts +126 -0
- package/src/commands.ts +71 -0
- package/src/index.ts +353 -40
- package/src/invariant.ts +3 -3
- package/src/mentions.ts +193 -0
- package/src/models.ts +66 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -0
- package/src/render/markdown.ts +235 -0
- package/src/render/projection.ts +117 -10
- package/src/render/status.ts +14 -2
- package/src/render/text.ts +24 -0
- package/src/render/tool-preview.ts +34 -0
- package/src/skills.ts +104 -0
- package/src/startup.ts +91 -0
- package/src/store.ts +10 -4
- package/src/theme.ts +4 -0
package/src/approval.ts
ADDED
|
@@ -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
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -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-
|
|
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
|
|
5
|
-
* blue, whale wordmark), folds submitted prompts into the same
|
|
6
|
-
* session,
|
|
7
|
-
* and
|
|
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-
|
|
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 {
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
}
|