dsh-code 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  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 +106 -62
  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 +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  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/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -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 +130 -4
  30. package/lib/types/render/status.d.ts +9 -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 +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -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 +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
package/src/index.ts CHANGED
@@ -18,12 +18,13 @@ import { createElement } from 'react'
18
18
  import type { Context } from '@deepseek-ai/cordis'
19
19
  import z from '@deepseek-ai/schemastery'
20
20
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
21
- import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
21
+ import type { Agent, AgentHandle, AgentStatus, Inbox, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
22
22
  import type {} from '@deepseek-ai/dsh-agent-default-model'
23
23
  import type {} from '@deepseek-ai/dsh-attachment'
24
24
  import { createUserMessage, MessageId, type ContentBlock } from '@deepseek-ai/dsh-llm'
25
25
  import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
26
26
  import { SessionId, SessionLogOffset, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
27
+ import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
27
28
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
28
29
  // Type-only: carries the ctx.sessionTitle service merge for /title.
29
30
  import type {} from '@deepseek-ai/dsh-session-title'
@@ -31,12 +32,12 @@ import type {} from '@deepseek-ai/dsh-session-title'
31
32
  // and the cmdline Context merge for the appExit host value.
32
33
  import type {} from '@deepseek-ai/cordis-plugin-loader'
33
34
  import type {} from '@deepseek-ai/dsh-cmdline'
34
- import { App, type NoticeTone } from './app.ts'
35
+ import { App, type NoticeTone, type QueueMutation } from './app.ts'
35
36
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
36
37
  import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
37
38
  import { internals, type TuiMount } from './internals.ts'
38
39
  import { syncModelCapabilities } from './model-capabilities.ts'
39
- import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
40
+ import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, pendingModelSelection, resolveEffectiveSelection, type ModelRow } from './models.ts'
40
41
  import {
41
42
  discoverProviderModels,
42
43
  loadProviderSettings,
@@ -70,21 +71,30 @@ import {
70
71
  subscribeProviderAuthorizations,
71
72
  } from './authorization.ts'
72
73
  import { selectForkSeed } from './fork.ts'
73
- import { 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'
77
86
  import {
78
87
  applyPendingPermission,
79
- cyclePermission as cyclePermissionPreset,
80
88
  effectivePermission,
81
89
  listPermissionRows,
82
90
  permissionPresetsFrom,
83
91
  selectPermission,
84
92
  } from './permissions.ts'
85
93
  import { listPluginRows } from './plugin-inventory.ts'
94
+ import { applyLauncherUpdate, probeLauncherUpdate } from './update.ts'
86
95
  import { parseAnimationsPref } from './render/animations.ts'
87
96
  import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
97
+ import { parseLanguageName, setLanguage, t, type LanguageName } from './i18n.ts'
88
98
  import {
89
99
  isSubagentSession,
90
100
  matchSessionId,
@@ -100,7 +110,13 @@ import {
100
110
  type SessionQueryService,
101
111
  type SessionRow,
102
112
  } from './session-directory.ts'
113
+ import type { JobRow, SearchRow } from './kernel-panels.ts'
103
114
  import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
115
+ import { turnUsages, type UsageView } from './render/usage.ts'
116
+ // Type-only import: merges the projection registry into the Context type so
117
+ // `ctx.get('sessionProjections')` is typed (the service itself is mounted by
118
+ // dsh-base at runtime).
119
+ import type {} from '@deepseek-ai/dsh-session-projection'
104
120
 
105
121
  /** Stable Cordis plugin name. */
106
122
  export const name = 'tui-runner'
@@ -128,7 +144,7 @@ export const Config: z<Config> = z.object({
128
144
  /** Process-facing effects of the runner: the Ink mount plus the launcher's exit request. */
129
145
  interface TuiIo {
130
146
  mount: typeof internals.mount
131
- exit(code: number): 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,85 @@ export function submissionBelongsToSession(origin: string | undefined, activeSes
269
394
  return origin === undefined || origin === '' || origin === activeSessionId
270
395
  }
271
396
 
397
+ /**
398
+ * Root-log catalog facts a resumed session must replay into the subagent
399
+ * feed: constructor seeds never fire on the live bus, so without this the
400
+ * children of a resumed session vanish behind a restart. The empty-child
401
+ * placeholder row (childId '') is a placeholder, not a child, and stays out.
402
+ */
403
+ export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
404
+ return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
405
+ event.type === 'subagent/catalog' && event.data.childId !== '')
406
+ }
407
+
408
+ /**
409
+ * Map one cross-session full-text hit onto the /search panel's row (pure).
410
+ * Labels fall back to the short id form — the engine's hit carries the
411
+ * strongest matching event, not the title observation.
412
+ */
413
+ export function searchHitToRow(hit: {
414
+ header: SessionHeader
415
+ bestMatch: { snippet: string; time: number }
416
+ }): SearchRow {
417
+ const subagent = hit.header.origin === 'subagent'
418
+ const cwd = hit.header.cwd ?? ''
419
+ // Session cwds may arrive in either separator style regardless of the
420
+ // observing host (a workspace synced from Windows), so split on both.
421
+ const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? ''
422
+ const preset = hit.header.agentPreset ?? ''
423
+ const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim()
424
+ return {
425
+ id: hit.header.id,
426
+ label: hit.header.id.slice(-12),
427
+ detail: [workspace, preset].filter(part => part !== '').join(' · '),
428
+ snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
429
+ updatedAt: hit.bestMatch.time,
430
+ subagent,
431
+ resumable: !subagent,
432
+ }
433
+ }
434
+
435
+ /** One Shift+Tab station decision for the mode cycle. */
436
+ export type ModeCycleDecision =
437
+ | { readonly kind: 'permission'; readonly preset: string }
438
+ | { readonly kind: 'plan-on' }
439
+ | { readonly kind: 'plan-off'; readonly preset: string }
440
+
441
+ /**
442
+ * Decide the next Shift+Tab station. The cycle keeps the preset table's
443
+ * own order (most restrictive first) and inserts ONE plan station between
444
+ * the most restrictive preset and the wrap target: with the shipped three
445
+ * presets the user sees workspace-write → danger-full-access → read-only
446
+ * → plan → workspace-write. Plan IS the most restrictive preset plus the
447
+ * plan prompt layer — entering it switches nothing (the cycle is already
448
+ * parked on read-only), and leaving it lands on the next preset after the
449
+ * most restrictive one. Without the /plan command the cycle is exactly the
450
+ * preset table.
451
+ *
452
+ * `planIntent` covers the committed fold's commit lag: upstream queues a
453
+ * plan switch during an open turn (and the command pipeline is async even
454
+ * idle), so the durable plan/mode event lands AFTER the press that chose
455
+ * it. While an intent from an earlier press is in flight it — not the
456
+ * stale committed fold — decides the station, so repeated presses advance
457
+ * the cycle instead of re-issuing the same plan transition (the stuck
458
+ * plan-on/plan-off toggle). Undefined falls back to the committed fold.
459
+ */
460
+ export function planCycleDecision(input: {
461
+ readonly names: readonly string[]
462
+ readonly current: string
463
+ readonly inPlan: boolean
464
+ readonly planAvailable: boolean
465
+ readonly planIntent?: boolean
466
+ }): ModeCycleDecision | undefined {
467
+ const names = input.names
468
+ if (names.length === 0) return undefined
469
+ const first = names[0]
470
+ if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
471
+ const at = names.indexOf(input.current)
472
+ if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
473
+ return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
474
+ }
475
+
272
476
  /**
273
477
  * Order-preserving gate for composer input while the startup prompt/images
274
478
  * are still preparing. Anything submitted before the startup delivery settles
@@ -366,7 +570,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
366
570
  /** The runner's connection between the React app and the process side. */
367
571
  interface AppBridge {
368
572
  /** Post one local notice line (feedback the transcript does not carry). */
369
- notify(text: string, tone?: NoticeTone): void
573
+ notify: (text: string, tone?: NoticeTone) => void
370
574
  }
371
575
 
372
576
  /**
@@ -412,6 +616,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
412
616
  mode: string
413
617
  selection: { picked?: ModelSelection }
414
618
  resumed: boolean
619
+ /**
620
+ * Root-log `subagent/catalog` facts (resume path): constructor seeds
621
+ * never fire on the live bus, so activation replays them into the
622
+ * subagent feed after its reset — a resumed session's children stay
623
+ * visible instead of vanishing behind a restart.
624
+ */
625
+ catalogSeed: readonly SessionEvent<'subagent/catalog'>[]
415
626
  }
416
627
 
417
628
  /** Prepare a complete next session before disturbing the currently visible one. */
@@ -465,6 +676,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
465
676
  cwd: nextCwd,
466
677
  agentPreset: mode,
467
678
  ...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
679
+ ...(next.origin === undefined ? {} : { origin: next.origin }),
468
680
  // 0.1.5 fork lineage: the seed marker lives on the metadata and the
469
681
  // inherited prefix length on the top-level option (the v0 header's
470
682
  // numeric `seedLength` field is gone from the create contract).
@@ -480,15 +692,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
480
692
  if (!next.resume && permissionPresets !== undefined) {
481
693
  applyPendingPermission(permissionPresets, session, pendingPermission)
482
694
  }
695
+ const seedEvents = session.snapshotEvents()
696
+ // Resume precedence, middle layer: the log's unconsumed `model/selection`
697
+ // (a pick the web host recorded that no request ever assembled) outranks
698
+ // the older request header; an in-process pick still outranks both.
699
+ if (next.resume && selectionState.picked === undefined) {
700
+ const pending = pendingModelSelection(seedEvents)
701
+ if (pending !== undefined) selectionState.picked = pending
702
+ }
483
703
  return {
484
704
  handle,
485
705
  agent: handle.agent,
486
706
  session,
487
- store: createTranscriptStore(session.snapshotEvents()),
707
+ store: createTranscriptStore(seedEvents),
488
708
  mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
489
709
  mode: mode ?? 'standard',
490
710
  selection: selectionState,
491
711
  resumed: next.resume,
712
+ catalogSeed: subagentCatalogSeed(seedEvents),
492
713
  }
493
714
  }
494
715
 
@@ -511,6 +732,46 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
511
732
  let pendingModeWork: Promise<void> = Promise.resolve()
512
733
  /** Permission preset selected before the first session exists. */
513
734
  let pendingPermission: string | undefined
735
+ /**
736
+ * Plan-mode choice made before the first session exists: materialized as a
737
+ * /plan registry command delivered ahead of the first queued input when the
738
+ * session composes, so the first assembled step already plans.
739
+ */
740
+ let pendingPlan = false
741
+ /**
742
+ * In-flight mid-session plan choice from the Shift+Tab cycle. Upstream
743
+ * queues a plan switch during an open turn (and the command pipeline is
744
+ * async even idle), so the committed plan/mode fold lags the press that
745
+ * chose it; the cycle reads this intent until the durable event lands,
746
+ * then the session/event funnel clears it.
747
+ */
748
+ let planIntent: boolean | undefined
749
+ /**
750
+ * Whether the pre-session effective preset composes plan mode, answered by
751
+ * the presets service composition inventory (minimal does not). Cached and
752
+ * refreshed whenever the pending mode moves; unknown reads as unavailable
753
+ * so one keypress at most lands before the answer arrives.
754
+ */
755
+ let preSessionPlanAvailable = false
756
+ let preSessionPlanKnown = false
757
+ const refreshPreSessionPlan = (): void => {
758
+ if (presets === undefined) {
759
+ preSessionPlanAvailable = false
760
+ preSessionPlanKnown = true
761
+ return
762
+ }
763
+ preSessionPlanKnown = false
764
+ void presets.compositionInventory().then(inventory => {
765
+ const id = pendingMode ?? normalizePresetId(presets.defaultId)
766
+ preSessionPlanAvailable = inventory.some(composition => composition.id === id
767
+ && composition.rows.some(row => row.moduleName === '@deepseek-ai/dsh-plan-mode' && row.enabled !== false))
768
+ preSessionPlanKnown = true
769
+ }, () => {
770
+ preSessionPlanAvailable = false
771
+ preSessionPlanKnown = true
772
+ })
773
+ }
774
+ refreshPreSessionPlan()
514
775
  /**
515
776
  * Monotonic session epoch: bumped on every successful activation, on every
516
777
  * first-session creation, and on quit. Async callbacks (mention prepares,
@@ -552,6 +813,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
552
813
  session = prepared.session
553
814
  store = prepared.store
554
815
  mentions = prepared.mentions
816
+ // Replayed catalog facts rebuild the resumed session's child rows before
817
+ // the first render (the live handler only folds events from now on).
818
+ for (const event of prepared.catalogSeed) subagents.apply(event.data.childId, event)
555
819
  }
556
820
 
557
821
  // Seed the transcript from the full session log: constructor seeds never
@@ -562,6 +826,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
562
826
  if (session === undefined) return
563
827
  if (subject.id === session.id) {
564
828
  store.apply(event)
829
+ // The committed plan fold caught up (or diverged via a typed /plan or
830
+ // an approved plan review): the durable event is the live truth again,
831
+ // so the cycle's in-flight intent retires.
832
+ if (event.type === 'plan/mode') planIntent = undefined
565
833
  // The parent-owned subagent catalog rides the ROOT log (0.1.5); each
566
834
  // fact describes one child, so it feeds that child's live row.
567
835
  if (event.type === 'subagent/catalog' && event.data.childId !== '') subagents.apply(event.data.childId, event)
@@ -677,7 +945,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
677
945
  let statuslineWarning: string | undefined
678
946
  let statuslineItems: readonly string[] = []
679
947
  try {
680
- statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
948
+ statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
681
949
  } catch (error) {
682
950
  statuslineItems = parseStatuslineItems(undefined)
683
951
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
@@ -693,7 +961,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
693
961
  statuslineItems = [...items]
694
962
  void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
695
963
  .catch((writeError: unknown) => {
696
- bridge.notify('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')
697
965
  })
698
966
  }
699
967
 
@@ -716,7 +984,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
716
984
  let themeWarning: string | undefined
717
985
  if (startup.theme === undefined) {
718
986
  try {
719
- setTheme(parseThemeName(JSON.parse(readFileSync(themePath, 'utf8')).theme))
987
+ setTheme(parseThemeName(readSettingsObject(themePath).theme))
720
988
  } catch (error) {
721
989
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
722
990
  themeWarning = error instanceof Error ? error.message : String(error)
@@ -729,7 +997,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
729
997
  setTheme(name)
730
998
  void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
731
999
  .catch((writeError: unknown) => {
732
- bridge.notify('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')
733
1021
  })
734
1022
  }
735
1023
 
@@ -742,8 +1030,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
742
1030
  let animationsEnabled = true
743
1031
  let animationsWarning: string | undefined
744
1032
  try {
745
- // `?? {}` keeps a literal `null` file from surfacing a cryptic TypeError.
746
- 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)
747
1036
  } catch (error) {
748
1037
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
749
1038
  animationsWarning = error instanceof Error ? error.message : String(error)
@@ -752,7 +1041,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
752
1041
  const saveAnimations = (enabled: boolean): void => {
753
1042
  void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
754
1043
  .catch((writeError: unknown) => {
755
- bridge.notify('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')
756
1045
  })
757
1046
  }
758
1047
 
@@ -796,19 +1085,32 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
796
1085
  .then(() => mkdir(dirname(historyPath), { recursive: true }))
797
1086
  .then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
798
1087
  .catch((writeError: unknown) => {
799
- bridge.notify('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')
800
1089
  })
801
1090
  }
802
1091
 
803
- /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
804
- const cancelQueued = (messageId: string): void => {
805
- 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
806
1096
  try {
807
- if (agent.inbox.remove(MessageId(messageId))) {
808
- 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
809
1111
  }
810
1112
  } catch (error: unknown) {
811
- 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')
812
1114
  }
813
1115
  }
814
1116
 
@@ -873,7 +1175,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
873
1175
  }
874
1176
  const registry = ctx.get('commands')
875
1177
  if (registry === undefined) {
876
- bridge.notify('no command registry is mounted in this composition', 'error')
1178
+ bridge.notify(t('notice.commandRegistryMissing'), 'error')
877
1179
  return
878
1180
  }
879
1181
  const controller = new AbortController()
@@ -901,13 +1203,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
901
1203
  source: { kind: 'user' },
902
1204
  }))
903
1205
  } catch (error: unknown) {
904
- bridge.notify(`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')
905
1207
  }
906
1208
  }
907
1209
  }, (error: unknown) => {
908
1210
  finish()
909
1211
  if (epoch !== atEpoch || agent !== currentAgent) return
910
- 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')
911
1217
  })
912
1218
  }
913
1219
 
@@ -915,13 +1221,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
915
1221
  let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
916
1222
 
917
1223
  /** Deliver one trimmed line to the live session, expanding mentions first. */
918
- const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ContentBlock[] = []): void => {
1224
+ const deliverLine = (line: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
919
1225
  const currentAgent = agent!
920
- const currentMentions = mentions!
1226
+ const currentMentions = mentions
921
1227
  // The command registry is a closed namespace: slash lines run out of
922
- // band and never reach the model through this path (steering keeps the
923
- // registry out of the inbox, so slash lines steer as literal text).
924
- if (images.length === 0 && isSlashLine(line) && mode === 'followup') {
1228
+ // band and never reach the model through this path.
1229
+ if (images.length === 0 && isSlashLine(line)) {
925
1230
  runSlash(line)
926
1231
  return
927
1232
  }
@@ -959,13 +1264,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
959
1264
  content,
960
1265
  source: { kind: 'user' },
961
1266
  })
962
- if (mode === 'steer') {
963
- // The queued message is visible as a pending transcript row (the
964
- // web queue-mirror contract); no notice noise on the happy path.
965
- currentAgent.steer(message)
966
- } else {
967
- currentAgent.followup(message)
968
- }
1267
+ // Steering is consumed at the next step boundary of the turn already
1268
+ // running; a followup becomes its own turn instead.
1269
+ if (mode === 'steer') currentAgent.steer(message)
1270
+ else currentAgent.followup(message)
969
1271
  } catch (error: unknown) {
970
1272
  bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
971
1273
  }
@@ -976,14 +1278,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
976
1278
  }
977
1279
  const controller = new AbortController()
978
1280
  pendingControllers.add(controller)
979
- enqueueDelivery(() => currentMentions.prepare(parsed, controller.signal).then((prepared) => {
980
- pendingControllers.delete(controller)
981
- deliver(prepared.text, prepared.additionalContext)
982
- }, (error: unknown) => {
983
- pendingControllers.delete(controller)
984
- if (controller.signal.aborted || epoch !== atEpoch) return
985
- bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
986
- }))
1281
+ // `enqueueDelivery` returns nothing; the delivery chain only orders the
1282
+ // work, so the promise is consumed here with an explicit void.
1283
+ enqueueDelivery(() => {
1284
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
1285
+ pendingControllers.delete(controller)
1286
+ deliver(prepared.text, prepared.additionalContext)
1287
+ }, (error: unknown) => {
1288
+ pendingControllers.delete(controller)
1289
+ if (controller.signal.aborted || epoch !== atEpoch) return
1290
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1291
+ })
1292
+ })
987
1293
  }
