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.
Files changed (81) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +4063 -844
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +100 -63
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +73 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +86 -31
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/inspector.d.ts +26 -0
  25. package/lib/types/render/lines.d.ts +21 -1
  26. package/lib/types/render/markdown.d.ts +1 -1
  27. package/lib/types/render/projection.d.ts +95 -4
  28. package/lib/types/render/status.d.ts +8 -8
  29. package/lib/types/render/text.d.ts +6 -0
  30. package/lib/types/render/usage.d.ts +113 -0
  31. package/lib/types/session-directory.d.ts +17 -0
  32. package/lib/types/startup.d.ts +1 -1
  33. package/lib/types/terminal-title.d.ts +8 -0
  34. package/lib/types/theme-panel.d.ts +2 -2
  35. package/lib/types/theme.d.ts +271 -52
  36. package/lib/types/update-panel.d.ts +5 -5
  37. package/lib/types/update.d.ts +10 -1
  38. package/lib/types/version.d.ts +4 -3
  39. package/package.json +24 -7
  40. package/src/app.ts +1155 -478
  41. package/src/approval.ts +166 -166
  42. package/src/authorization-panel.ts +19 -16
  43. package/src/editor-keys.ts +371 -371
  44. package/src/git-workflow.ts +229 -3
  45. package/src/i18n.ts +68 -0
  46. package/src/index.ts +412 -76
  47. package/src/input-split.ts +3 -3
  48. package/src/kernel-panels.ts +471 -89
  49. package/src/keyboard.ts +5 -4
  50. package/src/language-panel.ts +53 -0
  51. package/src/locales/en.ts +489 -0
  52. package/src/locales/zh.ts +488 -0
  53. package/src/mentions.ts +8 -4
  54. package/src/models.ts +264 -212
  55. package/src/panel-accent.ts +41 -0
  56. package/src/presets.ts +1 -1
  57. package/src/provider-settings.ts +1 -1
  58. package/src/rainbow.ts +208 -0
  59. package/src/render/animations.ts +104 -6
  60. package/src/render/editor.ts +20 -20
  61. package/src/render/export.ts +116 -95
  62. package/src/render/inspector.ts +42 -0
  63. package/src/render/lines.ts +628 -415
  64. package/src/render/markdown.ts +15 -3
  65. package/src/render/projection.ts +429 -19
  66. package/src/render/status.ts +41 -35
  67. package/src/render/text.ts +14 -0
  68. package/src/render/tool-preview.ts +77 -77
  69. package/src/render/usage.ts +430 -0
  70. package/src/render/width.ts +2 -2
  71. package/src/session-directory.ts +8 -6
  72. package/src/session-query.ts +8 -4
  73. package/src/startup.ts +3 -3
  74. package/src/subagents.ts +229 -229
  75. package/src/terminal-title.ts +22 -5
  76. package/src/theme-panel.ts +17 -21
  77. package/src/theme.ts +281 -33
  78. package/src/update-panel.ts +37 -27
  79. package/src/update.ts +19 -3
  80. package/src/version.ts +58 -20
  81. 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 { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
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): void
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 import('./kernel-panels.ts').JobRow[] {
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): void
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(session.snapshotEvents()),
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(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
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('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
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(JSON.parse(readFileSync(themePath, 'utf8')).theme))
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('theme save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
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
- // `?? {}` keeps a literal `null` file from surfacing a cryptic TypeError.
810
- animationsEnabled = parseAnimationsPref((JSON.parse(readFileSync(animationsPath, 'utf8')) ?? {}).animations)
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('animations save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
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('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
1088
+ bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
864
1089
  })
865
1090
  }
866
1091
 
867
- /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
868
- const cancelQueued = (messageId: string): void => {
869
- if (agent === undefined) return
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
- if (agent.inbox.remove(MessageId(messageId))) {
872
- bridge.notify('queued message cancelled')
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('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
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('no command registry is mounted in this composition', 'error')
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(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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', images: readonly ContentBlock[] = []): void => {
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 (steering keeps the
987
- // registry out of the inbox, so slash lines steer as literal text).
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
- if (mode === 'steer') {
1027
- // The queued message is visible as a pending transcript row (the
1028
- // web queue-mirror contract); no notice noise on the happy path.
1029
- currentAgent.steer(message)
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(() => currentMentions.prepare(parsed, controller.signal).then((prepared) => {
1044
- pendingControllers.delete(controller)
1045
- deliver(prepared.text, prepared.additionalContext)
1046
- }, (error: unknown) => {
1047
- pendingControllers.delete(controller)
1048
- if (controller.signal.aborted || epoch !== atEpoch) return
1049
- bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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.mode, item.images)
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', 'followup')
1378
+ deliverLine('/plan')
1136
1379
  }
1137
- for (const item of queued) deliverLine(item.text, item.mode, item.images)
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', images: readonly ContentBlock[] = []): void => {
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, mode, images)
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, mode, images))
1180
- const send = (text: string, mode: 'followup' | 'steer', images: readonly ContentBlock[] = []): void => {
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, 'followup', images)
1435
+ send(text, images)
1191
1436
  }
1192
1437
 
1193
1438
  /**
1194
- * Submit steering: a running driver consumes the text at its next step
1195
- * boundary (the inbox delivers between steps); an idle driver just starts
1196
- * a turn, so this doubles as the busy-state submit path.
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', images)
1445
+ send(text, images, 'steer')
1201
1446
  }
1202
1447
 
1203
- /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
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
- agent.cancel({ kind: 'user' })
1208
- bridge.notify('turn cancelled Ctrl+C or /quit to exit')
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. Returns the notice label, or '' when nothing changed.
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
- send('/plan', 'followup')
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
- send('/plan off', 'followup')
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
- subagents.reset()
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 = (argument: string): void => {
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
- void loadGitDiff(reviewCwd, argument, controller.signal).then(({ title, files }) => {
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(`review unavailable: ${error instanceof Error ? error.message : String(error)}`, 'error')
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), 'followup')
1730
- bridge.notify('review started under read-only permissions')
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(`review failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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
- cancelQueued,
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')