dsh-code 0.5.0 → 0.6.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.en.md +217 -0
- package/README.md +147 -138
- package/lib/devtools-CdTl3MNy.mjs +3643 -0
- package/lib/index.mjs +26428 -492
- package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
- package/lib/types/app.d.ts +10 -0
- package/lib/types/history.d.ts +79 -0
- package/lib/types/kernel-panels.d.ts +25 -0
- package/lib/types/render/animations.d.ts +10 -1
- package/lib/types/render/inspector.d.ts +2 -0
- package/lib/types/render/projection.d.ts +21 -1
- package/lib/types/render/status.d.ts +129 -14
- package/package.json +117 -117
- package/src/app.ts +2543 -2205
- package/src/history.ts +136 -0
- package/src/index.ts +248 -52
- package/src/kernel-panels.ts +168 -3
- package/src/render/animations.ts +14 -1
- package/src/render/export.ts +4 -0
- package/src/render/inspector.ts +12 -3
- package/src/render/lines.ts +21 -10
- package/src/render/projection.ts +71 -8
- package/src/render/status.ts +520 -66
- package/src/whale-glyph.ts +23 -23
- package/README.zh.md +0 -204
package/src/history.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global input recall: persistent cross-session entries plus this process's
|
|
3
|
+
* submissions, with Codex `ChatComposerHistory` semantics — empty submissions
|
|
4
|
+
* are ignored, adjacent duplicates collapse, the recall space skips
|
|
5
|
+
* persistent entries that duplicate a local one (local wins), and Up/Down
|
|
6
|
+
* navigation is gated so interior cursor movement never hijacks the draft.
|
|
7
|
+
*
|
|
8
|
+
* @module @deepseek-ai/dsh-tui/history
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Maximum entries retained in the persistent history file. */
|
|
12
|
+
export const HISTORY_MAX_ENTRIES = 500
|
|
13
|
+
|
|
14
|
+
/** Encode one entry for the history file (JSON keeps multi-line drafts intact). */
|
|
15
|
+
export function serializeHistoryEntry(text: string): string {
|
|
16
|
+
return JSON.stringify(text)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a persisted history file (one JSON entry per line): invalid lines
|
|
21
|
+
* drop out, empty entries are ignored, adjacent duplicates collapse, and the
|
|
22
|
+
* result keeps only the newest `max` entries.
|
|
23
|
+
* @param raw - file content, empty for a missing file.
|
|
24
|
+
* @param max - entry cap.
|
|
25
|
+
* @returns persistent entries, oldest first.
|
|
26
|
+
*/
|
|
27
|
+
export function parseHistoryFile(raw: string, max = HISTORY_MAX_ENTRIES): readonly string[] {
|
|
28
|
+
const kept: string[] = []
|
|
29
|
+
for (const line of raw.split('\n')) {
|
|
30
|
+
if (line === '') continue
|
|
31
|
+
let text: unknown
|
|
32
|
+
try {
|
|
33
|
+
text = JSON.parse(line)
|
|
34
|
+
} catch {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (typeof text !== 'string' || text === '') continue
|
|
38
|
+
if (kept.length > 0 && kept[kept.length - 1] === text) continue
|
|
39
|
+
kept.push(text)
|
|
40
|
+
}
|
|
41
|
+
return kept.slice(-max)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Append one entry to the persistent file content: JSON line, capped to the
|
|
46
|
+
* newest `max` entries with a trailing newline.
|
|
47
|
+
* @param current - existing file content.
|
|
48
|
+
* @param text - submission to persist.
|
|
49
|
+
* @param max - entry cap.
|
|
50
|
+
* @returns the new file content.
|
|
51
|
+
*/
|
|
52
|
+
export function appendHistoryContent(current: string, text: string, max = HISTORY_MAX_ENTRIES): string {
|
|
53
|
+
const entries = [...parseHistoryFile(current, max), text].slice(-max)
|
|
54
|
+
return entries.map(serializeHistoryEntry).join('\n') + '\n'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Record one in-session submission: empty text is ignored and an adjacent
|
|
59
|
+
* duplicate collapses (Codex `record_local_submission` semantics).
|
|
60
|
+
* @param local - current in-session entries, oldest first.
|
|
61
|
+
* @param text - the submitted prompt.
|
|
62
|
+
* @returns the updated local list.
|
|
63
|
+
*/
|
|
64
|
+
export function recordLocalEntry(local: readonly string[], text: string): readonly string[] {
|
|
65
|
+
if (text === '') return local
|
|
66
|
+
if (local.length > 0 && local[local.length - 1] === text) return local
|
|
67
|
+
return [...local, text]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the recall space, newest first: local entries, then persistent
|
|
72
|
+
* entries whose text is not duplicated locally (the local copy wins and the
|
|
73
|
+
* persistent twin is skipped — Codex's replay-seed dedup, applied to the
|
|
74
|
+
* whole local set).
|
|
75
|
+
* @param persistent - cross-session entries, oldest first.
|
|
76
|
+
* @param local - this process's submissions, oldest first.
|
|
77
|
+
* @returns recall entries, newest first.
|
|
78
|
+
*/
|
|
79
|
+
export function recallEntries(persistent: readonly string[], local: readonly string[]): readonly string[] {
|
|
80
|
+
const localSet = new Set(local)
|
|
81
|
+
return [...persistent.filter(entry => !localSet.has(entry)), ...local].reverse()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Shell-style recall navigation over a fixed recall space. */
|
|
85
|
+
export interface RecallState {
|
|
86
|
+
/** Recall entries, newest first (frozen at navigation start). */
|
|
87
|
+
entries: readonly string[]
|
|
88
|
+
/** Current recall index; null when not browsing. */
|
|
89
|
+
index: number | null
|
|
90
|
+
/** Draft saved when browsing started; restored on Down past the newest. */
|
|
91
|
+
savedDraft: string
|
|
92
|
+
/** The recalled text currently in the composer (the boundary gate's anchor). */
|
|
93
|
+
lastRecalled: string | null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fresh navigation state over one recall space. */
|
|
97
|
+
export function beginRecall(entries: readonly string[], draft: string): RecallState {
|
|
98
|
+
return { entries, index: null, savedDraft: draft, lastRecalled: null }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The outcome of one recall step. */
|
|
102
|
+
export interface RecallStep {
|
|
103
|
+
state: RecallState
|
|
104
|
+
/** The text to place in the composer; undefined means "no movement". */
|
|
105
|
+
entry: string | undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Move one entry older (Up, toward index +1 in the newest-first space). The
|
|
110
|
+
* first Up saves the current draft so Down past the newest can restore it
|
|
111
|
+
* (Claude-Code shell recall — the draft is never lost); the oldest entry
|
|
112
|
+
* stays put.
|
|
113
|
+
* @param state - current navigation state.
|
|
114
|
+
* @param draft - the composer text to preserve when browsing starts.
|
|
115
|
+
*/
|
|
116
|
+
export function recallOlder(state: RecallState, draft: string): RecallStep {
|
|
117
|
+
if (state.index === null) {
|
|
118
|
+
const entry = state.entries[0]
|
|
119
|
+
if (entry === undefined) return { state, entry: undefined }
|
|
120
|
+
return { state: { ...state, index: 0, savedDraft: draft, lastRecalled: entry }, entry }
|
|
121
|
+
}
|
|
122
|
+
if (state.index >= state.entries.length - 1) return { state, entry: undefined }
|
|
123
|
+
const entry = state.entries[state.index + 1]
|
|
124
|
+
return { state: { ...state, index: state.index + 1, lastRecalled: entry }, entry }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Move one entry newer (Down, toward index 0); past the newest, browsing ends and the saved draft returns. */
|
|
128
|
+
export function recallNewer(state: RecallState): RecallStep {
|
|
129
|
+
if (state.index === null) return { state, entry: undefined }
|
|
130
|
+
const next = state.index - 1
|
|
131
|
+
if (next < 0) {
|
|
132
|
+
return { state: { ...state, index: null, lastRecalled: null }, entry: state.savedDraft }
|
|
133
|
+
}
|
|
134
|
+
const entry = state.entries[next]
|
|
135
|
+
return { state: { ...state, index: next, lastRecalled: entry }, entry }
|
|
136
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
13
|
import { readFileSync } from 'node:fs'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
|
+
import { basename, dirname, join } from 'node:path'
|
|
16
17
|
import { createElement } from 'react'
|
|
17
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
18
19
|
import z from '@deepseek-ai/schemastery'
|
|
19
20
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
20
21
|
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
21
22
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
22
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
|
+
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
|
23
24
|
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
24
25
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
25
26
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
@@ -33,9 +34,11 @@ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
|
33
34
|
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
34
35
|
import { internals, type TuiMount } from './internals.ts'
|
|
35
36
|
import { loadModelDirectory, type ModelRow } from './models.ts'
|
|
36
|
-
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
37
|
+
import { createMentions, type MentionCandidate, type MentionsApi } from './mentions.ts'
|
|
37
38
|
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
38
|
-
import { createTranscriptStore } from './store.ts'
|
|
39
|
+
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
40
|
+
import { parseStatuslineItems } from './render/status.ts'
|
|
41
|
+
import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
|
|
39
42
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
40
43
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
41
44
|
import { buildExportMarkdown } from './render/export.ts'
|
|
@@ -185,11 +188,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
185
188
|
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
186
189
|
|
|
187
190
|
const cwd = process.cwd()
|
|
188
|
-
const target = await resolveTarget(startup, persistence, cwd)
|
|
189
191
|
const defaults = defaultModel.currentSelection()
|
|
190
192
|
const presets = agentPresetsFrom(ctx)
|
|
191
193
|
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
192
194
|
|
|
195
|
+
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
196
|
+
// nothing is persisted, until the user's first real input. Explicit flags
|
|
197
|
+
// (--resume/--continue/--session/--mode) keep the eager create/resume path.
|
|
198
|
+
const lazy = startup.kind === 'fresh' && startup.mode === undefined
|
|
199
|
+
|
|
193
200
|
interface ActiveSession {
|
|
194
201
|
handle: AgentHandle
|
|
195
202
|
agent: Agent
|
|
@@ -257,30 +264,42 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
|
260
|
-
let active
|
|
261
|
-
let agent
|
|
262
|
-
let session
|
|
263
|
-
let store =
|
|
264
|
-
let mentions
|
|
267
|
+
let active: ActiveSession | undefined
|
|
268
|
+
let agent: Agent | undefined
|
|
269
|
+
let session: Session | undefined
|
|
270
|
+
let store: TranscriptStore = createTranscriptStore()
|
|
271
|
+
let mentions: MentionsApi | undefined
|
|
272
|
+
|
|
273
|
+
if (!lazy) {
|
|
274
|
+
const target = await resolveTarget(startup, persistence, cwd)
|
|
275
|
+
const prepared = await prepare(target)
|
|
276
|
+
active = prepared
|
|
277
|
+
agent = prepared.agent
|
|
278
|
+
session = prepared.session
|
|
279
|
+
store = prepared.store
|
|
280
|
+
mentions = prepared.mentions
|
|
281
|
+
}
|
|
265
282
|
|
|
266
283
|
// Seed the transcript from the full session log: constructor seeds never
|
|
267
|
-
// fire on `session/event`, so a resumed session paints its history once
|
|
268
|
-
//
|
|
284
|
+
// fire on `session/event`, so a resumed session paints its history once
|
|
285
|
+
// before the first render. The handler reads the current session/store, so
|
|
286
|
+
// the deferred first session of a bare launch is covered by the same feed.
|
|
269
287
|
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
270
|
-
if (subject.id === session.id) store.apply(event)
|
|
288
|
+
if (session !== undefined && subject.id === session.id) store.apply(event)
|
|
271
289
|
})
|
|
272
290
|
|
|
273
291
|
const commands: CommandsView = watchCommands(ctx)
|
|
274
|
-
commands.setAgent(agent)
|
|
292
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
275
293
|
|
|
276
294
|
const skills: SkillsView = watchSkills(ctx)
|
|
277
|
-
skills.setAgent(agent)
|
|
295
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
278
296
|
|
|
279
297
|
// Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
|
|
280
|
-
// claimed, every other ask falls through to the fail-closed waterfall.
|
|
298
|
+
// claimed, every other ask falls through to the fail-closed waterfall. The
|
|
299
|
+
// owner predicate is empty until the first session exists.
|
|
281
300
|
const approval: ApprovalStore = mountApprovalAnswerer(
|
|
282
301
|
ctx,
|
|
283
|
-
candidate => candidate.id === agent.id,
|
|
302
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
284
303
|
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
285
304
|
)
|
|
286
305
|
|
|
@@ -293,6 +312,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
293
312
|
// process side (unknown commands, switch confirmations, cancels).
|
|
294
313
|
const bridge: AppBridge = { notify: () => {} }
|
|
295
314
|
|
|
315
|
+
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
316
|
+
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
317
|
+
// surfaced warning (the customization is user-authored, never silent).
|
|
318
|
+
const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
|
|
319
|
+
let statuslineWarning: string | undefined
|
|
320
|
+
let statuslineItems: readonly string[] = []
|
|
321
|
+
try {
|
|
322
|
+
statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
statuslineItems = parseStatuslineItems(undefined)
|
|
325
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
326
|
+
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const saveStatusline = (items: readonly string[]): void => {
|
|
330
|
+
statuslineItems = [...items]
|
|
331
|
+
// The config directory may not exist on a first save; create it before
|
|
332
|
+
// the write so a fresh install persists customizations.
|
|
333
|
+
void mkdir(dirname(statuslinePath), { recursive: true })
|
|
334
|
+
.then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
|
|
335
|
+
.catch((writeError: unknown) => {
|
|
336
|
+
bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Global input recall (Codex composer-history contract): one JSONL file
|
|
341
|
+
// under the DSH home. A missing file means an empty history; unreadable or
|
|
342
|
+
// corrupt content degrades to the valid lines it could parse, silently —
|
|
343
|
+
// recall is a convenience surface, never a gate.
|
|
344
|
+
const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
|
|
345
|
+
let inputHistory: readonly string[] = []
|
|
346
|
+
try {
|
|
347
|
+
inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
|
|
348
|
+
} catch {
|
|
349
|
+
inputHistory = []
|
|
350
|
+
}
|
|
351
|
+
const recordHistory = (text: string): void => {
|
|
352
|
+
if (text === '') return
|
|
353
|
+
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
354
|
+
// A missing file on the first save is not an error: start from empty.
|
|
355
|
+
let current = ''
|
|
356
|
+
try {
|
|
357
|
+
current = readFileSync(historyPath, 'utf8')
|
|
358
|
+
} catch {
|
|
359
|
+
current = ''
|
|
360
|
+
}
|
|
361
|
+
void mkdir(dirname(historyPath), { recursive: true })
|
|
362
|
+
.then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
|
|
363
|
+
.catch((writeError: unknown) => {
|
|
364
|
+
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
|
|
369
|
+
const cancelQueued = (messageId: string): void => {
|
|
370
|
+
if (agent === undefined) return
|
|
371
|
+
try {
|
|
372
|
+
if (agent.inbox.remove(MessageId(messageId))) {
|
|
373
|
+
bridge.notify('queued message cancelled')
|
|
374
|
+
}
|
|
375
|
+
} catch (error: unknown) {
|
|
376
|
+
bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
296
380
|
// The mount handle lives in a box: quit closes over it, while the mount
|
|
297
381
|
// itself is created after quit (the App element needs quit as a prop).
|
|
298
382
|
const mountRef: { current?: TuiMount } = {}
|
|
@@ -303,13 +387,21 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
303
387
|
switchQueue.cancel()
|
|
304
388
|
off()
|
|
305
389
|
mountRef.current?.unmount()
|
|
306
|
-
|
|
390
|
+
// A bare launch that exits before the first input has no session: exit
|
|
391
|
+
// cleanly without flushing or disposing anything.
|
|
392
|
+
const currentSession = session
|
|
393
|
+
const currentActive = active
|
|
394
|
+
if (currentSession === undefined || currentActive === undefined) {
|
|
395
|
+
io.exit(0)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
void sessions.flush(currentSession)
|
|
307
399
|
.catch((flushError: unknown) => {
|
|
308
400
|
// The session log already carries every durable event; a failed flush
|
|
309
401
|
// must not trap the user in a dead terminal, so report and still exit.
|
|
310
402
|
internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
|
|
311
403
|
})
|
|
312
|
-
.then(() =>
|
|
404
|
+
.then(() => currentActive.handle.dispose())
|
|
313
405
|
.catch((disposeError: unknown) => {
|
|
314
406
|
internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
|
|
315
407
|
})
|
|
@@ -318,6 +410,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
318
410
|
|
|
319
411
|
/** Run one slash line through the command registry (closed namespace). */
|
|
320
412
|
const runSlash = (line: string): void => {
|
|
413
|
+
const currentAgent = agent
|
|
414
|
+
if (currentAgent === undefined) return
|
|
321
415
|
if (line.startsWith('/mode ')) {
|
|
322
416
|
void switchModeAction(line.slice(6).trim())
|
|
323
417
|
return
|
|
@@ -332,13 +426,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
332
426
|
return
|
|
333
427
|
}
|
|
334
428
|
const controller = new AbortController()
|
|
335
|
-
void Promise.resolve().then(() => registry.execute(
|
|
429
|
+
void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
|
|
336
430
|
if (execution === undefined) {
|
|
337
431
|
// No command owns this line: send it verbatim so a user-invocable
|
|
338
432
|
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
339
433
|
// pre-step injection — the web composer's same fall-through.
|
|
340
434
|
try {
|
|
341
|
-
|
|
435
|
+
currentAgent.followup(createUserMessage({
|
|
342
436
|
content: [{ type: 'text', text: line }],
|
|
343
437
|
source: { kind: 'user' },
|
|
344
438
|
}))
|
|
@@ -351,10 +445,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
351
445
|
})
|
|
352
446
|
}
|
|
353
447
|
|
|
354
|
-
/** Deliver one
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
|
|
448
|
+
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
449
|
+
const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
|
|
450
|
+
const currentAgent = agent!
|
|
451
|
+
const currentMentions = mentions!
|
|
358
452
|
// The command registry is a closed namespace: slash lines run out of
|
|
359
453
|
// band and never reach the model through this path (steering keeps the
|
|
360
454
|
// registry out of the inbox, so slash lines steer as literal text).
|
|
@@ -362,9 +456,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
362
456
|
runSlash(line)
|
|
363
457
|
return
|
|
364
458
|
}
|
|
365
|
-
let parsed: ReturnType<
|
|
459
|
+
let parsed: ReturnType<MentionsApi['parse']>
|
|
366
460
|
try {
|
|
367
|
-
parsed =
|
|
461
|
+
parsed = currentMentions.parse(line)
|
|
368
462
|
} catch (error: unknown) {
|
|
369
463
|
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
370
464
|
return
|
|
@@ -374,16 +468,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
374
468
|
// the readable message (upstream README wiring: inject before the
|
|
375
469
|
// followup/steer that wakes the driver).
|
|
376
470
|
try {
|
|
377
|
-
if (context !== undefined)
|
|
471
|
+
if (context !== undefined) currentAgent.inject(context)
|
|
378
472
|
const message = createUserMessage({
|
|
379
473
|
content: [{ type: 'text', text: readable }],
|
|
380
474
|
source: { kind: 'user' },
|
|
381
475
|
})
|
|
382
476
|
if (mode === 'steer') {
|
|
383
|
-
|
|
384
|
-
|
|
477
|
+
// The queued message is visible as a pending transcript row (the
|
|
478
|
+
// web queue-mirror contract); no notice noise on the happy path.
|
|
479
|
+
currentAgent.steer(message)
|
|
385
480
|
} else {
|
|
386
|
-
|
|
481
|
+
currentAgent.followup(message)
|
|
387
482
|
}
|
|
388
483
|
} catch (error: unknown) {
|
|
389
484
|
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
@@ -394,7 +489,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
394
489
|
return
|
|
395
490
|
}
|
|
396
491
|
const controller = new AbortController()
|
|
397
|
-
void
|
|
492
|
+
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
398
493
|
deliver(prepared.text, prepared.additionalContext)
|
|
399
494
|
}, (error: unknown) => {
|
|
400
495
|
if (controller.signal.aborted) return
|
|
@@ -402,6 +497,56 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
402
497
|
})
|
|
403
498
|
}
|
|
404
499
|
|
|
500
|
+
// Deferred first-session creation for a bare launch: the session is composed
|
|
501
|
+
// only when the user submits real input (or /new), and every line that
|
|
502
|
+
// arrives during creation is delivered in order afterwards. A creation
|
|
503
|
+
// failure reports and clears the queue, leaving the transient state ready
|
|
504
|
+
// for the next attempt.
|
|
505
|
+
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
|
|
506
|
+
let creating: Promise<void> | undefined
|
|
507
|
+
const ensureSession = (mode?: string): void => {
|
|
508
|
+
if (creating !== undefined) return
|
|
509
|
+
const attempt = (async () => {
|
|
510
|
+
const next = await prepare({
|
|
511
|
+
sessionId: `session-${randomUUID()}`,
|
|
512
|
+
resume: false,
|
|
513
|
+
...(mode === undefined ? {} : { mode }),
|
|
514
|
+
})
|
|
515
|
+
if (quitting) {
|
|
516
|
+
void next.handle.dispose().catch(() => {})
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
active = next
|
|
520
|
+
agent = next.agent
|
|
521
|
+
session = next.session
|
|
522
|
+
store = next.store
|
|
523
|
+
mentions = next.mentions
|
|
524
|
+
commands.setAgent(agent)
|
|
525
|
+
skills.setAgent(agent)
|
|
526
|
+
renderCurrent()
|
|
527
|
+
const queued = pendingInputs.splice(0)
|
|
528
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
529
|
+
})().catch((error: unknown) => {
|
|
530
|
+
pendingInputs.length = 0
|
|
531
|
+
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
532
|
+
}).finally(() => {
|
|
533
|
+
creating = undefined
|
|
534
|
+
})
|
|
535
|
+
creating = attempt
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
539
|
+
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
540
|
+
const line = text.trim()
|
|
541
|
+
if (line === '') return
|
|
542
|
+
if (session === undefined) {
|
|
543
|
+
pendingInputs.push({ text: line, mode })
|
|
544
|
+
ensureSession()
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
deliverLine(line, mode)
|
|
548
|
+
}
|
|
549
|
+
|
|
405
550
|
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
406
551
|
const dispatch = (text: string): void => {
|
|
407
552
|
send(text, 'followup')
|
|
@@ -418,7 +563,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
418
563
|
|
|
419
564
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
420
565
|
const interrupt = (): boolean => {
|
|
421
|
-
if (agent.status !== 'running') return false
|
|
566
|
+
if (agent === undefined || agent.status !== 'running') return false
|
|
422
567
|
try {
|
|
423
568
|
agent.cancel({ kind: 'user' })
|
|
424
569
|
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
@@ -435,6 +580,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
435
580
|
* custom knob state wraps to the first declared preset.
|
|
436
581
|
*/
|
|
437
582
|
const cyclePermission = (): string => {
|
|
583
|
+
if (session === undefined) throw new Error('no session yet — submit a message to start')
|
|
438
584
|
const service = ctx.get('permissionPresets') as
|
|
439
585
|
| {
|
|
440
586
|
names: readonly string[]
|
|
@@ -460,6 +606,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
460
606
|
|
|
461
607
|
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
462
608
|
const selectModel = (row: ModelRow): string => {
|
|
609
|
+
if (active === undefined) throw new Error('no session yet — submit a message to start')
|
|
463
610
|
active.selection.picked = { provider: row.provider, model: row.model }
|
|
464
611
|
return `${row.provider}/${row.model}`
|
|
465
612
|
}
|
|
@@ -470,6 +617,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
470
617
|
* workspace; an absolute or cwd-relative argument overrides it.
|
|
471
618
|
*/
|
|
472
619
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
620
|
+
if (session === undefined) {
|
|
621
|
+
bridge.notify('no session yet — submit a message to start', 'warning')
|
|
622
|
+
return
|
|
623
|
+
}
|
|
473
624
|
const wanted = argument.trim()
|
|
474
625
|
const sessionCwd = session.header.cwd ?? cwd
|
|
475
626
|
const defaultName = `dsh-session-${session.id.slice(-8)}.md`
|
|
@@ -495,6 +646,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
495
646
|
const renameTitle = (argument: string): string => {
|
|
496
647
|
const title = argument.trim()
|
|
497
648
|
if (title === '') return 'usage: /title <text>'
|
|
649
|
+
if (session === undefined) return 'no session yet — submit a message to start'
|
|
498
650
|
const service = ctx.get('sessionTitle')
|
|
499
651
|
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
500
652
|
try {
|
|
@@ -524,10 +676,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
524
676
|
|
|
525
677
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
526
678
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
679
|
+
const currentAgent = agent
|
|
680
|
+
const currentActive = active
|
|
681
|
+
if (currentAgent === undefined || currentActive === undefined) {
|
|
682
|
+
throw new Error('no session yet — submit a message to start')
|
|
683
|
+
}
|
|
684
|
+
const preset = await switchPreset(presets, currentAgent, id)
|
|
685
|
+
currentActive.mode = preset.id
|
|
686
|
+
commands.setAgent(currentAgent)
|
|
687
|
+
skills.setAgent(currentAgent)
|
|
531
688
|
renderCurrent()
|
|
532
689
|
return preset.id
|
|
533
690
|
}
|
|
@@ -549,16 +706,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
549
706
|
renderCurrent()
|
|
550
707
|
} catch (error: unknown) {
|
|
551
708
|
active = previous
|
|
552
|
-
agent = previous
|
|
553
|
-
session = previous
|
|
554
|
-
store = previous.store
|
|
555
|
-
mentions = previous
|
|
556
|
-
commands.setAgent(agent)
|
|
557
|
-
skills.setAgent(agent)
|
|
709
|
+
agent = previous?.agent
|
|
710
|
+
session = previous?.session
|
|
711
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
712
|
+
mentions = previous?.mentions
|
|
713
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
714
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
558
715
|
await next.handle.dispose()
|
|
559
716
|
renderCurrent()
|
|
560
717
|
throw error
|
|
561
718
|
}
|
|
719
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
720
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
721
|
+
if (previous === undefined) {
|
|
722
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
562
725
|
let cleanupWarning: string | undefined
|
|
563
726
|
try {
|
|
564
727
|
await sessions.flush(previous.session)
|
|
@@ -582,11 +745,20 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
582
745
|
)
|
|
583
746
|
|
|
584
747
|
const requestSwitch = (request: PendingSwitch): void => {
|
|
748
|
+
if (session === undefined) {
|
|
749
|
+
// No session yet (a bare launch using /resume before any input): activate
|
|
750
|
+
// the target directly — there is no running turn to wait on and nothing
|
|
751
|
+
// to flush.
|
|
752
|
+
void activate(request.target).catch((error: unknown) => {
|
|
753
|
+
bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
754
|
+
})
|
|
755
|
+
return
|
|
756
|
+
}
|
|
585
757
|
if (request.target.sessionId === session.id) {
|
|
586
758
|
bridge.notify('that session is already active', 'warning')
|
|
587
759
|
return
|
|
588
760
|
}
|
|
589
|
-
const outcome = switchQueue.request(agent
|
|
761
|
+
const outcome = switchQueue.request(agent!, request)
|
|
590
762
|
if (outcome === 'queued') {
|
|
591
763
|
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
592
764
|
}
|
|
@@ -603,7 +775,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
603
775
|
if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
|
|
604
776
|
throw new Error('subagent conversations are read-only in /resume; resume a root session')
|
|
605
777
|
}
|
|
606
|
-
if (agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
778
|
+
if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
607
779
|
throw new Error('that session is already live in another owner')
|
|
608
780
|
}
|
|
609
781
|
return matches[0]!.header.id
|
|
@@ -616,6 +788,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
616
788
|
}
|
|
617
789
|
|
|
618
790
|
const createSession = (mode?: string): void => {
|
|
791
|
+
// /new before any input is the first-session creation itself, not a switch.
|
|
792
|
+
if (session === undefined) {
|
|
793
|
+
ensureSession(mode)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
619
796
|
const nextCwd = session.header.cwd ?? cwd
|
|
620
797
|
const id = `session-${randomUUID()}`
|
|
621
798
|
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
@@ -634,10 +811,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
634
811
|
}
|
|
635
812
|
|
|
636
813
|
const appElement = (): ReturnType<typeof createElement> => {
|
|
637
|
-
|
|
814
|
+
// A bare launch mounts with placeholder facts until the first input
|
|
815
|
+
// composes a real session: empty session id/mode, the deployment default
|
|
816
|
+
// model, and the working directory's basename. `status.ts` drops empty
|
|
817
|
+
// mode/sessionId, so the bar renders only the identity it actually has.
|
|
818
|
+
const sessionCwd = session?.header.cwd ?? cwd
|
|
638
819
|
const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
|
|
639
820
|
return createElement(App, {
|
|
640
|
-
key: session
|
|
821
|
+
key: session?.id ?? 'pending',
|
|
641
822
|
store,
|
|
642
823
|
approval,
|
|
643
824
|
questions,
|
|
@@ -647,15 +828,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
647
828
|
cwd: basename(sessionCwd),
|
|
648
829
|
workspaceRoot: sessionCwd,
|
|
649
830
|
branch: gitBranch(sessionCwd),
|
|
650
|
-
sessionId: session.id.slice(-8),
|
|
651
|
-
resumed: active
|
|
652
|
-
mode: active
|
|
831
|
+
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
832
|
+
resumed: active?.resumed ?? false,
|
|
833
|
+
mode: active?.mode ?? '',
|
|
653
834
|
dispatch,
|
|
654
835
|
steer,
|
|
655
836
|
interrupt,
|
|
656
837
|
quit,
|
|
657
838
|
loadModels: () => loadModelDirectory(ctx),
|
|
658
|
-
loadMentions: mentions
|
|
839
|
+
loadMentions: mentions === undefined
|
|
840
|
+
? () => Promise.resolve<readonly MentionCandidate[]>([])
|
|
841
|
+
: mentions.candidates,
|
|
659
842
|
cyclePermission,
|
|
660
843
|
selectModel,
|
|
661
844
|
exportTranscript,
|
|
@@ -668,6 +851,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
668
851
|
switchSession,
|
|
669
852
|
cancelSessionSwitch,
|
|
670
853
|
loadPlugins: () => listPluginRows(ctx),
|
|
854
|
+
statusline: statuslineItems,
|
|
855
|
+
saveStatusline,
|
|
856
|
+
history: inputHistory,
|
|
857
|
+
recordHistory,
|
|
858
|
+
cancelQueued,
|
|
671
859
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
672
860
|
})
|
|
673
861
|
}
|
|
@@ -677,6 +865,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
677
865
|
}
|
|
678
866
|
|
|
679
867
|
mountRef.current = io.mount(appElement())
|
|
868
|
+
|
|
869
|
+
// A corrupt statusline config must not vanish silently: surface it once
|
|
870
|
+
// the notice channel is live, after the first frame settles.
|
|
871
|
+
if (statuslineWarning !== undefined) {
|
|
872
|
+
setTimeout(() => {
|
|
873
|
+
bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
|
|
874
|
+
}, 50)
|
|
875
|
+
}
|
|
680
876
|
}
|
|
681
877
|
|
|
682
878
|
/**
|