dsh-code 1.0.7 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +70 -24
- package/README.md +71 -25
- package/bin/deepseek.mjs +202 -39
- package/cordis.patch.yml +13 -4
- package/lib/index.mjs +5002 -1061
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
- package/lib/types/app.d.ts +120 -63
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/history.d.ts +10 -0
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +74 -2
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +89 -32
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +498 -0
- package/lib/types/locales/zh.d.ts +9 -0
- package/lib/types/mentions.d.ts +7 -3
- package/lib/types/models.d.ts +14 -0
- package/lib/types/panel-accent.d.ts +28 -0
- package/lib/types/rainbow.d.ts +69 -0
- package/lib/types/render/animations.d.ts +42 -0
- package/lib/types/render/inspector.d.ts +26 -0
- package/lib/types/render/lines.d.ts +21 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +95 -4
- package/lib/types/render/status.d.ts +12 -9
- package/lib/types/render/text.d.ts +6 -0
- package/lib/types/render/usage.d.ts +113 -0
- package/lib/types/session-directory.d.ts +42 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +8 -0
- package/lib/types/theme-panel.d.ts +2 -2
- package/lib/types/theme.d.ts +271 -52
- package/lib/types/update-panel.d.ts +27 -6
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +6 -3
- package/package.json +26 -7
- package/src/app.ts +1426 -627
- package/src/approval.ts +166 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +24 -18
- package/src/editor-keys.ts +371 -371
- package/src/fork.ts +11 -7
- package/src/git-workflow.ts +229 -3
- package/src/history.ts +14 -0
- package/src/i18n.ts +68 -0
- package/src/index.ts +503 -128
- package/src/input-split.ts +27 -7
- package/src/kernel-panels.ts +528 -113
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +538 -0
- package/src/locales/zh.ts +537 -0
- package/src/mentions.ts +8 -4
- package/src/models.ts +264 -212
- package/src/panel-accent.ts +41 -0
- package/src/presets.ts +1 -1
- package/src/provider-settings.ts +1 -1
- package/src/rainbow.ts +218 -0
- package/src/render/animations.ts +104 -6
- package/src/render/editor.ts +20 -20
- package/src/render/export.ts +116 -95
- package/src/render/inspector.ts +42 -0
- package/src/render/lines.ts +628 -415
- package/src/render/markdown.ts +15 -3
- package/src/render/projection.ts +429 -19
- package/src/render/status.ts +119 -62
- package/src/render/text.ts +15 -0
- package/src/render/tool-preview.ts +77 -77
- package/src/render/usage.ts +430 -0
- package/src/render/width.ts +2 -2
- package/src/session-directory.ts +90 -9
- package/src/session-query.ts +8 -4
- package/src/session-switch.ts +14 -0
- package/src/startup.ts +3 -3
- package/src/store.ts +19 -1
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +22 -5
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +148 -31
- package/src/update.ts +19 -3
- package/src/version.ts +63 -20
- package/src/whale-glyph.ts +23 -23
package/src/index.ts
CHANGED
|
@@ -18,25 +18,26 @@ import { createElement } from 'react'
|
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
19
|
import z from '@deepseek-ai/schemastery'
|
|
20
20
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
-
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
21
|
+
import type { Agent, AgentHandle, AgentStatus, Inbox, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
22
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
24
24
|
import { createUserMessage, MessageId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
25
25
|
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
26
26
|
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
27
|
-
import
|
|
27
|
+
import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
|
28
|
+
import { SessionAlreadyOwnedError, type SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
28
29
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
29
30
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
30
31
|
// Empty type imports carry the loader Context merge for the settlement await
|
|
31
32
|
// and the cmdline Context merge for the appExit host value.
|
|
32
33
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
33
34
|
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
34
|
-
import { App, type NoticeTone } from './app.ts'
|
|
35
|
+
import { App, type NoticeTone, type QueueMutation } from './app.ts'
|
|
35
36
|
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
36
37
|
import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
|
|
37
38
|
import { internals, type TuiMount } from './internals.ts'
|
|
38
39
|
import { syncModelCapabilities } from './model-capabilities.ts'
|
|
39
|
-
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
40
|
+
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, pendingModelSelection, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
40
41
|
import {
|
|
41
42
|
discoverProviderModels,
|
|
42
43
|
loadProviderSettings,
|
|
@@ -70,7 +71,15 @@ import {
|
|
|
70
71
|
subscribeProviderAuthorizations,
|
|
71
72
|
} from './authorization.ts'
|
|
72
73
|
import { selectForkSeed } from './fork.ts'
|
|
73
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
buildReviewPrompt,
|
|
76
|
+
listReviewBranches,
|
|
77
|
+
listReviewCommits,
|
|
78
|
+
loadCommitDiff,
|
|
79
|
+
loadGitDiff,
|
|
80
|
+
mergeBaseWith,
|
|
81
|
+
type ReviewSelection,
|
|
82
|
+
} from './git-workflow.ts'
|
|
74
83
|
import type { TuiStartup } from './startup.ts'
|
|
75
84
|
import { SessionSwitchQueue } from './session-switch.ts'
|
|
76
85
|
import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
|
|
@@ -85,22 +94,32 @@ import { listPluginRows } from './plugin-inventory.ts'
|
|
|
85
94
|
import { applyLauncherUpdate, probeLauncherUpdate } from './update.ts'
|
|
86
95
|
import { parseAnimationsPref } from './render/animations.ts'
|
|
87
96
|
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
97
|
+
import { parseLanguageName, setLanguage, t, type LanguageName } from './i18n.ts'
|
|
88
98
|
import {
|
|
99
|
+
acquireSessionDeletionLeases,
|
|
89
100
|
isSubagentSession,
|
|
90
101
|
matchSessionId,
|
|
91
102
|
mergeSessionTitles,
|
|
92
103
|
newestRootForCwd,
|
|
104
|
+
sessionRowMatchesQuery,
|
|
93
105
|
isSessionArtifactName,
|
|
94
106
|
jsonlSessionRoot,
|
|
95
107
|
planSessionDeletion,
|
|
96
108
|
projectSessionRows,
|
|
109
|
+
releaseSessionDeletionLeases,
|
|
97
110
|
sessionArtifactDirectory,
|
|
98
111
|
sessionDirectoryFor,
|
|
99
112
|
type SessionDirectoryOptions,
|
|
100
113
|
type SessionQueryService,
|
|
101
114
|
type SessionRow,
|
|
102
115
|
} from './session-directory.ts'
|
|
116
|
+
import type { JobRow, SearchRow } from './kernel-panels.ts'
|
|
103
117
|
import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
|
|
118
|
+
import { turnUsages, type UsageView } from './render/usage.ts'
|
|
119
|
+
// Type-only import: merges the projection registry into the Context type so
|
|
120
|
+
// `ctx.get('sessionProjections')` is typed (the service itself is mounted by
|
|
121
|
+
// dsh-base at runtime).
|
|
122
|
+
import type {} from '@deepseek-ai/dsh-session-projection'
|
|
104
123
|
|
|
105
124
|
/** Stable Cordis plugin name. */
|
|
106
125
|
export const name = 'tui-runner'
|
|
@@ -128,7 +147,7 @@ export const Config: z<Config> = z.object({
|
|
|
128
147
|
/** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
|
|
129
148
|
interface TuiIo {
|
|
130
149
|
mount: typeof internals.mount
|
|
131
|
-
exit(code: number)
|
|
150
|
+
exit: (code: number) => void
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
@@ -147,7 +166,7 @@ function fail(io: TuiIo, error: unknown): void {
|
|
|
147
166
|
* @param caller - the active agent (undefined sees only unowned jobs).
|
|
148
167
|
* @returns job rows in registration order; never throws.
|
|
149
168
|
*/
|
|
150
|
-
function listJobs(ctx: Context, caller: Agent | undefined): readonly
|
|
169
|
+
function listJobs(ctx: Context, caller: Agent | undefined): readonly JobRow[] {
|
|
151
170
|
const jobs = ctx.get('jobs')
|
|
152
171
|
if (jobs === undefined) return []
|
|
153
172
|
try {
|
|
@@ -165,6 +184,23 @@ function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./k
|
|
|
165
184
|
}
|
|
166
185
|
}
|
|
167
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Read one user-level settings file as a plain object. The callers all treat a
|
|
189
|
+
* missing file as "unset" and a corrupt one as "warn and fall back", so this
|
|
190
|
+
* helper owns the one distinction they share: readable JSON that is not an
|
|
191
|
+
* object is corruption, not an absent preference, and must not surface as a
|
|
192
|
+
* cryptic property access on `null`.
|
|
193
|
+
* @param path - absolute path of the settings file.
|
|
194
|
+
* @returns the parsed object; the caller narrows each field itself.
|
|
195
|
+
*/
|
|
196
|
+
function readSettingsObject(path: string): Record<string, unknown> {
|
|
197
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
198
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
199
|
+
throw new Error(`${basename(path)} must contain a JSON object`)
|
|
200
|
+
}
|
|
201
|
+
return parsed as Record<string, unknown>
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
/**
|
|
169
205
|
* Resolve the working directory's git branch for the status line.
|
|
170
206
|
* @param cwd - the session's working directory.
|
|
@@ -189,6 +225,8 @@ interface Target {
|
|
|
189
225
|
cwd?: string
|
|
190
226
|
seed?: readonly SessionEvent[]
|
|
191
227
|
parentSession?: SessionId
|
|
228
|
+
/** Marks the session as a subagent conversation in the durable header. */
|
|
229
|
+
origin?: 'subagent'
|
|
192
230
|
seedLength?: number
|
|
193
231
|
}
|
|
194
232
|
|
|
@@ -253,10 +291,100 @@ export async function runQuitSequence(
|
|
|
253
291
|
/** One composer submission waiting behind the startup delivery. */
|
|
254
292
|
export interface QueuedSubmission {
|
|
255
293
|
readonly text: string
|
|
294
|
+
/** `steer` inserts into the running turn; `followup` waits for the next one. */
|
|
256
295
|
readonly mode: 'followup' | 'steer'
|
|
257
296
|
readonly images: readonly ContentBlock[]
|
|
258
297
|
}
|
|
259
298
|
|
|
299
|
+
/** What one requested queue mutation did; the runner maps it to one notice. */
|
|
300
|
+
export type QueueMutationOutcome =
|
|
301
|
+
| 'removed'
|
|
302
|
+
| 'edited'
|
|
303
|
+
| 'steered'
|
|
304
|
+
| 'unavailable'
|
|
305
|
+
| 'empty'
|
|
306
|
+
| 'steerUnavailable'
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Replace one queued message's text while keeping its attachments. A queue
|
|
310
|
+
* edit rewrites what the user typed, not what they attached: image and file
|
|
311
|
+
* blocks ride through in delivery order (text first, then attachments, the
|
|
312
|
+
* shape {@link deliverLine} submits). Dropping them here would silently strip
|
|
313
|
+
* an attachment the user already confirmed, so this is the edit's single
|
|
314
|
+
* definition and the panel's read-only marker only mirrors it.
|
|
315
|
+
*/
|
|
316
|
+
export function queueEditContent(content: readonly ContentBlock[], text: string): ContentBlock[] {
|
|
317
|
+
const attachments = content.filter(block => block.type !== 'text')
|
|
318
|
+
return [{ type: 'text', text }, ...attachments]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Apply one terminal queue mutation to the live inbox. The decision and the
|
|
323
|
+
* inbox change are pure over the supplied handles so every branch is testable
|
|
324
|
+
* without an agent; steering itself is injected because it wakes the driver
|
|
325
|
+
* rather than mutating the inbox. The durable inbox splices remain the UI's
|
|
326
|
+
* single source of truth — this helper never reports a state the inbox did not
|
|
327
|
+
* actually reach.
|
|
328
|
+
* @param inbox - the live agent inbox (pending lists plus its mutators).
|
|
329
|
+
* @param status - the agent's lifecycle status; steering needs `running`.
|
|
330
|
+
* @param messageId - identity of the queued message to mutate.
|
|
331
|
+
* @param action - the requested mutation.
|
|
332
|
+
* @param steer - submits the removed message as next-step steering.
|
|
333
|
+
* @returns the outcome the caller reports.
|
|
334
|
+
*/
|
|
335
|
+
export function applyQueueMutation(
|
|
336
|
+
inbox: Pick<Inbox, 'nextTurn' | 'append' | 'remove' | 'replace'>,
|
|
337
|
+
status: AgentStatus,
|
|
338
|
+
messageId: string,
|
|
339
|
+
action: QueueMutation,
|
|
340
|
+
steer: (message: UserMessage) => void,
|
|
341
|
+
): QueueMutationOutcome {
|
|
342
|
+
const id = MessageId(messageId)
|
|
343
|
+
const message = inbox.nextTurn.find(candidate => candidate.id === id)
|
|
344
|
+
if (message === undefined) return 'unavailable'
|
|
345
|
+
switch (action.kind) {
|
|
346
|
+
case 'remove':
|
|
347
|
+
return inbox.remove(id) ? 'removed' : 'unavailable'
|
|
348
|
+
case 'edit':
|
|
349
|
+
if (action.text.trim() === '') return 'empty'
|
|
350
|
+
inbox.replace(id, createUserMessage({
|
|
351
|
+
content: queueEditContent(message.content, action.text),
|
|
352
|
+
source: message.source,
|
|
353
|
+
}))
|
|
354
|
+
return 'edited'
|
|
355
|
+
case 'steer':
|
|
356
|
+
if (status !== 'running') return 'steerUnavailable'
|
|
357
|
+
// Steer promotes the message out of next-turn, so a failing submit must
|
|
358
|
+
// put it back: the row the user was looking at never just disappears.
|
|
359
|
+
if (!inbox.remove(id)) return 'unavailable'
|
|
360
|
+
try {
|
|
361
|
+
steer(message)
|
|
362
|
+
} catch (error: unknown) {
|
|
363
|
+
inbox.append('next-turn', message)
|
|
364
|
+
throw error
|
|
365
|
+
}
|
|
366
|
+
return 'steered'
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Cancel the active turn while keeping the next-turn queue, then wake the
|
|
372
|
+
* driver again so the preserved messages actually run. `cancel` clears
|
|
373
|
+
* pending work by default and never wakes the driver on its own, so the queue
|
|
374
|
+
* is captured first and re-submitted afterwards: a waking submission latches
|
|
375
|
+
* the wake while the aborted activity converges to idle, which is what turns
|
|
376
|
+
* "preserved" into "sent next" instead of "parked forever". Next-step
|
|
377
|
+
* steering is deliberately dropped — it belonged to the cancelled turn.
|
|
378
|
+
* @param agent - the live agent handle.
|
|
379
|
+
* @returns how many queued messages were preserved across the abort.
|
|
380
|
+
*/
|
|
381
|
+
export function cancelPreservingQueue(agent: Pick<Agent, 'inbox' | 'cancel' | 'followup'>): number {
|
|
382
|
+
const queued = [...agent.inbox.nextTurn]
|
|
383
|
+
agent.cancel({ kind: 'user' })
|
|
384
|
+
for (const message of queued) agent.followup(message)
|
|
385
|
+
return queued.length
|
|
386
|
+
}
|
|
387
|
+
|
|
260
388
|
/**
|
|
261
389
|
* Whether a tagged submission still belongs to the active session. Attachment
|
|
262
390
|
* prepares resolve on the microtask timeline, while a queued session switch
|
|
@@ -269,6 +397,44 @@ export function submissionBelongsToSession(origin: string | undefined, activeSes
|
|
|
269
397
|
return origin === undefined || origin === '' || origin === activeSessionId
|
|
270
398
|
}
|
|
271
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Root-log catalog facts a resumed session must replay into the subagent
|
|
402
|
+
* feed: constructor seeds never fire on the live bus, so without this the
|
|
403
|
+
* children of a resumed session vanish behind a restart. The empty-child
|
|
404
|
+
* placeholder row (childId '') is a placeholder, not a child, and stays out.
|
|
405
|
+
*/
|
|
406
|
+
export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
|
|
407
|
+
return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
|
|
408
|
+
event.type === 'subagent/catalog' && event.data.childId !== '')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Map one cross-session full-text hit onto the /search panel's row (pure).
|
|
413
|
+
* Labels fall back to the short id form — the engine's hit carries the
|
|
414
|
+
* strongest matching event, not the title observation.
|
|
415
|
+
*/
|
|
416
|
+
export function searchHitToRow(hit: {
|
|
417
|
+
header: SessionHeader
|
|
418
|
+
bestMatch: { snippet: string; time: number }
|
|
419
|
+
}): SearchRow {
|
|
420
|
+
const subagent = hit.header.origin === 'subagent'
|
|
421
|
+
const cwd = hit.header.cwd ?? ''
|
|
422
|
+
// Session cwds may arrive in either separator style regardless of the
|
|
423
|
+
// observing host (a workspace synced from Windows), so split on both.
|
|
424
|
+
const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? ''
|
|
425
|
+
const preset = hit.header.agentPreset ?? ''
|
|
426
|
+
const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim()
|
|
427
|
+
return {
|
|
428
|
+
id: hit.header.id,
|
|
429
|
+
label: hit.header.id.slice(-12),
|
|
430
|
+
detail: [workspace, preset].filter(part => part !== '').join(' · '),
|
|
431
|
+
snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
|
|
432
|
+
updatedAt: hit.bestMatch.time,
|
|
433
|
+
subagent,
|
|
434
|
+
resumable: !subagent,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
272
438
|
/** One Shift+Tab station decision for the mode cycle. */
|
|
273
439
|
export type ModeCycleDecision =
|
|
274
440
|
| { readonly kind: 'permission'; readonly preset: string }
|
|
@@ -285,17 +451,26 @@ export type ModeCycleDecision =
|
|
|
285
451
|
* parked on read-only), and leaving it lands on the next preset after the
|
|
286
452
|
* most restrictive one. Without the /plan command the cycle is exactly the
|
|
287
453
|
* preset table.
|
|
454
|
+
*
|
|
455
|
+
* `planIntent` covers the committed fold's commit lag: upstream queues a
|
|
456
|
+
* plan switch during an open turn (and the command pipeline is async even
|
|
457
|
+
* idle), so the durable plan/mode event lands AFTER the press that chose
|
|
458
|
+
* it. While an intent from an earlier press is in flight it — not the
|
|
459
|
+
* stale committed fold — decides the station, so repeated presses advance
|
|
460
|
+
* the cycle instead of re-issuing the same plan transition (the stuck
|
|
461
|
+
* plan-on/plan-off toggle). Undefined falls back to the committed fold.
|
|
288
462
|
*/
|
|
289
463
|
export function planCycleDecision(input: {
|
|
290
464
|
readonly names: readonly string[]
|
|
291
465
|
readonly current: string
|
|
292
466
|
readonly inPlan: boolean
|
|
293
467
|
readonly planAvailable: boolean
|
|
468
|
+
readonly planIntent?: boolean
|
|
294
469
|
}): ModeCycleDecision | undefined {
|
|
295
470
|
const names = input.names
|
|
296
471
|
if (names.length === 0) return undefined
|
|
297
|
-
const first = names[0]
|
|
298
|
-
if (input.inPlan) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
472
|
+
const first = names[0]
|
|
473
|
+
if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
299
474
|
const at = names.indexOf(input.current)
|
|
300
475
|
if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
|
|
301
476
|
return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
|
|
@@ -398,7 +573,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
|
|
|
398
573
|
/** The runner's connection between the React app and the process side. */
|
|
399
574
|
interface AppBridge {
|
|
400
575
|
/** Post one local notice line (feedback the transcript does not carry). */
|
|
401
|
-
notify(text: string, tone?: NoticeTone)
|
|
576
|
+
notify: (text: string, tone?: NoticeTone) => void
|
|
402
577
|
}
|
|
403
578
|
|
|
404
579
|
/**
|
|
@@ -444,6 +619,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
444
619
|
mode: string
|
|
445
620
|
selection: { picked?: ModelSelection }
|
|
446
621
|
resumed: boolean
|
|
622
|
+
/**
|
|
623
|
+
* Root-log `subagent/catalog` facts (resume path): constructor seeds
|
|
624
|
+
* never fire on the live bus, so activation replays them into the
|
|
625
|
+
* subagent feed after its reset — a resumed session's children stay
|
|
626
|
+
* visible instead of vanishing behind a restart.
|
|
627
|
+
*/
|
|
628
|
+
catalogSeed: readonly SessionEvent<'subagent/catalog'>[]
|
|
447
629
|
}
|
|
448
630
|
|
|
449
631
|
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
@@ -497,6 +679,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
497
679
|
cwd: nextCwd,
|
|
498
680
|
agentPreset: mode,
|
|
499
681
|
...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
|
|
682
|
+
...(next.origin === undefined ? {} : { origin: next.origin }),
|
|
500
683
|
// 0.1.5 fork lineage: the seed marker lives on the metadata and the
|
|
501
684
|
// inherited prefix length on the top-level option (the v0 header's
|
|
502
685
|
// numeric `seedLength` field is gone from the create contract).
|
|
@@ -512,15 +695,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
512
695
|
if (!next.resume && permissionPresets !== undefined) {
|
|
513
696
|
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
514
697
|
}
|
|
698
|
+
const seedEvents = session.snapshotEvents()
|
|
699
|
+
// Resume precedence, middle layer: the log's unconsumed `model/selection`
|
|
700
|
+
// (a pick the web host recorded that no request ever assembled) outranks
|
|
701
|
+
// the older request header; an in-process pick still outranks both.
|
|
702
|
+
if (next.resume && selectionState.picked === undefined) {
|
|
703
|
+
const pending = pendingModelSelection(seedEvents)
|
|
704
|
+
if (pending !== undefined) selectionState.picked = pending
|
|
705
|
+
}
|
|
515
706
|
return {
|
|
516
707
|
handle,
|
|
517
708
|
agent: handle.agent,
|
|
518
709
|
session,
|
|
519
|
-
store: createTranscriptStore(
|
|
710
|
+
store: createTranscriptStore(seedEvents),
|
|
520
711
|
mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
|
|
521
712
|
mode: mode ?? 'standard',
|
|
522
713
|
selection: selectionState,
|
|
523
714
|
resumed: next.resume,
|
|
715
|
+
catalogSeed: subagentCatalogSeed(seedEvents),
|
|
524
716
|
}
|
|
525
717
|
}
|
|
526
718
|
|
|
@@ -549,6 +741,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
549
741
|
* session composes, so the first assembled step already plans.
|
|
550
742
|
*/
|
|
551
743
|
let pendingPlan = false
|
|
744
|
+
/**
|
|
745
|
+
* In-flight mid-session plan choice from the Shift+Tab cycle. Upstream
|
|
746
|
+
* queues a plan switch during an open turn (and the command pipeline is
|
|
747
|
+
* async even idle), so the committed plan/mode fold lags the press that
|
|
748
|
+
* chose it; the cycle reads this intent until the durable event lands,
|
|
749
|
+
* then the session/event funnel clears it.
|
|
750
|
+
*/
|
|
751
|
+
let planIntent: boolean | undefined
|
|
552
752
|
/**
|
|
553
753
|
* Whether the pre-session effective preset composes plan mode, answered by
|
|
554
754
|
* the presets service composition inventory (minimal does not). Cached and
|
|
@@ -616,6 +816,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
616
816
|
session = prepared.session
|
|
617
817
|
store = prepared.store
|
|
618
818
|
mentions = prepared.mentions
|
|
819
|
+
// Replayed catalog facts rebuild the resumed session's child rows before
|
|
820
|
+
// the first render (the live handler only folds events from now on).
|
|
821
|
+
for (const event of prepared.catalogSeed) subagents.apply(event.data.childId, event)
|
|
619
822
|
}
|
|
620
823
|
|
|
621
824
|
// Seed the transcript from the full session log: constructor seeds never
|
|
@@ -626,6 +829,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
626
829
|
if (session === undefined) return
|
|
627
830
|
if (subject.id === session.id) {
|
|
628
831
|
store.apply(event)
|
|
832
|
+
// The committed plan fold caught up (or diverged via a typed /plan or
|
|
833
|
+
// an approved plan review): the durable event is the live truth again,
|
|
834
|
+
// so the cycle's in-flight intent retires.
|
|
835
|
+
if (event.type === 'plan/mode') planIntent = undefined
|
|
629
836
|
// The parent-owned subagent catalog rides the ROOT log (0.1.5); each
|
|
630
837
|
// fact describes one child, so it feeds that child's live row.
|
|
631
838
|
if (event.type === 'subagent/catalog' && event.data.childId !== '') subagents.apply(event.data.childId, event)
|
|
@@ -741,7 +948,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
741
948
|
let statuslineWarning: string | undefined
|
|
742
949
|
let statuslineItems: readonly string[] = []
|
|
743
950
|
try {
|
|
744
|
-
statuslineItems = parseStatuslineItems(
|
|
951
|
+
statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
|
|
745
952
|
} catch (error) {
|
|
746
953
|
statuslineItems = parseStatuslineItems(undefined)
|
|
747
954
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
@@ -757,7 +964,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
757
964
|
statuslineItems = [...items]
|
|
758
965
|
void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
|
|
759
966
|
.catch((writeError: unknown) => {
|
|
760
|
-
bridge.notify('
|
|
967
|
+
bridge.notify(t('notice.statuslineSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
761
968
|
})
|
|
762
969
|
}
|
|
763
970
|
|
|
@@ -780,7 +987,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
780
987
|
let themeWarning: string | undefined
|
|
781
988
|
if (startup.theme === undefined) {
|
|
782
989
|
try {
|
|
783
|
-
setTheme(parseThemeName(
|
|
990
|
+
setTheme(parseThemeName(readSettingsObject(themePath).theme))
|
|
784
991
|
} catch (error) {
|
|
785
992
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
786
993
|
themeWarning = error instanceof Error ? error.message : String(error)
|
|
@@ -793,7 +1000,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
793
1000
|
setTheme(name)
|
|
794
1001
|
void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
|
|
795
1002
|
.catch((writeError: unknown) => {
|
|
796
|
-
bridge.notify('
|
|
1003
|
+
bridge.notify(t('notice.themeSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// /language persistence: one user-level JSON file beside theme.json. A
|
|
1008
|
+
// missing file means English; a corrupt file degrades to English with a
|
|
1009
|
+
// surfaced warning.
|
|
1010
|
+
const languagePath = join(homedir(), '.dsh', 'dsh-code', 'language.json')
|
|
1011
|
+
let languageWarning: string | undefined
|
|
1012
|
+
try {
|
|
1013
|
+
setLanguage(parseLanguageName(readSettingsObject(languagePath).language))
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1016
|
+
languageWarning = error instanceof Error ? error.message : String(error)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const saveLanguage = (name: LanguageName): void => {
|
|
1020
|
+
setLanguage(name)
|
|
1021
|
+
void settingsPersistence.save(languagePath, JSON.stringify({ language: name }, null, 2) + '\n')
|
|
1022
|
+
.catch((writeError: unknown) => {
|
|
1023
|
+
bridge.notify(t('notice.languageSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
797
1024
|
})
|
|
798
1025
|
}
|
|
799
1026
|
|
|
@@ -806,8 +1033,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
806
1033
|
let animationsEnabled = true
|
|
807
1034
|
let animationsWarning: string | undefined
|
|
808
1035
|
try {
|
|
809
|
-
//
|
|
810
|
-
|
|
1036
|
+
// A literal `null` file reads as corruption and surfaces the warning the
|
|
1037
|
+
// block above promises, instead of a property access on `null`.
|
|
1038
|
+
animationsEnabled = parseAnimationsPref(readSettingsObject(animationsPath).animations)
|
|
811
1039
|
} catch (error) {
|
|
812
1040
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
813
1041
|
animationsWarning = error instanceof Error ? error.message : String(error)
|
|
@@ -816,7 +1044,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
816
1044
|
const saveAnimations = (enabled: boolean): void => {
|
|
817
1045
|
void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
|
|
818
1046
|
.catch((writeError: unknown) => {
|
|
819
|
-
bridge.notify('
|
|
1047
|
+
bridge.notify(t('notice.animationsSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
820
1048
|
})
|
|
821
1049
|
}
|
|
822
1050
|
|
|
@@ -860,19 +1088,32 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
860
1088
|
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
861
1089
|
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
862
1090
|
.catch((writeError: unknown) => {
|
|
863
|
-
bridge.notify('
|
|
1091
|
+
bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
864
1092
|
})
|
|
865
1093
|
}
|
|
866
1094
|
|
|
867
|
-
/**
|
|
868
|
-
const
|
|
869
|
-
|
|
1095
|
+
/** Mutate one next-turn inbox item; durable inbox splices remain the UI truth. */
|
|
1096
|
+
const updateQueued = (messageId: string, action: QueueMutation): void => {
|
|
1097
|
+
const current = agent
|
|
1098
|
+
if (current === undefined) return
|
|
870
1099
|
try {
|
|
871
|
-
|
|
872
|
-
|
|
1100
|
+
const outcome = applyQueueMutation(
|
|
1101
|
+
current.inbox,
|
|
1102
|
+
current.status,
|
|
1103
|
+
messageId,
|
|
1104
|
+
action,
|
|
1105
|
+
message => current.steer(message),
|
|
1106
|
+
)
|
|
1107
|
+
switch (outcome) {
|
|
1108
|
+
case 'removed': bridge.notify(t('notice.queueCancelled')); return
|
|
1109
|
+
case 'edited': bridge.notify(t('notice.queueEdited')); return
|
|
1110
|
+
case 'steered': bridge.notify(t('notice.queueSteered')); return
|
|
1111
|
+
case 'empty': bridge.notify(t('notice.queueEditEmpty'), 'warning'); return
|
|
1112
|
+
case 'steerUnavailable': bridge.notify(t('notice.queueSteerUnavailable'), 'warning'); return
|
|
1113
|
+
case 'unavailable': bridge.notify(t('notice.queueUnavailable'), 'warning'); return
|
|
873
1114
|
}
|
|
874
1115
|
} catch (error: unknown) {
|
|
875
|
-
bridge.notify('
|
|
1116
|
+
bridge.notify(t('notice.queueActionFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
876
1117
|
}
|
|
877
1118
|
}
|
|
878
1119
|
|
|
@@ -937,7 +1178,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
937
1178
|
}
|
|
938
1179
|
const registry = ctx.get('commands')
|
|
939
1180
|
if (registry === undefined) {
|
|
940
|
-
bridge.notify('
|
|
1181
|
+
bridge.notify(t('notice.commandRegistryMissing'), 'error')
|
|
941
1182
|
return
|
|
942
1183
|
}
|
|
943
1184
|
const controller = new AbortController()
|
|
@@ -965,13 +1206,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
965
1206
|
source: { kind: 'user' },
|
|
966
1207
|
}))
|
|
967
1208
|
} catch (error: unknown) {
|
|
968
|
-
bridge.notify(
|
|
1209
|
+
bridge.notify(t('notice.commandFallbackFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
969
1210
|
}
|
|
970
1211
|
}
|
|
971
1212
|
}, (error: unknown) => {
|
|
972
1213
|
finish()
|
|
973
1214
|
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
974
|
-
|
|
1215
|
+
// A failed plan switch never appends the plan/mode event the cycle's
|
|
1216
|
+
// intent retirement waits for, so the in-flight choice dies here too —
|
|
1217
|
+
// otherwise every later Shift+Tab reads a phantom plan state.
|
|
1218
|
+
if (line === '/plan' || line === '/plan off') planIntent = undefined
|
|
1219
|
+
bridge.notify(t('notice.commandFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
975
1220
|
})
|
|
976
1221
|
}
|
|
977
1222
|
|
|
@@ -979,13 +1224,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
979
1224
|
let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
|
|
980
1225
|
|
|
981
1226
|
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
982
|
-
const deliverLine = (line: string, mode: 'followup' | 'steer'
|
|
1227
|
+
const deliverLine = (line: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
983
1228
|
const currentAgent = agent!
|
|
984
|
-
const currentMentions = mentions
|
|
1229
|
+
const currentMentions = mentions
|
|
985
1230
|
// The command registry is a closed namespace: slash lines run out of
|
|
986
|
-
// band and never reach the model through this path
|
|
987
|
-
|
|
988
|
-
if (images.length === 0 && isSlashLine(line) && mode === 'followup') {
|
|
1231
|
+
// band and never reach the model through this path.
|
|
1232
|
+
if (images.length === 0 && isSlashLine(line)) {
|
|
989
1233
|
runSlash(line)
|
|
990
1234
|
return
|
|
991
1235
|
}
|
|
@@ -993,7 +1237,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
993
1237
|
try {
|
|
994
1238
|
parsed = currentMentions.parse(line)
|
|
995
1239
|
} catch (error: unknown) {
|
|
996
|
-
bridge.notify(
|
|
1240
|
+
bridge.notify(t('notice.invalidReference', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
997
1241
|
return
|
|
998
1242
|
}
|
|
999
1243
|
// Ordered delivery: the inbox order IS the user's message order. A line
|
|
@@ -1023,15 +1267,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1023
1267
|
content,
|
|
1024
1268
|
source: { kind: 'user' },
|
|
1025
1269
|
})
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
} else {
|
|
1031
|
-
currentAgent.followup(message)
|
|
1032
|
-
}
|
|
1270
|
+
// Steering is consumed at the next step boundary of the turn already
|
|
1271
|
+
// running; a followup becomes its own turn instead.
|
|
1272
|
+
if (mode === 'steer') currentAgent.steer(message)
|
|
1273
|
+
else currentAgent.followup(message)
|
|
1033
1274
|
} catch (error: unknown) {
|
|
1034
|
-
bridge.notify(
|
|
1275
|
+
bridge.notify(t('notice.messageFailed', { kind: mode === 'steer' ? 'steering' : 'message', message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1035
1276
|
}
|
|
1036
1277
|
}
|
|
1037
1278
|
if (parsed.references.length === 0) {
|
|
@@ -1040,14 +1281,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1040
1281
|
}
|
|
1041
1282
|
const controller = new AbortController()
|
|
1042
1283
|
pendingControllers.add(controller)
|
|
1043
|
-
enqueueDelivery
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1284
|
+
// `enqueueDelivery` returns nothing; the delivery chain only orders the
|
|
1285
|
+
// work, so the promise is consumed here with an explicit void.
|
|
1286
|
+
enqueueDelivery(() => {
|
|
1287
|
+
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
1288
|
+
pendingControllers.delete(controller)
|
|
1289
|
+
deliver(prepared.text, prepared.additionalContext)
|
|
1290
|
+
}, (error: unknown) => {
|
|
1291
|
+
pendingControllers.delete(controller)
|
|
1292
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1293
|
+
bridge.notify(t('notice.referenceFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1294
|
+
})
|
|
1295
|
+
})
|
|
1051
1296
|
}
|
|
1052
1297
|
|
|
1053
1298
|
// Deferred first-session creation for a bare launch: the session is composed
|
|
@@ -1073,7 +1318,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1073
1318
|
// (which would orphan the live one without a dispose).
|
|
1074
1319
|
if (session !== undefined) {
|
|
1075
1320
|
const queued = pendingInputs.splice(0)
|
|
1076
|
-
for (const item of queued) deliverLine(item.text, item.
|
|
1321
|
+
for (const item of queued) deliverLine(item.text, item.images, item.mode)
|
|
1077
1322
|
return
|
|
1078
1323
|
}
|
|
1079
1324
|
const next = await prepare({
|
|
@@ -1093,6 +1338,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1093
1338
|
store = next.store
|
|
1094
1339
|
mentions = next.mentions
|
|
1095
1340
|
subagents.reset()
|
|
1341
|
+
for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1096
1342
|
pendingMode = undefined
|
|
1097
1343
|
pendingPermission = undefined
|
|
1098
1344
|
commands.setAgent(agent)
|
|
@@ -1121,7 +1367,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1121
1367
|
}
|
|
1122
1368
|
await next.handle.dispose().catch(() => {})
|
|
1123
1369
|
if (!quitting) renderCurrent()
|
|
1124
|
-
bridge.notify(
|
|
1370
|
+
bridge.notify(t('notice.activationFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1125
1371
|
return
|
|
1126
1372
|
}
|
|
1127
1373
|
abortPendingControllers()
|
|
@@ -1132,52 +1378,62 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1132
1378
|
// A pre-session plan choice materializes as the registry command
|
|
1133
1379
|
// delivered AHEAD of the queued lines, so the first assembled step
|
|
1134
1380
|
// of the user's opening message already runs in plan mode.
|
|
1135
|
-
deliverLine('/plan'
|
|
1381
|
+
deliverLine('/plan')
|
|
1136
1382
|
}
|
|
1137
|
-
for (const item of queued) deliverLine(item.text, item.
|
|
1383
|
+
for (const item of queued) deliverLine(item.text, item.images, item.mode)
|
|
1138
1384
|
} finally {
|
|
1139
1385
|
creating = false
|
|
1140
1386
|
}
|
|
1141
1387
|
}).catch((error: unknown) => {
|
|
1142
1388
|
pendingInputs.length = 0
|
|
1143
|
-
bridge.notify(
|
|
1389
|
+
bridge.notify(t('notice.creationFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1144
1390
|
})
|
|
1145
1391
|
}
|
|
1146
1392
|
|
|
1147
1393
|
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
1148
|
-
const sendNow = (text: string, mode: 'followup' | 'steer'
|
|
1394
|
+
const sendNow = (text: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
1149
1395
|
// Blank check on the trimmed form; the payload itself keeps the draft's
|
|
1150
1396
|
// exact whitespace unless the line is a syntactic slash command.
|
|
1151
1397
|
const line = submissionPayload(text)
|
|
1152
1398
|
if (line.trim() === '' && images.length === 0) return
|
|
1399
|
+
// A switch between the idle wait and the handoff keeps the OLD session
|
|
1400
|
+
// installed: a line delivered now would start a turn the handoff discards.
|
|
1401
|
+
// Refusing loudly beats losing it silently — the caller can retry after the
|
|
1402
|
+
// switch, and `switchQueue.cancel()` is the way out.
|
|
1403
|
+
if (switchQueue.activating) {
|
|
1404
|
+
bridge.notify(t('notice.switchInProgress'), 'warning')
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1153
1407
|
if (images.length === 0 && line.startsWith('/mode ')) {
|
|
1154
1408
|
void switchModeAction(line.slice(6).trim()).then(
|
|
1155
|
-
selected => bridge.notify(
|
|
1156
|
-
error => bridge.notify(
|
|
1409
|
+
selected => bridge.notify(t('notice.modeChanged', { value: selected })),
|
|
1410
|
+
error => bridge.notify(t('notice.modeChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1157
1411
|
)
|
|
1158
1412
|
return
|
|
1159
1413
|
}
|
|
1160
1414
|
if (images.length === 0 && line.startsWith('/permission ')) {
|
|
1161
1415
|
try {
|
|
1162
1416
|
const selected = setPermissionAction(line.slice(12).trim())
|
|
1163
|
-
bridge.notify(
|
|
1417
|
+
bridge.notify(t('notice.permissionChanged', { value: selected }))
|
|
1164
1418
|
} catch (error: unknown) {
|
|
1165
|
-
bridge.notify(
|
|
1419
|
+
bridge.notify(t('notice.permissionChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1166
1420
|
}
|
|
1167
1421
|
return
|
|
1168
1422
|
}
|
|
1169
1423
|
if (session === undefined) {
|
|
1424
|
+
// The delivery mode rides the buffered line: a steer picked before the
|
|
1425
|
+
// first session exists must still steer once that session composes.
|
|
1170
1426
|
pendingInputs.push({ text: line, mode, images })
|
|
1171
1427
|
ensureSession()
|
|
1172
1428
|
return
|
|
1173
1429
|
}
|
|
1174
|
-
deliverLine(line,
|
|
1430
|
+
deliverLine(line, images, mode)
|
|
1175
1431
|
}
|
|
1176
1432
|
|
|
1177
1433
|
// Startup serialization: input submitted while the startup prompt/images
|
|
1178
1434
|
// are still preparing queues behind the initial request.
|
|
1179
|
-
const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text,
|
|
1180
|
-
const send = (text: string, mode: 'followup' | 'steer'
|
|
1435
|
+
const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, images, mode))
|
|
1436
|
+
const send = (text: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
1181
1437
|
inputGate.submit({ text, mode, images })
|
|
1182
1438
|
}
|
|
1183
1439
|
|
|
@@ -1187,28 +1443,33 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1187
1443
|
// session (queued switch): the composing session is gone, so the stale
|
|
1188
1444
|
// delivery is dropped instead of landing in the new session's inbox.
|
|
1189
1445
|
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1190
|
-
send(text,
|
|
1446
|
+
send(text, images)
|
|
1191
1447
|
}
|
|
1192
1448
|
|
|
1193
1449
|
/**
|
|
1194
|
-
*
|
|
1195
|
-
* boundary
|
|
1196
|
-
*
|
|
1450
|
+
* Deliver one line as steering: a running driver consumes it at its next
|
|
1451
|
+
* step boundary, an idle one starts a turn with it. The composer's Tab
|
|
1452
|
+
* toggle picks this over {@link dispatch} for the next submission.
|
|
1197
1453
|
*/
|
|
1198
1454
|
const steer = (text: string, images: readonly ContentBlock[] = [], origin?: string): void => {
|
|
1199
1455
|
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1200
|
-
send(text, 'steer'
|
|
1456
|
+
send(text, images, 'steer')
|
|
1201
1457
|
}
|
|
1202
1458
|
|
|
1203
|
-
/**
|
|
1459
|
+
/**
|
|
1460
|
+
* Interrupt the running turn (Esc); true when a turn was actually
|
|
1461
|
+
* cancelled. {@link cancelPreservingQueue} keeps the next-turn queue alive
|
|
1462
|
+
* AND re-wakes the driver, so the preserved messages run instead of
|
|
1463
|
+
* parking; next-step steering dies with the turn.
|
|
1464
|
+
*/
|
|
1204
1465
|
const interrupt = (): boolean => {
|
|
1205
1466
|
if (agent === undefined || agent.status !== 'running') return false
|
|
1206
1467
|
try {
|
|
1207
|
-
|
|
1208
|
-
bridge.notify(
|
|
1468
|
+
const preserved = cancelPreservingQueue(agent)
|
|
1469
|
+
bridge.notify(t(preserved > 0 ? 'notice.turnCancelledKeepQueue' : 'notice.turnCancelled'))
|
|
1209
1470
|
return true
|
|
1210
1471
|
} catch (error: unknown) {
|
|
1211
|
-
bridge.notify(
|
|
1472
|
+
bridge.notify(t('notice.cancelFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1212
1473
|
return false
|
|
1213
1474
|
}
|
|
1214
1475
|
}
|
|
@@ -1233,11 +1494,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1233
1494
|
* so minimal sessions and the pre-session state cycle permissions only).
|
|
1234
1495
|
* Plan transitions submit the upstream registry command — it stays the
|
|
1235
1496
|
* single owner of plan state; the TUI renders the durable plan/mode event
|
|
1236
|
-
* it appends.
|
|
1497
|
+
* it appends. Because that event lags the press (upstream queues the
|
|
1498
|
+
* switch during an open turn), each mid-session plan decision records the
|
|
1499
|
+
* choice in `planIntent` and the next press reads it back, so the cycle
|
|
1500
|
+
* advances stations instead of re-issuing one transition. Returns the
|
|
1501
|
+
* notice label, or '' when nothing changed.
|
|
1237
1502
|
*/
|
|
1238
1503
|
const cycleMode = (): string => {
|
|
1239
1504
|
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
1240
|
-
bridge.notify('
|
|
1505
|
+
bridge.notify(t('notice.permissionPresetsUnmounted'), 'warning')
|
|
1241
1506
|
return ''
|
|
1242
1507
|
}
|
|
1243
1508
|
try {
|
|
@@ -1250,6 +1515,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1250
1515
|
names: permissionPresets.names,
|
|
1251
1516
|
current: effectivePermission(permissionPresets, session, pendingPermission),
|
|
1252
1517
|
inPlan: preSession ? pendingPlan : store.getView().plan === true,
|
|
1518
|
+
...(preSession ? {} : { planIntent }),
|
|
1253
1519
|
planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
|
|
1254
1520
|
})
|
|
1255
1521
|
if (decision === undefined) return ''
|
|
@@ -1270,7 +1536,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1270
1536
|
renderCurrent()
|
|
1271
1537
|
return 'plan → on (applies to the first session)'
|
|
1272
1538
|
}
|
|
1273
|
-
|
|
1539
|
+
planIntent = true
|
|
1540
|
+
send('/plan')
|
|
1274
1541
|
return 'plan → on'
|
|
1275
1542
|
}
|
|
1276
1543
|
// Leaving plan lands on the station after the most restrictive
|
|
@@ -1281,11 +1548,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1281
1548
|
renderCurrent()
|
|
1282
1549
|
return `plan → off · permission → ${decision.preset}`
|
|
1283
1550
|
}
|
|
1284
|
-
|
|
1551
|
+
planIntent = false
|
|
1552
|
+
send('/plan off')
|
|
1285
1553
|
selectPermission(permissionPresets, session, decision.preset)
|
|
1286
1554
|
return `plan → off · permission → ${decision.preset}`
|
|
1287
1555
|
} catch (error: unknown) {
|
|
1288
|
-
bridge.notify(
|
|
1556
|
+
bridge.notify(t('notice.modeChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1289
1557
|
return ''
|
|
1290
1558
|
}
|
|
1291
1559
|
}
|
|
@@ -1311,7 +1579,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1311
1579
|
// it. Save failures degrade to a notice — the in-session switch already
|
|
1312
1580
|
// took effect and must not roll back (the web contract).
|
|
1313
1581
|
void defaultModel.saveSelection(selection).catch((error: unknown) => {
|
|
1314
|
-
bridge.notify(
|
|
1582
|
+
bridge.notify(t('notice.modelNotDefault', { message: error instanceof Error ? error.message : String(error) }), 'warning')
|
|
1315
1583
|
})
|
|
1316
1584
|
// Advisory immediate validation (web selectModel parity): run the same
|
|
1317
1585
|
// local resolveCallConfig check the request pipeline would, so a stale
|
|
@@ -1330,7 +1598,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1330
1598
|
model: selection.model,
|
|
1331
1599
|
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
1332
1600
|
})).catch((error: unknown) => {
|
|
1333
|
-
bridge.notify(
|
|
1601
|
+
bridge.notify(t('notice.modelSelectionRejected', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1334
1602
|
})
|
|
1335
1603
|
}
|
|
1336
1604
|
return `${row.provider}/${row.model}`
|
|
@@ -1359,7 +1627,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1359
1627
|
*/
|
|
1360
1628
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
1361
1629
|
if (session === undefined) {
|
|
1362
|
-
bridge.notify('
|
|
1630
|
+
bridge.notify(t('notice.noSessionYet'), 'warning')
|
|
1363
1631
|
return
|
|
1364
1632
|
}
|
|
1365
1633
|
const wanted = argument.trim()
|
|
@@ -1376,9 +1644,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1376
1644
|
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
1377
1645
|
try {
|
|
1378
1646
|
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
1379
|
-
bridge.notify(
|
|
1647
|
+
bridge.notify(t('notice.exported', { path: target }))
|
|
1380
1648
|
} catch (error: unknown) {
|
|
1381
|
-
bridge.notify(
|
|
1649
|
+
bridge.notify(t('notice.exportFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1382
1650
|
}
|
|
1383
1651
|
}
|
|
1384
1652
|
|
|
@@ -1427,13 +1695,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1427
1695
|
}
|
|
1428
1696
|
}))
|
|
1429
1697
|
}
|
|
1430
|
-
const projected = projectSessionRows(records, options, updated)
|
|
1431
|
-
// Titles are the expensive fold. Fetch
|
|
1432
|
-
//
|
|
1433
|
-
const
|
|
1698
|
+
const projected = projectSessionRows(records, { ...options, query: '' }, updated)
|
|
1699
|
+
// Titles are the expensive fold. Fetch the first picker page when idle;
|
|
1700
|
+
// a non-empty query loads more so the displayed title can match.
|
|
1701
|
+
const titleBudget = options.query.trim() === '' ? 32 : Math.min(projected.length, 128)
|
|
1702
|
+
const page = projected.slice(0, titleBudget)
|
|
1434
1703
|
if (page.length === 0) return projected
|
|
1435
1704
|
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
1436
|
-
|
|
1705
|
+
const titled = mergeSessionTitles(projected, observations)
|
|
1706
|
+
return titled.filter(row => sessionRowMatchesQuery(row, options.query))
|
|
1437
1707
|
}
|
|
1438
1708
|
|
|
1439
1709
|
/**
|
|
@@ -1451,9 +1721,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1451
1721
|
* Backends without a derivable artifact (non-JSONL) refuse the WHOLE
|
|
1452
1722
|
* deletion here — no file has been touched yet, so a backend or layout
|
|
1453
1723
|
* surprise can never strand a half-deleted subtree.
|
|
1454
|
-
* 3.
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1724
|
+
* 3. Acquire every node's public persistence write handle before touching
|
|
1725
|
+
* files. The JSONL backend holds its cross-process kernel lease for each
|
|
1726
|
+
* handle, so another terminal's live session refuses the whole deletion.
|
|
1727
|
+
* 4. Artifacts are removed children-first while every lease remains held:
|
|
1728
|
+
* only an I/O error mid-delete can stop it short (reported with
|
|
1729
|
+
* removed/total counts), leaving the shallowest lineage intact.
|
|
1457
1730
|
*
|
|
1458
1731
|
* @param id - the root session id to delete.
|
|
1459
1732
|
* @returns the outcome line for the panel/notice.
|
|
@@ -1468,7 +1741,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1468
1741
|
// layout-check every node up front, so a refusal never leaves a
|
|
1469
1742
|
// partially removed subtree behind.
|
|
1470
1743
|
const root = jsonlSessionRoot(persistence)
|
|
1471
|
-
if (root === undefined) {
|
|
1744
|
+
if (root === undefined || persistence === undefined) {
|
|
1472
1745
|
return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
|
|
1473
1746
|
}
|
|
1474
1747
|
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
@@ -1482,14 +1755,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1482
1755
|
}
|
|
1483
1756
|
dirs.set(node.id, dir)
|
|
1484
1757
|
}
|
|
1758
|
+
let leases
|
|
1759
|
+
try {
|
|
1760
|
+
leases = await acquireSessionDeletionLeases(persistence, plan.nodes.map(node => node.id))
|
|
1761
|
+
} catch (error: unknown) {
|
|
1762
|
+
if (error instanceof SessionAlreadyOwnedError) {
|
|
1763
|
+
return `cannot delete ${error.sessionId.slice(-12)} — it is open in this or another process`
|
|
1764
|
+
}
|
|
1765
|
+
return `cannot safely lock sessions for deletion: ${error instanceof Error ? error.message : String(error)}`
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1485
1768
|
let removed = 0
|
|
1769
|
+
let outcome: string | undefined
|
|
1486
1770
|
for (const node of plan.nodes) {
|
|
1487
1771
|
const dir = dirs.get(node.id)!
|
|
1488
1772
|
try {
|
|
1489
1773
|
// Remove every canonical generation artifact this build knows; other
|
|
1490
|
-
// sibling files are never ours to delete
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1774
|
+
// sibling files are never ours to delete. The POSIX session.lock file
|
|
1775
|
+
// deliberately remains because unlinking a held flock inode would
|
|
1776
|
+
// forfeit the backend's exclusion guarantee.
|
|
1493
1777
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
1494
1778
|
for (const entry of entries) {
|
|
1495
1779
|
if (entry.isFile() && isSessionArtifactName(entry.name)) {
|
|
@@ -1499,10 +1783,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1499
1783
|
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1500
1784
|
removed += 1
|
|
1501
1785
|
} catch (error: unknown) {
|
|
1502
|
-
|
|
1786
|
+
outcome = `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
|
|
1787
|
+
break
|
|
1503
1788
|
}
|
|
1504
1789
|
}
|
|
1505
|
-
|
|
1790
|
+
outcome ??= `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1791
|
+
try {
|
|
1792
|
+
await releaseSessionDeletionLeases(leases)
|
|
1793
|
+
} catch (error: unknown) {
|
|
1794
|
+
return `${outcome}; failed to release deletion locks: ${error instanceof Error ? error.message : String(error)}`
|
|
1795
|
+
}
|
|
1796
|
+
return outcome
|
|
1506
1797
|
}
|
|
1507
1798
|
|
|
1508
1799
|
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
@@ -1511,6 +1802,26 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1511
1802
|
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1512
1803
|
}
|
|
1513
1804
|
|
|
1805
|
+
/**
|
|
1806
|
+
* Read one session's usage blocks for the /usage panel: the mounted
|
|
1807
|
+
* projection's session totals plus the meter's own per-turn fold over the
|
|
1808
|
+
* durable log (which the panel merges by model). A deployment without the
|
|
1809
|
+
* projection registry renders the totals as explicitly unavailable rather
|
|
1810
|
+
* than as zeros. The read is synchronous — the registry materializes a cell
|
|
1811
|
+
* on first touch — so it is handed to the panel behind a resolved promise,
|
|
1812
|
+
* which keeps the fold out of the keystroke that opens the panel.
|
|
1813
|
+
* @param current - the session to read, or undefined before the first one.
|
|
1814
|
+
* @returns the resolved panel data.
|
|
1815
|
+
*/
|
|
1816
|
+
const loadUsage = (current: Session | undefined): Promise<UsageView> => {
|
|
1817
|
+
if (current === undefined) return Promise.resolve({ turns: [] })
|
|
1818
|
+
const values = ctx.get('sessionProjections')?.snapshot(current, ['tokenUsage']).values
|
|
1819
|
+
return Promise.resolve({
|
|
1820
|
+
totals: values?.tokenUsage,
|
|
1821
|
+
turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
|
|
1822
|
+
})
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1514
1825
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
1515
1826
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1516
1827
|
const currentAgent = agent
|
|
@@ -1579,17 +1890,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1579
1890
|
commands.setAgent(agent)
|
|
1580
1891
|
skills.setAgent(agent)
|
|
1581
1892
|
try {
|
|
1893
|
+
// Reseed the feed BEFORE the first frame of the new session so no
|
|
1894
|
+
// stale row from the previous one flashes; a rolled-back handoff
|
|
1895
|
+
// re-seeds the previous session's catalog the same way.
|
|
1896
|
+
subagents.reset()
|
|
1897
|
+
for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1582
1898
|
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1583
1899
|
renderCurrent()
|
|
1584
1900
|
// Only a successful handoff may clear the transient per-session
|
|
1585
1901
|
// surfaces: a rolled-back switch keeps the previous session's
|
|
1586
1902
|
// subagent feed plus the user's pre-session /mode and permission
|
|
1587
1903
|
// picks (the bare-launch promise: explicit choices survive until
|
|
1588
|
-
// composition takes them).
|
|
1589
|
-
|
|
1904
|
+
// composition takes them). The in-flight cycle intent belonged to
|
|
1905
|
+
// the previous session's presses; the new session's committed fold
|
|
1906
|
+
// decides from here.
|
|
1590
1907
|
pendingMode = undefined
|
|
1591
1908
|
pendingPermission = undefined
|
|
1592
1909
|
pendingPlan = false
|
|
1910
|
+
planIntent = undefined
|
|
1593
1911
|
} catch (error: unknown) {
|
|
1594
1912
|
active = previous
|
|
1595
1913
|
agent = previous?.agent
|
|
@@ -1598,6 +1916,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1598
1916
|
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1599
1917
|
if (agent !== undefined) commands.setAgent(agent)
|
|
1600
1918
|
if (agent !== undefined) skills.setAgent(agent)
|
|
1919
|
+
subagents.reset()
|
|
1920
|
+
if (previous !== undefined) {
|
|
1921
|
+
for (const event of previous.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1922
|
+
}
|
|
1923
|
+
// The failed handoff disposed the incoming session; the restored
|
|
1924
|
+
// store's committed plan fold is the truth, so any cycle intent
|
|
1925
|
+
// collected against the switch churn retires too.
|
|
1926
|
+
planIntent = undefined
|
|
1601
1927
|
await next.handle.dispose()
|
|
1602
1928
|
if (!quitting) renderCurrent()
|
|
1603
1929
|
throw error
|
|
@@ -1614,7 +1940,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1614
1940
|
// effect AFTER it, so an immediate notice reaches the UNMOUNTED
|
|
1615
1941
|
// instance and React drops it silently. Defer past the commit.
|
|
1616
1942
|
setTimeout(() => {
|
|
1617
|
-
bridge.notify(
|
|
1943
|
+
bridge.notify(t(next.resumed ? 'notice.sessionResumed' : 'notice.sessionCreated', {
|
|
1944
|
+
id: next.session.id.slice(-12),
|
|
1945
|
+
mode: next.mode,
|
|
1946
|
+
}))
|
|
1618
1947
|
}, 0)
|
|
1619
1948
|
return
|
|
1620
1949
|
}
|
|
@@ -1622,23 +1951,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1622
1951
|
try {
|
|
1623
1952
|
await sessions.flush(previous.session)
|
|
1624
1953
|
} catch (error: unknown) {
|
|
1625
|
-
cleanupWarning =
|
|
1954
|
+
cleanupWarning = t('notice.flushFailed', { message: error instanceof Error ? error.message : String(error) })
|
|
1626
1955
|
}
|
|
1627
1956
|
try {
|
|
1628
1957
|
await previous.handle.dispose()
|
|
1629
1958
|
} catch (error: unknown) {
|
|
1630
|
-
|
|
1959
|
+
const release = t('notice.agentReleaseFailed', { message: error instanceof Error ? error.message : String(error) })
|
|
1960
|
+
cleanupWarning = cleanupWarning === undefined ? release : `${cleanupWarning}; ${release}`
|
|
1631
1961
|
}
|
|
1962
|
+
const shortId = next.session.id.slice(-12)
|
|
1632
1963
|
bridge.notify(cleanupWarning === undefined
|
|
1633
|
-
?
|
|
1634
|
-
:
|
|
1964
|
+
? t(next.resumed ? 'notice.sessionResumed' : 'notice.sessionCreated', { id: shortId, mode: next.mode })
|
|
1965
|
+
: t('notice.sessionSwitchedDirty', { id: shortId, detail: cleanupWarning }),
|
|
1635
1966
|
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1636
1967
|
})
|
|
1637
1968
|
}
|
|
1638
1969
|
|
|
1639
1970
|
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
1640
1971
|
async request => { if (!quitting) await activate(request.target) },
|
|
1641
|
-
error => bridge.notify(
|
|
1972
|
+
error => bridge.notify(t('notice.sessionSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1642
1973
|
)
|
|
1643
1974
|
|
|
1644
1975
|
const requestSwitch = (request: PendingSwitch): void => {
|
|
@@ -1647,17 +1978,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1647
1978
|
// the target directly — there is no running turn to wait on and nothing
|
|
1648
1979
|
// to flush.
|
|
1649
1980
|
void activate(request.target).catch((error: unknown) => {
|
|
1650
|
-
bridge.notify(
|
|
1981
|
+
bridge.notify(t('notice.sessionSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1651
1982
|
})
|
|
1652
1983
|
return
|
|
1653
1984
|
}
|
|
1654
1985
|
if (request.target.sessionId === session.id) {
|
|
1655
|
-
bridge.notify('
|
|
1986
|
+
bridge.notify(t('notice.alreadyActive'), 'warning')
|
|
1656
1987
|
return
|
|
1657
1988
|
}
|
|
1658
1989
|
const outcome = switchQueue.request(agent!, request)
|
|
1659
1990
|
if (outcome === 'queued') {
|
|
1660
|
-
bridge.notify(
|
|
1991
|
+
bridge.notify(t('notice.switchQueued', { label: request.label }))
|
|
1661
1992
|
}
|
|
1662
1993
|
}
|
|
1663
1994
|
|
|
@@ -1669,7 +2000,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1669
2000
|
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
1670
2001
|
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
1671
2002
|
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
1672
|
-
const matched = matches[0]
|
|
2003
|
+
const matched = matches[0]
|
|
1673
2004
|
// Same lineage gate as the CLI --resume path and the picker.
|
|
1674
2005
|
if (isSubagentSession(matched.header)) {
|
|
1675
2006
|
throw new Error('subagent conversations are read-only; resume a root session')
|
|
@@ -1683,7 +2014,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1683
2014
|
const requestResume = (wanted: string): void => {
|
|
1684
2015
|
void resolveResumeId(wanted).then(id => {
|
|
1685
2016
|
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
1686
|
-
}, (error: unknown) => bridge.notify(
|
|
2017
|
+
}, (error: unknown) => bridge.notify(t('notice.resumeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'))
|
|
1687
2018
|
}
|
|
1688
2019
|
|
|
1689
2020
|
const createSession = (mode?: string): void => {
|
|
@@ -1697,7 +2028,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1697
2028
|
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
1698
2029
|
}
|
|
1699
2030
|
|
|
1700
|
-
const reviewChanges = (
|
|
2031
|
+
const reviewChanges = (selection: ReviewSelection): void => {
|
|
1701
2032
|
// Works from a bare launch too: with no session yet the read-only
|
|
1702
2033
|
// choice goes to pendingPermission (materialized when the first
|
|
1703
2034
|
// session composes) and the review prompt queues behind that
|
|
@@ -1717,27 +2048,37 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1717
2048
|
const finish = (): void => {
|
|
1718
2049
|
pendingControllers.delete(controller)
|
|
1719
2050
|
}
|
|
1720
|
-
|
|
2051
|
+
// Branch reviews diff from the precomputed merge base (what would
|
|
2052
|
+
// actually land), commit reviews the commit's own patch, everything
|
|
2053
|
+
// else reviews the uncommitted working tree.
|
|
2054
|
+
const load = selection.kind === 'commit'
|
|
2055
|
+
? loadCommitDiff(reviewCwd, selection.sha, controller.signal)
|
|
2056
|
+
: selection.kind === 'base-branch'
|
|
2057
|
+
? mergeBaseWith(reviewCwd, selection.branch, controller.signal)
|
|
2058
|
+
.then(base => loadGitDiff(reviewCwd, base ?? selection.branch, controller.signal))
|
|
2059
|
+
: loadGitDiff(reviewCwd, '', controller.signal)
|
|
2060
|
+
const note = selection.kind === 'custom' ? selection.instructions : undefined
|
|
2061
|
+
void load.then(({ title, files }) => {
|
|
1721
2062
|
finish()
|
|
1722
2063
|
if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent) return
|
|
1723
2064
|
try {
|
|
1724
2065
|
setPermissionAction('read-only')
|
|
1725
2066
|
} catch (error: unknown) {
|
|
1726
|
-
bridge.notify(
|
|
2067
|
+
bridge.notify(t('notice.reviewUnavailable', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1727
2068
|
return
|
|
1728
2069
|
}
|
|
1729
|
-
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title
|
|
1730
|
-
bridge.notify('
|
|
2070
|
+
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title, note))
|
|
2071
|
+
bridge.notify(t('notice.reviewStarted'))
|
|
1731
2072
|
}, (error: unknown) => {
|
|
1732
2073
|
finish()
|
|
1733
2074
|
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1734
|
-
bridge.notify(
|
|
2075
|
+
bridge.notify(t('notice.reviewFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1735
2076
|
})
|
|
1736
2077
|
}
|
|
1737
2078
|
|
|
1738
2079
|
const forkSession = (argument: string): void => {
|
|
1739
2080
|
if (session === undefined || active === undefined) {
|
|
1740
|
-
bridge.notify('
|
|
2081
|
+
bridge.notify(t('notice.noSessionYet'), 'warning')
|
|
1741
2082
|
return
|
|
1742
2083
|
}
|
|
1743
2084
|
try {
|
|
@@ -1761,18 +2102,42 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1761
2102
|
label: id.slice(-12),
|
|
1762
2103
|
})
|
|
1763
2104
|
} catch (error: unknown) {
|
|
1764
|
-
bridge.notify(
|
|
2105
|
+
bridge.notify(t('notice.forkFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1765
2106
|
}
|
|
1766
2107
|
}
|
|
1767
2108
|
|
|
1768
2109
|
const switchSession = (row: SessionRow): void => {
|
|
1769
2110
|
if (!row.resumable) {
|
|
1770
|
-
bridge.notify('
|
|
2111
|
+
bridge.notify(t('notice.subagentsReadOnly'), 'warning')
|
|
1771
2112
|
return
|
|
1772
2113
|
}
|
|
1773
2114
|
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
1774
2115
|
}
|
|
1775
2116
|
|
|
2117
|
+
// /search reads the SAME in-process engine the model's session_search
|
|
2118
|
+
// tools use (the bundle's skip-tolerant subclass). The row may be disabled
|
|
2119
|
+
// by a deployment; /search then degrades to a notice instead of a panel.
|
|
2120
|
+
const searchSessions = sessionQuery === undefined
|
|
2121
|
+
? undefined
|
|
2122
|
+
: async (query: string, signal?: AbortSignal): Promise<readonly SearchRow[]> => {
|
|
2123
|
+
const page = await sessionQuery.searchSessions({ query, limit: 30 }, signal === undefined ? undefined : { signal })
|
|
2124
|
+
const rows = page.items.map(hit => searchHitToRow(hit))
|
|
2125
|
+
// Best-effort title enrichment (the same snapshots /resume merges):
|
|
2126
|
+
// a failure keeps the short-id labels instead of failing the search.
|
|
2127
|
+
try {
|
|
2128
|
+
const observations = await sessionQuery.readTitleSnapshots(rows.map(row => row.id), signal)
|
|
2129
|
+
const titles = new Map<string, string>()
|
|
2130
|
+
for (const observation of observations) {
|
|
2131
|
+
if (observation.status !== 'fulfilled') continue
|
|
2132
|
+
const title = observation.value?.title?.title
|
|
2133
|
+
if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
|
|
2134
|
+
}
|
|
2135
|
+
return rows.map(row => titles.has(row.id) ? { ...row, label: titles.get(row.id)! } : row)
|
|
2136
|
+
} catch {
|
|
2137
|
+
return rows
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
1776
2141
|
const cancelSessionSwitch = (): boolean => {
|
|
1777
2142
|
return switchQueue.cancel()
|
|
1778
2143
|
}
|
|
@@ -1854,6 +2219,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1854
2219
|
renameTitle,
|
|
1855
2220
|
copyLastResponse,
|
|
1856
2221
|
loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
|
|
2222
|
+
listReviewBranches: (signal?: AbortSignal) => listReviewBranches(session?.header.cwd ?? cwd, signal),
|
|
2223
|
+
listReviewCommits: (signal?: AbortSignal) => listReviewCommits(session?.header.cwd ?? cwd, signal),
|
|
1857
2224
|
reviewChanges,
|
|
1858
2225
|
loadPresets: () => presets.list(),
|
|
1859
2226
|
switchMode: switchModeAction,
|
|
@@ -1864,6 +2231,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1864
2231
|
forkSession,
|
|
1865
2232
|
loadSessions,
|
|
1866
2233
|
loadSessionTranscript,
|
|
2234
|
+
loadUsage: () => loadUsage(session),
|
|
1867
2235
|
loadSubagents: () => {
|
|
1868
2236
|
const current = session
|
|
1869
2237
|
if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
|
|
@@ -1871,22 +2239,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1871
2239
|
.then(rows => rows.filter(row => row.parent === current.id && row.subagent))
|
|
1872
2240
|
},
|
|
1873
2241
|
switchSession,
|
|
2242
|
+
searchSessions,
|
|
1874
2243
|
cancelSessionSwitch,
|
|
1875
2244
|
loadPlugins: () => listPluginRows(ctx),
|
|
1876
2245
|
// The launcher owns every update decision; the TUI only drives its
|
|
1877
2246
|
// read-only probe and streamed apply as child processes.
|
|
1878
2247
|
probeUpdate: () => probeLauncherUpdate(),
|
|
1879
|
-
applyUpdate: onLine => applyLauncherUpdate(onLine),
|
|
2248
|
+
applyUpdate: (onLine, plan) => applyLauncherUpdate(onLine, undefined, plan),
|
|
1880
2249
|
loadJobs: () => listJobs(ctx, active?.agent),
|
|
1881
2250
|
statusline: statuslineItems,
|
|
1882
2251
|
saveStatusline,
|
|
1883
2252
|
applyEditorKeys,
|
|
1884
2253
|
saveTheme,
|
|
2254
|
+
saveLanguage,
|
|
1885
2255
|
animations: animationsEnabled,
|
|
1886
2256
|
saveAnimations,
|
|
1887
2257
|
history: inputHistory,
|
|
1888
2258
|
recordHistory,
|
|
1889
|
-
|
|
2259
|
+
updateQueued,
|
|
1890
2260
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
1891
2261
|
})
|
|
1892
2262
|
}
|
|
@@ -1903,41 +2273,46 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1903
2273
|
// the agent always receives the startup prompt first.
|
|
1904
2274
|
if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
|
|
1905
2275
|
if ((startup.images?.length ?? 0) > 0) {
|
|
1906
|
-
bridge.notify(
|
|
2276
|
+
bridge.notify(t('notice.startupImages', { count: startup.images!.length, plural: startup.images!.length === 1 ? '' : 's' }))
|
|
1907
2277
|
}
|
|
1908
2278
|
void inputGate.run(async deliver => {
|
|
1909
2279
|
const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'))
|
|
1910
|
-
if (images.length > 0) bridge.notify(
|
|
2280
|
+
if (images.length > 0) bridge.notify(t('notice.startupImagesAttached', { count: images.length, plural: images.length === 1 ? '' : 's' }))
|
|
1911
2281
|
deliver({ text: startup.prompt ?? '', mode: 'followup', images })
|
|
1912
2282
|
}).catch((error: unknown) => {
|
|
1913
|
-
bridge.notify(
|
|
2283
|
+
bridge.notify(t('notice.initialPromptFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1914
2284
|
})
|
|
1915
2285
|
}
|
|
1916
2286
|
|
|
1917
2287
|
async function copyLastResponse(): Promise<string> {
|
|
1918
2288
|
const text = latestAssistantText(store.getView())
|
|
1919
|
-
if (text === undefined) return '
|
|
2289
|
+
if (text === undefined) return t('notice.copyEmpty')
|
|
1920
2290
|
await copyText(text)
|
|
1921
|
-
return 'copied
|
|
2291
|
+
return t('notice.copied')
|
|
1922
2292
|
}
|
|
1923
2293
|
|
|
1924
2294
|
// A corrupt statusline config must not vanish silently: surface it once
|
|
1925
2295
|
// the notice channel is live, after the first frame settles.
|
|
1926
2296
|
if (statuslineWarning !== undefined) {
|
|
1927
2297
|
setTimeout(() => {
|
|
1928
|
-
bridge.notify('
|
|
2298
|
+
bridge.notify(t('notice.statuslineConfigUnreadable', { message: statuslineWarning }), 'warning')
|
|
1929
2299
|
}, 50)
|
|
1930
2300
|
}
|
|
1931
2301
|
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
2302
|
+
if (languageWarning !== undefined) {
|
|
2303
|
+
setTimeout(() => {
|
|
2304
|
+
bridge.notify(t('notice.languageConfigUnreadable', { message: languageWarning }), 'warning')
|
|
2305
|
+
}, 0)
|
|
2306
|
+
}
|
|
1932
2307
|
if (themeWarning !== undefined) {
|
|
1933
2308
|
setTimeout(() => {
|
|
1934
|
-
bridge.notify('
|
|
2309
|
+
bridge.notify(t('notice.themeConfigUnreadable', { message: themeWarning }), 'warning')
|
|
1935
2310
|
}, 50)
|
|
1936
2311
|
}
|
|
1937
2312
|
// And for a corrupt animations file (on-by-default fallback stays live).
|
|
1938
2313
|
if (animationsWarning !== undefined) {
|
|
1939
2314
|
setTimeout(() => {
|
|
1940
|
-
bridge.notify('
|
|
2315
|
+
bridge.notify(t('notice.animationsConfigUnreadable', { message: animationsWarning }), 'warning')
|
|
1941
2316
|
}, 50)
|
|
1942
2317
|
}
|
|
1943
2318
|
|