dsh-code 1.0.6 → 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 +123 -26
- package/README.md +124 -27
- package/bin/deepseek.mjs +283 -35
- package/cordis.patch.yml +97 -0
- package/lib/index.mjs +5008 -881
- package/lib/session-query.mjs +150 -0
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
- package/lib/types/app.d.ts +106 -62
- 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 +100 -1
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +107 -29
- 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/editor.d.ts +4 -3
- package/lib/types/render/ime-cursor.d.ts +60 -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 +130 -4
- package/lib/types/render/status.d.ts +9 -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 +17 -0
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +66 -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 +49 -0
- package/lib/types/update.d.ts +75 -0
- package/lib/types/version.d.ts +4 -3
- package/package.json +246 -90
- package/src/app.ts +1369 -509
- 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 +534 -80
- package/src/input-split.ts +3 -3
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +554 -86
- 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 +25 -24
- package/src/render/export.ts +116 -95
- package/src/render/ime-cursor.ts +147 -0
- 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 +572 -21
- package/src/render/status.ts +59 -39
- 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 +239 -0
- package/src/startup.ts +3 -3
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +190 -0
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +256 -0
- package/src/update.ts +126 -0
- 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,21 +71,30 @@ 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'
|
|
77
86
|
import {
|
|
78
87
|
applyPendingPermission,
|
|
79
|
-
cyclePermission as cyclePermissionPreset,
|
|
80
88
|
effectivePermission,
|
|
81
89
|
listPermissionRows,
|
|
82
90
|
permissionPresetsFrom,
|
|
83
91
|
selectPermission,
|
|
84
92
|
} from './permissions.ts'
|
|
85
93
|
import { listPluginRows } from './plugin-inventory.ts'
|
|
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,85 @@ 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
|
+
|
|
435
|
+
/** One Shift+Tab station decision for the mode cycle. */
|
|
436
|
+
export type ModeCycleDecision =
|
|
437
|
+
| { readonly kind: 'permission'; readonly preset: string }
|
|
438
|
+
| { readonly kind: 'plan-on' }
|
|
439
|
+
| { readonly kind: 'plan-off'; readonly preset: string }
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Decide the next Shift+Tab station. The cycle keeps the preset table's
|
|
443
|
+
* own order (most restrictive first) and inserts ONE plan station between
|
|
444
|
+
* the most restrictive preset and the wrap target: with the shipped three
|
|
445
|
+
* presets the user sees workspace-write → danger-full-access → read-only
|
|
446
|
+
* → plan → workspace-write. Plan IS the most restrictive preset plus the
|
|
447
|
+
* plan prompt layer — entering it switches nothing (the cycle is already
|
|
448
|
+
* parked on read-only), and leaving it lands on the next preset after the
|
|
449
|
+
* most restrictive one. Without the /plan command the cycle is exactly the
|
|
450
|
+
* preset table.
|
|
451
|
+
*
|
|
452
|
+
* `planIntent` covers the committed fold's commit lag: upstream queues a
|
|
453
|
+
* plan switch during an open turn (and the command pipeline is async even
|
|
454
|
+
* idle), so the durable plan/mode event lands AFTER the press that chose
|
|
455
|
+
* it. While an intent from an earlier press is in flight it — not the
|
|
456
|
+
* stale committed fold — decides the station, so repeated presses advance
|
|
457
|
+
* the cycle instead of re-issuing the same plan transition (the stuck
|
|
458
|
+
* plan-on/plan-off toggle). Undefined falls back to the committed fold.
|
|
459
|
+
*/
|
|
460
|
+
export function planCycleDecision(input: {
|
|
461
|
+
readonly names: readonly string[]
|
|
462
|
+
readonly current: string
|
|
463
|
+
readonly inPlan: boolean
|
|
464
|
+
readonly planAvailable: boolean
|
|
465
|
+
readonly planIntent?: boolean
|
|
466
|
+
}): ModeCycleDecision | undefined {
|
|
467
|
+
const names = input.names
|
|
468
|
+
if (names.length === 0) return undefined
|
|
469
|
+
const first = names[0]
|
|
470
|
+
if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
471
|
+
const at = names.indexOf(input.current)
|
|
472
|
+
if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
|
|
473
|
+
return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
|
|
474
|
+
}
|
|
475
|
+
|
|
272
476
|
/**
|
|
273
477
|
* Order-preserving gate for composer input while the startup prompt/images
|
|
274
478
|
* are still preparing. Anything submitted before the startup delivery settles
|
|
@@ -366,7 +570,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
|
|
|
366
570
|
/** The runner's connection between the React app and the process side. */
|
|
367
571
|
interface AppBridge {
|
|
368
572
|
/** Post one local notice line (feedback the transcript does not carry). */
|
|
369
|
-
notify(text: string, tone?: NoticeTone)
|
|
573
|
+
notify: (text: string, tone?: NoticeTone) => void
|
|
370
574
|
}
|
|
371
575
|
|
|
372
576
|
/**
|
|
@@ -412,6 +616,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
412
616
|
mode: string
|
|
413
617
|
selection: { picked?: ModelSelection }
|
|
414
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'>[]
|
|
415
626
|
}
|
|
416
627
|
|
|
417
628
|
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
@@ -465,6 +676,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
465
676
|
cwd: nextCwd,
|
|
466
677
|
agentPreset: mode,
|
|
467
678
|
...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
|
|
679
|
+
...(next.origin === undefined ? {} : { origin: next.origin }),
|
|
468
680
|
// 0.1.5 fork lineage: the seed marker lives on the metadata and the
|
|
469
681
|
// inherited prefix length on the top-level option (the v0 header's
|
|
470
682
|
// numeric `seedLength` field is gone from the create contract).
|
|
@@ -480,15 +692,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
480
692
|
if (!next.resume && permissionPresets !== undefined) {
|
|
481
693
|
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
482
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
|
+
}
|
|
483
703
|
return {
|
|
484
704
|
handle,
|
|
485
705
|
agent: handle.agent,
|
|
486
706
|
session,
|
|
487
|
-
store: createTranscriptStore(
|
|
707
|
+
store: createTranscriptStore(seedEvents),
|
|
488
708
|
mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
|
|
489
709
|
mode: mode ?? 'standard',
|
|
490
710
|
selection: selectionState,
|
|
491
711
|
resumed: next.resume,
|
|
712
|
+
catalogSeed: subagentCatalogSeed(seedEvents),
|
|
492
713
|
}
|
|
493
714
|
}
|
|
494
715
|
|
|
@@ -511,6 +732,46 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
511
732
|
let pendingModeWork: Promise<void> = Promise.resolve()
|
|
512
733
|
/** Permission preset selected before the first session exists. */
|
|
513
734
|
let pendingPermission: string | undefined
|
|
735
|
+
/**
|
|
736
|
+
* Plan-mode choice made before the first session exists: materialized as a
|
|
737
|
+
* /plan registry command delivered ahead of the first queued input when the
|
|
738
|
+
* session composes, so the first assembled step already plans.
|
|
739
|
+
*/
|
|
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
|
|
749
|
+
/**
|
|
750
|
+
* Whether the pre-session effective preset composes plan mode, answered by
|
|
751
|
+
* the presets service composition inventory (minimal does not). Cached and
|
|
752
|
+
* refreshed whenever the pending mode moves; unknown reads as unavailable
|
|
753
|
+
* so one keypress at most lands before the answer arrives.
|
|
754
|
+
*/
|
|
755
|
+
let preSessionPlanAvailable = false
|
|
756
|
+
let preSessionPlanKnown = false
|
|
757
|
+
const refreshPreSessionPlan = (): void => {
|
|
758
|
+
if (presets === undefined) {
|
|
759
|
+
preSessionPlanAvailable = false
|
|
760
|
+
preSessionPlanKnown = true
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
preSessionPlanKnown = false
|
|
764
|
+
void presets.compositionInventory().then(inventory => {
|
|
765
|
+
const id = pendingMode ?? normalizePresetId(presets.defaultId)
|
|
766
|
+
preSessionPlanAvailable = inventory.some(composition => composition.id === id
|
|
767
|
+
&& composition.rows.some(row => row.moduleName === '@deepseek-ai/dsh-plan-mode' && row.enabled !== false))
|
|
768
|
+
preSessionPlanKnown = true
|
|
769
|
+
}, () => {
|
|
770
|
+
preSessionPlanAvailable = false
|
|
771
|
+
preSessionPlanKnown = true
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
refreshPreSessionPlan()
|
|
514
775
|
/**
|
|
515
776
|
* Monotonic session epoch: bumped on every successful activation, on every
|
|
516
777
|
* first-session creation, and on quit. Async callbacks (mention prepares,
|
|
@@ -552,6 +813,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
552
813
|
session = prepared.session
|
|
553
814
|
store = prepared.store
|
|
554
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)
|
|
555
819
|
}
|
|
556
820
|
|
|
557
821
|
// Seed the transcript from the full session log: constructor seeds never
|
|
@@ -562,6 +826,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
562
826
|
if (session === undefined) return
|
|
563
827
|
if (subject.id === session.id) {
|
|
564
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
|
|
565
833
|
// The parent-owned subagent catalog rides the ROOT log (0.1.5); each
|
|
566
834
|
// fact describes one child, so it feeds that child's live row.
|
|
567
835
|
if (event.type === 'subagent/catalog' && event.data.childId !== '') subagents.apply(event.data.childId, event)
|
|
@@ -677,7 +945,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
677
945
|
let statuslineWarning: string | undefined
|
|
678
946
|
let statuslineItems: readonly string[] = []
|
|
679
947
|
try {
|
|
680
|
-
statuslineItems = parseStatuslineItems(
|
|
948
|
+
statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
|
|
681
949
|
} catch (error) {
|
|
682
950
|
statuslineItems = parseStatuslineItems(undefined)
|
|
683
951
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
@@ -693,7 +961,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
693
961
|
statuslineItems = [...items]
|
|
694
962
|
void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
|
|
695
963
|
.catch((writeError: unknown) => {
|
|
696
|
-
bridge.notify('
|
|
964
|
+
bridge.notify(t('notice.statuslineSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
697
965
|
})
|
|
698
966
|
}
|
|
699
967
|
|
|
@@ -716,7 +984,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
716
984
|
let themeWarning: string | undefined
|
|
717
985
|
if (startup.theme === undefined) {
|
|
718
986
|
try {
|
|
719
|
-
setTheme(parseThemeName(
|
|
987
|
+
setTheme(parseThemeName(readSettingsObject(themePath).theme))
|
|
720
988
|
} catch (error) {
|
|
721
989
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
722
990
|
themeWarning = error instanceof Error ? error.message : String(error)
|
|
@@ -729,7 +997,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
729
997
|
setTheme(name)
|
|
730
998
|
void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
|
|
731
999
|
.catch((writeError: unknown) => {
|
|
732
|
-
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')
|
|
733
1021
|
})
|
|
734
1022
|
}
|
|
735
1023
|
|
|
@@ -742,8 +1030,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
742
1030
|
let animationsEnabled = true
|
|
743
1031
|
let animationsWarning: string | undefined
|
|
744
1032
|
try {
|
|
745
|
-
//
|
|
746
|
-
|
|
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)
|
|
747
1036
|
} catch (error) {
|
|
748
1037
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
749
1038
|
animationsWarning = error instanceof Error ? error.message : String(error)
|
|
@@ -752,7 +1041,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
752
1041
|
const saveAnimations = (enabled: boolean): void => {
|
|
753
1042
|
void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
|
|
754
1043
|
.catch((writeError: unknown) => {
|
|
755
|
-
bridge.notify('
|
|
1044
|
+
bridge.notify(t('notice.animationsSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
756
1045
|
})
|
|
757
1046
|
}
|
|
758
1047
|
|
|
@@ -796,19 +1085,32 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
796
1085
|
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
797
1086
|
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
798
1087
|
.catch((writeError: unknown) => {
|
|
799
|
-
bridge.notify('
|
|
1088
|
+
bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
800
1089
|
})
|
|
801
1090
|
}
|
|
802
1091
|
|
|
803
|
-
/**
|
|
804
|
-
const
|
|
805
|
-
|
|
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
|
|
806
1096
|
try {
|
|
807
|
-
|
|
808
|
-
|
|
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
|
|
809
1111
|
}
|
|
810
1112
|
} catch (error: unknown) {
|
|
811
|
-
bridge.notify('
|
|
1113
|
+
bridge.notify(t('notice.queueActionFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
812
1114
|
}
|
|
813
1115
|
}
|
|
814
1116
|
|
|
@@ -873,7 +1175,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
873
1175
|
}
|
|
874
1176
|
const registry = ctx.get('commands')
|
|
875
1177
|
if (registry === undefined) {
|
|
876
|
-
bridge.notify('
|
|
1178
|
+
bridge.notify(t('notice.commandRegistryMissing'), 'error')
|
|
877
1179
|
return
|
|
878
1180
|
}
|
|
879
1181
|
const controller = new AbortController()
|
|
@@ -901,13 +1203,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
901
1203
|
source: { kind: 'user' },
|
|
902
1204
|
}))
|
|
903
1205
|
} catch (error: unknown) {
|
|
904
|
-
bridge.notify(
|
|
1206
|
+
bridge.notify(t('notice.commandFallbackFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
905
1207
|
}
|
|
906
1208
|
}
|
|
907
1209
|
}, (error: unknown) => {
|
|
908
1210
|
finish()
|
|
909
1211
|
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
910
|
-
|
|
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')
|
|
911
1217
|
})
|
|
912
1218
|
}
|
|
913
1219
|
|
|
@@ -915,13 +1221,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
915
1221
|
let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
|
|
916
1222
|
|
|
917
1223
|
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
918
|
-
const deliverLine = (line: string, mode: 'followup' | 'steer'
|
|
1224
|
+
const deliverLine = (line: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
919
1225
|
const currentAgent = agent!
|
|
920
|
-
const currentMentions = mentions
|
|
1226
|
+
const currentMentions = mentions
|
|
921
1227
|
// The command registry is a closed namespace: slash lines run out of
|
|
922
|
-
// band and never reach the model through this path
|
|
923
|
-
|
|
924
|
-
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)) {
|
|
925
1230
|
runSlash(line)
|
|
926
1231
|
return
|
|
927
1232
|
}
|
|
@@ -959,13 +1264,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
959
1264
|
content,
|
|
960
1265
|
source: { kind: 'user' },
|
|
961
1266
|
})
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
} else {
|
|
967
|
-
currentAgent.followup(message)
|
|
968
|
-
}
|
|
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)
|
|
969
1271
|
} catch (error: unknown) {
|
|
970
1272
|
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
971
1273
|
}
|
|
@@ -976,14 +1278,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
976
1278
|
}
|
|
977
1279
|
const controller = new AbortController()
|
|
978
1280
|
pendingControllers.add(controller)
|
|
979
|
-
enqueueDelivery
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
+
})
|
|
987
1293
|
}
|
|
988
1294
|
|
|
989
1295
|
// Deferred first-session creation for a bare launch: the session is composed
|
|
@@ -1009,7 +1315,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1009
1315
|
// (which would orphan the live one without a dispose).
|
|
1010
1316
|
if (session !== undefined) {
|
|
1011
1317
|
const queued = pendingInputs.splice(0)
|
|
1012
|
-
for (const item of queued) deliverLine(item.text, item.
|
|
1318
|
+
for (const item of queued) deliverLine(item.text, item.images, item.mode)
|
|
1013
1319
|
return
|
|
1014
1320
|
}
|
|
1015
1321
|
const next = await prepare({
|
|
@@ -1029,6 +1335,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1029
1335
|
store = next.store
|
|
1030
1336
|
mentions = next.mentions
|
|
1031
1337
|
subagents.reset()
|
|
1338
|
+
for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
|
|
1032
1339
|
pendingMode = undefined
|
|
1033
1340
|
pendingPermission = undefined
|
|
1034
1341
|
commands.setAgent(agent)
|
|
@@ -1063,7 +1370,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1063
1370
|
abortPendingControllers()
|
|
1064
1371
|
epoch += 1
|
|
1065
1372
|
const queued = pendingInputs.splice(0)
|
|
1066
|
-
|
|
1373
|
+
if (pendingPlan) {
|
|
1374
|
+
pendingPlan = false
|
|
1375
|
+
// A pre-session plan choice materializes as the registry command
|
|
1376
|
+
// delivered AHEAD of the queued lines, so the first assembled step
|
|
1377
|
+
// of the user's opening message already runs in plan mode.
|
|
1378
|
+
deliverLine('/plan')
|
|
1379
|
+
}
|
|
1380
|
+
for (const item of queued) deliverLine(item.text, item.images, item.mode)
|
|
1067
1381
|
} finally {
|
|
1068
1382
|
creating = false
|
|
1069
1383
|
}
|
|
@@ -1074,7 +1388,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1074
1388
|
}
|
|
1075
1389
|
|
|
1076
1390
|
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
1077
|
-
const sendNow = (text: string, mode: 'followup' | 'steer'
|
|
1391
|
+
const sendNow = (text: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
|
|
1078
1392
|
// Blank check on the trimmed form; the payload itself keeps the draft's
|
|
1079
1393
|
// exact whitespace unless the line is a syntactic slash command.
|
|
1080
1394
|
const line = submissionPayload(text)
|
|
@@ -1096,17 +1410,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1096
1410
|
return
|
|
1097
1411
|
}
|
|
1098
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.
|
|
1099
1415
|
pendingInputs.push({ text: line, mode, images })
|
|
1100
1416
|
ensureSession()
|
|
1101
1417
|
return
|
|
1102
1418
|
}
|
|
1103
|
-
deliverLine(line,
|
|
1419
|
+
deliverLine(line, images, mode)
|
|
1104
1420
|
}
|
|
1105
1421
|
|
|
1106
1422
|
// Startup serialization: input submitted while the startup prompt/images
|
|
1107
1423
|
// are still preparing queues behind the initial request.
|
|
1108
|
-
const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text,
|
|
1109
|
-
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 => {
|
|
1110
1426
|
inputGate.submit({ text, mode, images })
|
|
1111
1427
|
}
|
|
1112
1428
|
|
|
@@ -1116,25 +1432,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1116
1432
|
// session (queued switch): the composing session is gone, so the stale
|
|
1117
1433
|
// delivery is dropped instead of landing in the new session's inbox.
|
|
1118
1434
|
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1119
|
-
send(text,
|
|
1435
|
+
send(text, images)
|
|
1120
1436
|
}
|
|
1121
1437
|
|
|
1122
1438
|
/**
|
|
1123
|
-
*
|
|
1124
|
-
* boundary
|
|
1125
|
-
*
|
|
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.
|
|
1126
1442
|
*/
|
|
1127
1443
|
const steer = (text: string, images: readonly ContentBlock[] = [], origin?: string): void => {
|
|
1128
1444
|
if (!submissionBelongsToSession(origin, session?.id)) return
|
|
1129
|
-
send(text, 'steer'
|
|
1445
|
+
send(text, images, 'steer')
|
|
1130
1446
|
}
|
|
1131
1447
|
|
|
1132
|
-
/**
|
|
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
|
+
*/
|
|
1133
1454
|
const interrupt = (): boolean => {
|
|
1134
1455
|
if (agent === undefined || agent.status !== 'running') return false
|
|
1135
1456
|
try {
|
|
1136
|
-
|
|
1137
|
-
bridge.notify(
|
|
1457
|
+
const preserved = cancelPreservingQueue(agent)
|
|
1458
|
+
bridge.notify(t(preserved > 0 ? 'notice.turnCancelledKeepQueue' : 'notice.turnCancelled'))
|
|
1138
1459
|
return true
|
|
1139
1460
|
} catch (error: unknown) {
|
|
1140
1461
|
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
@@ -1157,24 +1478,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1157
1478
|
}
|
|
1158
1479
|
|
|
1159
1480
|
/**
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1481
|
+
* Shift+Tab mode cycle: permission presets in table order, then the plan
|
|
1482
|
+
* station when the composition offers the /plan command (preset-mounted,
|
|
1483
|
+
* so minimal sessions and the pre-session state cycle permissions only).
|
|
1484
|
+
* Plan transitions submit the upstream registry command — it stays the
|
|
1485
|
+
* single owner of plan state; the TUI renders the durable plan/mode event
|
|
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.
|
|
1163
1491
|
*/
|
|
1164
|
-
const
|
|
1492
|
+
const cycleMode = (): string => {
|
|
1165
1493
|
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
1166
1494
|
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
1167
1495
|
return ''
|
|
1168
1496
|
}
|
|
1169
1497
|
try {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1498
|
+
// Pre-session the plan station rides the pending choice; once a
|
|
1499
|
+
// session exists the scoped /plan command descriptor decides, and the
|
|
1500
|
+
// durable plan/mode event is the live truth.
|
|
1501
|
+
const preSession = session === undefined
|
|
1502
|
+
if (preSession && !preSessionPlanKnown) refreshPreSessionPlan()
|
|
1503
|
+
const decision = planCycleDecision({
|
|
1504
|
+
names: permissionPresets.names,
|
|
1505
|
+
current: effectivePermission(permissionPresets, session, pendingPermission),
|
|
1506
|
+
inPlan: preSession ? pendingPlan : store.getView().plan === true,
|
|
1507
|
+
...(preSession ? {} : { planIntent }),
|
|
1508
|
+
planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
|
|
1509
|
+
})
|
|
1510
|
+
if (decision === undefined) return ''
|
|
1511
|
+
if (decision.kind === 'permission') {
|
|
1512
|
+
const next = selectPermission(permissionPresets, session, decision.preset)
|
|
1513
|
+
if (preSession) {
|
|
1514
|
+
pendingPermission = next
|
|
1515
|
+
renderCurrent()
|
|
1516
|
+
}
|
|
1517
|
+
return `permission → ${next}`
|
|
1518
|
+
}
|
|
1519
|
+
if (decision.kind === 'plan-on') {
|
|
1520
|
+
// Plan IS the most restrictive preset plus the plan prompt layer:
|
|
1521
|
+
// the cycle arrives here from that preset, so permission needs no
|
|
1522
|
+
// switch — only the plan mode itself toggles.
|
|
1523
|
+
if (preSession) {
|
|
1524
|
+
pendingPlan = true
|
|
1525
|
+
renderCurrent()
|
|
1526
|
+
return 'plan → on (applies to the first session)'
|
|
1527
|
+
}
|
|
1528
|
+
planIntent = true
|
|
1529
|
+
send('/plan')
|
|
1530
|
+
return 'plan → on'
|
|
1531
|
+
}
|
|
1532
|
+
// Leaving plan lands on the station after the most restrictive
|
|
1533
|
+
// preset (workspace-write with the shipped table).
|
|
1534
|
+
if (preSession) {
|
|
1535
|
+
pendingPlan = false
|
|
1536
|
+
pendingPermission = decision.preset
|
|
1173
1537
|
renderCurrent()
|
|
1538
|
+
return `plan → off · permission → ${decision.preset}`
|
|
1174
1539
|
}
|
|
1175
|
-
|
|
1540
|
+
planIntent = false
|
|
1541
|
+
send('/plan off')
|
|
1542
|
+
selectPermission(permissionPresets, session, decision.preset)
|
|
1543
|
+
return `plan → off · permission → ${decision.preset}`
|
|
1176
1544
|
} catch (error: unknown) {
|
|
1177
|
-
bridge.notify(`
|
|
1545
|
+
bridge.notify(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1178
1546
|
return ''
|
|
1179
1547
|
}
|
|
1180
1548
|
}
|
|
@@ -1400,6 +1768,26 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1400
1768
|
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1401
1769
|
}
|
|
1402
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
|
+
|
|
1403
1791
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
1404
1792
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1405
1793
|
const currentAgent = agent
|
|
@@ -1468,16 +1856,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1468
1856
|
commands.setAgent(agent)
|
|
1469
1857
|
skills.setAgent(agent)
|
|
1470
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)
|
|
1471
1864
|
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1472
1865
|
renderCurrent()
|
|
1473
1866
|
// Only a successful handoff may clear the transient per-session
|
|
1474
1867
|
// surfaces: a rolled-back switch keeps the previous session's
|
|
1475
1868
|
// subagent feed plus the user's pre-session /mode and permission
|
|
1476
1869
|
// picks (the bare-launch promise: explicit choices survive until
|
|
1477
|
-
// composition takes them).
|
|
1478
|
-
|
|
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.
|
|
1479
1873
|
pendingMode = undefined
|
|
1480
1874
|
pendingPermission = undefined
|
|
1875
|
+
pendingPlan = false
|
|
1876
|
+
planIntent = undefined
|
|
1481
1877
|
} catch (error: unknown) {
|
|
1482
1878
|
active = previous
|
|
1483
1879
|
agent = previous?.agent
|
|
@@ -1486,6 +1882,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1486
1882
|
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1487
1883
|
if (agent !== undefined) commands.setAgent(agent)
|
|
1488
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
|
|
1489
1893
|
await next.handle.dispose()
|
|
1490
1894
|
if (!quitting) renderCurrent()
|
|
1491
1895
|
throw error
|
|
@@ -1557,7 +1961,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1557
1961
|
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
1558
1962
|
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
1559
1963
|
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
1560
|
-
const matched = matches[0]
|
|
1964
|
+
const matched = matches[0]
|
|
1561
1965
|
// Same lineage gate as the CLI --resume path and the picker.
|
|
1562
1966
|
if (isSubagentSession(matched.header)) {
|
|
1563
1967
|
throw new Error('subagent conversations are read-only; resume a root session')
|
|
@@ -1585,7 +1989,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1585
1989
|
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
1586
1990
|
}
|
|
1587
1991
|
|
|
1588
|
-
const reviewChanges = (
|
|
1992
|
+
const reviewChanges = (selection: ReviewSelection): void => {
|
|
1589
1993
|
// Works from a bare launch too: with no session yet the read-only
|
|
1590
1994
|
// choice goes to pendingPermission (materialized when the first
|
|
1591
1995
|
// session composes) and the review prompt queues behind that
|
|
@@ -1605,21 +2009,31 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1605
2009
|
const finish = (): void => {
|
|
1606
2010
|
pendingControllers.delete(controller)
|
|
1607
2011
|
}
|
|
1608
|
-
|
|
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 }) => {
|
|
1609
2023
|
finish()
|
|
1610
2024
|
if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent) return
|
|
1611
2025
|
try {
|
|
1612
2026
|
setPermissionAction('read-only')
|
|
1613
2027
|
} catch (error: unknown) {
|
|
1614
|
-
bridge.notify(
|
|
2028
|
+
bridge.notify(t('notice.reviewUnavailable', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1615
2029
|
return
|
|
1616
2030
|
}
|
|
1617
|
-
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title
|
|
1618
|
-
bridge.notify('
|
|
2031
|
+
send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title, note))
|
|
2032
|
+
bridge.notify(t('notice.reviewStarted'))
|
|
1619
2033
|
}, (error: unknown) => {
|
|
1620
2034
|
finish()
|
|
1621
2035
|
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1622
|
-
bridge.notify(
|
|
2036
|
+
bridge.notify(t('notice.reviewFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1623
2037
|
})
|
|
1624
2038
|
}
|
|
1625
2039
|
|
|
@@ -1661,6 +2075,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1661
2075
|
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
1662
2076
|
}
|
|
1663
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
|
+
|
|
1664
2102
|
const cancelSessionSwitch = (): boolean => {
|
|
1665
2103
|
return switchQueue.cancel()
|
|
1666
2104
|
}
|
|
@@ -1703,6 +2141,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1703
2141
|
resumed: active?.resumed ?? false,
|
|
1704
2142
|
mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
|
|
1705
2143
|
permission,
|
|
2144
|
+
/** Pre-session plan choice for the status badge until a session composes. */
|
|
2145
|
+
pendingPlan: session === undefined && pendingPlan,
|
|
1706
2146
|
dispatch,
|
|
1707
2147
|
steer,
|
|
1708
2148
|
interrupt,
|
|
@@ -1729,7 +2169,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1729
2169
|
prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
|
|
1730
2170
|
inspectFiles: paths => inspectFilePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
|
|
1731
2171
|
prepareFiles: (paths, signal) => saveFilePaths(paths, ctx.get('attachments'), signal),
|
|
1732
|
-
|
|
2172
|
+
cycleMode,
|
|
1733
2173
|
setPermission: setPermissionAction,
|
|
1734
2174
|
selectModel,
|
|
1735
2175
|
subagentModel: subagentModelLabel(),
|
|
@@ -1740,6 +2180,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1740
2180
|
renameTitle,
|
|
1741
2181
|
copyLastResponse,
|
|
1742
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),
|
|
1743
2185
|
reviewChanges,
|
|
1744
2186
|
loadPresets: () => presets.list(),
|
|
1745
2187
|
switchMode: switchModeAction,
|
|
@@ -1750,6 +2192,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1750
2192
|
forkSession,
|
|
1751
2193
|
loadSessions,
|
|
1752
2194
|
loadSessionTranscript,
|
|
2195
|
+
loadUsage: () => loadUsage(session),
|
|
1753
2196
|
loadSubagents: () => {
|
|
1754
2197
|
const current = session
|
|
1755
2198
|
if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
|
|
@@ -1757,18 +2200,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1757
2200
|
.then(rows => rows.filter(row => row.parent === current.id && row.subagent))
|
|
1758
2201
|
},
|
|
1759
2202
|
switchSession,
|
|
2203
|
+
searchSessions,
|
|
1760
2204
|
cancelSessionSwitch,
|
|
1761
2205
|
loadPlugins: () => listPluginRows(ctx),
|
|
2206
|
+
// The launcher owns every update decision; the TUI only drives its
|
|
2207
|
+
// read-only probe and streamed apply as child processes.
|
|
2208
|
+
probeUpdate: () => probeLauncherUpdate(),
|
|
2209
|
+
applyUpdate: (onLine, plan) => applyLauncherUpdate(onLine, undefined, plan),
|
|
1762
2210
|
loadJobs: () => listJobs(ctx, active?.agent),
|
|
1763
2211
|
statusline: statuslineItems,
|
|
1764
2212
|
saveStatusline,
|
|
1765
2213
|
applyEditorKeys,
|
|
1766
2214
|
saveTheme,
|
|
2215
|
+
saveLanguage,
|
|
1767
2216
|
animations: animationsEnabled,
|
|
1768
2217
|
saveAnimations,
|
|
1769
2218
|
history: inputHistory,
|
|
1770
2219
|
recordHistory,
|
|
1771
|
-
|
|
2220
|
+
updateQueued,
|
|
1772
2221
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
1773
2222
|
})
|
|
1774
2223
|
}
|
|
@@ -1811,6 +2260,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1811
2260
|
}, 50)
|
|
1812
2261
|
}
|
|
1813
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
|
+
}
|
|
1814
2268
|
if (themeWarning !== undefined) {
|
|
1815
2269
|
setTimeout(() => {
|
|
1816
2270
|
bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')
|