988
1294
 
989
1295
  // Deferred first-session creation for a bare launch: the session is composed
@@ -1009,7 +1315,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1009
1315
  // (which would orphan the live one without a dispose).
1010
1316
  if (session !== undefined) {
1011
1317
  const queued = pendingInputs.splice(0)
1012
- for (const item of queued) deliverLine(item.text, item.mode, item.images)
1318
+ for (const item of queued) deliverLine(item.text, item.images, item.mode)
1013
1319
  return
1014
1320
  }
1015
1321
  const next = await prepare({
@@ -1029,6 +1335,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1029
1335
  store = next.store
1030
1336
  mentions = next.mentions
1031
1337
  subagents.reset()
1338
+ for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
1032
1339
  pendingMode = undefined
1033
1340
  pendingPermission = undefined
1034
1341
  commands.setAgent(agent)
@@ -1063,7 +1370,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1063
1370
  abortPendingControllers()
1064
1371
  epoch += 1
1065
1372
  const queued = pendingInputs.splice(0)
1066
- for (const item of queued) deliverLine(item.text, item.mode, item.images)
1373
+ if (pendingPlan) {
1374
+ pendingPlan = false
1375
+ // A pre-session plan choice materializes as the registry command
1376
+ // delivered AHEAD of the queued lines, so the first assembled step
1377
+ // of the user's opening message already runs in plan mode.
1378
+ deliverLine('/plan')
1379
+ }
1380
+ for (const item of queued) deliverLine(item.text, item.images, item.mode)
1067
1381
  } finally {
1068
1382
  creating = false
1069
1383
  }
@@ -1074,7 +1388,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1074
1388
  }
