dsh-code 1.0.7 → 1.2.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 +4063 -844
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
- package/lib/types/app.d.ts +100 -63
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +73 -1
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +86 -31
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +450 -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 +8 -8
- 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 +17 -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 +5 -5
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +4 -3
- package/package.json +24 -7
- package/src/app.ts +1155 -478
- package/src/approval.ts +166 -166
- package/src/authorization-panel.ts +19 -16
- package/src/editor-keys.ts +371 -371
- package/src/git-workflow.ts +229 -3
- package/src/i18n.ts +68 -0
- package/src/index.ts +412 -76
- package/src/input-split.ts +3 -3
- package/src/kernel-panels.ts +471 -89
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +489 -0
- package/src/locales/zh.ts +488 -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 +208 -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 +41 -35
- package/src/render/text.ts +14 -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 +8 -6
- package/src/session-query.ts +8 -4
- package/src/startup.ts +3 -3
- 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 +37 -27
- package/src/update.ts +19 -3
- package/src/version.ts +58 -20
- package/src/whale-glyph.ts +23 -23
package/src/index.ts
CHANGED
|
@@ -18,12 +18,13 @@ 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 { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
|
27
28
|
import 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'
|
|
@@ -31,12 +32,12 @@ import type {} from '@deepseek-ai/dsh-session-title'
|
|
|
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,6 +94,7 @@ 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 {
|
|
89
99
|
isSubagentSession,
|
|
90
100
|
matchSessionId,
|
|
@@ -100,7 +110,13 @@ import {
|
|
|
100
110
|
type SessionQueryService,
|
|
101
111
|
type SessionRow,
|
|
102
112
|
} from './session-directory.ts'
|
|
113
|
+
import type { JobRow, SearchRow } from './kernel-panels.ts'
|
|
103
114
|
import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
|
|
115
|
+
import { turnUsages, type UsageView } from './render/usage.ts'
|
|
116
|
+
// Type-only import: merges the projection registry into the Context type so
|
|
117
|
+
// `ctx.get('sessionProjections')` is typed (the service itself is mounted by
|
|
118
|
+
// dsh-base at runtime).
|
|
119
|
+
import type {} from '@deepseek-ai/dsh-session-projection'
|
|
104
120
|
|
|
105
121
|
/** Stable Cordis plugin name. */
|
|
106
122
|
export const name = 'tui-runner'
|
|
@@ -128,7 +144,7 @@ export const Config: z<Config> = z.object({
|
|
|
128
144
|
/** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
|
|
129
145
|
interface TuiIo {
|
|
130
146
|
mount: typeof internals.mount
|
|
131
|
-
exit(code: number)
|
|
147
|
+
exit: (code: number) => void
|
|
132
148
|
}
|
|
133
149
|
|
|
134
150
|
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
@@ -147,7 +163,7 @@ function fail(io: TuiIo, error: unknown): void {
|
|
|
147
163
|
* @param caller - the active agent (undefined sees only unowned jobs).
|
|
148
164
|
* @returns job rows in registration order; never throws.
|
|
149
165
|
*/
|
|
150
|
-
function listJobs(ctx: Context, caller: Agent | undefined): readonly
|
|
166
|
+
function listJobs(ctx: Context, caller: Agent | undefined): readonly JobRow[] {
|
|
151
167
|
const jobs = ctx.get('jobs')
|
|
152
168
|
if (jobs === undefined) return []
|
|
153
169
|
try {
|
|
@@ -165,6 +181,23 @@ function listJobs(ctx: Context, caller: Agent | undefined): readonly import('./k
|
|
|
165
181
|
}
|
|
166
182
|
}
|
|
167
183
|
|
|
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
|
+
|
|
168
201
|
/**
|
|
169
202
|
* Resolve the working directory's git branch for the status line.
|
|
170
203
|
* @param cwd - the session's working directory.
|
|
@@ -189,6 +222,8 @@ interface Target {
|
|
|
189
222
|
cwd?: string
|
|
190
223
|
seed?: readonly SessionEvent[]
|
|
191
224
|
parentSession?: SessionId
|
|
225
|
+
/** Marks the session as a subagent conversation in the durable header. */
|
|
226
|
+
origin?: 'subagent'
|
|
192
227
|
seedLength?: number
|
|
193
228
|
}
|
|
194
229
|
|
|
@@ -253,10 +288,100 @@ export async function runQuitSequence(
|
|
|
253
288
|
/** One composer submission waiting behind the startup delivery. */
|
|
254
289
|
export interface QueuedSubmission {
|
|
255
290
|
readonly text: string
|
|
291
|
+
/** `steer` inserts into the running turn; `followup` waits for the next one. */
|
|
256
292
|
readonly mode: 'followup' | 'steer'
|
|
257
293
|
readonly images: readonly ContentBlock[]
|
|
258
294
|
}
|
|
259
295
|
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
260
385
|
/**
|
|
261
386
|
* Whether a tagged submission still belongs to the active session. Attachment
|
|
262
387
|
* prepares resolve on the microtask timeline, while a queued session switch
|
|
@@ -269,6 +394,44 @@ export function submissionBelongsToSession(origin: string | undefined, activeSes
|
|
|
269
394
|
return origin === undefined || origin === '' || origin === activeSessionId
|
|
270
395
|
}
|
|
271
396
|
|
|
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
|
+
|
|
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
|
+
|
|
272
435
|
/** One Shift+Tab station decision for the mode cycle. */
|
|
273
436
|
export type ModeCycleDecision =
|
|
274
437
|
| { readonly kind: 'permission'; readonly preset: string }
|
|
@@ -285,17 +448,26 @@ export type ModeCycleDecision =
|
|
|
285
448
|
* parked on read-only), and leaving it lands on the next preset after the
|
|
286
449
|
* most restrictive one. Without the /plan command the cycle is exactly the
|
|
287
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.
|
|
288
459
|
*/
|
|
289
460
|
export function planCycleDecision(input: {
|
|
290
461
|
readonly names: readonly string[]
|
|
291
462
|
readonly current: string
|
|
292
463
|
readonly inPlan: boolean
|
|
293
464
|
readonly planAvailable: boolean
|
|
465
|
+
readonly planIntent?: boolean
|
|
294
466
|
}): ModeCycleDecision | undefined {
|
|
295
467
|
const names = input.names
|
|
296
468
|
if (names.length === 0) return undefined
|
|
297
|
-
const first = names[0]
|
|
298
|
-
if (input.inPlan) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
469
|
+
const first = names[0]
|
|
470
|
+
if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
299
471
|
const at = names.indexOf(input.current)
|
|
300
472
|
if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
|
|
301
473
|
return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
|
|
@@ -398,7 +570,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
|
|
|
398
570
|
/** The runner's connection between the React app and the process side. */
|
|
399
571
|
interface AppBridge {
|
|
400
572
|
/** Post one local notice line (feedback the transcript does not carry). */
|
|
401
|
-
notify(text: string, tone?: NoticeTone)
|
|
573
|
+
notify: (text: string, tone?: NoticeTone) => void
|
|
402
574
|
}
|
|
403
575
|
|
|
404
576
|
/**
|
|
@@ -444,6 +616,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
444
616
|
mode: string
|
|
445
617
|
selection: { picked?: ModelSelection }
|
|
446
618
|
resumed: boolean
|
|
619
|
+
/**
|
|
620
|
+
* Root-log `subagent/catalog` facts (resume path): constructor seeds
|
|
621
|
+
* never fire on the live bus, so activation replays them into the
|
|
622
|
+
* subagent feed after its reset — a resumed session's children stay
|
|
623
|
+
* visible instead of vanishing behind a restart.
|
|
624
|
+
*/
|
|
625
|
+
catalogSeed: readonly SessionEvent<'subagent/catalog'>[]
|
|
447
626
|
}
|
|
448
627
|
|
|
449
628
|
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
@@ -497,6 +676,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
497
676
|
cwd: nextCwd,
|
|
498
677
|
agentPreset: mode,
|
|
499
678
|
...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
|
|
679
|
+
...(next.origin === undefined ? {} : { origin: next.origin }),
|
|
500
680
|
// 0.1.5 fork lineage: the seed marker lives on the metadata and the
|
|
501
681
|
// inherited prefix length on the top-level option (the v0 header's
|
|
502
682
|
// numeric `seedLength` field is gone from the create contract).
|
|
@@ -512,15 +692,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
512
692
|
if (!next.resume && permissionPresets !== undefined) {
|
|
513
693
|
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
514
694
|
}
|
|
695
|
+
const seedEvents = session.snapshotEvents()
|
|
696
|
+
// Resume precedence, middle layer: the log's unconsumed `model/selection`
|
|
697
|
+
// (a pick the web host recorded that no request ever assembled) outranks
|
|
698
|
+
// the older request header; an in-process pick still outranks both.
|
|
699
|
+
if (next.resume && selectionState.picked === undefined) {
|
|
700
|
+
const pending = pendingModelSelection(seedEvents)
|
|
701
|
+
if (pending !== undefined) selectionState.picked = pending
|
|
702
|
+
}
|
|
515
703
|
return {
|
|
516
704
|
handle,
|
|
517
705
|
agent: handle.agent,
|
|
518
706
|
session,
|
|
519
|
-
store: createTranscriptStore(
|
|
707
|
+
store: createTranscriptStore(seedEvents),
|
|
520
708
|
mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
|
|
521
709
|
mode: mode ?? 'standard',
|
|
522
710
|
selection: selectionState,
|
|
523
711
|
resumed: next.resume,
|
|
712
|
+
catalogSeed: subagentCatalogSeed(seedEvents),
|
|
524
713
|
}
|
|
525
714
|
}
|
|
526
715
|
|
|
@@ -549,6 +738,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
549
738
|
* session composes, so the first assembled step already plans.
|
|
550
739
|
*/
|
|
551
740
|
let pendingPlan = false
|
|
741
|
+
/**
|
|
742
|
+
* In-flight mid-session plan choice from the Shift+Tab cycle. Upstream
|
|
743
|
+
* queues a plan switch during an open turn (and the command pipeline is
|
|
744
|
+
* async even idle), so the committed plan/mode fold lags the press that
|
|
745
|
+
* chose it; the cycle reads this intent until the durable event lands,
|
|
746
|
+
* then the session/event funnel clears it.
|
|
747
|
+
*/
|
|
748
|
+
let planIntent: boolean | undefined
|
|
552
749
|
/**
|
|
553
750
|
* Whether the pre-session effective preset composes plan mode, answered by
|
|
554
751
|
* the presets service composition inventory (minimal does not). Cached and
|
|
@@ -616,6 +813,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
616
813
|
session = prepared.session
|
|
617
814
|
store = prepared.store
|
|
618
815
|
mentions = prepared.mentions
|
|
816
|
+
// Replayed catalog facts rebuild the resumed session's child rows before
|
|
817
|
+
// the first render (the live handler only folds events from now on).
|
|
818
|
+
for (const event of prepared.catalogSeed) subagents.apply(event.data.childId, event)
|
|
619
819
|
}
|
|
620
820
|
|
|
621
821
|
// Seed the transcript from the full session log: constructor seeds never
|
|
@@ -626,6 +826,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
626
826
|
if (session === undefined) return
|
|
627
827
|
if (subject.id === session.id) {
|
|
628
828
|
store.apply(event)
|
|
829
|
+
// The committed plan fold caught up (or diverged via a typed /plan or
|
|
830
|
+
// an approved plan review): the durable event is the live truth again,
|
|
831
|
+
// so the cycle's in-flight intent retires.
|
|
832
|
+
if (event.type === 'plan/mode') planIntent = undefined
|
|
629
833
|
// The parent-owned subagent catalog rides the ROOT log (0.1.5); each
|
|
630
834
|
// fact describes one child, so it feeds that child's live row.
|
|
631
835
|
if (event.type === 'subagent/catalog' && event.data.childId !== '') subagents.apply(event.data.childId, event)
|
|
@@ -741,7 +945,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
741
945
|
let statuslineWarning: string | undefined
|
|
742
946
|
let statuslineItems: readonly string[] = []
|
|
743
947
|
try {
|
|
744
|
-
statuslineItems = parseStatuslineItems(
|
|
948
|
+
statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
|
|
745
949
|
} catch (error) {
|
|
746
950
|
statuslineItems = parseStatuslineItems(undefined)
|
|
747
951
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
@@ -757,7 +961,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
757
961
|
statuslineItems = [...items]
|
|
758
962
|
void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
|
|
759
963
|
.catch((writeError: unknown) => {
|
|
760
|
-
bridge.notify('
|
|
964
|
+
bridge.notify(t('notice.statuslineSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
761
965
|
})
|
|
762
966
|
}
|
|
763
967
|
|
|
@@ -780,7 +984,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
780
984
|
let themeWarning: string | undefined
|
|
781
985
|
if (startup.theme === undefined) {
|
|
782
986
|
try {
|
|
783
|
-
setTheme(parseThemeName(
|
|
987
|
+
setTheme(parseThemeName(readSettingsObject(themePath).theme))
|
|
784
988
|
} catch (error) {
|
|
785
989
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
786
990
|
themeWarning = error instanceof Error ? error.message : String(error)
|
|
@@ -793,7 +997,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
793
997
|
setTheme(name)
|
|
794
998
|
void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
|
|
795
999
|
.catch((writeError: unknown) => {
|
|
796
|
-
bridge.notify('
|
|
1000
|
+
bridge.notify(t('notice.themeSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// /language persistence: one user-level JSON file beside theme.json. A
|
|
1005
|
+
// missing file means English; a corrupt file degrades to English with a
|
|
1006
|
+
// surfaced warning.
|
|
1007
|
+
const languagePath = join(homedir(), '.dsh', 'dsh-code', 'language.json')
|
|
1008
|
+
let languageWarning: string | undefined
|
|
1009
|
+
try {
|
|
1010
|
+
setLanguage(parseLanguageName(readSettingsObject(languagePath).language))
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1013
|
+
languageWarning = error instanceof Error ? error.message : String(error)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const saveLanguage = (name: LanguageName): void => {
|
|
1017
|
+
setLanguage(name)
|
|
1018
|
+
void settingsPersistence.save(languagePath, JSON.stringify({ language: name }, null, 2) + '\n')
|
|
1019
|
+
.catch((writeError: unknown) => {
|
|
1020
|
+
bridge.notify(t('notice.languageSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
797
1021
|
})
|
|
798
1022
|
}
|
|
799
1023
|
|
|
@@ -806,8 +1030,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
806
1030
|
let animationsEnabled = true
|
|
807
1031
|
let animationsWarning: string | undefined
|
|
808
1032
|
try {
|
|
809
|
-
//
|
|
810
|
-
|
|
1033
|
+
// A literal `null` file reads as corruption and surfaces the warning the
|
|
1034
|
+
// block above promises, instead of a property access on `null`.
|
|
1035
|
+
animationsEnabled = parseAnimationsPref(readSettingsObject(animationsPath).animations)
|
|
811
1036
|
} catch (error) {
|
|
812
1037
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
813
1038
|
animationsWarning = error instanceof Error ? error.message : String(error)
|
|
@@ -816,7 +1041,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
816
1041
|
const saveAnimations = (enabled: boolean): void => {
|
|
817
1042
|
void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
|
|
818
1043
|
.catch((writeError: unknown) => {
|
|
819
|
-
bridge.notify('
|
|
1044
|
+
bridge.notify(t('notice.animationsSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
820
1045
|
})
|
|
821
1046
|
}
|
|
822
1047
|
|
|
@@ -860,19 +1085,32 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
860
1085
|
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
861
1086
|
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
862
1087
|
.catch((writeError: unknown) => {
|
|
863
|
-
bridge.notify('
|
|
1088
|
+
bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
864
1089
|
})
|
|
865
1090
|
}
|
|
866
1091
|
|
|
867
|
-
/**
|
|
868
|
-
const
|
|
869
|
-
|
|
1092
|
+
/** Mutate one next-turn inbox item; durable inbox splices remain the UI truth. */
|
|
1093
|
+
const updateQueued = (messageId: string, action: QueueMutation): void => {
|
|
1094
|
+
const current = agent
|
|
1095
|
+
if (current === undefined) return
|
|
870
1096
|
try {
|
|
871
|
-
|
|
872
|
-
|
|
1097
|
+
const outcome = applyQueueMutation(
|
|
1098
|
+
current.inbox,
|
|
1099
|
+
current.status,
|
|
1100
|
+
messageId,
|
|
1101
|
+
action,
|
|
1102
|
+
message => current.steer(message),
|
|
1103
|
+
)
|
|
1104
|
+
switch (outcome) {
|
|
1105
|
+
case 'removed': bridge.notify(t('notice.queueCancelled')); return
|
|
1106
|
+
case 'edited': bridge.notify(t('notice.queueEdited')); return
|
|
1107
|
+
case 'steered': bridge.notify(t('notice.queueSteered')); return
|
|
1108
|
+
case 'empty': bridge.notify(t('notice.queueEditEmpty'), 'warning'); return
|
|
1109
|
+
case 'steerUnavailable': bridge.notify(t('notice.queueSteerUnavailable'), 'warning'); return
|
|
1110
|
+
case 'unavailable': bridge.notify(t('notice.queueUnavailable'), 'warning'); return
|
|
873
1111
|
}
|
|
874
1112
|
} catch (error: unknown) {
|
|
875
|
-
bridge.notify('
|
|
1113
|
+
bridge.notify(t('notice.queueActionFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
876
1114
|
}
|
|
877
1115
|
}
|
|
878
1116
|
|
|
@@ -937,7 +1175,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
937
1175
|
}
|
|
938
1176
|
const registry = ctx.get('commands')
|
|
939
1177
|
if (registry === undefined) {
|
|
940
|
-
bridge.notify('
|
|
1178
|
+
bridge.notify(t('notice.commandRegistryMissing'), 'error')
|
|
941
1179
|
return
|
|
942
1180
|
}
|
|
943
1181
|
const controller = new AbortController()
|
|
@@ -965,13 +1203,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
965
1203
|
source: { kind: 'user' },
|
|
966
1204
|
}))
|
|
967
1205
|
} catch (error: unknown) {
|
|
968
|
-
bridge.notify(
|
|
1206
|
+
bridge.notify(t('notice.commandFallbackFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
969
1207
|
}
|
|
970
1208
|
}
|
|
971
1209
|
}, (error: unknown) => {
|
|
972
1210
|
finish()
|
|
973
1211
|
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
974
|
-
|
|
1212
|
+
// A failed plan switch never appends the plan/mode event the cycle's
|
|
1213
|
+
// intent retirement waits for, so the in-flight choice dies here too —
|
|
1214
|
+
// otherwise every later Shift+Tab reads a phantom plan state.
|
|
1215
|
+
if (line === '/plan' || line === '/plan off') planIntent = undefined
|
|
1216
|
+
bridge.notify(t('notice.commandFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
975
1217
|
})
|
|
976
1218
|
}
|
|
977
1219
|
|
|
@@ -979,13 +1221,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
979
1221
|
let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
|
|
980
1222
|
|
|
981
1223
|
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
982
|
-
const deliverLine = (line: string, mode: 'followup' | 'steer'
|
|
1224
|
+
const deliverLine = (line: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
983
1225
|
const currentAgent = agent!
|
|
984
|
-
const currentMentions = mentions
|
|
1226
|
+
const currentMentions = mentions
|
|
985
1227
|
// 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') {
|
|
1228
|
+
// band and never reach the model through this path.
|
|
1229
|
+
if (images.length === 0 && isSlashLine(line)) {
|
|
989
1230
|
runSlash(line)
|
|
990
1231
|
return
|
|
991
1232
|
}
|
|
@@ -1023,13 +1264,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1023
1264
|
content,
|
|
1024
1265
|
source: { kind: 'user' },
|
|
1025
1266
|
})
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
} else {
|
|
1031
|
-
currentAgent.followup(message)
|
|
1032
|
-
}
|
|
1267
|
+
// Steering is consumed at the next step boundary of the turn already
|
|
1268
|
+
// running; a followup becomes its own turn instead.
|
|
1269
|
+
if (mode === 'steer') currentAgent.steer(message)
|
|
1270
|
+
else currentAgent.followup(message)
|
|
1033
1271
|
} catch (error: unknown) {
|
|
1034
1272
|
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1035
1273
|
}
|
|
@@ -1040,14 +1278,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1040
1278
|
}
|
|
1041
1279
|
const controller = new AbortController()
|
|
1042
1280
|
pendingControllers.add(controller)
|
|
1043
|
-
enqueueDelivery
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1281
|
+
// `enqueueDelivery` returns nothing; the delivery chain only orders the
|
|
1282
|
+
// work, so the promise is consumed here with an explicit void.
|
|
1283
|
+
enqueueDelivery(() => {
|
|
1284
|
+
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
1285
|
+
pendingControllers.delete(controller)
|
|
1286
|
+
deliver(prepared.text, prepared.additionalContext)
|
|
1287
|
+
}, (error: unknown) => {
|
|
1288
|
+
pendingControllers.delete(controller)
|
|
1289
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1290
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1291
|
+
})
|
|
1292
|
+
})
|
|
1051
1293
|
}
|
|
1052
1294
|
|
|
1053
1295
|
// Deferred first-session creation for a bare launch: the session is composed
|
|
@@ -1073,7 +1315,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1073
1315
|
// (which would orphan the live one without a dispose).
|
|
1074
1316
|
if (session !== undefined) {
|
|
1075
1317
|
const queued = pendingInputs.splice(0)
|
|
1076
|
-
for (const item of queued) deliverLine(item.text, item.
|
|
1318
|
+
for (const item of queued) deliverLine(item.text, item.images, item.mode)
|
|
1077
1319
|
return
|
|
1078
1320
|
}
|
|
1079
1321
|
const next = await prepare({
|
|
@@ -1093,6 +1335,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1093
1335
|
store = next.store
|
|
1094
1336
|
mentions = next.mentions
|
|
1095
1337
|
subagents.reset()
|
|
1338
|
+
for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1096
1339
|
pendingMode = undefined
|
|
1097
1340
|
pendingPermission = undefined
|
|
1098
1341
|
commands.setAgent(agent)
|
|
@@ -1132,9 +1375,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1132
1375
|
// A pre-session plan choice materializes as the registry command
|
|
1133
1376
|
// delivered AHEAD of the queued lines, so the first assembled step
|
|
1134
1377
|
// of the user's opening message already runs in plan mode.
|
|
1135
|
-
deliverLine('/plan'
|
|
1378
|
+
deliverLine('/plan')
|
|
1136
1379
|
}
|
|
1137
|
-
for (const item of queued) deliverLine(item.text, item.
|
|
1380
|
+
for (const item of queued) deliverLine(item.text, item.images, item.mode)
|
|
1138
1381
|
} finally {
|
|
1139
1382
|
creating = false
|
|
1140
1383
|
}
|
|
@@ -1145,7 +1388,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1145
1388
|
}
|
|
1146
1389
|
|
|
1147
1390
|
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
1148
|
-
const sendNow = (text: string, mode: 'followup' | 'steer'
|
|
1391
|
+
const sendNow = (text: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
1149
1392
|
// Blank check on the trimmed form; the payload itself keeps the draft's
|
|
1150
1393
|
// exact whitespace unless the line is a syntactic slash command.
|
|
1151
1394
|
const line = submissionPayload(text)
|
|
@@ -1167,17 +1410,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1167
1410
|
return
|
|
1168
1411
|
}
|
|
1169
1412
|
if (session === undefined) {
|
|
1413
|
+
// The delivery mode rides the buffered line: a steer picked before the
|
|
1414
|
+
// first session exists must still steer once that session composes.
|
|
1170
1415
|
pendingInputs.push({ text: line, mode, images })
|
|
1171
1416
|
ensureSession()
|
|
1172
1417
|
return
|
|
1173
1418
|
}
|
|
1174
|
-
deliverLine(line,
|
|
1419
|
+
deliverLine(line, images, mode)
|
|
1175
1420
|
}
|
|
1176
1421
|
|
|
1177
1422
|
// Startup serialization: input submitted while the startup prompt/images
|
|
1178
1423
|
// 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'
|
|
1424
|
+
const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, images, mode))
|
|
1425
|
+
const send = (text: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
1181
1426
|
inputGate.submit({ text, mode, images })
|
|
1182
1427
|
}
|
|
1183
1428
|
|
|
@@ -1187,25 +1432,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1187
1432
|
// session (queued switch): the composing session is gone, so the stale
|
|
1188
1433
|
// delivery is dropped instead of landing in the new session's inbox.
|
|
1189
1434
|
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1190
|
-
send(text,
|
|
1435
|
+
send(text, images)
|
|
1191
1436
|
}
|
|
1192
1437
|
|
|
1193
1438
|
/**
|
|
1194
|
-
*
|
|
1195
|
-
* boundary
|
|
1196
|
-
*
|
|
1439
|
+
* Deliver one line as steering: a running driver consumes it at its next
|
|
1440
|
+
* step boundary, an idle one starts a turn with it. The composer's Tab
|
|
1441
|
+
* toggle picks this over {@link dispatch} for the next submission.
|
|
1197
1442
|
*/
|
|
1198
1443
|
const steer = (text: string, images: readonly ContentBlock[] = [], origin?: string): void => {
|
|
1199
1444
|
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1200
|
-
send(text, 'steer'
|
|
1445
|
+
send(text, images, 'steer')
|
|
1201
1446
|
}
|
|
1202
1447
|
|
|
1203
|
-
/**
|
|
1448
|
+
/**
|
|
1449
|
+
* Interrupt the running turn (Esc); true when a turn was actually
|
|
1450
|
+
* cancelled. {@link cancelPreservingQueue} keeps the next-turn queue alive
|
|
1451
|
+
* AND re-wakes the driver, so the preserved messages run instead of
|
|
1452
|
+
* parking; next-step steering dies with the turn.
|
|
1453
|
+
*/
|
|
1204
1454
|
const interrupt = (): boolean => {
|
|
1205
1455
|
if (agent === undefined || agent.status !== 'running') return false
|
|
1206
1456
|
try {
|
|
1207
|
-
|
|
1208
|
-
bridge.notify(
|
|
1457
|
+
const preserved = cancelPreservingQueue(agent)
|
|
1458
|
+
bridge.notify(t(preserved > 0 ? 'notice.turnCancelledKeepQueue' : 'notice.turnCancelled'))
|
|
1209
1459
|
return true
|
|
1210
1460
|
} catch (error: unknown) {
|
|
1211
1461
|
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
@@ -1233,7 +1483,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1233
1483
|
* so minimal sessions and the pre-session state cycle permissions only).
|
|
1234
1484
|
* Plan transitions submit the upstream registry command — it stays the
|
|
1235
1485
|
* single owner of plan state; the TUI renders the durable plan/mode event
|
|
1236
|
-
* it appends.
|
|
1486
|
+
* it appends. Because that event lags the press (upstream queues the
|
|
1487
|
+
* switch during an open turn), each mid-session plan decision records the
|
|
1488
|
+
* choice in `planIntent` and the next press reads it back, so the cycle
|
|
1489
|
+
* advances stations instead of re-issuing one transition. Returns the
|
|
1490
|
+
* notice label, or '' when nothing changed.
|
|
1237
1491
|
*/
|
|
1238
1492
|
const cycleMode = (): string => {
|
|
1239
1493
|
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
@@ -1250,6 +1504,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1250
1504
|
names: permissionPresets.names,
|
|
1251
1505
|
current: effectivePermission(permissionPresets, session, pendingPermission),
|
|
1252
1506
|
inPlan: preSession ? pendingPlan : store.getView().plan === true,
|
|
1507
|
+
...(preSession ? {} : { planIntent }),
|
|
1253
1508
|
planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
|
|
1254
1509
|
})
|
|
1255
1510
|
if (decision === undefined) return ''
|
|
@@ -1270,7 +1525,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1270
1525
|
renderCurrent()
|
|
1271
1526
|
return 'plan → on (applies to the first session)'
|
|
1272
1527
|
}
|
|
1273
|
-
|
|
1528
|
+
planIntent = true
|
|
1529
|
+
send('/plan')
|
|
1274
1530
|
return 'plan → on'
|
|
1275
1531
|
}
|
|
1276
1532
|
// Leaving plan lands on the station after the most restrictive
|
|
@@ -1281,7 +1537,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1281
1537
|
renderCurrent()
|
|
1282
1538
|
return `plan → off · permission → ${decision.preset}`
|
|
1283
1539
|
}
|
|
1284
|
-
|
|
1540
|
+
planIntent = false
|
|
1541
|
+
send('/plan off')
|
|
1285
1542
|
selectPermission(permissionPresets, session, decision.preset)
|
|
1286
1543
|
return `plan → off · permission → ${decision.preset}`
|
|
1287
1544
|
} catch (error: unknown) {
|
|
@@ -1511,6 +1768,26 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1511
1768
|
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1512
1769
|
}
|
|
1513
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* Read one session's usage blocks for the /usage panel: the mounted
|
|
1773
|
+
* projection's session totals plus the meter's own per-turn fold over the
|
|
1774
|
+
* durable log (which the panel merges by model). A deployment without the
|
|
1775
|
+
* projection registry renders the totals as explicitly unavailable rather
|
|
1776
|
+
* than as zeros. The read is synchronous — the registry materializes a cell
|
|
1777
|
+
* on first touch — so it is handed to the panel behind a resolved promise,
|
|
1778
|
+
* which keeps the fold out of the keystroke that opens the panel.
|
|
1779
|
+
* @param current - the session to read, or undefined before the first one.
|
|
1780
|
+
* @returns the resolved panel data.
|
|
1781
|
+
*/
|
|
1782
|
+
const loadUsage = (current: Session | undefined): Promise<UsageView> => {
|
|
1783
|
+
if (current === undefined) return Promise.resolve({ turns: [] })
|
|
1784
|
+
const values = ctx.get('sessionProjections')?.snapshot(current, ['tokenUsage']).values
|
|
1785
|
+
return Promise.resolve({
|
|
1786
|
+
totals: values?.tokenUsage,
|
|
1787
|
+
turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
|
|
1788
|
+
})
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1514
1791
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
1515
1792
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1516
1793
|
const currentAgent = agent
|
|
@@ -1579,17 +1856,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1579
1856
|
commands.setAgent(agent)
|
|
1580
1857
|
skills.setAgent(agent)
|
|
1581
1858
|
try {
|
|
1859
|
+
// Reseed the feed BEFORE the first frame of the new session so no
|
|
1860
|
+
// stale row from the previous one flashes; a rolled-back handoff
|
|
1861
|
+
// re-seeds the previous session's catalog the same way.
|
|
1862
|
+
subagents.reset()
|
|
1863
|
+
for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1582
1864
|
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1583
1865
|
renderCurrent()
|
|
1584
1866
|
// Only a successful handoff may clear the transient per-session
|
|
1585
1867
|
// surfaces: a rolled-back switch keeps the previous session's
|
|
1586
1868
|
// subagent feed plus the user's pre-session /mode and permission
|
|
1587
1869
|
// picks (the bare-launch promise: explicit choices survive until
|
|
1588
|
-
// composition takes them).
|
|
1589
|
-
|
|
1870
|
+
// composition takes them). The in-flight cycle intent belonged to
|
|
1871
|
+
// the previous session's presses; the new session's committed fold
|
|
1872
|
+
// decides from here.
|
|
1590
1873
|
pendingMode = undefined
|
|
1591
1874
|
pendingPermission = undefined
|
|
1592
1875
|
pendingPlan = false
|
|
1876
|
+
planIntent = undefined
|
|
1593
1877
|
} catch (error: unknown) {
|
|
1594
1878
|
active = previous
|
|
1595
1879
|
agent = previous?.agent
|
|
@@ -1598,6 +1882,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1598
1882
|
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1599
1883
|
if (agent !== undefined) commands.setAgent(agent)
|
|
1600
1884
|
if (agent !== undefined) skills.setAgent(agent)
|
|
1885
|
+
subagents.reset()
|
|
1886
|
+
if (previous !== undefined) {
|
|
1887
|
+
for (const event of previous.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1888
|
+
}
|
|
1889
|
+
// The failed handoff disposed the incoming session; the restored
|
|
1890
|
+
// store's committed plan fold is the truth, so any cycle intent
|
|
1891
|
+
// collected against the switch churn retires too.
|
|
1892
|
+
planIntent = undefined
|
|
1601
1893
|
await next.handle.dispose()
|
|
1602
1894
|
if (!quitting) renderCurrent()
|
|
1603
1895
|
throw error
|
|
@@ -1669,7 +1961,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1669
1961
|
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
1670
1962
|
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
1671
1963
|
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
1672
|
-
const matched = matches[0]
|
|
1964
|
+
const matched = matches[0]
|
|
1673
1965
|
// Same lineage gate as the CLI --resume path and the picker.
|
|
1674
1966
|
if (isSubagentSession(matched.header)) {
|
|
1675
1967
|
throw new Error('subagent conversations are read-only; resume a root session')
|
|
@@ -1697,7 +1989,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1697
1989
|
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
1698
1990
|
}
|
|
1699
1991
|
|
|
1700
|
-
const reviewChanges = (
|
|
1992
|
+
const reviewChanges = (selection: ReviewSelection): void => {
|
|
1701
1993
|
// Works from a bare launch too: with no session yet the read-only
|
|
1702
1994
|
// choice goes to pendingPermission (materialized when the first
|
|
1703
1995
|
// session composes) and the review prompt queues behind that
|
|
@@ -1717,21 +2009,31 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1717
2009
|
const finish = (): void => {
|
|
1718
2010
|
pendingControllers.delete(controller)
|
|
1719
2011
|
}
|
|
1720
|
-
|
|
2012
|
+
// Branch reviews diff from the precomputed merge base (what would
|
|
2013
|
+
// actually land), commit reviews the commit's own patch, everything
|
|
2014
|
+
// else reviews the uncommitted working tree.
|
|
2015
|
+
const load = selection.kind === 'commit'
|
|
2016
|
+
? loadCommitDiff(reviewCwd, selection.sha, controller.signal)
|
|
2017
|
+
: selection.kind === 'base-branch'
|
|
2018
|
+
? mergeBaseWith(reviewCwd, selection.branch, controller.signal)
|
|
2019
|
+
.then(base => loadGitDiff(reviewCwd, base ?? selection.branch, controller.signal))
|
|
2020
|
+
: loadGitDiff(reviewCwd, '', controller.signal)
|
|
2021
|
+
const note = selection.kind === 'custom' ? selection.instructions : undefined
|
|
2022
|
+
void load.then(({ title, files }) => {
|
|
1721
2023
|
finish()
|
|
1722
2024
|
if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent) return
|
|
1723
2025
|
try {
|
|
1724
2026
|
setPermissionAction('read-only')
|
|
1725
2027
|
} catch (error: unknown) {
|
|
1726
|
-
bridge.notify(
|
|
2028
|
+
bridge.notify(t('notice.reviewUnavailable', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1727
2029
|
return
|
|
1728
2030
|
}
|
|
1729
|
-
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title
|
|
1730
|
-
bridge.notify('
|
|
2031
|
+
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title, note))
|
|
2032
|
+
bridge.notify(t('notice.reviewStarted'))
|
|
1731
2033
|
}, (error: unknown) => {
|
|
1732
2034
|
finish()
|
|
1733
2035
|
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1734
|
-
bridge.notify(
|
|
2036
|
+
bridge.notify(t('notice.reviewFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1735
2037
|
})
|
|
1736
2038
|
}
|
|
1737
2039
|
|
|
@@ -1773,6 +2075,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1773
2075
|
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
1774
2076
|
}
|
|
1775
2077
|
|
|
2078
|
+
// /search reads the SAME in-process engine the model's session_search
|
|
2079
|
+
// tools use (the bundle's skip-tolerant subclass). The row may be disabled
|
|
2080
|
+
// by a deployment; /search then degrades to a notice instead of a panel.
|
|
2081
|
+
const searchSessions = sessionQuery === undefined
|
|
2082
|
+
? undefined
|
|
2083
|
+
: async (query: string, signal?: AbortSignal): Promise<readonly SearchRow[]> => {
|
|
2084
|
+
const page = await sessionQuery.searchSessions({ query, limit: 30 }, signal === undefined ? undefined : { signal })
|
|
2085
|
+
const rows = page.items.map(hit => searchHitToRow(hit))
|
|
2086
|
+
// Best-effort title enrichment (the same snapshots /resume merges):
|
|
2087
|
+
// a failure keeps the short-id labels instead of failing the search.
|
|
2088
|
+
try {
|
|
2089
|
+
const observations = await sessionQuery.readTitleSnapshots(rows.map(row => row.id), signal)
|
|
2090
|
+
const titles = new Map<string, string>()
|
|
2091
|
+
for (const observation of observations) {
|
|
2092
|
+
if (observation.status !== 'fulfilled') continue
|
|
2093
|
+
const title = observation.value?.title?.title
|
|
2094
|
+
if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
|
|
2095
|
+
}
|
|
2096
|
+
return rows.map(row => titles.has(row.id) ? { ...row, label: titles.get(row.id)! } : row)
|
|
2097
|
+
} catch {
|
|
2098
|
+
return rows
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1776
2102
|
const cancelSessionSwitch = (): boolean => {
|
|
1777
2103
|
return switchQueue.cancel()
|
|
1778
2104
|
}
|
|
@@ -1854,6 +2180,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1854
2180
|
renameTitle,
|
|
1855
2181
|
copyLastResponse,
|
|
1856
2182
|
loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
|
|
2183
|
+
listReviewBranches: (signal?: AbortSignal) => listReviewBranches(session?.header.cwd ?? cwd, signal),
|
|
2184
|
+
listReviewCommits: (signal?: AbortSignal) => listReviewCommits(session?.header.cwd ?? cwd, signal),
|
|
1857
2185
|
reviewChanges,
|
|
1858
2186
|
loadPresets: () => presets.list(),
|
|
1859
2187
|
switchMode: switchModeAction,
|
|
@@ -1864,6 +2192,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1864
2192
|
forkSession,
|
|
1865
2193
|
loadSessions,
|
|
1866
2194
|
loadSessionTranscript,
|
|
2195
|
+
loadUsage: () => loadUsage(session),
|
|
1867
2196
|
loadSubagents: () => {
|
|
1868
2197
|
const current = session
|
|
1869
2198
|
if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
|
|
@@ -1871,22 +2200,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1871
2200
|
.then(rows => rows.filter(row => row.parent === current.id && row.subagent))
|
|
1872
2201
|
},
|
|
1873
2202
|
switchSession,
|
|
2203
|
+
searchSessions,
|
|
1874
2204
|
cancelSessionSwitch,
|
|
1875
2205
|
loadPlugins: () => listPluginRows(ctx),
|
|
1876
2206
|
// The launcher owns every update decision; the TUI only drives its
|
|
1877
2207
|
// read-only probe and streamed apply as child processes.
|
|
1878
2208
|
probeUpdate: () => probeLauncherUpdate(),
|
|
1879
|
-
applyUpdate: onLine => applyLauncherUpdate(onLine),
|
|
2209
|
+
applyUpdate: (onLine, plan) => applyLauncherUpdate(onLine, undefined, plan),
|
|
1880
2210
|
loadJobs: () => listJobs(ctx, active?.agent),
|
|
1881
2211
|
statusline: statuslineItems,
|
|
1882
2212
|
saveStatusline,
|
|
1883
2213
|
applyEditorKeys,
|
|
1884
2214
|
saveTheme,
|
|
2215
|
+
saveLanguage,
|
|
1885
2216
|
animations: animationsEnabled,
|
|
1886
2217
|
saveAnimations,
|
|
1887
2218
|
history: inputHistory,
|
|
1888
2219
|
recordHistory,
|
|
1889
|
-
|
|
2220
|
+
updateQueued,
|
|
1890
2221
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
1891
2222
|
})
|
|
1892
2223
|
}
|
|
@@ -1929,6 +2260,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1929
2260
|
}, 50)
|
|
1930
2261
|
}
|
|
1931
2262
|
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
2263
|
+
if (languageWarning !== undefined) {
|
|
2264
|
+
setTimeout(() => {
|
|
2265
|
+
bridge.notify(t('notice.languageConfigUnreadable', { message: languageWarning }), 'warning')
|
|
2266
|
+
}, 0)
|
|
2267
|
+
}
|
|
1932
2268
|
if (themeWarning !== undefined) {
|
|
1933
2269
|
setTimeout(() => {
|
|
1934
2270
|
bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
|