dsh-code 0.5.0 → 0.6.1
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 +219 -0
- package/README.md +148 -137
- package/bin/deepseek.mjs +38 -3
- package/lib/devtools-CdTl3MNy.mjs +3643 -0
- package/lib/index.mjs +26440 -499
- 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 +31 -8
- 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 +253 -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 +82 -15
- 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,61 @@ 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
|
+
// The App mounts with a placeholder key until the first input; the
|
|
527
|
+
// key-change remount below must start from a clean screen or the ghost
|
|
528
|
+
// static header stays visible above the new one (same source-backed
|
|
529
|
+
// clear the session-switch path performs).
|
|
530
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
531
|
+
renderCurrent()
|
|
532
|
+
const queued = pendingInputs.splice(0)
|
|
533
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
534
|
+
})().catch((error: unknown) => {
|
|
535
|
+
pendingInputs.length = 0
|
|
536
|
+
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
537
|
+
}).finally(() => {
|
|
538
|
+
creating = undefined
|
|
539
|
+
})
|
|
540
|
+
creating = attempt
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
544
|
+
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
545
|
+
const line = text.trim()
|
|
546
|
+
if (line === '') return
|
|
547
|
+
if (session === undefined) {
|
|
548
|
+
pendingInputs.push({ text: line, mode })
|
|
549
|
+
ensureSession()
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
deliverLine(line, mode)
|
|
553
|
+
}
|
|
554
|
+
|
|
405
555
|
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
406
556
|
const dispatch = (text: string): void => {
|
|
407
557
|
send(text, 'followup')
|
|
@@ -418,7 +568,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
418
568
|
|
|
419
569
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
420
570
|
const interrupt = (): boolean => {
|
|
421
|
-
if (agent.status !== 'running') return false
|
|
571
|
+
if (agent === undefined || agent.status !== 'running') return false
|
|
422
572
|
try {
|
|
423
573
|
agent.cancel({ kind: 'user' })
|
|
424
574
|
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
@@ -435,6 +585,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
435
585
|
* custom knob state wraps to the first declared preset.
|
|
436
586
|
*/
|
|
437
587
|
const cyclePermission = (): string => {
|
|
588
|
+
if (session === undefined) throw new Error('no session yet — submit a message to start')
|
|
438
589
|
const service = ctx.get('permissionPresets') as
|
|
439
590
|
| {
|
|
440
591
|
names: readonly string[]
|
|
@@ -460,6 +611,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
460
611
|
|
|
461
612
|
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
462
613
|
const selectModel = (row: ModelRow): string => {
|
|
614
|
+
if (active === undefined) throw new Error('no session yet — submit a message to start')
|
|
463
615
|
active.selection.picked = { provider: row.provider, model: row.model }
|
|
464
616
|
return `${row.provider}/${row.model}`
|
|
465
617
|
}
|
|
@@ -470,6 +622,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
470
622
|
* workspace; an absolute or cwd-relative argument overrides it.
|
|
471
623
|
*/
|
|
472
624
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
625
|
+
if (session === undefined) {
|
|
626
|
+
bridge.notify('no session yet — submit a message to start', 'warning')
|
|
627
|
+
return
|
|
628
|
+
}
|
|
473
629
|
const wanted = argument.trim()
|
|
474
630
|
const sessionCwd = session.header.cwd ?? cwd
|
|
475
631
|
const defaultName = `dsh-session-${session.id.slice(-8)}.md`
|
|
@@ -495,6 +651,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
495
651
|
const renameTitle = (argument: string): string => {
|
|
496
652
|
const title = argument.trim()
|
|
497
653
|
if (title === '') return 'usage: /title <text>'
|
|
654
|
+
if (session === undefined) return 'no session yet — submit a message to start'
|
|
498
655
|
const service = ctx.get('sessionTitle')
|
|
499
656
|
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
500
657
|
try {
|
|
@@ -524,10 +681,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
524
681
|
|
|
525
682
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
526
683
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
684
|
+
const currentAgent = agent
|
|
685
|
+
const currentActive = active
|
|
686
|
+
if (currentAgent === undefined || currentActive === undefined) {
|
|
687
|
+
throw new Error('no session yet — submit a message to start')
|
|
688
|
+
}
|
|
689
|
+
const preset = await switchPreset(presets, currentAgent, id)
|
|
690
|
+
currentActive.mode = preset.id
|
|
691
|
+
commands.setAgent(currentAgent)
|
|
692
|
+
skills.setAgent(currentAgent)
|
|
531
693
|
renderCurrent()
|
|
532
694
|
return preset.id
|
|
533
695
|
}
|
|
@@ -549,16 +711,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
549
711
|
renderCurrent()
|
|
550
712
|
} catch (error: unknown) {
|
|
551
713
|
active = previous
|
|
552
|
-
agent = previous
|
|
553
|
-
session = previous
|
|
554
|
-
store = previous.store
|
|
555
|
-
mentions = previous
|
|
556
|
-
commands.setAgent(agent)
|
|
557
|
-
skills.setAgent(agent)
|
|
714
|
+
agent = previous?.agent
|
|
715
|
+
session = previous?.session
|
|
716
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
717
|
+
mentions = previous?.mentions
|
|
718
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
719
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
558
720
|
await next.handle.dispose()
|
|
559
721
|
renderCurrent()
|
|
560
722
|
throw error
|
|
561
723
|
}
|
|
724
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
725
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
726
|
+
if (previous === undefined) {
|
|
727
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
562
730
|
let cleanupWarning: string | undefined
|
|
563
731
|
try {
|
|
564
732
|
await sessions.flush(previous.session)
|
|
@@ -582,11 +750,20 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
582
750
|
)
|
|
583
751
|
|
|
584
752
|
const requestSwitch = (request: PendingSwitch): void => {
|
|
753
|
+
if (session === undefined) {
|
|
754
|
+
// No session yet (a bare launch using /resume before any input): activate
|
|
755
|
+
// the target directly — there is no running turn to wait on and nothing
|
|
756
|
+
// to flush.
|
|
757
|
+
void activate(request.target).catch((error: unknown) => {
|
|
758
|
+
bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
759
|
+
})
|
|
760
|
+
return
|
|
761
|
+
}
|
|
585
762
|
if (request.target.sessionId === session.id) {
|
|
586
763
|
bridge.notify('that session is already active', 'warning')
|
|
587
764
|
return
|
|
588
765
|
}
|
|
589
|
-
const outcome = switchQueue.request(agent
|
|
766
|
+
const outcome = switchQueue.request(agent!, request)
|
|
590
767
|
if (outcome === 'queued') {
|
|
591
768
|
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
592
769
|
}
|
|
@@ -603,7 +780,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
603
780
|
if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
|
|
604
781
|
throw new Error('subagent conversations are read-only in /resume; resume a root session')
|
|
605
782
|
}
|
|
606
|
-
if (agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
783
|
+
if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
607
784
|
throw new Error('that session is already live in another owner')
|
|
608
785
|
}
|
|
609
786
|
return matches[0]!.header.id
|
|
@@ -616,6 +793,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
616
793
|
}
|
|
617
794
|
|
|
618
795
|
const createSession = (mode?: string): void => {
|
|
796
|
+
// /new before any input is the first-session creation itself, not a switch.
|
|
797
|
+
if (session === undefined) {
|
|
798
|
+
ensureSession(mode)
|
|
799
|
+
return
|
|
800
|
+
}
|
|
619
801
|
const nextCwd = session.header.cwd ?? cwd
|
|
620
802
|
const id = `session-${randomUUID()}`
|
|
621
803
|
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
@@ -634,10 +816,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
634
816
|
}
|
|
635
817
|
|
|
636
818
|
const appElement = (): ReturnType<typeof createElement> => {
|
|
637
|
-
|
|
819
|
+
// A bare launch mounts with placeholder facts until the first input
|
|
820
|
+
// composes a real session: empty session id/mode, the deployment default
|
|
821
|
+
// model, and the working directory's basename. `status.ts` drops empty
|
|
822
|
+
// mode/sessionId, so the bar renders only the identity it actually has.
|
|
823
|
+
const sessionCwd = session?.header.cwd ?? cwd
|
|
638
824
|
const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
|
|
639
825
|
return createElement(App, {
|
|
640
|
-
key: session
|
|
826
|
+
key: session?.id ?? 'pending',
|
|
641
827
|
store,
|
|
642
828
|
approval,
|
|
643
829
|
questions,
|
|
@@ -647,15 +833,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
647
833
|
cwd: basename(sessionCwd),
|
|
648
834
|
workspaceRoot: sessionCwd,
|
|
649
835
|
branch: gitBranch(sessionCwd),
|
|
650
|
-
sessionId: session.id.slice(-8),
|
|
651
|
-
resumed: active
|
|
652
|
-
mode: active
|
|
836
|
+
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
837
|
+
resumed: active?.resumed ?? false,
|
|
838
|
+
mode: active?.mode ?? '',
|
|
653
839
|
dispatch,
|
|
654
840
|
steer,
|
|
655
841
|
interrupt,
|
|
656
842
|
quit,
|
|
657
843
|
loadModels: () => loadModelDirectory(ctx),
|
|
658
|
-
loadMentions: mentions
|
|
844
|
+
loadMentions: mentions === undefined
|
|
845
|
+
? () => Promise.resolve<readonly MentionCandidate[]>([])
|
|
846
|
+
: mentions.candidates,
|
|
659
847
|
cyclePermission,
|
|
660
848
|
selectModel,
|
|
661
849
|
exportTranscript,
|
|
@@ -668,6 +856,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
668
856
|
switchSession,
|
|
669
857
|
cancelSessionSwitch,
|
|
670
858
|
loadPlugins: () => listPluginRows(ctx),
|
|
859
|
+
statusline: statuslineItems,
|
|
860
|
+
saveStatusline,
|
|
861
|
+
history: inputHistory,
|
|
862
|
+
recordHistory,
|
|
863
|
+
cancelQueued,
|
|
671
864
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
672
865
|
})
|
|
673
866
|
}
|
|
@@ -677,6 +870,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
677
870
|
}
|
|
678
871
|
|
|
679
872
|
mountRef.current = io.mount(appElement())
|
|
873
|
+
|
|
874
|
+
// A corrupt statusline config must not vanish silently: surface it once
|
|
875
|
+
// the notice channel is live, after the first frame settles.
|
|
876
|
+
if (statuslineWarning !== undefined) {
|
|
877
|
+
setTimeout(() => {
|
|
878
|
+
bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
|
|
879
|
+
}, 50)
|
|
880
|
+
}
|
|
680
881
|
}
|
|
681
882
|
|
|
682
883
|
/**
|