1075
1389
 
1076
1390
  /** Deliver one readable line to the agent, expanding session mentions first. */
1077
- const sendNow = (text: string, mode: 'followup' | 'steer', images: readonly ContentBlock[] = []): void => {
1391
+ const sendNow = (text: string, images: readonly ContentBlock[] = [], mode: 'followup' | 'steer' = 'followup'): void => {
1078
1392
  // Blank check on the trimmed form; the payload itself keeps the draft's
1079
1393
  // exact whitespace unless the line is a syntactic slash command.
1080
1394
  const line = submissionPayload(text)
@@ -1096,17 +1410,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1096
1410
  return
1097
1411
  }
1098
1412
  if (session === undefined) {
1413
+ // The delivery mode rides the buffered line: a steer picked before the
1414
+ // first session exists must still steer once that session composes.
1099
1415
  pendingInputs.push({ text: line, mode, images })
1100
1416
  ensureSession()
1101
1417
  return
1102
1418
  }
1103
- deliverLine(line, mode, images)
1419
+ deliverLine(line, images, mode)
1104
1420
  }
1105
1421
 
1106
1422
  // Startup serialization: input submitted while the startup prompt/images
1107
1423
  // are still preparing queues behind the initial request.
1108
- const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, mode, images))
1109
- 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 => {
1110
1426
  inputGate.submit({ text, mode, images })
1111
1427
  }
