dsh-code 0.4.0 → 0.5.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 +201 -61
- package/README.zh.md +204 -65
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +1108 -250
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +19 -1
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/inspector.d.ts +4 -0
- package/lib/types/render/status.d.ts +2 -0
- package/lib/types/render/text.d.ts +9 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/package.json +6 -1
- package/src/app.ts +311 -75
- package/src/commands.ts +15 -1
- package/src/index.ts +332 -133
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +254 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/inspector.ts +13 -4
- package/src/render/markdown.ts +15 -1
- package/src/render/status.ts +3 -0
- package/src/render/text.ts +34 -6
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- package/src/pictures/1.png +0 -0
package/src/index.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
5
|
-
*
|
|
6
|
-
* durable session, answers approval asks with a y/n bar,
|
|
7
|
-
*
|
|
8
|
-
* process exit.
|
|
4
|
+
* creates or resumes preset-composed Agents through the core registry, keeps
|
|
5
|
+
* one Ink owner while the active session changes, folds submitted prompts
|
|
6
|
+
* into the selected durable session, answers approval asks with a y/n bar,
|
|
7
|
+
* dispatches slash commands, and on quit flushes and requests process exit.
|
|
9
8
|
*
|
|
10
9
|
* @module @deepseek-ai/dsh-code
|
|
11
10
|
*/
|
|
@@ -18,7 +17,7 @@ import { createElement } from 'react'
|
|
|
18
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
18
|
import z from '@deepseek-ai/schemastery'
|
|
20
19
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
-
import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
20
|
+
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
21
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
22
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
24
23
|
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
@@ -29,7 +28,7 @@ import type {} from '@deepseek-ai/dsh-session-title'
|
|
|
29
28
|
// and the cmdline Context merge for the appExit host value.
|
|
30
29
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
31
30
|
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
32
|
-
import { App } from './app.ts'
|
|
31
|
+
import { App, type NoticeTone } from './app.ts'
|
|
33
32
|
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
34
33
|
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
35
34
|
import { internals, type TuiMount } from './internals.ts'
|
|
@@ -41,6 +40,16 @@ import { watchSkills, type SkillsView } from './skills.ts'
|
|
|
41
40
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
42
41
|
import { buildExportMarkdown } from './render/export.ts'
|
|
43
42
|
import type { TuiStartup } from './startup.ts'
|
|
43
|
+
import { SessionSwitchQueue } from './session-switch.ts'
|
|
44
|
+
import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
|
|
45
|
+
import { listPluginRows } from './plugin-inventory.ts'
|
|
46
|
+
import {
|
|
47
|
+
mergeSessionTitles,
|
|
48
|
+
projectSessionRows,
|
|
49
|
+
type SessionDirectoryOptions,
|
|
50
|
+
type SessionQueryService,
|
|
51
|
+
type SessionRow,
|
|
52
|
+
} from './session-directory.ts'
|
|
44
53
|
|
|
45
54
|
/** Stable Cordis plugin name. */
|
|
46
55
|
export const name = 'tui-runner'
|
|
@@ -51,13 +60,14 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
|
|
51
60
|
/** Plugin config: the startup resolved from this app's injected provider service. */
|
|
52
61
|
export interface Config {
|
|
53
62
|
/** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
|
|
54
|
-
startup: { kind: string; sessionId?: string }
|
|
63
|
+
startup: { kind: string; sessionId?: string; mode?: string }
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
export const Config: z<Config> = z.object({
|
|
58
67
|
startup: z.object({
|
|
59
68
|
kind: z.string().required(),
|
|
60
69
|
sessionId: z.string(),
|
|
70
|
+
mode: z.string(),
|
|
61
71
|
}),
|
|
62
72
|
})
|
|
63
73
|
|
|
@@ -93,6 +103,8 @@ function gitBranch(cwd: string): string {
|
|
|
93
103
|
interface Target {
|
|
94
104
|
sessionId: string
|
|
95
105
|
resume: boolean
|
|
106
|
+
mode?: string
|
|
107
|
+
cwd?: string
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
/**
|
|
@@ -104,8 +116,8 @@ interface Target {
|
|
|
104
116
|
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
105
117
|
*/
|
|
106
118
|
async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
107
|
-
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
|
|
108
|
-
if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false }
|
|
119
|
+
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
120
|
+
if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
109
121
|
if (persistence === undefined) {
|
|
110
122
|
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
111
123
|
}
|
|
@@ -149,7 +161,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
|
|
|
149
161
|
/** The runner's connection between the React app and the process side. */
|
|
150
162
|
interface AppBridge {
|
|
151
163
|
/** Post one local notice line (feedback the transcript does not carry). */
|
|
152
|
-
notify(text: string): void
|
|
164
|
+
notify(text: string, tone?: NoticeTone): void
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
/**
|
|
@@ -168,76 +180,92 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
168
180
|
const defaultModel = ctx.get('agentDefaultModel')
|
|
169
181
|
const sessions = ctx.get('sessions')
|
|
170
182
|
const persistence = ctx.get('sessionPersistence')
|
|
183
|
+
const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
|
|
171
184
|
// Early process shutdown can dispose the tree while settlement is pending.
|
|
172
185
|
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
173
186
|
|
|
174
187
|
const cwd = process.cwd()
|
|
175
188
|
const target = await resolveTarget(startup, persistence, cwd)
|
|
176
|
-
|
|
177
189
|
const defaults = defaultModel.currentSelection()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
190
|
+
const presets = agentPresetsFrom(ctx)
|
|
191
|
+
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
192
|
+
|
|
193
|
+
interface ActiveSession {
|
|
194
|
+
handle: AgentHandle
|
|
195
|
+
agent: Agent
|
|
196
|
+
session: Session
|
|
197
|
+
store: ReturnType<typeof createTranscriptStore>
|
|
198
|
+
mentions: MentionsApi
|
|
199
|
+
mode: string
|
|
200
|
+
selection: { picked?: ModelSelection }
|
|
201
|
+
resumed: boolean
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
205
|
+
const prepare = async (next: Target): Promise<ActiveSession> => {
|
|
206
|
+
const nextCwd = next.cwd ?? cwd
|
|
207
|
+
const selectionState: { picked?: ModelSelection } = {}
|
|
208
|
+
let mode = next.mode
|
|
209
|
+
if (!next.resume) mode = (await presets.resolve(mode)).id
|
|
210
|
+
const setup = async (agentCtx: Context): Promise<void> => {
|
|
211
|
+
const sessionPreset = next.resume
|
|
212
|
+
? resolvePreset(agentCtx.agent!.session)
|
|
213
|
+
: mode
|
|
214
|
+
const mounted = await presets.mount(agentCtx, sessionPreset)
|
|
215
|
+
mode = mounted.id
|
|
216
|
+
const selection: ModelSelectionRef = {
|
|
217
|
+
get current(): ModelSelection | undefined {
|
|
218
|
+
if (selectionState.picked !== undefined) return selectionState.picked
|
|
219
|
+
const logged = agentCtx.agent?.session.requestHeader()?.config
|
|
220
|
+
if (logged !== undefined) {
|
|
221
|
+
return {
|
|
222
|
+
provider: logged.provider,
|
|
223
|
+
model: logged.model,
|
|
224
|
+
...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
|
|
200
225
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
agent = created.agent
|
|
234
|
-
session = agent.session
|
|
226
|
+
}
|
|
227
|
+
return defaults
|
|
228
|
+
},
|
|
229
|
+
set current(value: ModelSelection | undefined) { selectionState.picked = value },
|
|
230
|
+
assembled: undefined,
|
|
231
|
+
}
|
|
232
|
+
installModelSelection(agentCtx, selection)
|
|
233
|
+
}
|
|
234
|
+
const handle = next.resume
|
|
235
|
+
? await agents.resume({
|
|
236
|
+
resumeSessionId: SessionId(next.sessionId),
|
|
237
|
+
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
238
|
+
setup,
|
|
239
|
+
})
|
|
240
|
+
: await agents.create({
|
|
241
|
+
sessionId: SessionId(next.sessionId),
|
|
242
|
+
meta: { cwd: nextCwd, agentPreset: mode },
|
|
243
|
+
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
244
|
+
setup,
|
|
245
|
+
})
|
|
246
|
+
const session = handle.agent.session
|
|
247
|
+
const sessionCwd = session.header.cwd ?? nextCwd
|
|
248
|
+
return {
|
|
249
|
+
handle,
|
|
250
|
+
agent: handle.agent,
|
|
251
|
+
session,
|
|
252
|
+
store: createTranscriptStore(session.events),
|
|
253
|
+
mentions: createMentions(ctx, handle.agent, sessionCwd),
|
|
254
|
+
mode: mode ?? 'standard',
|
|
255
|
+
selection: selectionState,
|
|
256
|
+
resumed: next.resume,
|
|
257
|
+
}
|
|
235
258
|
}
|
|
236
259
|
|
|
260
|
+
let active = await prepare(target)
|
|
261
|
+
let agent = active.agent
|
|
262
|
+
let session = active.session
|
|
263
|
+
let store = active.store
|
|
264
|
+
let mentions = active.mentions
|
|
265
|
+
|
|
237
266
|
// Seed the transcript from the full session log: constructor seeds never
|
|
238
267
|
// fire on `session/event`, so a resumed session paints its history once,
|
|
239
268
|
// here, before the first render.
|
|
240
|
-
const store = createTranscriptStore(session.events)
|
|
241
269
|
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
242
270
|
if (subject.id === session.id) store.apply(event)
|
|
243
271
|
})
|
|
@@ -261,10 +289,6 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
261
289
|
// through this same pipe.
|
|
262
290
|
const questions: QuestionStore = mountQuestionProvider(ctx)
|
|
263
291
|
|
|
264
|
-
// @mention support: workspace file scan plus the opt-in session-reference
|
|
265
|
-
// service (the patch mounts it); submission expands session mentions.
|
|
266
|
-
const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
|
|
267
|
-
|
|
268
292
|
// The bridge the React app registers on mount: local notices from the
|
|
269
293
|
// process side (unknown commands, switch confirmations, cancels).
|
|
270
294
|
const bridge: AppBridge = { notify: () => {} }
|
|
@@ -276,6 +300,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
276
300
|
const quit = (): void => {
|
|
277
301
|
if (quitting) return
|
|
278
302
|
quitting = true
|
|
303
|
+
switchQueue.cancel()
|
|
279
304
|
off()
|
|
280
305
|
mountRef.current?.unmount()
|
|
281
306
|
void sessions.flush(session)
|
|
@@ -284,29 +309,45 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
284
309
|
// must not trap the user in a dead terminal, so report and still exit.
|
|
285
310
|
internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
|
|
286
311
|
})
|
|
312
|
+
.then(() => active.handle.dispose())
|
|
313
|
+
.catch((disposeError: unknown) => {
|
|
314
|
+
internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
|
|
315
|
+
})
|
|
287
316
|
.then(() => { io.exit(0) })
|
|
288
317
|
}
|
|
289
318
|
|
|
290
319
|
/** Run one slash line through the command registry (closed namespace). */
|
|
291
320
|
const runSlash = (line: string): void => {
|
|
321
|
+
if (line.startsWith('/mode ')) {
|
|
322
|
+
void switchModeAction(line.slice(6).trim())
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
if (line.startsWith('/resume ')) {
|
|
326
|
+
requestResume(line.slice(8).trim())
|
|
327
|
+
return
|
|
328
|
+
}
|
|
292
329
|
const registry = ctx.get('commands')
|
|
293
330
|
if (registry === undefined) {
|
|
294
|
-
bridge.notify('no command registry is mounted in this composition')
|
|
331
|
+
bridge.notify('no command registry is mounted in this composition', 'error')
|
|
295
332
|
return
|
|
296
333
|
}
|
|
297
334
|
const controller = new AbortController()
|
|
298
|
-
void registry.execute(agent, line, controller.signal).then((execution) => {
|
|
335
|
+
void Promise.resolve().then(() => registry.execute(agent, line, controller.signal)).then((execution) => {
|
|
299
336
|
if (execution === undefined) {
|
|
300
337
|
// No command owns this line: send it verbatim so a user-invocable
|
|
301
338
|
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
302
339
|
// pre-step injection — the web composer's same fall-through.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
340
|
+
try {
|
|
341
|
+
agent.followup(createUserMessage({
|
|
342
|
+
content: [{ type: 'text', text: line }],
|
|
343
|
+
source: { kind: 'user' },
|
|
344
|
+
}))
|
|
345
|
+
} catch (error: unknown) {
|
|
346
|
+
bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
347
|
+
}
|
|
307
348
|
}
|
|
308
349
|
}, (error: unknown) => {
|
|
309
|
-
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}
|
|
350
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
310
351
|
})
|
|
311
352
|
}
|
|
312
353
|
|
|
@@ -325,23 +366,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
325
366
|
try {
|
|
326
367
|
parsed = mentions.parse(line)
|
|
327
368
|
} catch (error: unknown) {
|
|
328
|
-
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}
|
|
369
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
329
370
|
return
|
|
330
371
|
}
|
|
331
372
|
const deliver = (readable: string, context?: UserMessage): void => {
|
|
332
373
|
// Session snapshots ride the inbox as model-facing context ahead of
|
|
333
374
|
// the readable message (upstream README wiring: inject before the
|
|
334
375
|
// followup/steer that wakes the driver).
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
376
|
+
try {
|
|
377
|
+
if (context !== undefined) agent.inject(context)
|
|
378
|
+
const message = createUserMessage({
|
|
379
|
+
content: [{ type: 'text', text: readable }],
|
|
380
|
+
source: { kind: 'user' },
|
|
381
|
+
})
|
|
382
|
+
if (mode === 'steer') {
|
|
383
|
+
agent.steer(message)
|
|
384
|
+
bridge.notify('steering queued — the next step sees it')
|
|
385
|
+
} else {
|
|
386
|
+
agent.followup(message)
|
|
387
|
+
}
|
|
388
|
+
} catch (error: unknown) {
|
|
389
|
+
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
345
390
|
}
|
|
346
391
|
}
|
|
347
392
|
if (parsed.references.length === 0) {
|
|
@@ -353,7 +398,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
353
398
|
deliver(prepared.text, prepared.additionalContext)
|
|
354
399
|
}, (error: unknown) => {
|
|
355
400
|
if (controller.signal.aborted) return
|
|
356
|
-
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}
|
|
401
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
357
402
|
})
|
|
358
403
|
}
|
|
359
404
|
|
|
@@ -374,9 +419,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
374
419
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
375
420
|
const interrupt = (): boolean => {
|
|
376
421
|
if (agent.status !== 'running') return false
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
422
|
+
try {
|
|
423
|
+
agent.cancel({ kind: 'user' })
|
|
424
|
+
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
425
|
+
return true
|
|
426
|
+
} catch (error: unknown) {
|
|
427
|
+
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
428
|
+
return false
|
|
429
|
+
}
|
|
380
430
|
}
|
|
381
431
|
|
|
382
432
|
/**
|
|
@@ -393,19 +443,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
393
443
|
}
|
|
394
444
|
| undefined
|
|
395
445
|
if (service === undefined || service.names.length === 0) {
|
|
396
|
-
bridge.notify('permission presets are not mounted in this composition')
|
|
446
|
+
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
397
447
|
return ''
|
|
398
448
|
}
|
|
399
449
|
const at = service.names.indexOf(service.current(session.events))
|
|
400
450
|
const next = service.names[(at + 1) % service.names.length] ?? ''
|
|
401
451
|
if (next === '') return ''
|
|
402
|
-
|
|
403
|
-
|
|
452
|
+
try {
|
|
453
|
+
service.set(session, next)
|
|
454
|
+
return next
|
|
455
|
+
} catch (error: unknown) {
|
|
456
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
457
|
+
return ''
|
|
458
|
+
}
|
|
404
459
|
}
|
|
405
460
|
|
|
406
461
|
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
407
462
|
const selectModel = (row: ModelRow): string => {
|
|
408
|
-
picked = { provider: row.provider, model: row.model }
|
|
463
|
+
active.selection.picked = { provider: row.provider, model: row.model }
|
|
409
464
|
return `${row.provider}/${row.model}`
|
|
410
465
|
}
|
|
411
466
|
|
|
@@ -416,18 +471,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
416
471
|
*/
|
|
417
472
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
418
473
|
const wanted = argument.trim()
|
|
474
|
+
const sessionCwd = session.header.cwd ?? cwd
|
|
419
475
|
const defaultName = `dsh-session-${session.id.slice(-8)}.md`
|
|
420
476
|
const target = wanted === ''
|
|
421
|
-
? join(
|
|
477
|
+
? join(sessionCwd, defaultName)
|
|
422
478
|
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
423
479
|
? wanted
|
|
424
|
-
: join(
|
|
480
|
+
: join(sessionCwd, wanted)
|
|
425
481
|
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
426
482
|
try {
|
|
427
483
|
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
428
484
|
bridge.notify(`exported to ${target}`)
|
|
429
485
|
} catch (error: unknown) {
|
|
430
|
-
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}
|
|
486
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
431
487
|
}
|
|
432
488
|
}
|
|
433
489
|
|
|
@@ -449,35 +505,178 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
449
505
|
}
|
|
450
506
|
}
|
|
451
507
|
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
508
|
+
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
509
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
510
|
+
const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
|
|
511
|
+
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
512
|
+
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
513
|
+
const page = projected.slice(0, 32)
|
|
514
|
+
if (page.length === 0) return projected
|
|
515
|
+
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
516
|
+
return mergeSessionTitles(projected, observations)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
520
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
521
|
+
const snapshot = await sessionQuery.readSession(id, signal)
|
|
522
|
+
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const switchModeAction = async (id: string): Promise<string> => {
|
|
526
|
+
if (id === '') throw new Error('usage: /mode <preset>')
|
|
527
|
+
const preset = await switchPreset(presets, agent, id)
|
|
528
|
+
active.mode = preset.id
|
|
529
|
+
commands.setAgent(agent)
|
|
530
|
+
skills.setAgent(agent)
|
|
531
|
+
renderCurrent()
|
|
532
|
+
return preset.id
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
536
|
+
|
|
537
|
+
const activate = async (nextTarget: Target): Promise<void> => {
|
|
538
|
+
const previous = active
|
|
539
|
+
const next = await prepare(nextTarget)
|
|
540
|
+
active = next
|
|
541
|
+
agent = next.agent
|
|
542
|
+
session = next.session
|
|
543
|
+
store = next.store
|
|
544
|
+
mentions = next.mentions
|
|
545
|
+
commands.setAgent(agent)
|
|
546
|
+
skills.setAgent(agent)
|
|
547
|
+
try {
|
|
548
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
549
|
+
renderCurrent()
|
|
550
|
+
} catch (error: unknown) {
|
|
551
|
+
active = previous
|
|
552
|
+
agent = previous.agent
|
|
553
|
+
session = previous.session
|
|
554
|
+
store = previous.store
|
|
555
|
+
mentions = previous.mentions
|
|
556
|
+
commands.setAgent(agent)
|
|
557
|
+
skills.setAgent(agent)
|
|
558
|
+
await next.handle.dispose()
|
|
559
|
+
renderCurrent()
|
|
560
|
+
throw error
|
|
561
|
+
}
|
|
562
|
+
let cleanupWarning: string | undefined
|
|
563
|
+
try {
|
|
564
|
+
await sessions.flush(previous.session)
|
|
565
|
+
} catch (error: unknown) {
|
|
566
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
await previous.handle.dispose()
|
|
570
|
+
} catch (error: unknown) {
|
|
571
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
572
|
+
}
|
|
573
|
+
bridge.notify(cleanupWarning === undefined
|
|
574
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
575
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
576
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
580
|
+
async request => { if (!quitting) await activate(request.target) },
|
|
581
|
+
error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
const requestSwitch = (request: PendingSwitch): void => {
|
|
585
|
+
if (request.target.sessionId === session.id) {
|
|
586
|
+
bridge.notify('that session is already active', 'warning')
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
const outcome = switchQueue.request(agent, request)
|
|
590
|
+
if (outcome === 'queued') {
|
|
591
|
+
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const resolveResumeId = async (wanted: string): Promise<string> => {
|
|
596
|
+
if (wanted === '') throw new Error('usage: /resume <id|prefix>')
|
|
597
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
598
|
+
const records = await sessionQuery.listSessions()
|
|
599
|
+
const exact = records.filter(record => record.header.id === wanted)
|
|
600
|
+
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
601
|
+
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
602
|
+
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
603
|
+
if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
|
|
604
|
+
throw new Error('subagent conversations are read-only in /resume; resume a root session')
|
|
605
|
+
}
|
|
606
|
+
if (agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
607
|
+
throw new Error('that session is already live in another owner')
|
|
608
|
+
}
|
|
609
|
+
return matches[0]!.header.id
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const requestResume = (wanted: string): void => {
|
|
613
|
+
void resolveResumeId(wanted).then(id => {
|
|
614
|
+
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
615
|
+
}, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const createSession = (mode?: string): void => {
|
|
619
|
+
const nextCwd = session.header.cwd ?? cwd
|
|
620
|
+
const id = `session-${randomUUID()}`
|
|
621
|
+
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const switchSession = (row: SessionRow): void => {
|
|
625
|
+
if (!row.resumable) {
|
|
626
|
+
bridge.notify('subagent conversations are read-only', 'warning')
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const cancelSessionSwitch = (): boolean => {
|
|
633
|
+
return switchQueue.cancel()
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const appElement = (): ReturnType<typeof createElement> => {
|
|
637
|
+
const sessionCwd = session.header.cwd ?? cwd
|
|
638
|
+
const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
|
|
639
|
+
return createElement(App, {
|
|
640
|
+
key: session.id,
|
|
641
|
+
store,
|
|
642
|
+
approval,
|
|
643
|
+
questions,
|
|
644
|
+
commands,
|
|
645
|
+
skills,
|
|
646
|
+
model,
|
|
647
|
+
cwd: basename(sessionCwd),
|
|
648
|
+
workspaceRoot: sessionCwd,
|
|
649
|
+
branch: gitBranch(sessionCwd),
|
|
650
|
+
sessionId: session.id.slice(-8),
|
|
651
|
+
resumed: active.resumed,
|
|
652
|
+
mode: active.mode,
|
|
653
|
+
dispatch,
|
|
654
|
+
steer,
|
|
655
|
+
interrupt,
|
|
656
|
+
quit,
|
|
657
|
+
loadModels: () => loadModelDirectory(ctx),
|
|
658
|
+
loadMentions: mentions.candidates,
|
|
659
|
+
cyclePermission,
|
|
660
|
+
selectModel,
|
|
661
|
+
exportTranscript,
|
|
662
|
+
renameTitle,
|
|
663
|
+
loadPresets: () => presets.list(),
|
|
664
|
+
switchMode: switchModeAction,
|
|
665
|
+
createSession,
|
|
666
|
+
loadSessions,
|
|
667
|
+
loadSessionTranscript,
|
|
668
|
+
switchSession,
|
|
669
|
+
cancelSessionSwitch,
|
|
670
|
+
loadPlugins: () => listPluginRows(ctx),
|
|
671
|
+
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const renderCurrent = (): void => {
|
|
676
|
+
mountRef.current?.rerender(appElement())
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
mountRef.current = io.mount(appElement())
|
|
481
680
|
}
|
|
482
681
|
|
|
483
682
|
/**
|
|
@@ -492,8 +691,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
492
691
|
: config.startup.kind === 'latest'
|
|
493
692
|
? { kind: 'latest' }
|
|
494
693
|
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
495
|
-
? { kind: 'named', sessionId: config.startup.sessionId }
|
|
496
|
-
: { kind: 'fresh' }
|
|
694
|
+
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
|
|
695
|
+
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
|
|
497
696
|
// Read through the global service store, not the property proxy: appExit is
|
|
498
697
|
// an optional host value, never an injected dependency.
|
|
499
698
|
const exit = ctx.get('appExit')
|
package/src/internals.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { ReactElement } from 'react'
|
|
|
11
11
|
|
|
12
12
|
/** A mounted terminal app instance; the runner owns unmount ordering. */
|
|
13
13
|
export interface TuiMount {
|
|
14
|
+
/** Replace the root element while preserving Ink's single terminal owner. */
|
|
15
|
+
rerender(element: ReactElement): void
|
|
14
16
|
/** Tear the terminal app down before flush and exit. */
|
|
15
17
|
unmount(): void
|
|
16
18
|
}
|
|
@@ -28,6 +30,9 @@ export const internals: {
|
|
|
28
30
|
mount: (element: ReactElement): TuiMount => {
|
|
29
31
|
const instance = render(element)
|
|
30
32
|
return {
|
|
33
|
+
rerender(element: ReactElement): void {
|
|
34
|
+
instance.rerender(element)
|
|
35
|
+
},
|
|
31
36
|
unmount(): void {
|
|
32
37
|
instance.unmount()
|
|
33
38
|
},
|