dsh-code 1.2.0 → 1.4.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 +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +6679 -5232
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +32 -45
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +65 -5
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +8 -0
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +46 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
- package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
- package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +707 -4108
- package/src/attachments.ts +65 -19
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +164 -645
- package/src/input-split.ts +24 -4
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +66 -5
- package/src/locales/zh.ts +66 -5
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
- package/src/provider-settings.ts +38 -0
- package/src/rainbow.ts +13 -3
- package/src/render/inspector.ts +23 -0
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +10 -1
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +206 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{fork.ts → session/fork.ts} +11 -7
- package/src/{history.ts → session/history.ts} +14 -0
- package/src/{session-directory.ts → session/session-directory.ts} +83 -4
- package/src/{session-switch.ts → session/session-switch.ts} +14 -0
- package/src/{store.ts → session/store.ts} +20 -2
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- package/src/version.ts +5 -0
- /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
package/src/index.ts
CHANGED
|
@@ -10,29 +10,49 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
14
13
|
import { homedir } from 'node:os'
|
|
15
|
-
import {
|
|
16
|
-
import { basename,
|
|
14
|
+
import { writeFile as writeFileAsync } from 'node:fs/promises'
|
|
15
|
+
import { basename, join } from 'node:path'
|
|
17
16
|
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, AgentHandle,
|
|
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 type {} from '@deepseek-ai/dsh-attachment'
|
|
24
|
-
import { createUserMessage,
|
|
23
|
+
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
25
24
|
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
26
|
-
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type
|
|
25
|
+
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
27
26
|
import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
|
28
|
-
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
29
27
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
30
28
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
31
29
|
// Empty type imports carry the loader Context merge for the settlement await
|
|
32
30
|
// and the cmdline Context merge for the appExit host value.
|
|
33
31
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
34
32
|
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
35
|
-
import { App
|
|
33
|
+
import { App } from './app.ts'
|
|
34
|
+
import type { NoticeTone, QueueMutation } from './ui/ui-contract.ts'
|
|
35
|
+
import { planCycleDecision } from './runner/mode-cycle.ts'
|
|
36
|
+
export { planCycleDecision, type ModeCycleDecision } from './runner/mode-cycle.ts'
|
|
37
|
+
import { runQuitSequence, type QuitCleanupStep } from './runner/quit.ts'
|
|
38
|
+
export { runQuitSequence, type QuitCleanupStep } from './runner/quit.ts'
|
|
39
|
+
import { exportSessionIdSuffix, resolveTarget, type Target } from './runner/session-target.ts'
|
|
40
|
+
export { exportSessionIdSuffix, resolveTarget } from './runner/session-target.ts'
|
|
41
|
+
import {
|
|
42
|
+
applyQueueMutation,
|
|
43
|
+
cancelPreservingQueue,
|
|
44
|
+
StartupInputGate,
|
|
45
|
+
submissionBelongsToSession,
|
|
46
|
+
} from './runner/submissions.ts'
|
|
47
|
+
export {
|
|
48
|
+
applyQueueMutation,
|
|
49
|
+
cancelPreservingQueue,
|
|
50
|
+
queueEditContent,
|
|
51
|
+
StartupInputGate,
|
|
52
|
+
submissionBelongsToSession,
|
|
53
|
+
type QueuedSubmission,
|
|
54
|
+
type QueueMutationOutcome,
|
|
55
|
+
} from './runner/submissions.ts'
|
|
36
56
|
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
37
57
|
import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
|
|
38
58
|
import { internals, type TuiMount } from './internals.ts'
|
|
@@ -44,6 +64,7 @@ import {
|
|
|
44
64
|
removeProviderSettings,
|
|
45
65
|
saveProviderCredential,
|
|
46
66
|
saveProviderConfiguration,
|
|
67
|
+
enableProviderSubscription,
|
|
47
68
|
subscribeProviderSettings,
|
|
48
69
|
unsetProviderCredential,
|
|
49
70
|
} from './provider-settings.ts'
|
|
@@ -52,10 +73,10 @@ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
|
52
73
|
// Type-only import merges the settings Events declarations ('settings/updated',
|
|
53
74
|
// 'settings/document-updated') into this program's Cordis bus typing.
|
|
54
75
|
import type {} from '@deepseek-ai/dsh-settings'
|
|
55
|
-
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
56
|
-
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
76
|
+
import { createTranscriptStore, type TranscriptStore } from './session/store.ts'
|
|
77
|
+
import { createSubagentFeed, subagentCatalogSeed, type SubagentFeedView } from './session/subagents.ts'
|
|
78
|
+
export { subagentCatalogSeed } from './session/subagents.ts'
|
|
57
79
|
import { parseStatuslineItems } from './render/status.ts'
|
|
58
|
-
import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
59
80
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
60
81
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
61
82
|
import { buildExportMarkdown } from './render/export.ts'
|
|
@@ -70,7 +91,8 @@ import {
|
|
|
70
91
|
openAuthorizationUrl,
|
|
71
92
|
subscribeProviderAuthorizations,
|
|
72
93
|
} from './authorization.ts'
|
|
73
|
-
import { selectForkSeed } from './fork.ts'
|
|
94
|
+
import { selectForkSeed } from './session/fork.ts'
|
|
95
|
+
import { gitBranch } from './git-workflow.ts'
|
|
74
96
|
import {
|
|
75
97
|
buildReviewPrompt,
|
|
76
98
|
listReviewBranches,
|
|
@@ -81,7 +103,7 @@ import {
|
|
|
81
103
|
type ReviewSelection,
|
|
82
104
|
} from './git-workflow.ts'
|
|
83
105
|
import type { TuiStartup } from './startup.ts'
|
|
84
|
-
import { SessionSwitchQueue } from './session-switch.ts'
|
|
106
|
+
import { SessionSwitchQueue } from './session/session-switch.ts'
|
|
85
107
|
import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
|
|
86
108
|
import {
|
|
87
109
|
applyPendingPermission,
|
|
@@ -97,21 +119,18 @@ import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
|
97
119
|
import { parseLanguageName, setLanguage, t, type LanguageName } from './i18n.ts'
|
|
98
120
|
import {
|
|
99
121
|
isSubagentSession,
|
|
100
|
-
matchSessionId,
|
|
101
|
-
mergeSessionTitles,
|
|
102
|
-
newestRootForCwd,
|
|
103
|
-
isSessionArtifactName,
|
|
104
|
-
jsonlSessionRoot,
|
|
105
|
-
planSessionDeletion,
|
|
106
|
-
projectSessionRows,
|
|
107
|
-
sessionArtifactDirectory,
|
|
108
|
-
sessionDirectoryFor,
|
|
109
|
-
type SessionDirectoryOptions,
|
|
110
122
|
type SessionQueryService,
|
|
111
123
|
type SessionRow,
|
|
112
|
-
} from './session-directory.ts'
|
|
113
|
-
import type { JobRow
|
|
114
|
-
import {
|
|
124
|
+
} from './session/session-directory.ts'
|
|
125
|
+
import type { JobRow } from './panels/kernel-panels.ts'
|
|
126
|
+
import { searchHitToRow, type SearchRow } from './runner/search-rows.ts'
|
|
127
|
+
export { searchHitToRow } from './runner/search-rows.ts'
|
|
128
|
+
import { createUserSettingsPersistence } from './settings-file.ts'
|
|
129
|
+
import { preferencePath, readPreference, savePreference } from './runner/preferences.ts'
|
|
130
|
+
import { createInputHistory } from './runner/input-history.ts'
|
|
131
|
+
import { createSessionIo } from './runner/session-io.ts'
|
|
132
|
+
import { EXPECTED_HARNESS_VERSION, probeRunningHarness, requireHarnessVersion } from './runner/harness-gate.ts'
|
|
133
|
+
import { resolveStartupConfig } from './runner/startup-config.ts'
|
|
115
134
|
import { turnUsages, type UsageView } from './render/usage.ts'
|
|
116
135
|
// Type-only import: merges the projection registry into the Context type so
|
|
117
136
|
// `ctx.get('sessionProjections')` is typed (the service itself is mounted by
|
|
@@ -181,373 +200,16 @@ function listJobs(ctx: Context, caller: Agent | undefined): readonly JobRow[] {
|
|
|
181
200
|
}
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
/**
|
|
185
|
-
* Read one user-level settings file as a plain object. The callers all treat a
|
|
186
|
-
* missing file as "unset" and a corrupt one as "warn and fall back", so this
|
|
187
|
-
* helper owns the one distinction they share: readable JSON that is not an
|
|
188
|
-
* object is corruption, not an absent preference, and must not surface as a
|
|
189
|
-
* cryptic property access on `null`.
|
|
190
|
-
* @param path - absolute path of the settings file.
|
|
191
|
-
* @returns the parsed object; the caller narrows each field itself.
|
|
192
|
-
*/
|
|
193
|
-
function readSettingsObject(path: string): Record<string, unknown> {
|
|
194
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
195
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
196
|
-
throw new Error(`${basename(path)} must contain a JSON object`)
|
|
197
|
-
}
|
|
198
|
-
return parsed as Record<string, unknown>
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Resolve the working directory's git branch for the status line.
|
|
203
|
-
* @param cwd - the session's working directory.
|
|
204
|
-
* @returns the branch name, or '' outside a repository or on a detached HEAD.
|
|
205
|
-
*/
|
|
206
|
-
function gitBranch(cwd: string): string {
|
|
207
|
-
try {
|
|
208
|
-
const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
|
|
209
|
-
return ref?.[1] ?? ''
|
|
210
|
-
} catch {
|
|
211
|
-
// Only the single HEAD read is attempted, so the sole reachable failure is
|
|
212
|
-
// a missing repository (or unreadable HEAD file): the branch group drops out.
|
|
213
|
-
return ''
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** The session identity this invocation will run, plus whether it is resumed. */
|
|
218
|
-
interface Target {
|
|
219
|
-
sessionId: string
|
|
220
|
-
resume: boolean
|
|
221
|
-
mode?: string
|
|
222
|
-
cwd?: string
|
|
223
|
-
seed?: readonly SessionEvent[]
|
|
224
|
-
parentSession?: SessionId
|
|
225
|
-
/** Marks the session as a subagent conversation in the durable header. */
|
|
226
|
-
origin?: 'subagent'
|
|
227
|
-
seedLength?: number
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
232
|
-
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
233
|
-
* user text: path separators must never leak into the default export filename
|
|
234
|
-
* (which would escape the session cwd).
|
|
235
|
-
* @param id - the session id.
|
|
236
|
-
* @returns at most the last 8 filename-safe characters.
|
|
237
|
-
*/
|
|
238
|
-
export function exportSessionIdSuffix(id: string): string {
|
|
239
|
-
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** One ordered step of the terminal quit cleanup. */
|
|
243
|
-
export interface QuitCleanupStep {
|
|
244
|
-
/** Step label used in diagnostics and tests. */
|
|
245
|
-
readonly name: string
|
|
246
|
-
/** The step's async work; a rejection is contained by the sequence. */
|
|
247
|
-
readonly run: () => Promise<void>
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Run the ordered quit cleanup, then request exit. Every step rejection is
|
|
252
|
-
* contained (reported through `onError`) so a failed flush or dispose never
|
|
253
|
-
* skips the remaining cleanup; the exit request is always reached exactly
|
|
254
|
-
* once.
|
|
255
|
-
* @param steps - the cleanup steps in dependency order (settle the visible
|
|
256
|
-
* session, await the final in-flight composition, await durable recall).
|
|
257
|
-
* @param exit - the terminal exit request (code 0).
|
|
258
|
-
* @param onError - optional failure sink; called once per failing step and
|
|
259
|
-
* itself contained, so a throwing sink cannot abort the sequence.
|
|
260
|
-
* @returns the names of the steps that started, in order (for tests).
|
|
261
|
-
*/
|
|
262
|
-
export async function runQuitSequence(
|
|
263
|
-
steps: readonly QuitCleanupStep[],
|
|
264
|
-
exit: (code: number) => void,
|
|
265
|
-
onError?: (name: string, error: unknown) => void,
|
|
266
|
-
): Promise<readonly string[]> {
|
|
267
|
-
const started: string[] = []
|
|
268
|
-
for (const step of steps) {
|
|
269
|
-
started.push(step.name)
|
|
270
|
-
try {
|
|
271
|
-
await step.run()
|
|
272
|
-
} catch (error) {
|
|
273
|
-
try {
|
|
274
|
-
onError?.(step.name, error)
|
|
275
|
-
} catch {
|
|
276
|
-
// The failure sink must never abort the cleanup sequence.
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
exit(0)
|
|
282
|
-
} catch {
|
|
283
|
-
// The exit request itself must not become an unhandled rejection.
|
|
284
|
-
}
|
|
285
|
-
return started
|
|
286
|
-
}
|
|
287
203
|
|
|
288
|
-
/** One composer submission waiting behind the startup delivery. */
|
|
289
|
-
export interface QueuedSubmission {
|
|
290
|
-
readonly text: string
|
|
291
|
-
/** `steer` inserts into the running turn; `followup` waits for the next one. */
|
|
292
|
-
readonly mode: 'followup' | 'steer'
|
|
293
|
-
readonly images: readonly ContentBlock[]
|
|
294
|
-
}
|
|
295
204
|
|
|
296
|
-
/** What one requested queue mutation did; the runner maps it to one notice. */
|
|
297
|
-
export type QueueMutationOutcome =
|
|
298
|
-
| 'removed'
|
|
299
|
-
| 'edited'
|
|
300
|
-
| 'steered'
|
|
301
|
-
| 'unavailable'
|
|
302
|
-
| 'empty'
|
|
303
|
-
| 'steerUnavailable'
|
|
304
205
|
|
|
305
|
-
/**
|
|
306
|
-
* Replace one queued message's text while keeping its attachments. A queue
|
|
307
|
-
* edit rewrites what the user typed, not what they attached: image and file
|
|
308
|
-
* blocks ride through in delivery order (text first, then attachments, the
|
|
309
|
-
* shape {@link deliverLine} submits). Dropping them here would silently strip
|
|
310
|
-
* an attachment the user already confirmed, so this is the edit's single
|
|
311
|
-
* definition and the panel's read-only marker only mirrors it.
|
|
312
|
-
*/
|
|
313
|
-
export function queueEditContent(content: readonly ContentBlock[], text: string): ContentBlock[] {
|
|
314
|
-
const attachments = content.filter(block => block.type !== 'text')
|
|
315
|
-
return [{ type: 'text', text }, ...attachments]
|
|
316
|
-
}
|
|
317
206
|
|
|
318
|
-
/**
|
|
319
|
-
* Apply one terminal queue mutation to the live inbox. The decision and the
|
|
320
|
-
* inbox change are pure over the supplied handles so every branch is testable
|
|
321
|
-
* without an agent; steering itself is injected because it wakes the driver
|
|
322
|
-
* rather than mutating the inbox. The durable inbox splices remain the UI's
|
|
323
|
-
* single source of truth — this helper never reports a state the inbox did not
|
|
324
|
-
* actually reach.
|
|
325
|
-
* @param inbox - the live agent inbox (pending lists plus its mutators).
|
|
326
|
-
* @param status - the agent's lifecycle status; steering needs `running`.
|
|
327
|
-
* @param messageId - identity of the queued message to mutate.
|
|
328
|
-
* @param action - the requested mutation.
|
|
329
|
-
* @param steer - submits the removed message as next-step steering.
|
|
330
|
-
* @returns the outcome the caller reports.
|
|
331
|
-
*/
|
|
332
|
-
export function applyQueueMutation(
|
|
333
|
-
inbox: Pick<Inbox, 'nextTurn' | 'append' | 'remove' | 'replace'>,
|
|
334
|
-
status: AgentStatus,
|
|
335
|
-
messageId: string,
|
|
336
|
-
action: QueueMutation,
|
|
337
|
-
steer: (message: UserMessage) => void,
|
|
338
|
-
): QueueMutationOutcome {
|
|
339
|
-
const id = MessageId(messageId)
|
|
340
|
-
const message = inbox.nextTurn.find(candidate => candidate.id === id)
|
|
341
|
-
if (message === undefined) return 'unavailable'
|
|
342
|
-
switch (action.kind) {
|
|
343
|
-
case 'remove':
|
|
344
|
-
return inbox.remove(id) ? 'removed' : 'unavailable'
|
|
345
|
-
case 'edit':
|
|
346
|
-
if (action.text.trim() === '') return 'empty'
|
|
347
|
-
inbox.replace(id, createUserMessage({
|
|
348
|
-
content: queueEditContent(message.content, action.text),
|
|
349
|
-
source: message.source,
|
|
350
|
-
}))
|
|
351
|
-
return 'edited'
|
|
352
|
-
case 'steer':
|
|
353
|
-
if (status !== 'running') return 'steerUnavailable'
|
|
354
|
-
// Steer promotes the message out of next-turn, so a failing submit must
|
|
355
|
-
// put it back: the row the user was looking at never just disappears.
|
|
356
|
-
if (!inbox.remove(id)) return 'unavailable'
|
|
357
|
-
try {
|
|
358
|
-
steer(message)
|
|
359
|
-
} catch (error: unknown) {
|
|
360
|
-
inbox.append('next-turn', message)
|
|
361
|
-
throw error
|
|
362
|
-
}
|
|
363
|
-
return 'steered'
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
207
|
|
|
367
|
-
/**
|
|
368
|
-
* Cancel the active turn while keeping the next-turn queue, then wake the
|
|
369
|
-
* driver again so the preserved messages actually run. `cancel` clears
|
|
370
|
-
* pending work by default and never wakes the driver on its own, so the queue
|
|
371
|
-
* is captured first and re-submitted afterwards: a waking submission latches
|
|
372
|
-
* the wake while the aborted activity converges to idle, which is what turns
|
|
373
|
-
* "preserved" into "sent next" instead of "parked forever". Next-step
|
|
374
|
-
* steering is deliberately dropped — it belonged to the cancelled turn.
|
|
375
|
-
* @param agent - the live agent handle.
|
|
376
|
-
* @returns how many queued messages were preserved across the abort.
|
|
377
|
-
*/
|
|
378
|
-
export function cancelPreservingQueue(agent: Pick<Agent, 'inbox' | 'cancel' | 'followup'>): number {
|
|
379
|
-
const queued = [...agent.inbox.nextTurn]
|
|
380
|
-
agent.cancel({ kind: 'user' })
|
|
381
|
-
for (const message of queued) agent.followup(message)
|
|
382
|
-
return queued.length
|
|
383
|
-
}
|
|
384
208
|
|
|
385
|
-
/**
|
|
386
|
-
* Whether a tagged submission still belongs to the active session. Attachment
|
|
387
|
-
* prepares resolve on the microtask timeline, while a queued session switch
|
|
388
|
-
* remounts the app asynchronously — the composing instance's unmount cleanup
|
|
389
|
-
* runs too late to abort, so the delivery itself carries the composing
|
|
390
|
-
* session's full id and the runner drops it here when the world moved on.
|
|
391
|
-
* An untagged (synchronous) or pending-session ('') submission always passes.
|
|
392
|
-
*/
|
|
393
|
-
export function submissionBelongsToSession(origin: string | undefined, activeSessionId: string | undefined): boolean {
|
|
394
|
-
return origin === undefined || origin === '' || origin === activeSessionId
|
|
395
|
-
}
|
|
396
209
|
|
|
397
|
-
/**
|
|
398
|
-
* Root-log catalog facts a resumed session must replay into the subagent
|
|
399
|
-
* feed: constructor seeds never fire on the live bus, so without this the
|
|
400
|
-
* children of a resumed session vanish behind a restart. The empty-child
|
|
401
|
-
* placeholder row (childId '') is a placeholder, not a child, and stays out.
|
|
402
|
-
*/
|
|
403
|
-
export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
|
|
404
|
-
return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
|
|
405
|
-
event.type === 'subagent/catalog' && event.data.childId !== '')
|
|
406
|
-
}
|
|
407
210
|
|
|
408
|
-
/**
|
|
409
|
-
* Map one cross-session full-text hit onto the /search panel's row (pure).
|
|
410
|
-
* Labels fall back to the short id form — the engine's hit carries the
|
|
411
|
-
* strongest matching event, not the title observation.
|
|
412
|
-
*/
|
|
413
|
-
export function searchHitToRow(hit: {
|
|
414
|
-
header: SessionHeader
|
|
415
|
-
bestMatch: { snippet: string; time: number }
|
|
416
|
-
}): SearchRow {
|
|
417
|
-
const subagent = hit.header.origin === 'subagent'
|
|
418
|
-
const cwd = hit.header.cwd ?? ''
|
|
419
|
-
// Session cwds may arrive in either separator style regardless of the
|
|
420
|
-
// observing host (a workspace synced from Windows), so split on both.
|
|
421
|
-
const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? ''
|
|
422
|
-
const preset = hit.header.agentPreset ?? ''
|
|
423
|
-
const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim()
|
|
424
|
-
return {
|
|
425
|
-
id: hit.header.id,
|
|
426
|
-
label: hit.header.id.slice(-12),
|
|
427
|
-
detail: [workspace, preset].filter(part => part !== '').join(' · '),
|
|
428
|
-
snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
|
|
429
|
-
updatedAt: hit.bestMatch.time,
|
|
430
|
-
subagent,
|
|
431
|
-
resumable: !subagent,
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
211
|
|
|
435
|
-
/** One Shift+Tab station decision for the mode cycle. */
|
|
436
|
-
export type ModeCycleDecision =
|
|
437
|
-
| { readonly kind: 'permission'; readonly preset: string }
|
|
438
|
-
| { readonly kind: 'plan-on' }
|
|
439
|
-
| { readonly kind: 'plan-off'; readonly preset: string }
|
|
440
212
|
|
|
441
|
-
/**
|
|
442
|
-
* Decide the next Shift+Tab station. The cycle keeps the preset table's
|
|
443
|
-
* own order (most restrictive first) and inserts ONE plan station between
|
|
444
|
-
* the most restrictive preset and the wrap target: with the shipped three
|
|
445
|
-
* presets the user sees workspace-write → danger-full-access → read-only
|
|
446
|
-
* → plan → workspace-write. Plan IS the most restrictive preset plus the
|
|
447
|
-
* plan prompt layer — entering it switches nothing (the cycle is already
|
|
448
|
-
* parked on read-only), and leaving it lands on the next preset after the
|
|
449
|
-
* most restrictive one. Without the /plan command the cycle is exactly the
|
|
450
|
-
* preset table.
|
|
451
|
-
*
|
|
452
|
-
* `planIntent` covers the committed fold's commit lag: upstream queues a
|
|
453
|
-
* plan switch during an open turn (and the command pipeline is async even
|
|
454
|
-
* idle), so the durable plan/mode event lands AFTER the press that chose
|
|
455
|
-
* it. While an intent from an earlier press is in flight it — not the
|
|
456
|
-
* stale committed fold — decides the station, so repeated presses advance
|
|
457
|
-
* the cycle instead of re-issuing the same plan transition (the stuck
|
|
458
|
-
* plan-on/plan-off toggle). Undefined falls back to the committed fold.
|
|
459
|
-
*/
|
|
460
|
-
export function planCycleDecision(input: {
|
|
461
|
-
readonly names: readonly string[]
|
|
462
|
-
readonly current: string
|
|
463
|
-
readonly inPlan: boolean
|
|
464
|
-
readonly planAvailable: boolean
|
|
465
|
-
readonly planIntent?: boolean
|
|
466
|
-
}): ModeCycleDecision | undefined {
|
|
467
|
-
const names = input.names
|
|
468
|
-
if (names.length === 0) return undefined
|
|
469
|
-
const first = names[0]
|
|
470
|
-
if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
471
|
-
const at = names.indexOf(input.current)
|
|
472
|
-
if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
|
|
473
|
-
return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Order-preserving gate for composer input while the startup prompt/images
|
|
478
|
-
* are still preparing. Anything submitted before the startup delivery settles
|
|
479
|
-
* queues and flushes afterwards in submit order, so the initial request can
|
|
480
|
-
* never be overtaken by typing that raced a slow image preparation. The flush
|
|
481
|
-
* also runs when the startup delivery fails: user input is never stranded.
|
|
482
|
-
*/
|
|
483
|
-
export class StartupInputGate {
|
|
484
|
-
private readonly queued: QueuedSubmission[] = []
|
|
485
|
-
private pending = false
|
|
486
|
-
constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
|
|
487
|
-
|
|
488
|
-
/** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
|
|
489
|
-
submit(submission: QueuedSubmission): void {
|
|
490
|
-
if (this.pending) this.queued.push(submission)
|
|
491
|
-
else this.deliver(submission)
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Run the startup delivery — the callback receives the direct-delivery sink
|
|
496
|
-
* for the startup prompt itself — then flush everything that queued behind
|
|
497
|
-
* it, in order, even when the callback rejects.
|
|
498
|
-
*/
|
|
499
|
-
async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
|
|
500
|
-
this.pending = true
|
|
501
|
-
try {
|
|
502
|
-
await startup(submission => this.deliver(submission))
|
|
503
|
-
} finally {
|
|
504
|
-
this.pending = false
|
|
505
|
-
const queued = this.queued.splice(0)
|
|
506
|
-
for (const submission of queued) this.deliver(submission)
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Resolve the invocation's target session against the persisted headers.
|
|
513
|
-
* @param startup - the parsed startup flags.
|
|
514
|
-
* @param persistence - the persistence service; required for resume/latest.
|
|
515
|
-
* @param cwd - the working directory `--continue` filters by.
|
|
516
|
-
* @returns the target identity.
|
|
517
|
-
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
518
|
-
*/
|
|
519
|
-
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
520
|
-
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
521
|
-
if (startup.kind === 'named') {
|
|
522
|
-
// The id must not exist yet: reject before any Agent composition when the
|
|
523
|
-
// backend can tell us (a live collision is still caught by the session
|
|
524
|
-
// store at create time).
|
|
525
|
-
if (persistence !== undefined) {
|
|
526
|
-
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
527
|
-
if (headers.some(header => header.id === startup.sessionId)) {
|
|
528
|
-
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
532
|
-
}
|
|
533
|
-
if (persistence === undefined) {
|
|
534
|
-
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
535
|
-
}
|
|
536
|
-
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
537
|
-
if (startup.kind === 'resume') {
|
|
538
|
-
const matched = matchSessionId(headers, startup.sessionId)
|
|
539
|
-
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
540
|
-
// be a back door into appending root turns to a child's durable log.
|
|
541
|
-
if (isSubagentSession(matched)) {
|
|
542
|
-
throw new Error('subagent conversations are read-only; resume a root session')
|
|
543
|
-
}
|
|
544
|
-
return { sessionId: matched.id, resume: true }
|
|
545
|
-
}
|
|
546
|
-
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
547
|
-
const newest = newestRootForCwd(headers, cwd)
|
|
548
|
-
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
549
|
-
return { sessionId: newest.id, resume: true }
|
|
550
|
-
}
|
|
551
213
|
|
|
552
214
|
/**
|
|
553
215
|
* Resolve a bounded command preview for one pending approval: the request
|
|
@@ -941,17 +603,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
941
603
|
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
942
604
|
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
943
605
|
// surfaced warning (the customization is user-authored, never silent).
|
|
944
|
-
const statuslinePath =
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
|
|
949
|
-
} catch (error) {
|
|
950
|
-
statuslineItems = parseStatuslineItems(undefined)
|
|
951
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
952
|
-
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
953
|
-
}
|
|
954
|
-
}
|
|
606
|
+
const statuslinePath = preferencePath('statusline.json')
|
|
607
|
+
const statuslineRead = readPreference(statuslinePath, 'items', parseStatuslineItems)
|
|
608
|
+
const statuslineWarning: string | undefined = statuslineRead.warning
|
|
609
|
+
let statuslineItems: readonly string[] = statuslineRead.value ?? parseStatuslineItems(undefined)
|
|
955
610
|
// Serialized, crash-atomic writes for the user-level JSON files: the chain
|
|
956
611
|
// orders rapid consecutive saves (the LAST snapshot wins on disk), each
|
|
957
612
|
// write goes through a sibling temp file + rename, and quit waits for the
|
|
@@ -959,10 +614,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
959
614
|
const settingsPersistence = createUserSettingsPersistence()
|
|
960
615
|
const saveStatusline = (items: readonly string[]): void => {
|
|
961
616
|
statuslineItems = [...items]
|
|
962
|
-
|
|
963
|
-
.
|
|
964
|
-
|
|
965
|
-
})
|
|
617
|
+
savePreference(settingsPersistence, statuslinePath, 'items', items, message => {
|
|
618
|
+
bridge.notify(t('notice.statuslineSaveFailed', { message }), 'error')
|
|
619
|
+
})
|
|
966
620
|
}
|
|
967
621
|
|
|
968
622
|
// /vscode-keys: detect the hosting editor's user keybindings.json and pass
|
|
@@ -971,7 +625,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
971
625
|
const editorKeysEnv: EditorKeysEnv = {
|
|
972
626
|
env: process.env,
|
|
973
627
|
paths: { homedir: homedir(), appdata: process.env.APPDATA, platform: process.platform },
|
|
974
|
-
flagPath:
|
|
628
|
+
flagPath: preferencePath('editor-keys.json'),
|
|
975
629
|
}
|
|
976
630
|
const applyEditorKeys = (): Promise<string> => applyCtrlRPassthrough(editorKeysEnv)
|
|
977
631
|
|
|
@@ -980,45 +634,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
980
634
|
// degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
|
|
981
635
|
// auto detection > dark (auto detection itself is a later enhancement and
|
|
982
636
|
// currently falls back to dark inside theme.ts).
|
|
983
|
-
const themePath =
|
|
637
|
+
const themePath = preferencePath('theme.json')
|
|
984
638
|
let themeWarning: string | undefined
|
|
985
639
|
if (startup.theme === undefined) {
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
themeWarning = error instanceof Error ? error.message : String(error)
|
|
991
|
-
}
|
|
992
|
-
}
|
|
640
|
+
const read = readPreference(themePath, 'theme', parseThemeName)
|
|
641
|
+
themeWarning = read.warning
|
|
642
|
+
// A missing or corrupt file leaves theme.ts on its own dark default.
|
|
643
|
+
if (read.value !== undefined) setTheme(read.value)
|
|
993
644
|
} else {
|
|
994
645
|
setTheme(startup.theme)
|
|
995
646
|
}
|
|
996
647
|
const saveTheme = (name: ThemeName): void => {
|
|
997
648
|
setTheme(name)
|
|
998
|
-
|
|
999
|
-
.
|
|
1000
|
-
|
|
1001
|
-
})
|
|
649
|
+
savePreference(settingsPersistence, themePath, 'theme', name, message => {
|
|
650
|
+
bridge.notify(t('notice.themeSaveFailed', { message }), 'error')
|
|
651
|
+
})
|
|
1002
652
|
}
|
|
1003
653
|
|
|
1004
654
|
// /language persistence: one user-level JSON file beside theme.json. A
|
|
1005
655
|
// missing file means English; a corrupt file degrades to English with a
|
|
1006
656
|
// surfaced warning.
|
|
1007
|
-
const languagePath =
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1013
|
-
languageWarning = error instanceof Error ? error.message : String(error)
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
657
|
+
const languagePath = preferencePath('language.json')
|
|
658
|
+
const languageRead = readPreference(languagePath, 'language', parseLanguageName)
|
|
659
|
+
const languageWarning: string | undefined = languageRead.warning
|
|
660
|
+
// A missing or corrupt file leaves i18n on its own English default.
|
|
661
|
+
if (languageRead.value !== undefined) setLanguage(languageRead.value)
|
|
1016
662
|
const saveLanguage = (name: LanguageName): void => {
|
|
1017
663
|
setLanguage(name)
|
|
1018
|
-
|
|
1019
|
-
.
|
|
1020
|
-
|
|
1021
|
-
})
|
|
664
|
+
savePreference(settingsPersistence, languagePath, 'language', name, message => {
|
|
665
|
+
bridge.notify(t('notice.languageSaveFailed', { message }), 'error')
|
|
666
|
+
})
|
|
1022
667
|
}
|
|
1023
668
|
|
|
1024
669
|
// /animation persistence: one user-level JSON file under the DSH home,
|
|
@@ -1026,68 +671,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1026
671
|
// corrupt file degrades to on with a surfaced warning. Only an explicit
|
|
1027
672
|
// `false` disables (parseAnimationsPref), so hand-edited or partial files
|
|
1028
673
|
// never silently freeze the UI.
|
|
1029
|
-
const animationsPath =
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
animationsEnabled = parseAnimationsPref(readSettingsObject(animationsPath).animations)
|
|
1036
|
-
} catch (error) {
|
|
1037
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1038
|
-
animationsWarning = error instanceof Error ? error.message : String(error)
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
674
|
+
const animationsPath = preferencePath('animations.json')
|
|
675
|
+
// A literal `null` file reads as corruption and surfaces a warning, instead
|
|
676
|
+
// of a property access on `null`.
|
|
677
|
+
const animationsRead = readPreference(animationsPath, 'animations', parseAnimationsPref)
|
|
678
|
+
const animationsWarning: string | undefined = animationsRead.warning
|
|
679
|
+
const animationsEnabled = animationsRead.value ?? true
|
|
1041
680
|
const saveAnimations = (enabled: boolean): void => {
|
|
1042
|
-
|
|
1043
|
-
.
|
|
1044
|
-
|
|
1045
|
-
})
|
|
681
|
+
savePreference(settingsPersistence, animationsPath, 'animations', enabled, message => {
|
|
682
|
+
bridge.notify(t('notice.animationsSaveFailed', { message }), 'error')
|
|
683
|
+
})
|
|
1046
684
|
}
|
|
1047
685
|
|
|
1048
686
|
// Global input recall (Codex composer-history contract): one JSONL file
|
|
1049
687
|
// under the DSH home. A missing file means an empty history; unreadable or
|
|
1050
688
|
// corrupt content degrades to the valid lines it could parse, silently —
|
|
1051
689
|
// recall is a convenience surface, never a gate.
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
try {
|
|
1056
|
-
const rawHistory = readFileSync(historyPath, 'utf8')
|
|
1057
|
-
inputHistory = parseHistoryFile(rawHistory)
|
|
1058
|
-
// Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
|
|
1059
|
-
// accumulate in an append-only file; rewrite the canonical form once
|
|
1060
|
-
// per boot. The rewrite rides the same chain, so it lands before any
|
|
1061
|
-
// submission the user types next. An entry another terminal appends
|
|
1062
|
-
// inside the read-to-rename window is dropped — a millisecond-scale
|
|
1063
|
-
// gap at boot that recall tolerates by design.
|
|
1064
|
-
if (needsCompaction(rawHistory)) {
|
|
1065
|
-
historyWriteChain = historyWriteChain
|
|
1066
|
-
.then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
|
|
1067
|
-
.catch(() => {})
|
|
1068
|
-
}
|
|
1069
|
-
} catch {
|
|
1070
|
-
inputHistory = []
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* Serialized history writes: each submission appends one JSON line at the
|
|
1074
|
-
* end of the file, so concurrent terminals add entries after each other
|
|
1075
|
-
* instead of overwriting snapshots they read at their own boot. A
|
|
1076
|
-
* multi-line draft still occupies one physical line (JSON escapes the
|
|
1077
|
-
* newline), and a regular-length line reaches the disk as one positioned
|
|
1078
|
-
* write; an oversized paste may interleave mid-line, which the next
|
|
1079
|
-
* parse simply drops.
|
|
1080
|
-
*/
|
|
1081
|
-
const recordHistory = (text: string): void => {
|
|
1082
|
-
if (text === '') return
|
|
1083
|
-
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
1084
|
-
historyWriteChain = historyWriteChain
|
|
1085
|
-
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
1086
|
-
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
1087
|
-
.catch((writeError: unknown) => {
|
|
1088
|
-
bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
1089
|
-
})
|
|
1090
|
-
}
|
|
690
|
+
const inputHistory = createInputHistory(preferencePath('history.jsonl'), message => {
|
|
691
|
+
bridge.notify(t('notice.historySaveFailed', { message }), 'error')
|
|
692
|
+
})
|
|
1091
693
|
|
|
1092
694
|
/** Mutate one next-turn inbox item; durable inbox splices remain the UI truth. */
|
|
1093
695
|
const updateQueued = (messageId: string, action: QueueMutation): void => {
|
|
@@ -1159,7 +761,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1159
761
|
{ name: 'dispose', run: () => currentActive.handle.dispose() },
|
|
1160
762
|
]),
|
|
1161
763
|
{ name: 'composing', run: () => composing ?? Promise.resolve() },
|
|
1162
|
-
{ name: 'history', run: () =>
|
|
764
|
+
{ name: 'history', run: () => inputHistory.flush() },
|
|
1163
765
|
{ name: 'settings', run: () => settingsPersistence.flush() },
|
|
1164
766
|
]
|
|
1165
767
|
void runQuitSequence(steps, io.exit, report)
|
|
@@ -1234,7 +836,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1234
836
|
try {
|
|
1235
837
|
parsed = currentMentions.parse(line)
|
|
1236
838
|
} catch (error: unknown) {
|
|
1237
|
-
bridge.notify(
|
|
839
|
+
bridge.notify(t('notice.invalidReference', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1238
840
|
return
|
|
1239
841
|
}
|
|
1240
842
|
// Ordered delivery: the inbox order IS the user's message order. A line
|
|
@@ -1269,7 +871,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1269
871
|
if (mode === 'steer') currentAgent.steer(message)
|
|
1270
872
|
else currentAgent.followup(message)
|
|
1271
873
|
} catch (error: unknown) {
|
|
1272
|
-
bridge.notify(
|
|
874
|
+
bridge.notify(t('notice.messageFailed', { kind: mode === 'steer' ? 'steering' : 'message', message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1273
875
|
}
|
|
1274
876
|
}
|
|
1275
877
|
if (parsed.references.length === 0) {
|
|
@@ -1287,7 +889,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1287
889
|
}, (error: unknown) => {
|
|
1288
890
|
pendingControllers.delete(controller)
|
|
1289
891
|
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1290
|
-
bridge.notify(
|
|
892
|
+
bridge.notify(t('notice.referenceFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1291
893
|
})
|
|
1292
894
|
})
|
|
1293
895
|
}
|
|
@@ -1364,7 +966,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1364
966
|
}
|
|
1365
967
|
await next.handle.dispose().catch(() => {})
|
|
1366
968
|
if (!quitting) renderCurrent()
|
|
1367
|
-
bridge.notify(
|
|
969
|
+
bridge.notify(t('notice.activationFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1368
970
|
return
|
|
1369
971
|
}
|
|
1370
972
|
abortPendingControllers()
|
|
@@ -1383,7 +985,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1383
985
|
}
|
|
1384
986
|
}).catch((error: unknown) => {
|
|
1385
987
|
pendingInputs.length = 0
|
|
1386
|
-
bridge.notify(
|
|
988
|
+
bridge.notify(t('notice.creationFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1387
989
|
})
|
|
1388
990
|
}
|
|
1389
991
|
|
|
@@ -1393,19 +995,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1393
995
|
// exact whitespace unless the line is a syntactic slash command.
|
|
1394
996
|
const line = submissionPayload(text)
|
|
1395
997
|
if (line.trim() === '' && images.length === 0) return
|
|
998
|
+
// A switch between the idle wait and the handoff keeps the OLD session
|
|
999
|
+
// installed: a line delivered now would start a turn the handoff discards.
|
|
1000
|
+
// Refusing loudly beats losing it silently — the caller can retry after the
|
|
1001
|
+
// switch, and `switchQueue.cancel()` is the way out.
|
|
1002
|
+
if (switchQueue.activating) {
|
|
1003
|
+
bridge.notify(t('notice.switchInProgress'), 'warning')
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1396
1006
|
if (images.length === 0 && line.startsWith('/mode ')) {
|
|
1397
1007
|
void switchModeAction(line.slice(6).trim()).then(
|
|
1398
|
-
selected => bridge.notify(
|
|
1399
|
-
error => bridge.notify(
|
|
1008
|
+
selected => bridge.notify(t('notice.modeChanged', { value: selected })),
|
|
1009
|
+
error => bridge.notify(t('notice.modeChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1400
1010
|
)
|
|
1401
1011
|
return
|
|
1402
1012
|
}
|
|
1403
1013
|
if (images.length === 0 && line.startsWith('/permission ')) {
|
|
1404
1014
|
try {
|
|
1405
1015
|
const selected = setPermissionAction(line.slice(12).trim())
|
|
1406
|
-
bridge.notify(
|
|
1016
|
+
bridge.notify(t('notice.permissionChanged', { value: selected }))
|
|
1407
1017
|
} catch (error: unknown) {
|
|
1408
|
-
bridge.notify(
|
|
1018
|
+
bridge.notify(t('notice.permissionChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1409
1019
|
}
|
|
1410
1020
|
return
|
|
1411
1021
|
}
|
|
@@ -1458,7 +1068,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1458
1068
|
bridge.notify(t(preserved > 0 ? 'notice.turnCancelledKeepQueue' : 'notice.turnCancelled'))
|
|
1459
1069
|
return true
|
|
1460
1070
|
} catch (error: unknown) {
|
|
1461
|
-
bridge.notify(
|
|
1071
|
+
bridge.notify(t('notice.cancelFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1462
1072
|
return false
|
|
1463
1073
|
}
|
|
1464
1074
|
}
|
|
@@ -1491,7 +1101,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1491
1101
|
*/
|
|
1492
1102
|
const cycleMode = (): string => {
|
|
1493
1103
|
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
1494
|
-
bridge.notify('
|
|
1104
|
+
bridge.notify(t('notice.permissionPresetsUnmounted'), 'warning')
|
|
1495
1105
|
return ''
|
|
1496
1106
|
}
|
|
1497
1107
|
try {
|
|
@@ -1542,7 +1152,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1542
1152
|
selectPermission(permissionPresets, session, decision.preset)
|
|
1543
1153
|
return `plan → off · permission → ${decision.preset}`
|
|
1544
1154
|
} catch (error: unknown) {
|
|
1545
|
-
bridge.notify(
|
|
1155
|
+
bridge.notify(t('notice.modeChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1546
1156
|
return ''
|
|
1547
1157
|
}
|
|
1548
1158
|
}
|
|
@@ -1568,7 +1178,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1568
1178
|
// it. Save failures degrade to a notice — the in-session switch already
|
|
1569
1179
|
// took effect and must not roll back (the web contract).
|
|
1570
1180
|
void defaultModel.saveSelection(selection).catch((error: unknown) => {
|
|
1571
|
-
bridge.notify(
|
|
1181
|
+
bridge.notify(t('notice.modelNotDefault', { message: error instanceof Error ? error.message : String(error) }), 'warning')
|
|
1572
1182
|
})
|
|
1573
1183
|
// Advisory immediate validation (web selectModel parity): run the same
|
|
1574
1184
|
// local resolveCallConfig check the request pipeline would, so a stale
|
|
@@ -1587,7 +1197,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1587
1197
|
model: selection.model,
|
|
1588
1198
|
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
1589
1199
|
})).catch((error: unknown) => {
|
|
1590
|
-
bridge.notify(
|
|
1200
|
+
bridge.notify(t('notice.modelSelectionRejected', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1591
1201
|
})
|
|
1592
1202
|
}
|
|
1593
1203
|
return `${row.provider}/${row.model}`
|
|
@@ -1616,7 +1226,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1616
1226
|
*/
|
|
1617
1227
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
1618
1228
|
if (session === undefined) {
|
|
1619
|
-
bridge.notify('
|
|
1229
|
+
bridge.notify(t('notice.noSessionYet'), 'warning')
|
|
1620
1230
|
return
|
|
1621
1231
|
}
|
|
1622
1232
|
const wanted = argument.trim()
|
|
@@ -1633,9 +1243,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1633
1243
|
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
1634
1244
|
try {
|
|
1635
1245
|
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
1636
|
-
bridge.notify(
|
|
1246
|
+
bridge.notify(t('notice.exported', { path: target }))
|
|
1637
1247
|
} catch (error: unknown) {
|
|
1638
|
-
bridge.notify(
|
|
1248
|
+
bridge.notify(t('notice.exportFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1639
1249
|
}
|
|
1640
1250
|
}
|
|
1641
1251
|
|
|
@@ -1658,115 +1268,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1658
1268
|
}
|
|
1659
1269
|
}
|
|
1660
1270
|
|
|
1661
|
-
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
1662
|
-
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1663
|
-
const records = await sessionQuery.listSessions(signal)
|
|
1664
|
-
// Last-activity timestamps for sorting (codex UpdatedAt default): the
|
|
1665
|
-
// newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
|
|
1666
|
-
// the persistence `locate()` query, so paths are derived from the
|
|
1667
|
-
// backend's public config root. Backends without a JSONL config (or
|
|
1668
|
-
// vanished directories) fall back to createdAt inside the projection.
|
|
1669
|
-
const root = jsonlSessionRoot(persistence)
|
|
1670
|
-
const updated = new Map<string, number>()
|
|
1671
|
-
if (root !== undefined) {
|
|
1672
|
-
await Promise.all(records.map(async record => {
|
|
1673
|
-
try {
|
|
1674
|
-
const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
|
|
1675
|
-
const entries = await readdir(dir, { withFileTypes: true })
|
|
1676
|
-
const stats = await Promise.all(
|
|
1677
|
-
entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
|
|
1678
|
-
.map(entry => stat(join(dir, entry.name))),
|
|
1679
|
-
)
|
|
1680
|
-
const newest = Math.max(...stats.map(info => info.mtimeMs))
|
|
1681
|
-
if (Number.isFinite(newest)) updated.set(record.header.id, newest)
|
|
1682
|
-
} catch {
|
|
1683
|
-
// Artifact gone or unreadable: the projection falls back to createdAt.
|
|
1684
|
-
}
|
|
1685
|
-
}))
|
|
1686
|
-
}
|
|
1687
|
-
const projected = projectSessionRows(records, options, updated)
|
|
1688
|
-
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
1689
|
-
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
1690
|
-
const page = projected.slice(0, 32)
|
|
1691
|
-
if (page.length === 0) return projected
|
|
1692
|
-
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
1693
|
-
return mergeSessionTitles(projected, observations)
|
|
1694
|
-
}
|
|
1695
1271
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
* between planning and touching the filesystem:
|
|
1702
|
-
*
|
|
1703
|
-
* 1. `planSessionDeletion` collects the subtree and refuses when the root
|
|
1704
|
-
* or ANY member is live (a live child would outlive its deleted
|
|
1705
|
-
* parent), ordering the plan children-first.
|
|
1706
|
-
* 2. Every plan node must derive to a guarded artifact directory
|
|
1707
|
-
* (`encodeSegment(id)` layout beneath the backend's config root).
|
|
1708
|
-
* Backends without a derivable artifact (non-JSONL) refuse the WHOLE
|
|
1709
|
-
* deletion here — no file has been touched yet, so a backend or layout
|
|
1710
|
-
* surprise can never strand a half-deleted subtree.
|
|
1711
|
-
* 3. Artifacts are removed children-first: only an I/O error mid-delete
|
|
1712
|
-
* can stop it short (reported with removed/total counts), leaving the
|
|
1713
|
-
* shallowest lineage intact.
|
|
1714
|
-
*
|
|
1715
|
-
* @param id - the root session id to delete.
|
|
1716
|
-
* @returns the outcome line for the panel/notice.
|
|
1717
|
-
*/
|
|
1718
|
-
const deleteSession = async (id: string): Promise<string> => {
|
|
1719
|
-
if (sessionQuery === undefined) return 'session query is unavailable in this profile'
|
|
1720
|
-
if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
|
|
1721
|
-
const records = await sessionQuery.listSessions()
|
|
1722
|
-
const plan = planSessionDeletion(records, id)
|
|
1723
|
-
if (!plan.ok) return plan.reason
|
|
1724
|
-
// Phase 2 completes the plan before the first rm: derive and
|
|
1725
|
-
// layout-check every node up front, so a refusal never leaves a
|
|
1726
|
-
// partially removed subtree behind.
|
|
1727
|
-
const root = jsonlSessionRoot(persistence)
|
|
1728
|
-
if (root === undefined) {
|
|
1729
|
-
return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
|
|
1730
|
-
}
|
|
1731
|
-
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
1732
|
-
const dirs = new Map<string, string>()
|
|
1733
|
-
for (const node of plan.nodes) {
|
|
1734
|
-
const record = byId.get(node.id)
|
|
1735
|
-
if (record === undefined) return `no persisted session matches "${node.id}"`
|
|
1736
|
-
const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id)
|
|
1737
|
-
if (dir === undefined) {
|
|
1738
|
-
return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`
|
|
1739
|
-
}
|
|
1740
|
-
dirs.set(node.id, dir)
|
|
1741
|
-
}
|
|
1742
|
-
let removed = 0
|
|
1743
|
-
for (const node of plan.nodes) {
|
|
1744
|
-
const dir = dirs.get(node.id)!
|
|
1745
|
-
try {
|
|
1746
|
-
// Remove every canonical generation artifact this build knows; other
|
|
1747
|
-
// sibling files are never ours to delete, and the directory itself is
|
|
1748
|
-
// only removed once empty. An unreadable directory counts as a
|
|
1749
|
-
// failure (not a silent success) so the outcome line stays honest.
|
|
1750
|
-
const entries = await readdir(dir, { withFileTypes: true })
|
|
1751
|
-
for (const entry of entries) {
|
|
1752
|
-
if (entry.isFile() && isSessionArtifactName(entry.name)) {
|
|
1753
|
-
await rm(join(dir, entry.name), { force: true })
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1757
|
-
removed += 1
|
|
1758
|
-
} catch (error: unknown) {
|
|
1759
|
-
return `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
return `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
1766
|
-
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1767
|
-
const snapshot = await sessionQuery.readSession(id, signal)
|
|
1768
|
-
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1769
|
-
}
|
|
1272
|
+
const { loadSessions, deleteSession, loadSessionTranscript } = createSessionIo({
|
|
1273
|
+
sessionQuery,
|
|
1274
|
+
persistence,
|
|
1275
|
+
activeSessionId: () => session?.id,
|
|
1276
|
+
})
|
|
1770
1277
|
|
|
1771
1278
|
/**
|
|
1772
1279
|
* Read one session's usage blocks for the /usage panel: the mounted
|
|
@@ -1787,7 +1294,6 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1787
1294
|
turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
|
|
1788
1295
|
})
|
|
1789
1296
|
}
|
|
1790
|
-
|
|
1791
1297
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
1792
1298
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1793
1299
|
const currentAgent = agent
|
|
@@ -1906,7 +1412,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1906
1412
|
// effect AFTER it, so an immediate notice reaches the UNMOUNTED
|
|
1907
1413
|
// instance and React drops it silently. Defer past the commit.
|
|
1908
1414
|
setTimeout(() => {
|
|
1909
|
-
bridge.notify(
|
|
1415
|
+
bridge.notify(t(next.resumed ? 'notice.sessionResumed' : 'notice.sessionCreated', {
|
|
1416
|
+
id: next.session.id.slice(-12),
|
|
1417
|
+
mode: next.mode,
|
|
1418
|
+
}))
|
|
1910
1419
|
}, 0)
|
|
1911
1420
|
return
|
|
1912
1421
|
}
|
|
@@ -1914,23 +1423,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1914
1423
|
try {
|
|
1915
1424
|
await sessions.flush(previous.session)
|
|
1916
1425
|
} catch (error: unknown) {
|
|
1917
|
-
cleanupWarning =
|
|
1426
|
+
cleanupWarning = t('notice.flushFailed', { message: error instanceof Error ? error.message : String(error) })
|
|
1918
1427
|
}
|
|
1919
1428
|
try {
|
|
1920
1429
|
await previous.handle.dispose()
|
|
1921
1430
|
} catch (error: unknown) {
|
|
1922
|
-
|
|
1431
|
+
const release = t('notice.agentReleaseFailed', { message: error instanceof Error ? error.message : String(error) })
|
|
1432
|
+
cleanupWarning = cleanupWarning === undefined ? release : `${cleanupWarning}; ${release}`
|
|
1923
1433
|
}
|
|
1434
|
+
const shortId = next.session.id.slice(-12)
|
|
1924
1435
|
bridge.notify(cleanupWarning === undefined
|
|
1925
|
-
?
|
|
1926
|
-
:
|
|
1436
|
+
? t(next.resumed ? 'notice.sessionResumed' : 'notice.sessionCreated', { id: shortId, mode: next.mode })
|
|
1437
|
+
: t('notice.sessionSwitchedDirty', { id: shortId, detail: cleanupWarning }),
|
|
1927
1438
|
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1928
1439
|
})
|
|
1929
1440
|
}
|
|
1930
1441
|
|
|
1931
1442
|
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
1932
1443
|
async request => { if (!quitting) await activate(request.target) },
|
|
1933
|
-
error => bridge.notify(
|
|
1444
|
+
error => bridge.notify(t('notice.sessionSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1934
1445
|
)
|
|
1935
1446
|
|
|
1936
1447
|
const requestSwitch = (request: PendingSwitch): void => {
|
|
@@ -1939,17 +1450,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1939
1450
|
// the target directly — there is no running turn to wait on and nothing
|
|
1940
1451
|
// to flush.
|
|
1941
1452
|
void activate(request.target).catch((error: unknown) => {
|
|
1942
|
-
bridge.notify(
|
|
1453
|
+
bridge.notify(t('notice.sessionSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1943
1454
|
})
|
|
1944
1455
|
return
|
|
1945
1456
|
}
|
|
1946
1457
|
if (request.target.sessionId === session.id) {
|
|
1947
|
-
bridge.notify('
|
|
1458
|
+
bridge.notify(t('notice.alreadyActive'), 'warning')
|
|
1948
1459
|
return
|
|
1949
1460
|
}
|
|
1950
1461
|
const outcome = switchQueue.request(agent!, request)
|
|
1951
1462
|
if (outcome === 'queued') {
|
|
1952
|
-
bridge.notify(
|
|
1463
|
+
bridge.notify(t('notice.switchQueued', { label: request.label }))
|
|
1953
1464
|
}
|
|
1954
1465
|
}
|
|
1955
1466
|
|
|
@@ -1975,7 +1486,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1975
1486
|
const requestResume = (wanted: string): void => {
|
|
1976
1487
|
void resolveResumeId(wanted).then(id => {
|
|
1977
1488
|
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
1978
|
-
}, (error: unknown) => bridge.notify(
|
|
1489
|
+
}, (error: unknown) => bridge.notify(t('notice.resumeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'))
|
|
1979
1490
|
}
|
|
1980
1491
|
|
|
1981
1492
|
const createSession = (mode?: string): void => {
|
|
@@ -2039,7 +1550,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2039
1550
|
|
|
2040
1551
|
const forkSession = (argument: string): void => {
|
|
2041
1552
|
if (session === undefined || active === undefined) {
|
|
2042
|
-
bridge.notify('
|
|
1553
|
+
bridge.notify(t('notice.noSessionYet'), 'warning')
|
|
2043
1554
|
return
|
|
2044
1555
|
}
|
|
2045
1556
|
try {
|
|
@@ -2063,13 +1574,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2063
1574
|
label: id.slice(-12),
|
|
2064
1575
|
})
|
|
2065
1576
|
} catch (error: unknown) {
|
|
2066
|
-
bridge.notify(
|
|
1577
|
+
bridge.notify(t('notice.forkFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
2067
1578
|
}
|
|
2068
1579
|
}
|
|
2069
1580
|
|
|
2070
1581
|
const switchSession = (row: SessionRow): void => {
|
|
2071
1582
|
if (!row.resumable) {
|
|
2072
|
-
bridge.notify('
|
|
1583
|
+
bridge.notify(t('notice.subagentsReadOnly'), 'warning')
|
|
2073
1584
|
return
|
|
2074
1585
|
}
|
|
2075
1586
|
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
@@ -2151,6 +1662,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2151
1662
|
loadModelProviders: () => loadProviderSettings(ctx),
|
|
2152
1663
|
subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
|
|
2153
1664
|
saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
|
|
1665
|
+
enableModelProviderSubscription: target => enableProviderSubscription(ctx, target),
|
|
1666
|
+
attachSubagent: sessionQuery === undefined ? undefined : {
|
|
1667
|
+
// The seed is the child's durable log; the two buses are the same
|
|
1668
|
+
// process-local channels the root uses, filtered by the child's id
|
|
1669
|
+
// (an Agent's id IS its SessionId).
|
|
1670
|
+
load: (id, signal) => sessionQuery.readSession(id, signal).then(snapshot => snapshot.events),
|
|
1671
|
+
subscribeEvents: (id, onEvent) => {
|
|
1672
|
+
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
1673
|
+
if (subject.id === id) onEvent(event)
|
|
1674
|
+
})
|
|
1675
|
+
return off
|
|
1676
|
+
},
|
|
1677
|
+
subscribeStream: (id, onFrame) => {
|
|
1678
|
+
const off = ctx.on('agent/assistant-stream', ({ agent: source, frame }) => {
|
|
1679
|
+
if (source.id === id) onFrame(frame)
|
|
1680
|
+
})
|
|
1681
|
+
return off
|
|
1682
|
+
},
|
|
1683
|
+
},
|
|
2154
1684
|
saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
|
|
2155
1685
|
discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
|
|
2156
1686
|
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
@@ -2215,8 +1745,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2215
1745
|
saveLanguage,
|
|
2216
1746
|
animations: animationsEnabled,
|
|
2217
1747
|
saveAnimations,
|
|
2218
|
-
history: inputHistory,
|
|
2219
|
-
recordHistory,
|
|
1748
|
+
history: inputHistory.entries(),
|
|
1749
|
+
recordHistory: inputHistory.record,
|
|
2220
1750
|
updateQueued,
|
|
2221
1751
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
2222
1752
|
})
|
|
@@ -2234,29 +1764,29 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2234
1764
|
// the agent always receives the startup prompt first.
|
|
2235
1765
|
if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
|
|
2236
1766
|
if ((startup.images?.length ?? 0) > 0) {
|
|
2237
|
-
bridge.notify(
|
|
1767
|
+
bridge.notify(t('notice.startupImages', { count: startup.images!.length, plural: startup.images!.length === 1 ? '' : 's' }))
|
|
2238
1768
|
}
|
|
2239
1769
|
void inputGate.run(async deliver => {
|
|
2240
1770
|
const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'))
|
|
2241
|
-
if (images.length > 0) bridge.notify(
|
|
1771
|
+
if (images.length > 0) bridge.notify(t('notice.startupImagesAttached', { count: images.length, plural: images.length === 1 ? '' : 's' }))
|
|
2242
1772
|
deliver({ text: startup.prompt ?? '', mode: 'followup', images })
|
|
2243
1773
|
}).catch((error: unknown) => {
|
|
2244
|
-
bridge.notify(
|
|
1774
|
+
bridge.notify(t('notice.initialPromptFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
2245
1775
|
})
|
|
2246
1776
|
}
|
|
2247
1777
|
|
|
2248
1778
|
async function copyLastResponse(): Promise<string> {
|
|
2249
1779
|
const text = latestAssistantText(store.getView())
|
|
2250
|
-
if (text === undefined) return '
|
|
1780
|
+
if (text === undefined) return t('notice.copyEmpty')
|
|
2251
1781
|
await copyText(text)
|
|
2252
|
-
return 'copied
|
|
1782
|
+
return t('notice.copied')
|
|
2253
1783
|
}
|
|
2254
1784
|
|
|
2255
1785
|
// A corrupt statusline config must not vanish silently: surface it once
|
|
2256
1786
|
// the notice channel is live, after the first frame settles.
|
|
2257
1787
|
if (statuslineWarning !== undefined) {
|
|
2258
1788
|
setTimeout(() => {
|
|
2259
|
-
bridge.notify('
|
|
1789
|
+
bridge.notify(t('notice.statuslineConfigUnreadable', { message: statuslineWarning }), 'warning')
|
|
2260
1790
|
}, 50)
|
|
2261
1791
|
}
|
|
2262
1792
|
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
@@ -2267,13 +1797,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2267
1797
|
}
|
|
2268
1798
|
if (themeWarning !== undefined) {
|
|
2269
1799
|
setTimeout(() => {
|
|
2270
|
-
bridge.notify('
|
|
1800
|
+
bridge.notify(t('notice.themeConfigUnreadable', { message: themeWarning }), 'warning')
|
|
2271
1801
|
}, 50)
|
|
2272
1802
|
}
|
|
2273
1803
|
// And for a corrupt animations file (on-by-default fallback stays live).
|
|
2274
1804
|
if (animationsWarning !== undefined) {
|
|
2275
1805
|
setTimeout(() => {
|
|
2276
|
-
bridge.notify('
|
|
1806
|
+
bridge.notify(t('notice.animationsConfigUnreadable', { message: animationsWarning }), 'warning')
|
|
2277
1807
|
}, 50)
|
|
2278
1808
|
}
|
|
2279
1809
|
|
|
@@ -2295,22 +1825,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2295
1825
|
* @param config - validated startup config resolved from the tuiStartup provider.
|
|
2296
1826
|
*/
|
|
2297
1827
|
export function apply(ctx: Context, config: Config): void {
|
|
2298
|
-
// The
|
|
2299
|
-
//
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
|
|
2304
|
-
...(config.startup.images === undefined ? {} : { images: config.startup.images }),
|
|
2305
|
-
}
|
|
2306
|
-
const startup: TuiStartup =
|
|
2307
|
-
config.startup.kind === 'resume' && config.startup.sessionId !== undefined
|
|
2308
|
-
? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
|
|
2309
|
-
: config.startup.kind === 'latest'
|
|
2310
|
-
? { kind: 'latest', ...input }
|
|
2311
|
-
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
2312
|
-
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
2313
|
-
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
1828
|
+
// The host resolves every bare @deepseek-ai/* import against its own
|
|
1829
|
+
// installed copies with no version check anywhere on that path, so refuse
|
|
1830
|
+
// to run against an identified-but-different Harness before anything loads.
|
|
1831
|
+
requireHarnessVersion(EXPECTED_HARNESS_VERSION, probeRunningHarness(process.argv[1]))
|
|
1832
|
+
const startup = resolveStartupConfig(config)
|
|
2314
1833
|
// Read through the global service store, not the property proxy: appExit is
|
|
2315
1834
|
// an optional host value, never an injected dependency.
|
|
2316
1835
|
const exit = ctx.get('appExit')
|