1112
1428
 
@@ -1116,25 +1432,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1116
1432
  // session (queued switch): the composing session is gone, so the stale
1117
1433
  // delivery is dropped instead of landing in the new session's inbox.
1118
1434
  if (!submissionBelongsToSession(origin, session?.id)) return
1119
- send(text, 'followup', images)
1435
+ send(text, images)
1120
1436
  }
1121
1437
 
1122
1438
  /**
1123
- * Submit steering: a running driver consumes the text at its next step
1124
- * boundary (the inbox delivers between steps); an idle driver just starts
1125
- * 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.
1126
1442
  */
1127
1443
  const steer = (text: string, images: readonly ContentBlock[] = [], origin?: string): void => {
1128
1444
  if (!submissionBelongsToSession(origin, session?.id)) return
1129
- send(text, 'steer', images)
1445
+ send(text, images, 'steer')
1130
1446
  }
1131
1447
 
1132
- /** 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
+ */
1133
1454
  const interrupt = (): boolean => {
1134
1455
  if (agent === undefined || agent.status !== 'running') return false
1135
1456
  try {
1136
- agent.cancel({ kind: 'user' })
1137
- 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'))
1138
1459
  return true
1139
1460
  } catch (error: unknown) {
1140
1461
  bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
@@ -1157,24 +1478,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1157
1478
  }
1158
1479
 
1159
1480
  /**
1160
- * Cycle to the next permission preset (Shift+Tab). Before the first session,
1161
- * the choice remains process-local and is materialized when Harness creates
1162
- * that session; afterwards the canonical service writes durable events.
1481
+ * Shift+Tab mode cycle: permission presets in table order, then the plan
1482
+ * station when the composition offers the /plan command (preset-mounted,
1483
+ * so minimal sessions and the pre-session state cycle permissions only).
1484
+ * Plan transitions submit the upstream registry command — it stays the
1485
+ * single owner of plan state; the TUI renders the durable plan/mode event
1486
+ * it appends. Because that event lags the press (upstream queues the
1487
+ * switch during an open turn), each mid-session plan decision records the
1488
+ * choice in `planIntent` and the next press reads it back, so the cycle
1489
+ * advances stations instead of re-issuing one transition. Returns the
1490
+ * notice label, or '' when nothing changed.
1163
1491
  */
1164
- const cyclePermission = (): string => {
1492
+ const cycleMode = (): string => {
1165
1493
  if (permissionPresets === undefined || permissionPresets.names.length === 0) {
1166
1494
  bridge.notify('permission presets are not mounted in this composition', 'warning')
1167
1495
  return ''
1168
1496
  }
1169
1497
  try {
1170
- const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
1171
- if (session === undefined && next !== '') {
1172
- pendingPermission = next
1498
+ // Pre-session the plan station rides the pending choice; once a
1499
+ // session exists the scoped /plan command descriptor decides, and the
1500
+ // durable plan/mode event is the live truth.
1501
+ const preSession = session === undefined
1502
+ if (preSession && !preSessionPlanKnown) refreshPreSessionPlan()
1503
+ const decision = planCycleDecision({
1504
+ names: permissionPresets.names,
1505
+ current: effectivePermission(permissionPresets, session, pendingPermission),
1506
+ inPlan: preSession ? pendingPlan : store.getView().plan === true,
1507
+ ...(preSession ? {} : { planIntent }),
1508
+ planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
1509
+ })
1510
+ if (decision === undefined) return ''
1511
+ if (decision.kind === 'permission') {
1512
+ const next = selectPermission(permissionPresets, session, decision.preset)
1513
+ if (preSession) {
1514
+ pendingPermission = next
1515
+ renderCurrent()
1516
+ }
1517
+ return `permission → ${next}`
1518
+ }
1519
+ if (decision.kind === 'plan-on') {
1520
+ // Plan IS the most restrictive preset plus the plan prompt layer:
1521
+ // the cycle arrives here from that preset, so permission needs no
1522
+ // switch — only the plan mode itself toggles.
1523
+ if (preSession) {
1524
+ pendingPlan = true
1525
+ renderCurrent()
1526
+ return 'plan → on (applies to the first session)'
1527
+ }
1528
+ planIntent = true
1529
+ send('/plan')
1530
+ return 'plan → on'
1531
+ }
1532
+ // Leaving plan lands on the station after the most restrictive
1533
+ // preset (workspace-write with the shipped table).
1534
+ if (preSession) {
1535
+ pendingPlan = false
1536
+ pendingPermission = decision.preset
1173
1537
  renderCurrent()
1538
+ return `plan → off · permission → ${decision.preset}`
1174
1539
  }
1175
- return next
1540
+ planIntent = false
1541
+ send('/plan off')
1542
+ selectPermission(permissionPresets, session, decision.preset)
1543
+ return `plan → off · permission → ${decision.preset}`
1176
1544
  } catch (error: unknown) {
1177
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1545
+ bridge.notify(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1178
1546
  return ''
1179
1547
  }
1180
1548
  }
@@ -1400,6 +1768,26 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1400
1768
  return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
1401
1769
  }
1402
1770
 
1771
+ /**
1772
+ * Read one session's usage blocks for the /usage panel: the mounted
1773
+ * projection's session totals plus the meter's own per-turn fold over the
1774
+ * durable log (which the panel merges by model). A deployment without the
1775
+ * projection registry renders the totals as explicitly unavailable rather
1776
+ * than as zeros. The read is synchronous — the registry materializes a cell
1777
+ * on first touch — so it is handed to the panel behind a resolved promise,
1778
+ * which keeps the fold out of the keystroke that opens the panel.
1779
+ * @param current - the session to read, or undefined before the first one.
1780
+ * @returns the resolved panel data.
1781
+ */
1782
+ const loadUsage = (current: Session | undefined): Promise<UsageView> => {
1783
+ if (current === undefined) return Promise.resolve({ turns: [] })
1784
+ const values = ctx.get('sessionProjections')?.snapshot(current, ['tokenUsage']).values
1785
+ return Promise.resolve({
1786
+ totals: values?.tokenUsage,
1787
+ turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
1788
+ })
1789
+ }
1790
+
1403
1791
  const switchModeAction = async (id: string): Promise<string> => {
1404
1792
  if (id === '') throw new Error('usage: /mode <preset>')
1405
1793
  const currentAgent = agent
@@ -1468,16 +1856,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1468
1856
  commands.setAgent(agent)
1469
1857
  skills.setAgent(agent)
1470
1858
  try {
1859
+ // Reseed the feed BEFORE the first frame of the new session so no
1860
+ // stale row from the previous one flashes; a rolled-back handoff
1861
+ // re-seeds the previous session's catalog the same way.
1862
+ subagents.reset()
1863
+ for (const event of next.catalogSeed) subagents.apply(event.data.childId, event)
1471
1864
  process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
1472
1865
  renderCurrent()
1473
1866
  // Only a successful handoff may clear the transient per-session
1474
1867
  // surfaces: a rolled-back switch keeps the previous session's
1475
1868
  // subagent feed plus the user's pre-session /mode and permission
1476
1869
  // picks (the bare-launch promise: explicit choices survive until
1477
- // composition takes them).
1478
- 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.
1479
1873
  pendingMode = undefined
1480
1874
  pendingPermission = undefined
1875
+ pendingPlan = false
1876
+ planIntent = undefined
1481
1877
  } catch (error: unknown) {
1482
1878
  active = previous
1483
1879
  agent = previous?.agent
@@ -1486,6 +1882,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1486
1882
  mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
1487
1883
  if (agent !== undefined) commands.setAgent(agent)
1488
1884
  if (agent !== undefined) skills.setAgent(agent)
1885
+ subagents.reset()
1886
+ if (previous !== undefined) {
1887
+ for (const event of previous.catalogSeed) subagents.apply(event.data.childId, event)
1888
+ }
1889
+ // The failed handoff disposed the incoming session; the restored
1890
+ // store's committed plan fold is the truth, so any cycle intent
1891
+ // collected against the switch churn retires too.
1892
+ planIntent = undefined
1489
1893
  await next.handle.dispose()
1490
1894
  if (!quitting) renderCurrent()
1491
1895
  throw error
@@ -1557,7 +1961,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1557
1961
  const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
1558
1962
  if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
1559
1963
  if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
1560
- const matched = matches[0]!
1964
+ const matched = matches[0]
1561
1965
  // Same lineage gate as the CLI --resume path and the picker.
1562
1966
  if (isSubagentSession(matched.header)) {
1563
1967
  throw new Error('subagent conversations are read-only; resume a root session')
@@ -1585,7 +1989,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1585
1989
  requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
1586
1990
  }
1587
1991
 
1588
- const reviewChanges = (argument: string): void => {
1992
+ const reviewChanges = (selection: ReviewSelection): void => {
1589
1993
  // Works from a bare launch too: with no session yet the read-only
1590
1994
  // choice goes to pendingPermission (materialized when the first
1591
1995
  // session composes) and the review prompt queues behind that
@@ -1605,21 +2009,31 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1605
2009
  const finish = (): void => {
1606
2010
  pendingControllers.delete(controller)
1607
2011
  }
1608
- 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 }) => {
1609
2023
  finish()
1610
2024
  if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent) return
1611
2025
  try {
1612
2026
  setPermissionAction('read-only')
1613
2027
  } catch (error: unknown) {
1614
- bridge.notify(`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')
1615
2029
  return
1616
2030
  }
1617
- send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title), 'followup')
1618
- 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'))
1619
2033
  }, (error: unknown) => {
1620
2034
  finish()
1621
2035
  if (controller.signal.aborted || epoch !== atEpoch) return
1622
- 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')
1623
2037
  })
1624
2038
  }
1625
2039
 
@@ -1661,6 +2075,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1661
2075
  requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
1662
2076
  }
1663
2077
 
2078
+ // /search reads the SAME in-process engine the model's session_search
2079
+ // tools use (the bundle's skip-tolerant subclass). The row may be disabled
2080
+ // by a deployment; /search then degrades to a notice instead of a panel.
2081
+ const searchSessions = sessionQuery === undefined
2082
+ ? undefined
2083
+ : async (query: string, signal?: AbortSignal): Promise<readonly SearchRow[]> => {
2084
+ const page = await sessionQuery.searchSessions({ query, limit: 30 }, signal === undefined ? undefined : { signal })
2085
+ const rows = page.items.map(hit => searchHitToRow(hit))
2086
+ // Best-effort title enrichment (the same snapshots /resume merges):
2087
+ // a failure keeps the short-id labels instead of failing the search.
2088
+ try {
2089
+ const observations = await sessionQuery.readTitleSnapshots(rows.map(row => row.id), signal)
2090
+ const titles = new Map<string, string>()
2091
+ for (const observation of observations) {
2092
+ if (observation.status !== 'fulfilled') continue
2093
+ const title = observation.value?.title?.title
2094
+ if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
2095
+ }
2096
+ return rows.map(row => titles.has(row.id) ? { ...row, label: titles.get(row.id)! } : row)
2097
+ } catch {
2098
+ return rows
2099
+ }
2100
+ }
2101
+
1664
2102
  const cancelSessionSwitch = (): boolean => {
1665
2103
  return switchQueue.cancel()
1666
2104
  }
@@ -1703,6 +2141,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1703
2141
  resumed: active?.resumed ?? false,
1704
2142
  mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
1705
2143
  permission,
2144
+ /** Pre-session plan choice for the status badge until a session composes. */
2145
+ pendingPlan: session === undefined && pendingPlan,
1706
2146
  dispatch,
1707
2147
  steer,
1708
2148
  interrupt,
@@ -1729,7 +2169,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1729
2169
  prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
1730
2170
  inspectFiles: paths => inspectFilePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
1731
2171
  prepareFiles: (paths, signal) => saveFilePaths(paths, ctx.get('attachments'), signal),
1732
- cyclePermission,
2172
+ cycleMode,
1733
2173
  setPermission: setPermissionAction,
1734
2174
  selectModel,
1735
2175
  subagentModel: subagentModelLabel(),
@@ -1740,6 +2180,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1740
2180
  renameTitle,
1741
2181
  copyLastResponse,
1742
2182
  loadGitDiff: (argument: string) => loadGitDiff(session?.header.cwd ?? cwd, argument),
2183
+ listReviewBranches: (signal?: AbortSignal) => listReviewBranches(session?.header.cwd ?? cwd, signal),
2184
+ listReviewCommits: (signal?: AbortSignal) => listReviewCommits(session?.header.cwd ?? cwd, signal),
1743
2185
  reviewChanges,
1744
2186
  loadPresets: () => presets.list(),
1745
2187
  switchMode: switchModeAction,
@@ -1750,6 +2192,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1750
2192
  forkSession,
1751
2193
  loadSessions,
1752
2194
  loadSessionTranscript,
2195
+ loadUsage: () => loadUsage(session),
1753
2196
  loadSubagents: () => {
1754
2197
  const current = session
1755
2198
  if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
@@ -1757,18 +2200,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1757
2200
  .then(rows => rows.filter(row => row.parent === current.id && row.subagent))
1758
2201
  },
1759
2202
  switchSession,
2203
+ searchSessions,
1760
2204
  cancelSessionSwitch,
1761
2205
  loadPlugins: () => listPluginRows(ctx),
2206
+ // The launcher owns every update decision; the TUI only drives its
2207
+ // read-only probe and streamed apply as child processes.
2208
+ probeUpdate: () => probeLauncherUpdate(),
2209
+ applyUpdate: (onLine, plan) => applyLauncherUpdate(onLine, undefined, plan),
1762
2210
  loadJobs: () => listJobs(ctx, active?.agent),
1763
2211
  statusline: statuslineItems,
1764
2212
  saveStatusline,
1765
2213
  applyEditorKeys,
1766
2214
  saveTheme,
2215
+ saveLanguage,
1767
2216
  animations: animationsEnabled,
1768
2217
  saveAnimations,
1769
2218
  history: inputHistory,
1770
2219
  recordHistory,
1771
- cancelQueued,
2220
+ updateQueued,
1772
2221
  onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
1773
2222
  })
1774
2223
  }
@@ -1811,6 +2260,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1811
2260
  }, 50)
1812
2261
  }
1813
2262
  // Same one-shot surface for a corrupt theme file (dark fallback stays live).
2263
+ if (languageWarning !== undefined) {
2264
+ setTimeout(() => {
2265
+ bridge.notify(t('notice.languageConfigUnreadable', { message: languageWarning }), 'warning')
2266
+ }, 0)
2267
+ }
1814
2268
  if (themeWarning !== undefined) {
1815
2269
  setTimeout(() => {
1816
2270
  bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning')