dsh-code 1.0.7 → 1.3.0

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