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/app.ts CHANGED
@@ -26,18 +26,29 @@ import type { AskUserQuestionAnswerItem, AskUserQuestionItem } from '@deepseek-a
26
26
  import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
27
27
  import {
28
28
  dim,
29
+ diffBackground,
30
+ promptRowTokens,
31
+ rowBackground,
32
+ FLOW_ANCHORS,
29
33
  getPalette,
30
34
  getTheme,
31
35
  inkColor,
36
+ isPrismatic,
37
+ isRainbow,
38
+ themeFlow,
32
39
  setTheme,
33
40
  type RgbTriple,
34
41
  type ThemeName,
35
42
  } from './theme.ts'
43
+ import { panelAccent } from './panel-accent.ts'
44
+ import { parseRainbowArgument, rainbowRoll, rainbowSeedLabel, rerollRainbow } from './rainbow.ts'
36
45
  import { ThemePanel } from './theme-panel.ts'
37
- import { UpdatePanel } from './update-panel.ts'
46
+ import { LanguagePanel } from './language-panel.ts'
47
+ import { getLanguage, parseLanguageName, t, type LanguageName, type MessageKey } from './i18n.ts'
48
+ import { UpdatePanel, subscribeUpdateApplyRunning } from './update-panel.ts'
38
49
  import type { LauncherUpdateStatus } from './update.ts'
39
50
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
40
- import { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
51
+ import { dshKernelVersion, headerBrandTitle } from './version.ts'
41
52
  import type { TranscriptStore } from './store.ts'
42
53
  import { DEFAULT_TERMINAL_TITLE, sanitizeTerminalTitle, terminalTitleSequence, useTerminalTitle } from './terminal-title.ts'
43
54
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
@@ -59,9 +70,14 @@ import {
59
70
  deepseekWaveWordVisible,
60
71
  deepDivingGradientColor,
61
72
  deepDivingSparkColor,
73
+ flowColor,
62
74
  effortAboveHigh,
63
75
  isOfficialDeepSeekLabel,
64
76
  parseAnimationsArgument,
77
+ RAINBOW_BURST_DURATION_MS,
78
+ RAINBOW_BURST_TICK_MS,
79
+ rainbowBurstColumnBg,
80
+ rainbowSpectrumHue,
65
81
  type DeepseekWaveStyle,
66
82
  type DeepseekWaveTier,
67
83
  } from './render/animations.ts'
@@ -84,7 +100,8 @@ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
84
100
  import type { SkillsView, SkillRow } from './skills.ts'
85
101
  import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
86
102
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
87
- import { AgentsPanel, editQuery, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, SchedulePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
103
+ import type { UsageView } from './render/usage.ts'
104
+ import { AgentsPanel, editQuery, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, ReviewPickerPanel, SchedulePanel, SearchPanel, StatuslinePanel, runClock, SubagentPanel, UsagePanel, type JobRow, type SearchRow } from './kernel-panels.ts'
88
105
  import type { PresetRow } from './presets.ts'
89
106
  import type { PermissionRow } from './permissions.ts'
90
107
  import type { PluginRow } from './plugin-inventory.ts'
@@ -95,9 +112,10 @@ import {
95
112
  recallOlder,
96
113
  recordLocalEntry,
97
114
  type RecallState,
115
+ appendRecall,
98
116
  } from './history.ts'
99
117
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
100
- import type { GitDiffView } from './git-workflow.ts'
118
+ import { parseReviewArgument, type GitDiffView, type ReviewBranch, type ReviewCommit, type ReviewSelection } from './git-workflow.ts'
101
119
  import {
102
120
  authorizationForProvider,
103
121
  providerAuthorizationStatus,
@@ -108,6 +126,7 @@ import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './
108
126
  import {
109
127
  looksLikeImagePath,
110
128
  parsePastedAttachmentPaths,
129
+ looksLikePathDraft,
111
130
  type FilePathInspection,
112
131
  type ImagePathInspection,
113
132
  } from './attachments.ts'
@@ -141,6 +160,12 @@ function readSettledRowCap(): number {
141
160
 
142
161
  /** Reset region/style, clear the visible screen and scrollback, then home. */
143
162
  const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
163
+ /**
164
+ * Same as {@link RESIZE_REFLOW_CLEAR} without wiping native scrollback.
165
+ * History-cap trims remount `<Static>` but must not `\x1b[3J` a user who is
166
+ * reading earlier messages above the fold.
167
+ */
168
+ const TRIM_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[H'
144
169
  /** Ask terminals supporting DEC synchronized updates to hold the frame. */
145
170
  const SYNCHRONIZED_UPDATE_BEGIN = '\x1b[?2026h'
146
171
  /** Release the held frame after Ink has replayed the source-backed Static rows. */
@@ -148,7 +173,7 @@ const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l'
148
173
  import {
149
174
  layoutStatusBar,
150
175
  parseStatuslineItems,
151
- STATUS_CYCLE_HINT,
176
+ statusCycleHint,
152
177
  STATUS_GROUP_SEPARATOR,
153
178
  STATUS_ITEM_SEPARATOR,
154
179
  STATUS_ROW2_INDENT,
@@ -175,6 +200,7 @@ import {
175
200
  followInspectorCursor,
176
201
  inspectorViewport,
177
202
  layoutGutterRows,
203
+ liveRegionBudget,
178
204
  moveScroll,
179
205
  panelViewport,
180
206
  revealRow,
@@ -182,6 +208,8 @@ import {
182
208
  } from './render/inspector.ts'
183
209
  import {
184
210
  clampLiveAllocation,
211
+ diffLineStyle,
212
+ fillDiffLineBars,
185
213
  lineSegment,
186
214
  markdownLines,
187
215
  settledEntryLines,
@@ -224,39 +252,69 @@ import {
224
252
  export type NoticeTone = 'info' | 'warning' | 'error'
225
253
 
226
254
  /** One source of truth for TUI-owned slash commands in completion and `/help`. */
227
- const LOCAL_COMMANDS = [
228
- { label: '/help', description: 'show this overlay' },
229
- { label: '/model', description: 'switch the model and manage providers' },
230
- { label: '/effort', description: 'adjust reasoning effort for the current model' },
231
- { label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
232
- { label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
233
- { label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
234
- { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
235
- { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
236
- { label: '/plugin', description: 'inspect the live plugin composition' },
237
- { label: '/update', description: 'update dsh-code, the harness host, and profile plugins in one aligned step' },
238
- { label: '/jobs', description: 'inspect background jobs' },
239
- { label: '/schedule', description: 'inspect active reminders (created through schedule tools)' },
240
- { label: '/statusline', description: 'customize the status line items' },
241
- { label: '/theme', description: 'switch the color theme' },
242
- { label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
243
- { label: '/history', description: 'search and recall past prompts' },
244
- { label: '/agents', description: 'inspect subagent sessions of this conversation' },
245
- { label: '/todos', description: 'inspect the full todo list' },
246
- { label: '/subagent', description: 'choose the model delegated subagents run on' },
247
- { label: '/vscode-keys', description: 'pass ctrl+r through the vs code terminal' },
248
- { label: '/delete', description: 'delete a session and its subagent threads' },
249
- { label: '/clear', description: 'clear the screen' },
250
- { label: '/export', description: 'export the transcript to markdown (/export [path])' },
251
- { label: '/title', description: 'rename this session (/title <text>)' },
252
- { label: '/copy', description: 'copy the latest assistant response' },
253
- { label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
254
- { label: '/review', description: 'review Git changes under read-only permissions' },
255
- { label: '/quit', description: 'exit' },
255
+ /** One TUI-owned slash command: label plus its i18n description key. */
256
+ interface LocalCommand { readonly label: string; readonly descriptionKey: MessageKey }
257
+
258
+ const LOCAL_COMMANDS: readonly LocalCommand[] = [
259
+ { label: '/help', descriptionKey: 'cmd.help' },
260
+ { label: '/model', descriptionKey: 'cmd.model' },
261
+ { label: '/effort', descriptionKey: 'cmd.effort' },
262
+ { label: '/mode', descriptionKey: 'cmd.mode' },
263
+ { label: '/permission', descriptionKey: 'cmd.permission' },
264
+ { label: '/new', descriptionKey: 'cmd.new' },
265
+ { label: '/fork', descriptionKey: 'cmd.fork' },
266
+ { label: '/resume', descriptionKey: 'cmd.resume' },
267
+ { label: '/search', descriptionKey: 'cmd.search' },
268
+ { label: '/plugin', descriptionKey: 'cmd.plugin' },
269
+ { label: '/update', descriptionKey: 'cmd.update' },
270
+ { label: '/jobs', descriptionKey: 'cmd.jobs' },
271
+ { label: '/schedule', descriptionKey: 'cmd.schedule' },
272
+ { label: '/statusline', descriptionKey: 'cmd.statusline' },
273
+ { label: '/theme', descriptionKey: 'cmd.theme' },
274
+ { label: '/language', descriptionKey: 'cmd.language' },
275
+ { label: '/rainbow', descriptionKey: 'cmd.rainbow' },
276
+ { label: '/animation', descriptionKey: 'cmd.animation' },
277
+ { label: '/history', descriptionKey: 'cmd.history' },
278
+ { label: '/queue', descriptionKey: 'cmd.queue' },
279
+ { label: '/usage', descriptionKey: 'cmd.usage' },
280
+ { label: '/agents', descriptionKey: 'cmd.agents' },
281
+ { label: '/todos', descriptionKey: 'cmd.todos' },
282
+ { label: '/subagent', descriptionKey: 'cmd.subagent' },
283
+ { label: '/vscode-keys', descriptionKey: 'cmd.vscode-keys' },
284
+ { label: '/delete', descriptionKey: 'cmd.delete' },
285
+ { label: '/clear', descriptionKey: 'cmd.clear' },
286
+ { label: '/export', descriptionKey: 'cmd.export' },
287
+ { label: '/title', descriptionKey: 'cmd.title' },
288
+ { label: '/copy', descriptionKey: 'cmd.copy' },
289
+ { label: '/diff', descriptionKey: 'cmd.diff' },
290
+ { label: '/review', descriptionKey: 'cmd.review' },
291
+ { label: '/quit', descriptionKey: 'cmd.quit' },
256
292
  ] as const
257
293
 
258
294
  const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
259
295
 
296
+ /**
297
+ * TUI-local commands that take no input. Extra tokens used to fall through
298
+ * as a prompt (`/queue clear` reached the model); they now surface usage.
299
+ */
300
+ const BARE_LOCAL_COMMANDS = new Set([
301
+ 'quit', 'help', 'clear', 'copy', 'update', 'schedule', 'statusline', 'theme',
302
+ 'history', 'queue', 'usage', 'agents', 'todos', 'subagent',
303
+ ])
304
+
305
+ /** Split a slash line into the command name and any trailing input. */
306
+ function slashNameAndArgs(text: string): { readonly name: string; readonly args: string } | undefined {
307
+ const match = /^\/([a-z][a-z0-9_-]*)(?:$|[\t ](.*))$/u.exec(text)
308
+ if (match === null || match[1] === undefined) return undefined
309
+ return { name: match[1], args: (match[2] ?? '').trim() }
310
+ }
311
+
312
+ /** One mutation the terminal may request for a pending next-turn inbox item. */
313
+ export type QueueMutation =
314
+ | { readonly kind: 'remove' }
315
+ | { readonly kind: 'edit'; readonly text: string }
316
+ | { readonly kind: 'steer' }
317
+
260
318
  /** Props the runner hands the app; callbacks stay owned by the runner. */
261
319
  export interface AppProps {
262
320
  /** Event-fed transcript store for the live session. */
@@ -295,133 +353,151 @@ export interface AppProps {
295
353
  * an attachment prepare resolves after the app remounted onto another
296
354
  * session, and the runner drops the stale delivery then.
297
355
  */
298
- dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
299
- /** Submit steering, with the same stale-delivery guard as {@link dispatch}. */
300
- steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
356
+ dispatch: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
357
+ /**
358
+ * Submit one line as steering: it joins the turn already running at its next
359
+ * step boundary instead of waiting for the next turn. Same stale-delivery
360
+ * guard as {@link dispatch}.
361
+ */
362
+ steer: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
301
363
  /**
302
364
  * The FULL current session identity ('' while the first session is pending)
303
365
  * — the stale-delivery origin above. Distinct from the short display id.
304
366
  */
305
367
  sessionKey: string
306
368
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
307
- interrupt(): boolean
369
+ interrupt: () => boolean
308
370
  /** Quit: unmount, flush, and request process exit. */
309
- quit(): void
371
+ quit: () => void
310
372
  /** Load the selectable model directory (called when /model opens). */
311
- loadModels(): Promise<ModelDirectory>
373
+ loadModels: () => Promise<ModelDirectory>
312
374
  /** Load @mention candidates for the typed query (files + sessions). */
313
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
375
+ loadMentions: (query: string, signal?: AbortSignal) => Promise<readonly MentionCandidate[]>
314
376
  /** Validate draft image paths without committing attachment objects. */
315
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
377
+ inspectImages: (paths: readonly string[]) => Promise<readonly ImagePathInspection[]>
316
378
  /** Validate, normalize and persist images immediately before submission. */
317
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
379
+ prepareImages: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly ImageBlock[]>
318
380
  /** Validate draft non-image file paths without committing attachment objects. */
319
- inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
381
+ inspectFiles: (paths: readonly string[]) => Promise<readonly FilePathInspection[]>
320
382
  /** Persist non-image files immediately before submission as durable file blocks. */
321
- prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
383
+ prepareFiles: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly FileBlock[]>
322
384
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
323
- selectModel(row: ModelRow, effortId?: string): string
385
+ selectModel: (row: ModelRow, effortId?: string) => string
324
386
  /** The /subagent override label, '' when delegated agents follow the current model. */
325
387
  subagentModel: string
326
388
  /** Apply one /subagent model pick; returns the override label. */
327
- setSubagentModel(row: ModelRow, effortId?: string): string
389
+ setSubagentModel: (row: ModelRow, effortId?: string) => string
328
390
  /** Drop the /subagent override (delegated agents follow the current model). */
329
- clearSubagentModel(): void
391
+ clearSubagentModel: () => void
330
392
  /** Delete one session subtree; resolves with the outcome line. */
331
- deleteSession(id: string): Promise<string>
393
+ deleteSession: (id: string) => Promise<string>
332
394
  /** Load provider/settings/credential facts for the optional /model provider stage. */
333
- loadModelProviders?(): Promise<ProviderSettingsDirectory>
395
+ loadModelProviders?: () => Promise<ProviderSettingsDirectory>
334
396
  /** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
335
- subscribeModelProviders?(listener: () => void): () => void
397
+ subscribeModelProviders?: (listener: () => void) => () => void
336
398
  /** Store or rotate one provider credential through the Harness credential service. */
337
- saveModelProviderCredential?(target: ProviderTargetView, key: string): Promise<void>
399
+ saveModelProviderCredential?: (target: ProviderTargetView, key: string) => Promise<void>
338
400
  /** Remove one writable provider credential without removing its settings profile. */
339
- unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
401
+ unsetModelProviderCredential?: (target: ProviderTargetView) => Promise<void>
340
402
  /** Remove one user-owned provider profile and its page-managed credential. */
341
- removeModelProvider?(target: ProviderTargetView): Promise<void>
403
+ removeModelProvider?: (target: ProviderTargetView) => Promise<void>
342
404
  /** Save endpoint and explicit model capacities through the provider profile. */
343
- saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
405
+ saveModelProviderConfiguration?: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
344
406
  /**
345
407
  * Interrogate the provider's real endpoint (typed key wins over the stored
346
408
  * credential) for the models it actually serves — the discovery stage of
347
409
  * the provider setup page.
348
410
  */
349
- discoverModelProvider?(
411
+ discoverModelProvider?: (
350
412
  target: ProviderTargetView,
351
413
  request: { readonly apiKey?: string; readonly baseURL?: string },
352
414
  signal?: AbortSignal,
353
- ): Promise<readonly DiscoveredModelView[]>
415
+ ) => Promise<readonly DiscoveredModelView[]>
354
416
  /** Provider authorization flows and value-free stored-record facts. */
355
- loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
356
- subscribeProviderAuthorizations?(listener: () => void): () => void
357
- beginProviderAuthorization?(
417
+ loadProviderAuthorizations?: () => Promise<ProviderAuthorizationDirectory>
418
+ subscribeProviderAuthorizations?: (listener: () => void) => () => void
419
+ beginProviderAuthorization?: (
358
420
  row: ProviderAuthorizationRow,
359
421
  method: string,
360
422
  interaction: AuthorizationInteraction,
361
423
  signal: AbortSignal,
362
- ): Promise<AuthorizationStatus>
363
- cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
364
- logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
365
- openAuthorizationUrl?(url: string): boolean
366
- copyTextValue?(text: string): Promise<void>
424
+ ) => Promise<AuthorizationStatus>
425
+ cancelProviderAuthorization?: (row: ProviderAuthorizationRow) => void
426
+ logoutProviderAuthorization?: (row: ProviderAuthorizationRow) => Promise<void>
427
+ openAuthorizationUrl?: (url: string) => boolean
428
+ copyTextValue?: (text: string) => Promise<void>
367
429
  /** Cycle to the next mode station (Shift+Tab): a permission preset or a plan switch; returns the notice label. */
368
- cycleMode(): string
430
+ cycleMode: () => string
369
431
  /** Pre-session plan choice: shows the plan badge before the first session exists. */
370
432
  pendingPlan?: boolean
371
433
  /** Select or inspect a permission preset without requiring a pre-existing session. */
372
- setPermission(id: string): string
434
+ setPermission: (id: string) => string
373
435
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
374
- exportTranscript(argument: string): Promise<void>
436
+ exportTranscript: (argument: string) => Promise<void>
375
437
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
376
- renameTitle(argument: string): string
438
+ renameTitle: (argument: string) => string
377
439
  /** Copy the latest complete assistant response; resolves to notice text. */
378
- copyLastResponse(): Promise<string>
440
+ copyLastResponse: () => Promise<string>
379
441
  /** Load a complete read-only Git diff for the file-oriented viewport. */
380
- loadGitDiff(argument: string): Promise<GitDiffView>
442
+ loadGitDiff: (argument: string) => Promise<GitDiffView>
443
+ /** Local branches for the /review picker (absent: the picker hides the branch phase's list). */
444
+ listReviewBranches?: (signal?: AbortSignal) => Promise<readonly ReviewBranch[]>
445
+ /** Recent commits on the current branch for the /review picker. */
446
+ listReviewCommits?: (signal?: AbortSignal) => Promise<readonly ReviewCommit[]>
381
447
  /** Start a model review after applying the read-only permission preset. */
382
- reviewChanges(argument: string): void
448
+ reviewChanges: (selection: ReviewSelection) => void
383
449
  /** Preset/session/plugin kernel operations. */
384
- loadPresets(): Promise<readonly PresetRow[]>
385
- switchMode(id: string): Promise<string>
450
+ loadPresets: () => Promise<readonly PresetRow[]>
451
+ switchMode: (id: string) => Promise<string>
386
452
  /** Load the switchable permission presets for the /permission panel. */
387
- loadPermissions(): Promise<readonly PermissionRow[]>
388
- createSession(mode?: string): void
453
+ loadPermissions: () => Promise<readonly PermissionRow[]>
454
+ createSession: (mode?: string) => void
389
455
  /** Fork the active session at a completed-turn boundary. */
390
- forkSession(argument: string): void
391
- loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
392
- loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
456
+ forkSession: (argument: string) => void
457
+ loadSessions: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
458
+ loadSessionTranscript: (id: string, signal?: AbortSignal) => Promise<string>
459
+ /** Read the current session's usage blocks (projections plus per-turn fold). */
460
+ loadUsage: () => Promise<UsageView>
461
+ /**
462
+ * Full-text search over every persisted session (the in-process
463
+ * session-query engine). Absent when the deployment disabled the row;
464
+ * /search degrades to a notice instead of opening the panel.
465
+ */
466
+ searchSessions?: (query: string, signal?: AbortSignal) => Promise<readonly SearchRow[]>
393
467
  /** Load this session's subagent conversations (children by lineage). */
394
- loadSubagents(): Promise<readonly SessionRow[]>
395
- switchSession(row: SessionRow): void
396
- cancelSessionSwitch(): boolean
397
- loadPlugins(): readonly PluginRow[]
468
+ loadSubagents: () => Promise<readonly SessionRow[]>
469
+ switchSession: (row: SessionRow) => void
470
+ cancelSessionSwitch: () => boolean
471
+ loadPlugins: () => readonly PluginRow[]
398
472
  /** Caller-visible background jobs (the host jobs registry, read-only). */
399
- loadJobs(): readonly JobRow[]
473
+ loadJobs: () => readonly JobRow[]
400
474
  /** Probe the launcher's aligned update plan (read-only; never installs). */
401
- probeUpdate(): Promise<LauncherUpdateStatus>
475
+ probeUpdate: () => Promise<LauncherUpdateStatus>
402
476
  /** Run the launcher's aligned update; streams sanitized lines; resolves with the exit code. */
403
- applyUpdate(onLine: (line: string) => void): Promise<number>
477
+ applyUpdate: (onLine: (line: string) => void, plan?: { readonly dshSpec: string; readonly codeSpec: string; readonly pluginSpecs: readonly string[] }) => Promise<number>
404
478
  /** Registers the app's notice channel with the runner (called once on mount). */
405
- onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
479
+ onBridgeReady: (bridge: { notify: (text: string, tone?: NoticeTone) => void }) => void
406
480
  /** Ordered enabled status items (/statusline config); the runner owns persistence. */
407
481
  statusline: readonly string[]
408
482
  /** Persist a new statusline item set; the runner surfaces IO failures as notices. */
409
- saveStatusline(items: readonly string[]): void
483
+ saveStatusline: (items: readonly string[]) => void
484
+ /** Apply and persist one /language selection; the runner owns the language.json file. */
485
+ saveLanguage: (name: LanguageName) => void
410
486
  /** Apply and persist one /theme selection; the runner owns the theme.json file. */
411
- saveTheme?(name: ThemeName): void
487
+ saveTheme?: (name: ThemeName) => void
412
488
  /** Whether timed animations run at startup (animations.json; on by default
413
489
  * — like parseAnimationsPref, only an explicit false disables them). */
414
490
  animations?: boolean
415
491
  /** Apply and persist one /animation toggle; the runner owns the file. */
416
- saveAnimations?(enabled: boolean): void
492
+ saveAnimations?: (enabled: boolean) => void
417
493
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
418
494
  history: readonly string[]
419
495
  /** Persist one submitted prompt to the global history file. */
420
- recordHistory(text: string): void
421
- /** Cancel one queued inbox message by identity (Delete on the empty composer). */
422
- cancelQueued(messageId: string): void
496
+ recordHistory: (text: string) => void
497
+ /** Mutate one next-turn inbox message; durable inbox splices reconcile the result. */
498
+ updateQueued?: (messageId: string, action: QueueMutation) => void
423
499
  /** Apply the Ctrl+R terminal passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
424
- applyEditorKeys(): Promise<string>
500
+ applyEditorKeys: () => Promise<string>
425
501
  }
426
502
 
427
503
  /** Pad text with spaces to a visible-column target (menu name column). */
@@ -475,7 +551,13 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
475
551
  */
476
552
  function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
477
553
  const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
478
- return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
554
+ // Flowing themes (prismatic, rainbow) ride their anchor walk while busy;
555
+ // every other theme (and the frozen state) keeps the palette's live accent.
556
+ const flow = themeFlow()
557
+ const marker = flow !== undefined && animated
558
+ ? flowColor(tick * BUSY_CHASE_TICK_MS + flow.phaseMs, flow.anchors)
559
+ : getPalette().brandBright
560
+ return createElement(Text, { color: inkColor(marker) }, busyChaseFrame(tick) + ' ')
479
561
  }
480
562
 
481
563
  /** Blinking block caret appended to streaming text; solid when frozen. */
@@ -485,7 +567,7 @@ function Caret({ animated = true }: { animated?: boolean }): ReactElement {
485
567
  }
486
568
 
487
569
  /** One resettable input-caret phase shared by the entire composer. */
488
- function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
570
+ function useCursorBlink(active: boolean): { visible: boolean; reset: () => void } {
489
571
  const [epoch, setEpoch] = useState(0)
490
572
  const [visible, setVisible] = useState(true)
491
573
  useEffect(() => {
@@ -514,6 +596,13 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
514
596
  function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
515
597
  const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
516
598
  const palette = getPalette()
599
+ // Flowing themes walk their anchors for the shimmer highlight so
600
+ // streaming text glows along the spectrum; other themes keep the bright
601
+ // accent.
602
+ const flow = themeFlow()
603
+ const highlight = flow !== undefined && animated
604
+ ? flowColor(tick * DEEP_DIVING_SHIMMER_TICK_MS + flow.phaseMs, flow.anchors)
605
+ : palette.brandBright
517
606
  const graphemes = splitGraphemes(text)
518
607
  return createElement(
519
608
  Text,
@@ -527,8 +616,8 @@ function ShimmerLine({ text, animated = true }: { text: string; animated?: boole
527
616
  color: inkColor(!animated
528
617
  ? (sparkle ? palette.brandBright : palette.brandDeep)
529
618
  : sparkle
530
- ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
531
- : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
619
+ ? deepDivingSparkColor(tick, palette.brandDeep, highlight)
620
+ : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
532
621
  bold: sparkle || undefined,
533
622
  },
534
623
  grapheme.text,
@@ -556,27 +645,33 @@ function DeepDivingLine({ since, animated = true }: { since: number; animated?:
556
645
  * cap counts explicit newlines and terminal wrapping, slicing from the END so
557
646
  * the freshest tokens stay visible while a long reply streams; the complete
558
647
  * text lands in the flushed scrollback once the turn assembles it.
648
+ *
649
+ * Body wrap width for a streaming tail. `rowColumns` is the same width
650
+ * passed to `transcriptEntryLines` (terminal minus the last-column safety);
651
+ * the hanging prefix then shrinks the body so streamed text and settled
652
+ * markdown wrap on the same column.
559
653
  */
560
- function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children }: {
654
+ export function streamTailBodyColumns(rowColumns: number, prefix: string, continuationPrefix = prefix): number {
655
+ const width = Math.max(1, Math.floor(rowColumns))
656
+ const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
657
+ return Math.max(1, width - prefixColumns)
658
+ }
659
+
660
+ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children, columns }: {
561
661
  text: string
562
662
  dim: boolean
563
663
  maxRows: number
564
664
  prefix?: string
565
665
  continuationPrefix?: string
566
666
  children?: ReactElement
667
+ /** Same physical row width `transcriptEntryLines` uses (terminal minus 2). */
668
+ columns: number
567
669
  }): ReactElement {
568
- const columns = useStdout().stdout?.columns ?? 80
569
670
  const safeRows = Math.max(1, maxRows)
570
- // The final extra column keeps a caret from wrapping onto an unbudgeted
571
- // row. Both prefixes participate because every physical row repeats its
572
- // hanging indent.
573
- const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
574
- // Content takes the full physical row minus prefixes and the final wrap
575
- // column — a forced 10-column FLOOR on a narrower terminal made every row
576
- // autowrap onto a second, unbudgeted row (the live budget then
577
- // under-counted and the tree overflowed), so the width now shrinks with
578
- // the real terminal instead of flooring at 10.
579
- const contentColumns = Math.max(1, columns - 1 - prefixColumns)
671
+ // Both prefixes participate because every physical row repeats its hanging
672
+ // indent. The wrap matches settled markdown (row width minus prefix), so
673
+ // the flush at turn end does not reflow the last paragraph.
674
+ const contentColumns = streamTailBodyColumns(columns, prefix, continuationPrefix)
580
675
  const initial = displayTail(text, contentColumns, safeRows)
581
676
  // Reserve one row for the omission marker only when a marker is needed.
582
677
  const tail = initial.truncated && safeRows > 1
@@ -625,6 +720,18 @@ function segmentProps(style: MdSegment['style']): {
625
720
  return { color: undefined, bold: true, italic: true, strikethrough: undefined }
626
721
  case 'strike':
627
722
  return { color: inkColor(getPalette().dim), bold: undefined, italic: undefined, strikethrough: true }
723
+ case 'diffAdd':
724
+ case 'diffDel':
725
+ // Inline-markdown twin of lineStyleProps' diff cases: tinted rows for
726
+ // ```diff fences rendered through the markdown span path. The diff
727
+ // foreground tokens stay AA-legible both on the row tints (when the
728
+ // background rides along) and on the plain terminal background.
729
+ return {
730
+ color: inkColor(style === 'diffAdd' ? getPalette().diffAddFg : getPalette().diffDelFg),
731
+ bold: undefined,
732
+ italic: undefined,
733
+ strikethrough: undefined,
734
+ }
628
735
  default:
629
736
  return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
630
737
  }
@@ -637,20 +744,46 @@ function lineStyleProps(style: LineStyle): {
637
744
  italic: boolean | undefined
638
745
  strikethrough: boolean | undefined
639
746
  dimColor: boolean | undefined
747
+ backgroundColor: string | undefined
640
748
  } {
641
749
  switch (style) {
642
750
  case 'brand':
643
- return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
751
+ return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
644
752
  case 'success':
645
- return { color: inkColor(getPalette().success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
753
+ return { color: inkColor(getPalette().success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
646
754
  case 'error':
647
- return { color: inkColor(getPalette().error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
755
+ return { color: inkColor(getPalette().error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
648
756
  case 'warn':
649
- return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
757
+ return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
650
758
  case 'dimItalic':
651
- return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined }
759
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
760
+ // Codex diff rendering: added/removed lines carry a theme tint behind
761
+ // the sign and text, with the AA-tuned diff foreground tokens on top; the
762
+ // depth gate turns this into plain foreground styling on 16-color
763
+ // terminals.
764
+ case 'diffAdd':
765
+ return { color: inkColor(getPalette().diffAddFg), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: diffBackground('diffAdd') }
766
+ case 'diffDel':
767
+ return { color: inkColor(getPalette().diffDelFg), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: diffBackground('diffDel') }
768
+ // Prompt rows: one full-width bar per delivery kind. The tint comes from
769
+ // the row token and the foreground from its AA-tuned twin; the depth gate
770
+ // inside rowBackground degrades this to foreground-only on 16-color
771
+ // terminals, matching the diff rows.
772
+ case 'promptRow':
773
+ case 'promptQueuedRow':
774
+ case 'promptSteeredRow': {
775
+ const tokens = promptRowTokens(style === 'promptQueuedRow' ? 'queued' : style === 'promptSteeredRow' ? 'steered' : undefined)
776
+ return {
777
+ color: inkColor(getPalette()[tokens.fg]),
778
+ bold: undefined,
779
+ italic: undefined,
780
+ strikethrough: undefined,
781
+ dimColor: undefined,
782
+ backgroundColor: rowBackground(tokens.fg),
783
+ }
784
+ }
652
785
  default:
653
- return { ...segmentProps(style), dimColor: undefined }
786
+ return { ...segmentProps(style), dimColor: undefined, backgroundColor: undefined }
654
787
  }
655
788
  }
656
789
 
@@ -674,7 +807,7 @@ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
674
807
  }
675
808
 
676
809
  /** File-oriented, color-coded unified diff viewport. */
677
- function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): ReactElement {
810
+ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose: () => void }): ReactElement {
678
811
  const stdout = useStdout().stdout
679
812
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
680
813
  const [fileIndex, setFileIndex] = useState(0)
@@ -682,15 +815,7 @@ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): R
682
815
  const file = view.files[fileIndex]
683
816
  const lines = useMemo(() => {
684
817
  if (file === undefined) return textLines(' (no changes)', viewport.contentColumns, 'dim')
685
- return file.lines.flatMap(line => styledLines([
686
- lineSegment(line, line.startsWith('+') && !line.startsWith('+++')
687
- ? 'success'
688
- : line.startsWith('-') && !line.startsWith('---')
689
- ? 'error'
690
- : line.startsWith('@@') || line.startsWith('diff --git') || line.startsWith('index ')
691
- ? 'brand'
692
- : 'dim'),
693
- ], viewport.contentColumns))
818
+ return fillDiffLineBars(file.lines.flatMap(line => styledLines([lineSegment(line, diffLineStyle(line))], viewport.contentColumns)), viewport.contentColumns)
694
819
  }, [file, viewport.contentColumns])
695
820
  const visibleScroll = clampScroll(scroll, lines.length, viewport.bodyRows)
696
821
  useInput((input, key) => {
@@ -709,15 +834,16 @@ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): R
709
834
  else if (key.pageUp) setScroll(current => moveScroll(current, -viewport.bodyRows, lines.length, viewport.bodyRows))
710
835
  else if (key.pageDown) setScroll(current => moveScroll(current, viewport.bodyRows, lines.length, viewport.bodyRows))
711
836
  })
712
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length} files · esc/q close`, viewport.contentColumns))
837
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.diff.compact', { title: view.title, files: view.files.length }), viewport.contentColumns))
838
+ const accent = panelAccent('diff', getPalette().dim, getPalette().brand)
713
839
  return createElement(
714
840
  Box,
715
- { flexDirection: 'column', borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
716
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length === 0 ? 'no files' : `${fileIndex + 1}/${view.files.length} ${file?.path ?? ''}`} · rows ${lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(lines.length, visibleScroll + viewport.bodyRows)}/${lines.length}`, viewport.contentColumns)),
841
+ { flexDirection: 'column', borderStyle: 'round', borderColor: inkColor(accent.border), paddingX: 1 },
842
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length === 0 ? t('panel.diff.noFiles') : `${fileIndex + 1}/${view.files.length} ${file?.path ?? ''}`} · rows ${lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(lines.length, visibleScroll + viewport.bodyRows)}/${lines.length}`, viewport.contentColumns)),
717
843
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
718
844
  createElement(StyledRows, { lines: lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
719
845
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
720
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑/↓ scroll · g/G ends · esc/q close', viewport.contentColumns)),
846
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.diff.footer'), viewport.contentColumns)),
721
847
  )
722
848
  }
723
849
 
@@ -735,6 +861,32 @@ function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
735
861
  * keeps its historical three lines. Short or narrow terminals keep a one-line
736
862
  * form without the kernel line.
737
863
  */
864
+ /**
865
+ * One whale-glyph row painted as a frozen seven-color spectrum (column 0
866
+ * red, column 25 violet). Spaces stay uncolored so the silhouette punches
867
+ * through; adjacent same-hue blocks merge into one span.
868
+ */
869
+ function rainbowGlyphRow(row: string, rowKey: number): ReactElement {
870
+ const span = Math.max(1, WHALE_GLYPH_COLUMNS - 1)
871
+ const children: ReactElement[] = []
872
+ let start = 0
873
+ while (start < row.length) {
874
+ if (row[start] === ' ') {
875
+ let end = start + 1
876
+ while (end < row.length && row[end] === ' ') end += 1
877
+ children.push(createElement(Text, { key: start }, row.slice(start, end)))
878
+ start = end
879
+ continue
880
+ }
881
+ const color = inkColor(rainbowSpectrumHue(start / span))
882
+ let end = start + 1
883
+ while (end < row.length && row[end] !== ' ' && inkColor(rainbowSpectrumHue(end / span)) === color) end += 1
884
+ children.push(createElement(Text, { key: start, color }, row.slice(start, end)))
885
+ start = end
886
+ }
887
+ return createElement(Text, { key: rowKey }, ...children)
888
+ }
889
+
738
890
  function Header({ resumed }: { resumed: boolean }): ReactElement {
739
891
  const stdout = useStdout().stdout
740
892
  const rows = stdout?.rows ?? 40
@@ -743,13 +895,13 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
743
895
  const version = dshKernelVersion()
744
896
  return version === undefined ? undefined : `dsh-v${version}`
745
897
  })()
746
- const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`
898
+ const title = headerBrandTitle()
747
899
  const slogan = 'Into the Unknown 探索未至之境'
748
- const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
900
+ const hint = resumed ? t('header.hintResumed') : t('header.hint')
749
901
  const copyWidths = [visibleColumns(title), visibleColumns(slogan), visibleColumns(hint)]
750
902
  if (kernelLine !== undefined) copyWidths.push(visibleColumns(kernelLine))
751
903
  const copyColumns = Math.max(...copyWidths)
752
- const compact = `${title} · ${hint}`
904
+ const compact = kernelLine === undefined ? `${title} · ${hint}` : `${title} · ${kernelLine} · ${hint}`
753
905
  if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 10) {
754
906
  return createElement(
755
907
  Box,
@@ -768,7 +920,10 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
768
920
  createElement(
769
921
  Box,
770
922
  { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
771
- ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(getPalette().brand) }, row)),
923
+ ...WHALE_GLYPH.map((row, index) =>
924
+ isRainbow()
925
+ ? rainbowGlyphRow(row, index)
926
+ : createElement(Text, { key: index, color: inkColor(getPalette().brand) }, row)),
772
927
  ),
773
928
  createElement(
774
929
  Box,
@@ -803,7 +958,7 @@ function todoMark(status: TodoItem['status']): string {
803
958
  function AgentsLine({ rows, total }: { rows: readonly SubagentRow[]; total: number }): ReactElement | undefined {
804
959
  if (rows.length === 0) return undefined
805
960
  const running = rows.filter(row => row.state !== 'done').length
806
- const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
961
+ const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]
807
962
  const mark = newest.state === 'done' ? '✓' : newest.state === 'idle' ? '⏸' : '●'
808
963
  return createElement(
809
964
  Box,
@@ -856,7 +1011,7 @@ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose
856
1011
  const inProgress = todos.filter(todo => todo.status === 'in_progress').length
857
1012
  const pending = todos.length - completed - inProgress
858
1013
  const rows = todos.length === 0
859
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no todos yet')]
1014
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.todos.empty')}`)]
860
1015
  : todos.map(todo => createElement(
861
1016
  Text,
862
1017
  { key: todo.content, dimColor: true, wrap: 'truncate-end' },
@@ -885,40 +1040,202 @@ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose
885
1040
  })
886
1041
 
887
1042
  if (viewport.maxHeight === 0 || viewport.compact) {
888
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('todos · esc/q close', viewport.contentColumns))
1043
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.todos.compact'), viewport.contentColumns))
889
1044
  }
890
1045
 
1046
+ const accent = panelAccent('todos', getPalette().brand)
891
1047
  return createElement(
892
1048
  Box,
893
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
894
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`todos · ${completed}/${todos.length} done · ${inProgress} active · ${pending} pending · rows ${rows.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rows.length, visibleScroll + viewport.bodyRows)}/${rows.length}`, viewport.contentColumns)),
1049
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
1050
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.todos.title', { done: completed, total: todos.length, active: inProgress, pending, from: rows.length === 0 ? 0 : visibleScroll + 1, to: Math.min(rows.length, visibleScroll + viewport.bodyRows), rows: rows.length }), viewport.contentColumns)),
895
1051
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
896
1052
  ...rows.slice(visibleScroll, visibleScroll + viewport.bodyRows),
897
1053
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
898
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
1054
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(t('panel.todos.footer'), viewport.contentColumns))),
899
1055
  )
900
1056
  }
901
1057
 
902
1058
  const MemoTodoListPanel = memo(TodoListPanel)
903
1059
 
1060
+ /** Rows in the exact next-turn inbox order, never transcript append order. */
1061
+ export function queuedInboxRows(
1062
+ entries: readonly TranscriptEntry[],
1063
+ ids: readonly string[],
1064
+ ): readonly Extract<TranscriptEntry, { kind: 'pending' }>[] {
1065
+ const byId = new Map<string, Extract<TranscriptEntry, { kind: 'pending' }>>()
1066
+ for (const entry of entries) {
1067
+ if (entry.kind === 'pending' && entry.target === 'next-turn') byId.set(entry.messageId, entry)
1068
+ }
1069
+ return ids.flatMap(id => {
1070
+ const row = byId.get(id)
1071
+ return row === undefined ? [] : [row]
1072
+ })
1073
+ }
1074
+
1075
+ /** A bounded, keyboard-owned management surface for the durable next-turn inbox. */
1076
+ function QueuePanel({ rows, busy, update, onClose }: {
1077
+ rows: readonly Extract<TranscriptEntry, { kind: 'pending' }>[]
1078
+ busy: boolean
1079
+ update?: (messageId: string, action: QueueMutation) => void
1080
+ onClose: () => void
1081
+ }): ReactElement {
1082
+ const stdout = useStdout().stdout
1083
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1084
+ const [selected, setSelected] = useState(0)
1085
+ const [scroll, setScroll] = useState(0)
1086
+ const [editing, setEditing] = useState<{ messageId: string; text: string; cursor: number } | undefined>(undefined)
1087
+ // Ink can deliver a following key before its effect swaps the input
1088
+ // listener after an edit-mode render; this ref keeps the editor's key
1089
+ // stream coherent while the visible state catches up.
1090
+ const editingRef = useRef(editing)
1091
+ const current = rows[selected]
1092
+ const visibleScroll = revealRow(clampScroll(scroll, rows.length, viewport.bodyRows), selected, rows.length, viewport.bodyRows)
1093
+ const move = (delta: number): void => {
1094
+ setSelected(current => Math.max(0, Math.min(rows.length - 1, current + delta)))
1095
+ }
1096
+
1097
+ useEffect(() => {
1098
+ setSelected(current => Math.max(0, Math.min(rows.length - 1, current)))
1099
+ if (editing !== undefined && !rows.some(row => row.messageId === editing.messageId)) {
1100
+ editingRef.current = undefined
1101
+ setEditing(undefined)
1102
+ }
1103
+ }, [rows, editing])
1104
+ useEffect(() => {
1105
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
1106
+ }, [visibleScroll, scroll])
1107
+
1108
+ useStableInput((input, key) => {
1109
+ const activeEdit = editingRef.current
1110
+ if (activeEdit !== undefined) {
1111
+ if (key.escape) {
1112
+ editingRef.current = undefined
1113
+ setEditing(undefined)
1114
+ return
1115
+ }
1116
+ if (key.return) {
1117
+ if (activeEdit.text.trim() !== '') update?.(activeEdit.messageId, { kind: 'edit', text: activeEdit.text })
1118
+ editingRef.current = undefined
1119
+ setEditing(undefined)
1120
+ return
1121
+ }
1122
+ if (key.leftArrow) {
1123
+ const next = { ...activeEdit, cursor: moveCursorBy(activeEdit.text, activeEdit.cursor, -1) }
1124
+ editingRef.current = next
1125
+ setEditing(next)
1126
+ return
1127
+ }
1128
+ if (key.rightArrow) {
1129
+ const next = { ...activeEdit, cursor: moveCursorBy(activeEdit.text, activeEdit.cursor, 1) }
1130
+ editingRef.current = next
1131
+ setEditing(next)
1132
+ return
1133
+ }
1134
+ // Ink 5 reports 0x7F (backspace) and the forward-delete sequence as the
1135
+ // same `key.delete`, so a bare Delete binding here would erase on a
1136
+ // habitual Backspace. This management surface keeps `d` as its only
1137
+ // removal key instead of guessing which byte arrived.
1138
+ if (key.backspace || key.delete) {
1139
+ const edit = deleteBackward(activeEdit.text, activeEdit.cursor)
1140
+ const next = { ...activeEdit, text: edit.value, cursor: edit.cursor }
1141
+ editingRef.current = next
1142
+ setEditing(next)
1143
+ return
1144
+ }
1145
+ if (input !== '' && !key.ctrl && !key.meta) {
1146
+ const edit = insertText(activeEdit.text, activeEdit.cursor, input)
1147
+ const next = { ...activeEdit, text: edit.value, cursor: edit.cursor }
1148
+ editingRef.current = next
1149
+ setEditing(next)
1150
+ }
1151
+ return
1152
+ }
1153
+ if (key.escape || input === 'q') {
1154
+ onClose()
1155
+ return
1156
+ }
1157
+ if (key.upArrow) move(-1)
1158
+ else if (key.downArrow) move(1)
1159
+ else if (key.pageUp) move(-Math.max(1, viewport.bodyRows - 1))
1160
+ else if (key.pageDown) move(Math.max(1, viewport.bodyRows - 1))
1161
+ else if (input === 'g') setSelected(0)
1162
+ else if (input === 'G') setSelected(Math.max(0, rows.length - 1))
1163
+ else if (input === 'e' && current !== undefined) {
1164
+ // Text is editable on every row: an edit rewrites what the user typed
1165
+ // and carries the row's attachments through untouched, which is exactly
1166
+ // what the row's read-only attachment marker promises.
1167
+ const next = { messageId: current.messageId, text: current.text, cursor: current.text.length }
1168
+ editingRef.current = next
1169
+ setEditing(next)
1170
+ }
1171
+ else if (input === 'd' && current !== undefined) update?.(current.messageId, { kind: 'remove' })
1172
+ else if (key.return && current !== undefined && busy) update?.(current.messageId, { kind: 'steer' })
1173
+ }, true)
1174
+
1175
+ if (viewport.maxHeight === 0 || viewport.compact) {
1176
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.queue.compact'), viewport.contentColumns))
1177
+ }
1178
+ const body = rows.length === 0
1179
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.queue.empty'), viewport.contentColumns))]
1180
+ : rows.map((row, index) => {
1181
+ const selectedRow = index === selected
1182
+ const suffix = (row.images?.length ?? 0) + (row.files?.length ?? 0) > 0 ? ` ${t('panel.queue.attachments')}` : ''
1183
+ if (editing?.messageId === row.messageId) {
1184
+ const before = editing.text.slice(0, editing.cursor)
1185
+ const caret = editing.text.slice(editing.cursor, editing.cursor + 1) || ' '
1186
+ const after = editing.text.slice(editing.cursor + caret.length)
1187
+ return createElement(Text, { key: row.messageId, color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(`✎ ${before}[${caret}]${after}`, viewport.contentColumns))
1188
+ }
1189
+ return createElement(Text, { key: row.messageId, color: inkColor(selectedRow ? getPalette().brandBright : getPalette().dim), bold: selectedRow || undefined, wrap: 'truncate-end' }, truncateColumns(`${selectedRow ? '›' : ' '} ${index + 1}. ${singleLineText(row.text)}${suffix}`, viewport.contentColumns))
1190
+ })
1191
+ const footer = editing !== undefined
1192
+ ? t('panel.queue.editFooter')
1193
+ : busy
1194
+ ? t('panel.queue.footerBusy')
1195
+ : t('panel.queue.footerIdle')
1196
+ const accent = panelAccent('queue', getPalette().brand)
1197
+ return createElement(
1198
+ Box,
1199
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
1200
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.queue.title', { count: rows.length, from: rows.length === 0 ? 0 : visibleScroll + 1, to: Math.min(rows.length, visibleScroll + viewport.bodyRows) }), viewport.contentColumns)),
1201
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1202
+ ...body.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1203
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1204
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(footer, viewport.contentColumns)),
1205
+ )
1206
+ }
1207
+
904
1208
  /**
905
1209
  * Ink props for one status tone: the Codex status-line accent mapping over
906
1210
  * the DeepSeek palette, all blue by design — the status bar speaks only in
907
1211
  * degrees of blue (deep accent, primary figures, model identity, sky
908
1212
  * paths and done states), with amber/red reserved for warnings and errors.
909
1213
  */
910
- function statusToneProps(tone: StatusTone): {
1214
+ function statusToneProps(tone: StatusTone, flowMs?: number): {
911
1215
  color: string | undefined
912
1216
  bold: boolean | undefined
913
1217
  dimColor: boolean | undefined
914
1218
  } {
1219
+ if (isRainbow()) {
1220
+ // Carnival roll: every tone carries its rolled color (adjacent tones in
1221
+ // canonical on-screen order never match, the row boundary included);
1222
+ // the live dot rides the flow walk while busy. Bold emphasis carries
1223
+ // the semantic hierarchy so randomness never hides importance.
1224
+ const emphasized = tone === 'model' || tone === 'success' || tone === 'plan' || tone === 'warn' || tone === 'error'
1225
+ const color = tone === 'live' && flowMs !== undefined
1226
+ ? flowColor(flowMs, themeFlow()?.anchors ?? FLOW_ANCHORS)
1227
+ : rainbowRoll().toneColors[tone]
1228
+ return { color: inkColor(color), bold: emphasized || undefined, dimColor: undefined }
1229
+ }
915
1230
  switch (tone) {
916
1231
  case 'model':
917
1232
  // Same tone as the working-directory segment: the model name reads as
918
1233
  // a path fact, not a brand accent.
919
1234
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
920
1235
  case 'live':
921
- return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
1236
+ // With a flow sample (flowing theme while busy), the live dot rides
1237
+ // the anchor walk; otherwise the palette's live accent.
1238
+ return { color: inkColor(flowMs === undefined ? getPalette().brandBright : flowColor(flowMs, themeFlow()?.anchors ?? FLOW_ANCHORS)), bold: undefined, dimColor: undefined }
922
1239
  case 'path':
923
1240
  return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
924
1241
  case 'branch':
@@ -980,12 +1297,22 @@ function statusToneProps(tone: StatusTone): {
980
1297
  * the prompt keeps is always hues[0]. */
981
1298
  function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTriple, RgbTriple] {
982
1299
  const palette = getPalette()
1300
+ // Rainbow waves pick three SPECTRALLY SPREAD anchors from the rolled
1301
+ // pool — spectral neighbors would wash into one hue across the band.
1302
+ if (isRainbow()) {
1303
+ const anchors = rainbowRoll().flowAnchors
1304
+ const stride = Math.max(1, Math.floor(anchors.length / 3))
1305
+ return [anchors[0], anchors[stride], anchors[stride * 2]]
1306
+ }
1307
+ // Prismatic waves ride the flow anchors for both tiers — the model-switch
1308
+ // easter egg becomes a violet→fuchsia→cyan sweep.
1309
+ if (isPrismatic()) return [FLOW_ANCHORS[0], FLOW_ANCHORS[1], FLOW_ANCHORS[2]]
983
1310
  return tier === 'flash'
984
1311
  ? [palette.brandBright, palette.brand, palette.brandMid]
985
1312
  : [palette.brandBright, palette.code, palette.brandMid]
986
1313
  }
987
1314
 
988
- function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1315
+ function StatusLine({ facts, stats, busy, columns, items, onRows, animated }: {
989
1316
  facts: StatusFacts
990
1317
  stats: Parameters<typeof layoutStatusBar>[1]
991
1318
  busy: boolean
@@ -994,30 +1321,35 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
994
1321
  /** Reports the footer's exact physical row count (1 or 2) so the IME
995
1322
  * anchor ledger below the composer stays exact. */
996
1323
  onRows?: (rows: 1 | 2) => void
1324
+ /** Whether timed animations run (the persisted preference). */
1325
+ animated: boolean
997
1326
  }): ReactElement {
998
- const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
999
- busy,
1000
- items,
1001
- // Match the composer content budget: border + horizontal padding are
1002
- // already excluded, and layoutStatusBar shrinks this ceiling as needed.
1003
- contextWidth: Math.max(5, columns - 6),
1004
- }), [
1005
- facts.model,
1006
- facts.mode,
1007
- facts.cwd,
1008
- facts.branch,
1009
- facts.sessionId,
1010
- facts.title,
1011
- facts.sandbox,
1012
- facts.plan,
1013
- facts.permission,
1014
- facts.goal?.phase,
1015
- facts.goal?.rounds,
1016
- facts.goal?.max,
1327
+ // Flowing-theme busy flow: the identity cluster's live dot cycles the
1328
+ // anchor walk while a turn runs; static themes never start the timer.
1329
+ const flow = themeFlow()
1330
+ const flowActive = animated && busy && flow !== undefined
1331
+ const flowTick = useFrames(BUSY_CHASE_TICK_MS, flowActive)
1332
+ const flowMs = flowActive ? flowTick * BUSY_CHASE_TICK_MS + (flow?.phaseMs ?? 0) : undefined
1333
+ const language = getLanguage()
1334
+ const layout = useMemo(() => {
1335
+ // The layout reads translations through t(); naming the current language
1336
+ // here makes that external store value an explicit cache invalidator.
1337
+ void language
1338
+ return layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
1339
+ busy,
1340
+ items,
1341
+ // Match the composer content budget: border + horizontal padding are
1342
+ // already excluded, and layoutStatusBar shrinks this ceiling as needed.
1343
+ contextWidth: Math.max(5, columns - 6),
1344
+ })
1345
+ }, [
1346
+ facts,
1017
1347
  stats,
1018
1348
  busy,
1019
1349
  columns,
1020
1350
  items,
1351
+ // Labels come from t(); a language switch must rebuild the rows.
1352
+ language,
1021
1353
  ])
1022
1354
  // The IME anchor below the composer counts every row between the caret and
1023
1355
  // Ink's parked cursor, so the footer reports its exact row count one-way
@@ -1036,7 +1368,7 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1036
1368
  group.spans.forEach((span, spanIndex) => {
1037
1369
  leftParts.push(createElement(
1038
1370
  Text,
1039
- { key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone) },
1371
+ { key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) },
1040
1372
  span.text,
1041
1373
  ))
1042
1374
  })
@@ -1048,12 +1380,12 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1048
1380
  }
1049
1381
  rightParts.push(createElement(
1050
1382
  Text,
1051
- { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone) },
1383
+ { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) },
1052
1384
  span.text,
1053
1385
  ))
1054
1386
  })
1055
1387
  if (row.hint) {
1056
- rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, STATUS_CYCLE_HINT))
1388
+ rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, statusCycleHint()))
1057
1389
  }
1058
1390
  // Each row already fits the column budget; truncate-end stays as the
1059
1391
  // terminal-measurement backstop so a drifting cell count clips instead
@@ -1131,9 +1463,9 @@ const APPROVAL_OPTIONS: readonly ApprovalOption[] = [
1131
1463
  function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1132
1464
  snapshot: ApprovalSnapshot
1133
1465
  locked: boolean
1134
- notify(text: string, tone?: NoticeTone): void
1466
+ notify: (text: string, tone?: NoticeTone) => void
1135
1467
  /** Cancel the running turn (Ctrl+C), matching the composer's busy branch. */
1136
- interrupt(): boolean
1468
+ interrupt: () => boolean
1137
1469
  /** Render as the bounded one-line form even on tall terminals (another
1138
1470
  * human-asked surface already owns the full panel budget). */
1139
1471
  summarize?: boolean
@@ -1160,7 +1492,7 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1160
1492
  }
1161
1493
  ask.answer('rejected')
1162
1494
  if (option.key === 'reject-note') {
1163
- notify('rejected — type below what it should do differently (it steers the next step)', 'warning')
1495
+ notify(t('notice.rejected'), 'warning')
1164
1496
  }
1165
1497
  }
1166
1498
 
@@ -1184,35 +1516,35 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1184
1516
  return
1185
1517
  }
1186
1518
  if (key.return) {
1187
- decide(APPROVAL_OPTIONS[cursor]!)
1519
+ decide(APPROVAL_OPTIONS[cursor])
1188
1520
  return
1189
1521
  }
1190
1522
  if (key.escape) {
1191
- decide(APPROVAL_OPTIONS[2]!)
1523
+ decide(APPROVAL_OPTIONS[2])
1192
1524
  return
1193
1525
  }
1194
1526
  if (input === 'y' || input === 'Y') {
1195
- decide(APPROVAL_OPTIONS[0]!)
1527
+ decide(APPROVAL_OPTIONS[0])
1196
1528
  return
1197
1529
  }
1198
1530
  if (input === 'n' || input === 'N') {
1199
- decide(APPROVAL_OPTIONS[1]!)
1531
+ decide(APPROVAL_OPTIONS[1])
1200
1532
  return
1201
1533
  }
1202
1534
  if (input === 'd' || input === 'D') {
1203
- decide(APPROVAL_OPTIONS[2]!)
1535
+ decide(APPROVAL_OPTIONS[2])
1204
1536
  return
1205
1537
  }
1206
1538
  if (/^[1-9]$/u.test(input)) {
1207
1539
  const index = Number(input) - 1
1208
- if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index]!)
1540
+ if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index])
1209
1541
  }
1210
1542
  }, { isActive: active })
1211
1543
 
1212
1544
  if (pending === undefined) return undefined
1213
1545
  const queuedSuffix = snapshot.queued > 0 ? ` · +${snapshot.queued} queued` : ''
1214
1546
  if (viewport.maxHeight === 0 || viewport.compact || summarize === true) {
1215
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`approval${queuedSuffix} · enter/y allow · esc/n reject`, viewport.contentColumns))
1547
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('approval.compact', { queued: queuedSuffix }), viewport.contentColumns))
1216
1548
  }
1217
1549
  // Body budget: title + options + footer consume fixed rows; the command
1218
1550
  // preview shrinks with an explicit overflow marker (Codex's "[… N lines]").
@@ -1231,7 +1563,7 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1231
1563
  createElement(PanelGap, { visible: viewport.gapRows > 0 && body.length > 0 }),
1232
1564
  ...visibleBody.map((line, index) => createElement(StyledRows, { key: `body-${index}`, lines: [line] })),
1233
1565
  ...(overflow > 0
1234
- ? [createElement(Text, { key: 'overflow', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(`… +${overflow} more lines · ctrl+o shows the full call in the transcript`, viewport.contentColumns))]
1566
+ ? [createElement(Text, { key: 'overflow', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('approval.overflow', { count: overflow }), viewport.contentColumns))]
1235
1567
  : []),
1236
1568
  ...(body.length > 0 ? [createElement(PanelGap, { visible: viewport.gapRows > 0 })] : []),
1237
1569
  ...APPROVAL_OPTIONS.map((option, index) => {
@@ -1248,8 +1580,8 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1248
1580
  )
1249
1581
  }),
1250
1582
  createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(snapshot.answered
1251
- ? 'submitted'
1252
- : '↑↓ choose · enter confirm · y/n/d quick · esc reject', viewport.contentColumns)),
1583
+ ? t('approval.submitted')
1584
+ : t('approval.footer'), viewport.contentColumns)),
1253
1585
  )
1254
1586
  }
1255
1587
 
@@ -1332,7 +1664,7 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1332
1664
  }, [request])
1333
1665
 
1334
1666
  const question = pending?.request.questions[index]
1335
- const options = question?.options ?? []
1667
+ const options = useMemo(() => question?.options ?? [], [question])
1336
1668
  const isPlan = question?.intent?.kind === 'plan-review'
1337
1669
  const isMulti = question?.multiSelect === true
1338
1670
  const currentDraft = drafts[index] ?? initialQuestionDraft(question)
@@ -1622,21 +1954,21 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1622
1954
 
1623
1955
  if (pending === undefined || question === undefined) return undefined
1624
1956
  if (viewport.maxHeight === 0 || viewport.compact) {
1625
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
1957
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? t('question.compact.plan') : t('question.compact.normal'), viewport.contentColumns))
1626
1958
  }
1627
1959
  const footerBase = submitted
1628
- ? 'submitted'
1960
+ ? t('question.submitted')
1629
1961
  : mode === 'custom'
1630
1962
  ? options.length === 0
1631
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1632
- : '↑↓/pgup/pgdn scroll · type answer · enter submit · tab/esc or empty backspace: options'
1963
+ ? t('question.customNoOptions')
1964
+ : t('question.customOptions')
1633
1965
  : options.length === 0
1634
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1966
+ ? t('question.customNoOptions')
1635
1967
  : isMulti
1636
- ? '↑↓ choose · pgup/pgdn scroll · space/1-9 toggle · enter submit · c custom · esc interrupt'
1637
- : '↑↓ choose · pgup/pgdn scroll · 1-9 pick · enter submit · c custom · esc interrupt'
1968
+ ? t('question.multiOptions')
1969
+ : t('question.singleOptions')
1638
1970
  const footer = pending.request.questions.length > 1 && !submitted
1639
- ? `${footerBase} · ←→/ctrl+p/n switch question`
1971
+ ? `${footerBase}${t('question.switch')}`
1640
1972
  : footerBase
1641
1973
  return createElement(
1642
1974
  Box,
@@ -1644,12 +1976,12 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1644
1976
  createElement(
1645
1977
  Text,
1646
1978
  { color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep), bold: true, wrap: 'truncate-end' },
1647
- truncateColumns(`${isPlan ? '📋 plan review' : 'question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
1979
+ truncateColumns(`${isPlan ? t('question.title.plan') : t('question.title.normal')} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
1648
1980
  ),
1649
1981
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1650
1982
  createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
1651
1983
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1652
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1984
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1653
1985
  )
1654
1986
  }
1655
1987
 
@@ -1659,16 +1991,16 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1659
1991
  error: string | undefined
1660
1992
  /** `provider/model` label of the applied model: the cursor lands on it once. */
1661
1993
  current?: string
1662
- onSelect(row: ModelRow): void
1663
- onProviders?(): void
1664
- onRetry(): void
1665
- onClose(): void
1994
+ onSelect: (row: ModelRow) => void
1995
+ onProviders?: () => void
1996
+ onRetry: () => void
1997
+ onClose: () => void
1666
1998
  }): ReactElement {
1667
1999
  const [query, setQuery] = useState('')
1668
2000
  const [cursor, setCursor] = useState(0)
1669
2001
  const stdout = useStdout().stdout
1670
2002
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1671
- const rows = directory?.rows ?? []
2003
+ const rows = useMemo(() => directory?.rows ?? [], [directory])
1672
2004
  // Direct-typing filter over provider and model names (the /mode contract):
1673
2005
  // printable keys edit the query, so a long directory is searchable without
1674
2006
  // a separate search mode. With a query active, q/r/g/G stop acting as
@@ -1679,6 +2011,13 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1679
2011
  return rows.filter(row => `${row.provider} ${row.providerName ?? ''} ${row.model} ${row.modelName}`.toLowerCase().includes(needle))
1680
2012
  }, [rows, query])
1681
2013
  const positioned = useRef(false)
2014
+ // Latest-value ref: Ink re-subscribes useInput when its effect flushes,
2015
+ // which can lag a committed render (a directory that just landed paints
2016
+ // before the subscription swaps). A keystroke in that window would meet a
2017
+ // stale closure — Enter died as an empty-filter no-op right after "2 of 4
2018
+ // match" painted. The handler reads render-fresh values through the ref.
2019
+ const liveRef = useRef({ filtered, query, cursor })
2020
+ liveRef.current = { filtered, query, cursor }
1682
2021
 
1683
2022
  useEffect(() => {
1684
2023
  // Open ON the applied model (Codex resumes the previous pick): the first
@@ -1697,7 +2036,7 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1697
2036
  // Position within the ACTIVE filter: the full-row index means nothing
1698
2037
  // when the query already narrowed the list while the directory loaded
1699
2038
  // (a late resolve must not place the cursor outside `filtered`).
1700
- const filteredIndex = filtered.indexOf(rows[index]!)
2039
+ const filteredIndex = filtered.indexOf(rows[index])
1701
2040
  setCursor(filteredIndex >= 0 ? filteredIndex : 0)
1702
2041
  } else if (cursor >= filtered.length) {
1703
2042
  setCursor(Math.max(0, filtered.length - 1))
@@ -1705,11 +2044,12 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1705
2044
  }, [rows, filtered, cursor, current])
1706
2045
 
1707
2046
  useInput((input, key) => {
1708
- if (key.escape || (input === 'q' && query === '')) {
2047
+ const { filtered: list, query: text, cursor: at } = liveRef.current
2048
+ if (key.escape || (input === 'q' && text === '')) {
1709
2049
  onClose()
1710
2050
  return
1711
2051
  }
1712
- if (input === 'r' && query === '') {
2052
+ if (input === 'r' && text === '') {
1713
2053
  onRetry()
1714
2054
  return
1715
2055
  }
@@ -1722,19 +2062,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1722
2062
  onClose()
1723
2063
  return
1724
2064
  }
1725
- const next = editQuery(query, input, key)
2065
+ const next = editQuery(text, input, key)
1726
2066
  if (next !== undefined) {
1727
2067
  setQuery(next)
1728
2068
  setCursor(0)
1729
2069
  return
1730
2070
  }
1731
- if (filtered.length === 0) return
2071
+ if (list.length === 0) return
1732
2072
  if (key.upArrow) {
1733
- setCursor(cursor > 0 ? cursor - 1 : filtered.length - 1)
2073
+ setCursor(at > 0 ? at - 1 : list.length - 1)
1734
2074
  return
1735
2075
  }
1736
2076
  if (key.downArrow) {
1737
- setCursor(cursor < filtered.length - 1 ? cursor + 1 : 0)
2077
+ setCursor(at < list.length - 1 ? at + 1 : 0)
1738
2078
  return
1739
2079
  }
1740
2080
  if (key.pageUp) {
@@ -1742,11 +2082,11 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1742
2082
  return
1743
2083
  }
1744
2084
  if (key.pageDown) {
1745
- setCursor(current => Math.min(filtered.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
2085
+ setCursor(current => Math.min(list.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1746
2086
  return
1747
2087
  }
1748
- if (key.return && filtered[cursor] !== undefined) {
1749
- onSelect(filtered[cursor])
2088
+ if (key.return && list[at] !== undefined) {
2089
+ onSelect(list[at])
1750
2090
  }
1751
2091
  })
1752
2092
 
@@ -1754,19 +2094,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1754
2094
  const providers = onProviders === undefined ? '' : ' · tab providers'
1755
2095
  const state = filtered.length === 0
1756
2096
  ? directory === undefined && error === undefined
1757
- ? 'loading'
2097
+ ? t('panel.model.loading')
1758
2098
  : error !== undefined
1759
- ? 'error'
1760
- : query === '' ? 'no models' : `no match for '${singleLineText(query)}'`
2099
+ ? t('panel.model.error')
2100
+ : query === '' ? t('panel.model.noModels') : t('panel.model.compactNoMatch', { query: singleLineText(query) })
1761
2101
  : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ''}`
1762
2102
  const tail = query === ''
1763
- ? 'type to filter · r retry · esc/q close'
1764
- : 'backspace edits · esc close'
1765
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model · ${state}${providers} · ${tail}`, viewport.contentColumns))
2103
+ ? t('panel.model.footer.filter')
2104
+ : t('panel.model.footer.filtered')
2105
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.model.compact', { state, providers, tail }), viewport.contentColumns))
1766
2106
  }
1767
2107
 
1768
2108
  const stateRows: ReactElement[] = directory === undefined && error === undefined
1769
- ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ' loading models…')]
2109
+ ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.loading')}`)]
1770
2110
  : error !== undefined
1771
2111
  ? [createElement(
1772
2112
  Text,
@@ -1779,12 +2119,12 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1779
2119
  : [createElement(
1780
2120
  Text,
1781
2121
  { key: 'failures', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1782
- truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
2122
+ truncateColumns(` ${t('panel.provider.failure', { providers: directory?.failures.join(', ') ?? '' })}`, viewport.contentColumns),
1783
2123
  )]),
1784
2124
  ...(rows.length === 0
1785
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
2125
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.noModels')}`)]
1786
2126
  : filtered.length === 0
1787
- ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` no models match '${singleLineText(query)}'`, viewport.contentColumns))]
2127
+ ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('panel.model.noMatch', { query: singleLineText(query) })}`, viewport.contentColumns))]
1788
2128
  : []),
1789
2129
  ]
1790
2130
  // Measurement and rendering share the same physical-row budget: state
@@ -1794,12 +2134,13 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1794
2134
  const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1795
2135
  const first = selectionWindow(cursor, filtered.length, rowBudget)
1796
2136
  const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
2137
+ const accent = panelAccent('model', getPalette().brand)
1797
2138
  return createElement(
1798
2139
  Box,
1799
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1800
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(query === ''
1801
- ? `/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`
1802
- : `/model select model · ${filtered.length} of ${rows.length} match '${singleLineText(query)}'`, viewport.contentColumns)),
2140
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2141
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(query === ''
2142
+ ? rows.length === 0 ? t('panel.model.title') : t('panel.model.titleCount', { index: cursor + 1, total: rows.length })
2143
+ : t('panel.model.titleMatches', { filtered: filtered.length, total: rows.length, query: singleLineText(query) }), viewport.contentColumns)),
1803
2144
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1804
2145
  ...visibleStateRows,
1805
2146
  ...visible.map((row) => {
@@ -1817,23 +2158,23 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1817
2158
  )
1818
2159
  }),
1819
2160
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1820
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(query === ''
1821
- ? `type to filter · ↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · tab providers'} · r retry · esc/q close`
1822
- : `↑↓ move · pgup/pgdn page · enter select · backspace edits · esc close`, viewport.contentColumns))),
2161
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(query === ''
2162
+ ? t('panel.model.footer.select', { providers: onProviders === undefined ? '' : ` · ${t('panel.model.providers')}` })
2163
+ : t('panel.model.footer.filteredSelect'), viewport.contentColumns))),
1823
2164
  )
1824
2165
  }
1825
2166
 
1826
2167
  /** Compact provider-state copy; only value-free credential facts cross this boundary. */
1827
2168
  function providerStateLabel(row: ProviderTargetView): string {
1828
- const route = row.active ? 'active' : 'dormant'
2169
+ const route = row.active ? t('panel.provider.active') : t('panel.provider.dormant')
1829
2170
  const credential = row.credential
1830
- if (credential?.kind === 'error') return `${route} · key status unavailable`
2171
+ if (credential?.kind === 'error') return t('panel.provider.state', { route, value: t('panel.provider.keyStatusUnavailable') })
1831
2172
  if (credential?.kind === 'facts') {
1832
- if (!credential.configured) return `${route} · key missing`
1833
- const source = credential.source === undefined ? 'configured' : singleLineText(credential.source)
1834
- return `${route} · key ${source}${credential.writable ? '' : ' · read-only'}`
2173
+ if (!credential.configured) return t('panel.provider.state', { route, value: t('panel.provider.noKey') })
2174
+ const source = credential.source === undefined ? t('panel.provider.configured') : singleLineText(credential.source)
2175
+ return t('panel.provider.state', { route, value: `${t('panel.provider.key', { value: source })}${credential.writable ? '' : ` · ${t('panel.provider.readOnly')}`}` })
1835
2176
  }
1836
- return `${route} · ${row.configured ? 'provider auth' : 'not configured'}`
2177
+ return t('panel.provider.state', { route, value: row.configured ? t('panel.provider.authConfigured') : t('panel.provider.noLogin') })
1837
2178
  }
1838
2179
 
1839
2180
  /** The provider-management stage reached from /model with `a`. */
@@ -1842,15 +2183,15 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1842
2183
  error: string | undefined
1843
2184
  authorizations: ProviderAuthorizationDirectory | undefined
1844
2185
  authorizationError: string | undefined
1845
- onConfigure(target: ProviderTargetView): void
1846
- onUnset(target: ProviderTargetView): void
1847
- onRemove(target: ProviderTargetView): void
1848
- onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1849
- onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1850
- onRetry(): void
1851
- onBack(): void
2186
+ onConfigure: (target: ProviderTargetView) => void
2187
+ onUnset: (target: ProviderTargetView) => void
2188
+ onRemove: (target: ProviderTargetView) => void
2189
+ onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => void
2190
+ onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => void
2191
+ onRetry: () => void
2192
+ onBack: () => void
1852
2193
  /** Leave the whole /model flow (Ctrl+C), not just this stage. */
1853
- onExit(): void
2194
+ onExit: () => void
1854
2195
  }): ReactElement {
1855
2196
  const stdout = useStdout().stdout
1856
2197
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -1912,9 +2253,9 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1912
2253
  if (input === 'd') {
1913
2254
  const facts = target.credential
1914
2255
  if (facts?.kind !== 'facts' || !facts.configured) {
1915
- setActionError('this provider has no configured API key to remove')
2256
+ setActionError(t('panel.provider.noConfiguredKey'))
1916
2257
  } else if (!facts.writable) {
1917
- setActionError('this API key is supplied read-only by the environment')
2258
+ setActionError(t('panel.provider.readOnlyKey'))
1918
2259
  } else {
1919
2260
  onUnset(target)
1920
2261
  }
@@ -1922,7 +2263,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1922
2263
  }
1923
2264
  if (input === 'x') {
1924
2265
  if (!target.removable) {
1925
- setActionError('this provider profile is not removable')
2266
+ setActionError(t('panel.provider.notRemovable'))
1926
2267
  } else {
1927
2268
  onRemove(target)
1928
2269
  }
@@ -1930,14 +2271,14 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1930
2271
  }
1931
2272
  const authorization = authorizationForProvider(authorizations, target.provider)
1932
2273
  if (input === 'l' || input === 'L') {
1933
- if (authorization === undefined) setActionError('this provider offers no interactive login flow')
1934
- else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
2274
+ if (authorization === undefined) setActionError(t('panel.provider.noLoginFlow'))
2275
+ else if (authorization.inFlight) setActionError(t('panel.provider.loginRunning'))
1935
2276
  else onLogin(target, authorization)
1936
2277
  return
1937
2278
  }
1938
2279
  if (input === 'o' || input === 'O') {
1939
- if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
1940
- else if (!authorization.record.writable) setActionError('this login record is read-only')
2280
+ if (authorization === undefined || !authorization.record.configured) setActionError(t('panel.provider.noLoginRecord'))
2281
+ else if (!authorization.record.writable) setActionError(t('panel.provider.readOnlyLogin'))
1941
2282
  else onLogout(target, authorization)
1942
2283
  return
1943
2284
  }
@@ -1946,7 +2287,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1946
2287
  // the configuration surface behind an undiscoverable chord.
1947
2288
  if (key.return) {
1948
2289
  if (target.settingsNs.length === 0) {
1949
- setActionError('this provider is not managed by Harness settings')
2290
+ setActionError(t('panel.provider.notManaged'))
1950
2291
  } else {
1951
2292
  onConfigure(target)
1952
2293
  }
@@ -1954,10 +2295,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1954
2295
  }, true)
1955
2296
 
1956
2297
  if (viewport.maxHeight === 0 || viewport.compact) {
1957
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model providers · enter configure · d remove key · esc back', viewport.contentColumns))
2298
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.providers.compact'), viewport.contentColumns))
1958
2299
  }
1959
2300
  const stateRows: ReactElement[] = directory === undefined && error === undefined
1960
- ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' loading providers…')]
2301
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` ${t('panel.provider.loading')}`)]
1961
2302
  : error !== undefined
1962
2303
  ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
1963
2304
  : [
@@ -1971,14 +2312,14 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1971
2312
  )),
1972
2313
  ...(authorizationError === undefined
1973
2314
  ? []
1974
- : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
2315
+ : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.provider.loginStatusUnavailable', { message: singleLineText(authorizationError) })}`, viewport.contentColumns))]),
1975
2316
  ...(authorizations?.failures ?? []).map((failure, index) => createElement(
1976
2317
  Text,
1977
2318
  { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1978
2319
  truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1979
2320
  )),
1980
2321
  ...(rows.length === 0
1981
- ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
2322
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` ${t('panel.provider.empty')}`)]
1982
2323
  : []),
1983
2324
  ]
1984
2325
  const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
@@ -1989,7 +2330,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1989
2330
  const itemRows: ReactElement[] = []
1990
2331
  for (let display = first; display < first + rowBudget && display < displayLength; display += 1) {
1991
2332
  if (hasSeparator && display === configuredCount) {
1992
- itemRows.push(createElement(Text, { key: 'separator', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ── not configured ──', viewport.contentColumns)))
2333
+ itemRows.push(createElement(Text, { key: 'separator', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.provider.notConfiguredDivider')}`, viewport.contentColumns)))
1993
2334
  continue
1994
2335
  }
1995
2336
  const index = hasSeparator && display > configuredCount ? display - 1 : display
@@ -2013,15 +2354,16 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
2013
2354
  truncateColumns((index === cursor ? '❯ ' : ' ') + displayText(label), viewport.contentColumns),
2014
2355
  ))
2015
2356
  }
2357
+ const accent = panelAccent('model-providers', getPalette().brand)
2016
2358
  return createElement(
2017
2359
  Box,
2018
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2019
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — providers${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
2360
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2361
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(rows.length === 0 ? t('panel.provider.title') : t('panel.provider.titleCount', { index: cursor + 1, total: rows.length }), viewport.contentColumns)),
2020
2362
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2021
2363
  ...visibleStateRows,
2022
2364
  ...itemRows,
2023
2365
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2024
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter configure · l login · o logout · d remove key · x remove provider · r retry · esc back', viewport.contentColumns)),
2366
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.providers.footer'), viewport.contentColumns)),
2025
2367
  )
2026
2368
  }
2027
2369
 
@@ -2049,14 +2391,14 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2049
2391
  target: ProviderTargetView
2050
2392
  /** Models with declared efforts (settings first, catalog-advertised after) a model row can copy from. */
2051
2393
  effortDonors: readonly EffortDonor[]
2052
- save(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
2394
+ save: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
2053
2395
  saveCredential: ((target: ProviderTargetView, key: string) => Promise<void>) | undefined
2054
- discover(target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal): Promise<readonly DiscoveredModelView[]>
2396
+ discover: (target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal) => Promise<readonly DiscoveredModelView[]>
2055
2397
  /** Report a successful save so the surface can notice the key rotation. */
2056
- done(result: { readonly key: boolean }): void
2057
- back(): void
2398
+ done: (result: { readonly key: boolean }) => void
2399
+ back: () => void
2058
2400
  /** Leave the whole /model flow (Ctrl+C), not just this page. */
2059
- onExit(): void
2401
+ onExit: () => void
2060
2402
  }): ReactElement {
2061
2403
  const stdout = useStdout().stdout
2062
2404
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2110,7 +2452,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2110
2452
 
2111
2453
  /** Compact declaration summary for the row label: count, off, or inherit. */
2112
2454
  const effortsSummary = (model: ProviderModelSettings): string => {
2113
- const raw = (model.extras as Record<string, unknown> | undefined)?.reasoningEfforts
2455
+ const raw = (model.extras)?.reasoningEfforts
2114
2456
  if (raw === false) return 'off'
2115
2457
  if (isDeclaredReasoningEfforts(raw)) return String(Object.keys(raw).length)
2116
2458
  return '~'
@@ -2263,7 +2605,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2263
2605
  }
2264
2606
  if (input === 'e' && selected !== undefined) {
2265
2607
  setError(undefined)
2266
- setEffDraft(serializeReasoningEfforts((selected.extras as Record<string, unknown> | undefined)?.reasoningEfforts))
2608
+ setEffDraft(serializeReasoningEfforts((selected.extras)?.reasoningEfforts))
2267
2609
  setEffEditing(true)
2268
2610
  return
2269
2611
  }
@@ -2291,19 +2633,20 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2291
2633
  // Never hide a live input surface: one visible row keeps the escape
2292
2634
  // route honest on extremely short terminals (the three fixed rows - key,
2293
2635
  // url, add-by-id - cannot fit below a three-row body).
2294
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('provider setup · terminal too small · esc back', viewport.contentColumns))
2636
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.setup.compact'), viewport.contentColumns))
2295
2637
  }
2296
2638
  if (page === 'donor') {
2297
2639
  const stateRow = donorRows.length === 0
2298
- ? createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' no model with declared efforts yet; declare one with e, or hand-write settings', viewport.contentColumns))
2299
- : createElement(Text, { key: 'hint', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' copy verbatim into ' + displayText(selected?.id ?? ''), viewport.contentColumns))
2640
+ ? createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.setup.noDonors')}`, viewport.contentColumns))
2641
+ : createElement(Text, { key: 'hint', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.setup.copyInto', { id: displayText(selected?.id ?? '') })}`, viewport.contentColumns))
2300
2642
  const donorBudget = Math.max(0, viewport.bodyRows - 2)
2301
2643
  const donorFirst = selectionWindow(donorIndex, donorRows.length, donorBudget)
2302
2644
  const donorVisible = donorRows.slice(donorFirst, donorFirst + donorBudget)
2645
+ const accent = panelAccent('model-efforts', getPalette().brand)
2303
2646
  return createElement(
2304
2647
  Box,
2305
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2306
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — copy efforts', viewport.contentColumns)),
2648
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2649
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.setup.copyTitle'), viewport.contentColumns)),
2307
2650
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2308
2651
  stateRow,
2309
2652
  ...donorVisible.map((donor, index) => {
@@ -2312,7 +2655,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2312
2655
  return createElement(Text, { key: donor.provider + '/' + donor.id, color: active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns))
2313
2656
  }),
2314
2657
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2315
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter copy · esc back', viewport.contentColumns)),
2658
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.setup.copyFooter'), viewport.contentColumns)),
2316
2659
  )
2317
2660
  }
2318
2661
  if (page === 'discover') {
@@ -2354,7 +2697,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2354
2697
  modelRows.push(createElement(Text, { key: 'add', color: cursor === index ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (cursor === index ? '>' : ' ') + ' + add by id' + (addDraft === '' ? '' : ' ' + addDraft + '▏'), viewport.contentColumns)))
2355
2698
  continue
2356
2699
  }
2357
- const model = models[index]!
2700
+ const model = models[index]
2358
2701
  const active = index === cursor
2359
2702
  const context = model.contextWindow === undefined ? '-' : String(model.contextWindow)
2360
2703
  const output = model.maxTokens === undefined ? '-' : String(model.maxTokens)
@@ -2364,10 +2707,11 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2364
2707
  : ' in:' + (active && field === 'ctx' ? '[' + context + ']' : context) + ' out:' + (active && field === 'out' ? '[' + output + ']' : output) + ' eff:' + effortsSummary(model)
2365
2708
  modelRows.push(createElement(Text, { key: model.id, color: active ? inkColor(getPalette().brandBright) : inkColor(getPalette().success), wrap: 'truncate-end' }, truncateColumns(' ' + (active ? '>' : ' ') + ' [x] ' + displayText(model.id) + tail, viewport.contentColumns)))
2366
2709
  }
2710
+ const accent = panelAccent('model-configure', getPalette().brand)
2367
2711
  return createElement(
2368
2712
  Box,
2369
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2370
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model configure ' + target.displayName, viewport.contentColumns)),
2713
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2714
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.setup.title', { provider: target.displayName }), viewport.contentColumns)),
2371
2715
  // The adapter's configuration diagnostic heads the editor: the provider
2372
2716
  // is here precisely because it stayed listed for repair.
2373
2717
  ...(target.diagnostic === undefined ? [] : [createElement(
@@ -2381,7 +2725,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2381
2725
  ...stateRows,
2382
2726
  ...modelRows,
2383
2727
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2384
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · ←→ in/out · space remove · e efforts · c copy efforts · tab discover · enter save · esc back', viewport.contentColumns)),
2728
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.setup.footer'), viewport.contentColumns)),
2385
2729
  )
2386
2730
  }
2387
2731
 
@@ -2397,11 +2741,11 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2397
2741
  baseURL: string
2398
2742
  apiKey: string
2399
2743
  configured: readonly string[]
2400
- discover(target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal): Promise<readonly DiscoveredModelView[]>
2401
- onAdopt(models: readonly DiscoveredModelView[]): void
2402
- back(): void
2744
+ discover: (target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal) => Promise<readonly DiscoveredModelView[]>
2745
+ onAdopt: (models: readonly DiscoveredModelView[]) => void
2746
+ back: () => void
2403
2747
  /** Leave the whole /model flow (Ctrl+C). */
2404
- onExit(): void
2748
+ onExit: () => void
2405
2749
  }): ReactElement {
2406
2750
  const stdout = useStdout().stdout
2407
2751
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2462,24 +2806,25 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2462
2806
  }
2463
2807
  }, true)
2464
2808
  if (viewport.maxHeight === 0) {
2465
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('model discovery · terminal too small · esc back', viewport.contentColumns))
2809
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.compact'), viewport.contentColumns))
2466
2810
  }
2467
2811
  const stateRows = loading
2468
- ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' discovering models…', viewport.contentColumns))]
2812
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.loading')}`, viewport.contentColumns))]
2469
2813
  : error !== undefined
2470
2814
  ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(' ' + error, viewport.contentColumns))]
2471
2815
  : rows.length === 0
2472
- ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' the endpoint advertised no models; add ids by hand on the setup page', viewport.contentColumns))]
2473
- : [createElement(Text, { key: 'summary', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + rows.length + ' advertised · ' + rows.filter(model => !known.has(model.id)).length + ' new · ' + checked.size + ' checked', viewport.contentColumns))]
2816
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.empty')}`, viewport.contentColumns))]
2817
+ : [createElement(Text, { key: 'summary', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.summary', { advertised: rows.length, newCount: rows.filter(model => !known.has(model.id)).length, checked: checked.size })}`, viewport.contentColumns))]
2474
2818
  // One spare row keeps the panel strictly below maxHeight even with the
2475
2819
  // gap collapsed (the at-equality regime makes Ink rewrite Static).
2476
2820
  const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 1)
2477
2821
  const first = selectionWindow(cursor, rows.length, rowBudget)
2478
2822
  const visible = rows.slice(first, first + rowBudget)
2823
+ const accent = panelAccent('model-discover', getPalette().brand)
2479
2824
  return createElement(
2480
2825
  Box,
2481
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2482
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model discover ' + target.displayName, viewport.contentColumns)),
2826
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2827
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.title', { provider: target.displayName }), viewport.contentColumns)),
2483
2828
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2484
2829
  ...stateRows,
2485
2830
  ...visible.map((model, index) => {
@@ -2491,7 +2836,7 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2491
2836
  return createElement(Text, { key: model.id, color: added ? inkColor(getPalette().dim) : active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns))
2492
2837
  }),
2493
2838
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2494
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · space check · enter adopt · f refetch · esc back', viewport.contentColumns)),
2839
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.footer'), viewport.contentColumns)),
2495
2840
  )
2496
2841
  }
2497
2842
 
@@ -2499,9 +2844,9 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2499
2844
  function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
2500
2845
  target: ProviderTargetView
2501
2846
  kind: 'credential' | 'provider'
2502
- confirm(target: ProviderTargetView): Promise<void>
2503
- done(): void
2504
- back(): void
2847
+ confirm: (target: ProviderTargetView) => Promise<void>
2848
+ done: () => void
2849
+ back: () => void
2505
2850
  }): ReactElement {
2506
2851
  const stdout = useStdout().stdout
2507
2852
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2559,7 +2904,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2559
2904
  skills: readonly SkillRow[]
2560
2905
  commandError: string | undefined
2561
2906
  skillError: string | undefined
2562
- onClose(): void
2907
+ onClose: () => void
2563
2908
  }): ReactElement {
2564
2909
  const stdout = useStdout().stdout
2565
2910
  const columns = stdout?.columns ?? 80
@@ -2569,19 +2914,19 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2569
2914
  const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
2570
2915
  const row = (label: string, description: string): ReactElement => createElement(
2571
2916
  Text,
2572
- { dimColor: true, wrap: 'truncate-end' },
2573
- ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
2917
+ { color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2918
+ ` ${padColumns(label, nameWidth)}${truncateColumns(displayText(description), descBudget)}`,
2574
2919
  )
2575
2920
  const content: ReactElement[] = [
2576
- createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
2577
- createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
2578
- createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
2579
- createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl/alt+r thinking · shift+tab permission preset'),
2580
- createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
2581
- createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
2582
- createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
2921
+ createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, t('help.keysTitle')),
2922
+ createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.submit')}`),
2923
+ createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.mentions')}`),
2924
+ createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.inspector')}`),
2925
+ createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.cancel')}`),
2926
+ createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.queue')}`),
2927
+ createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.edit')}`),
2583
2928
  createElement(Text, { key: 'commands-gap' }, ' '),
2584
- createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
2929
+ createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, t('help.commandsTitle')),
2585
2930
  ...(commandError === undefined
2586
2931
  ? []
2587
2932
  : [createElement(
@@ -2592,18 +2937,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2592
2937
  ...LOCAL_COMMANDS.map(command => createElement(
2593
2938
  Box,
2594
2939
  { key: `local-${command.label.slice(1)}` },
2595
- row(command.label, command.description),
2940
+ row(command.label, t(command.descriptionKey)),
2596
2941
  )),
2597
2942
  ...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
2598
2943
  Text,
2599
- { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
2600
- ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
2944
+ { key: `command-${descriptor.name}`, color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2945
+ ` ${padColumns(`/${descriptor.name}`, nameWidth)}${truncateColumns(displayText(descriptor.description), descBudget)}`,
2601
2946
  )),
2602
2947
  ...(skills.length === 0 && skillError === undefined
2603
2948
  ? []
2604
2949
  : [
2605
2950
  createElement(Text, { key: 'skills-gap' }, ' '),
2606
- createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills'),
2951
+ createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, t('help.skillsTitle')),
2607
2952
  ]),
2608
2953
  ...(skillError === undefined
2609
2954
  ? []
@@ -2614,8 +2959,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2614
2959
  )]),
2615
2960
  ...skills.map(skill => createElement(
2616
2961
  Text,
2617
- { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
2618
- ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
2962
+ { key: `skill-${skill.name}`, color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2963
+ ` ${padColumns(`/${skill.name}`, nameWidth)}${truncateColumns(displayText(skill.description), descBudget)}`,
2619
2964
  )),
2620
2965
  ]
2621
2966
  const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
@@ -2641,17 +2986,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2641
2986
  })
2642
2987
 
2643
2988
  if (viewport.maxHeight === 0 || viewport.compact) {
2644
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
2989
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('help.compact'), viewport.contentColumns))
2645
2990
  }
2646
2991
 
2992
+ const accent = panelAccent('help', getPalette().brand)
2647
2993
  return createElement(
2648
2994
  Box,
2649
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2650
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
2995
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2996
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
2651
2997
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2652
2998
  ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
2653
2999
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2654
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
3000
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(t('help.footer'), viewport.contentColumns))),
2655
3001
  )
2656
3002
  }
2657
3003
 
@@ -2661,7 +3007,8 @@ function verboseLine(text: string, columns: number): string {
2661
3007
  }
2662
3008
 
2663
3009
  /** The empty-composer placeholder text (shared by the static and wave paths). */
2664
- const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
3010
+ const composerPlaceholder = (mode: 'queue' | 'steer'): string =>
3011
+ t(mode === 'steer' ? 'composer.placeholderSteer' : 'composer.placeholder')
2665
3012
 
2666
3013
  /** One physical cell of the wave-painted composer row: a char plus styles. */
2667
3014
  interface ComposerCell {
@@ -2694,9 +3041,9 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
2694
3041
  const spans: ReactElement[] = []
2695
3042
  let start = 0
2696
3043
  while (start < cells.length) {
2697
- const cell = cells[start]!
3044
+ const cell = cells[start]
2698
3045
  let end = start + 1
2699
- while (end < cells.length && sameCellStyle(cells[end]!, cell)) end += 1
3046
+ while (end < cells.length && sameCellStyle(cells[end], cell)) end += 1
2700
3047
  spans.push(createElement(
2701
3048
  Text,
2702
3049
  {
@@ -2719,7 +3066,7 @@ function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): numb
2719
3066
  let column = 0
2720
3067
  for (let index = 0; index < cells.length; index += 1) {
2721
3068
  if (column === target) return index
2722
- column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
3069
+ column += cells[index].width ?? visibleColumns(cells[index].char)
2723
3070
  if (column > target) return undefined
2724
3071
  }
2725
3072
  return undefined
@@ -2792,11 +3139,13 @@ interface ComposerWaveProps {
2792
3139
  value: string
2793
3140
  /** Tier prompt glyph and accent color (persistent, like Codex's charge). */
2794
3141
  promptGlyph: string
3142
+ /** Empty-composer placeholder for the delivery mode in force. */
3143
+ placeholder: string
2795
3144
  promptColor: string
2796
3145
  /** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
2797
3146
  * cancelled by the gate, or unmounted (a modal panel froze the composer) —
2798
3147
  * so Input's played-key latch survives the leaf's unmount/remount cycle. */
2799
- onSettled(): void
3148
+ onSettled: () => void
2800
3149
  }
2801
3150
 
2802
3151
  /**
@@ -2864,7 +3213,7 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2864
3213
  }
2865
3214
  for (const span of splitGraphemes(parts.before)) push(span.text)
2866
3215
  if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
2867
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3216
+ const tail = placeholder ? props.placeholder : parts.after
2868
3217
  for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
2869
3218
  while (usedColumns < props.bandWidth) push(' ')
2870
3219
 
@@ -2873,9 +3222,9 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2873
3222
  const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
2874
3223
  const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
2875
3224
  const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
2876
- if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
3225
+ if (indices.every(index => index !== undefined && (cells[index].char === ' ' || cells[index].dim === true))) {
2877
3226
  for (let at = 0; at < word.length; at += 1) {
2878
- const cell = cells[indices[at]!]!
3227
+ const cell = cells[indices[at]!]
2879
3228
  cell.char = word[at]!
2880
3229
  cell.width = 1
2881
3230
  cell.color = inkColor(deepseekWaveWordHue(at, hues))
@@ -2887,11 +3236,11 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2887
3236
  if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
2888
3237
  const spark = deepseekWaveSpark(tick)
2889
3238
  const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
2890
- if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
2891
- cells[lastIndex]!.char = spark
2892
- cells[lastIndex]!.color = props.promptColor
2893
- cells[lastIndex]!.bold = true
2894
- cells[lastIndex]!.dim = false
3239
+ if (spark !== null && lastIndex !== undefined && cells[lastIndex].char === ' ') {
3240
+ cells[lastIndex].char = spark
3241
+ cells[lastIndex].color = props.promptColor
3242
+ cells[lastIndex].bold = true
3243
+ cells[lastIndex].dim = false
2895
3244
  }
2896
3245
  }
2897
3246
  return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
@@ -2905,16 +3254,110 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2905
3254
  )
2906
3255
  }
2907
3256
 
3257
+ /**
3258
+ * The /rainbow celebration leaf: a FIXED seven-color ribbon that slides
3259
+ * across the three-row composer band. Same cell model as ComposerWave so
3260
+ * CJK/emoji stay atomic; no wordmark, no sparkles — the spectrum is the
3261
+ * show. Strictly one-shot per burst id (Input latches onSettled).
3262
+ */
3263
+ function ComposerRainbowBurst(props: Omit<ComposerWaveProps, 'tier' | 'style'>): ReactElement {
3264
+ const durationMs = RAINBOW_BURST_DURATION_MS
3265
+ const { tick, done } = useWaveFrames(props.active, durationMs)
3266
+ const settledRef = useRef(false)
3267
+ const onSettledRef = useRef(props.onSettled)
3268
+ onSettledRef.current = props.onSettled
3269
+ const settle = (): void => {
3270
+ if (settledRef.current) return
3271
+ settledRef.current = true
3272
+ onSettledRef.current()
3273
+ }
3274
+ useEffect(() => {
3275
+ if (done) settle()
3276
+ }, [done])
3277
+ useEffect(() => () => {
3278
+ settle()
3279
+ }, [])
3280
+ if (!props.active || done || tick * RAINBOW_BURST_TICK_MS >= durationMs) return props.fallback
3281
+ const bandRgb = getPalette().composerBand
3282
+ const totalBandRows = props.rows.length + 2
3283
+ const burstBg = (row: number, column: number): string => {
3284
+ const rgb = rainbowBurstColumnBg(tick, column, props.bandWidth, bandRgb, row, totalBandRows)
3285
+ return rgb === null ? props.bandBg : inkColor(rgb)
3286
+ }
3287
+ const blankBandRow = (row: number): ReactElement => {
3288
+ const blanks: ComposerCell[] = []
3289
+ for (let column = 0; column < props.bandWidth; column += 1) {
3290
+ blanks.push({ char: ' ', width: 1, backgroundColor: burstBg(row, column) })
3291
+ }
3292
+ return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
3293
+ }
3294
+ const editorBurstRows = props.rows.map((row, visibleIndex) => {
3295
+ const sourceIndex = props.windowStart + visibleIndex
3296
+ const bandRow = visibleIndex + 1
3297
+ const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
3298
+ const placeholder = sourceIndex === 0 && props.value === ''
3299
+ const cells: ComposerCell[] = []
3300
+ let usedColumns = 0
3301
+ const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
3302
+ const width = visibleColumns(char)
3303
+ cells.push({ char, width, backgroundColor: burstBg(bandRow, usedColumns), ...extra })
3304
+ usedColumns += width
3305
+ }
3306
+ if (sourceIndex === 0) {
3307
+ push(props.promptGlyph, { color: props.promptColor, bold: true })
3308
+ push(' ', { color: props.promptColor })
3309
+ } else {
3310
+ push(' ')
3311
+ push(' ')
3312
+ }
3313
+ for (const span of splitGraphemes(parts.before)) push(span.text)
3314
+ if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
3315
+ const tail = placeholder ? props.placeholder : parts.after
3316
+ for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
3317
+ while (usedColumns < props.bandWidth) push(' ')
3318
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
3319
+ })
3320
+ return createElement(
3321
+ Box,
3322
+ { flexDirection: 'column', width: props.bandWidth },
3323
+ blankBandRow(0),
3324
+ ...editorBurstRows,
3325
+ blankBandRow(totalBandRows - 1),
3326
+ )
3327
+ }
3328
+
3329
+ /** One-word kind label per entry, so the inspector's ←→ walk names what
3330
+ * each step is instead of leaving the reader to infer it from the body. */
3331
+ function entryKindLabel(entry: TranscriptEntry | undefined): string {
3332
+ switch (entry?.kind) {
3333
+ case 'user': return 'user prompt'
3334
+ case 'pending': return 'queued prompt'
3335
+ case 'assistant': return 'reply'
3336
+ case 'tool': return 'tool call'
3337
+ case 'command': return 'command'
3338
+ case 'error': return 'turn error'
3339
+ case 'turn-marker': return 'turn end'
3340
+ case 'compaction': return 'compaction'
3341
+ case 'retry': return 'retry'
3342
+ case 'files': return 'files changed'
3343
+ case 'workflow': return 'workflow run'
3344
+ default: return 'empty'
3345
+ }
3346
+ }
3347
+
2908
3348
  /**
2909
3349
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
2910
3350
  * with independent history selection and content scrolling. The complete
2911
3351
  * retained entry is converted to physical rows, but only one viewport slice
2912
3352
  * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
2913
3353
  */
2914
- function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
2915
- const stdout = useStdout().stdout
2916
- const columns = stdout?.columns ?? 80
2917
- const rows = stdout?.rows ?? 30
3354
+ function VerbosePanel({ entries, onClose, columns, rows }: {
3355
+ entries: readonly TranscriptEntry[]
3356
+ onClose: () => void
3357
+ /** Live terminal columns from App's resize store — not useStdout, so memo cannot skip a reflow. */
3358
+ columns: number
3359
+ rows: number
3360
+ }): ReactElement {
2918
3361
  const viewport = inspectorViewport(columns, rows)
2919
3362
  const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
2920
3363
  const [scroll, setScroll] = useState(0)
@@ -2927,6 +3370,8 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
2927
3370
  [entry, viewport.contentColumns],
2928
3371
  )
2929
3372
  const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
3373
+ const visibleScrollRef = useRef(visibleScroll)
3374
+ visibleScrollRef.current = visibleScroll
2930
3375
 
2931
3376
  useEffect(() => {
2932
3377
  cursorRef.current = cursor
@@ -2936,7 +3381,7 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
2936
3381
  const current = cursorRef.current
2937
3382
  const next = followInspectorCursor(current, previousLength.current, entries.length)
2938
3383
  if (next !== current) {
2939
- savedScroll.current.set(current, visibleScroll)
3384
+ savedScroll.current.set(current, visibleScrollRef.current)
2940
3385
  setCursor(next)
2941
3386
  setScroll(savedScroll.current.get(next) ?? 0)
2942
3387
  }
@@ -3005,14 +3450,15 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
3005
3450
  return createElement(
3006
3451
  Text,
3007
3452
  { wrap: 'truncate-end' },
3008
- truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
3453
+ truncateColumns(t('panel.verbose.compact'), viewport.contentColumns),
3009
3454
  )
3010
3455
  }
3011
3456
 
3012
3457
  const title = entries.length === 0
3013
3458
  ? 'history details · empty'
3014
- : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
3459
+ : `history details · entry ${cursor + 1}/${entries.length} · ${entryKindLabel(entry)} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
3015
3460
  const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
3461
+ const accent = panelAccent('history-inspector', getPalette().brand)
3016
3462
  return createElement(
3017
3463
  Box,
3018
3464
  {
@@ -3020,11 +3466,11 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
3020
3466
  width: viewport.outerColumns,
3021
3467
  paddingX: 1,
3022
3468
  borderStyle: 'round',
3023
- borderColor: inkColor(getPalette().brand),
3469
+ borderColor: inkColor(accent.border),
3024
3470
  },
3025
3471
  createElement(
3026
3472
  Text,
3027
- { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
3473
+ { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' },
3028
3474
  truncateColumns(title, viewport.contentColumns),
3029
3475
  ),
3030
3476
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -3038,8 +3484,8 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
3038
3484
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
3039
3485
  createElement(
3040
3486
  Text,
3041
- { dimColor: true, wrap: 'truncate-end' },
3042
- dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
3487
+ { wrap: 'truncate-end' },
3488
+ dim(truncateColumns(t('panel.verbose.footer'), viewport.contentColumns)),
3043
3489
  ),
3044
3490
  )
3045
3491
  }
@@ -3068,6 +3514,16 @@ interface CompletionCandidate {
3068
3514
  origin: 'command' | 'skill' | 'mention'
3069
3515
  }
3070
3516
 
3517
+ /**
3518
+ * Wrap one completion-menu cursor step. An empty menu keeps the index at 0:
3519
+ * `% 0` is NaN, and a NaN index silently disables the highlight, the accept
3520
+ * key, and any later Enter that runs through the menu.
3521
+ */
3522
+ export function stepCompletionIndex(index: number, delta: number, count: number): number {
3523
+ if (count <= 0) return 0
3524
+ return ((index + delta) % count + count) % count
3525
+ }
3526
+
3071
3527
  /**
3072
3528
  * Resolve completion candidates for the current input: TUI-local commands,
3073
3529
  * the live registry descriptors, and user-invocable skills, filtered by the
@@ -3086,7 +3542,7 @@ export function completionCandidates(
3086
3542
  ): readonly CompletionCandidate[] {
3087
3543
  if (!value.startsWith('/')) return []
3088
3544
  const prefix = value.slice(1).split(' ')[0] ?? ''
3089
- const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
3545
+ const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ label: command.label, description: t(command.descriptionKey), origin: 'command' }))
3090
3546
  // Local commands shadow registry names (e.g. the TUI-local /permission works
3091
3547
  // before any session exists, while the registry child needs one), so
3092
3548
  // collisions cannot render two rows with the same key.
@@ -3151,10 +3607,10 @@ function completionMenuRowCount(terminalRows: number, rowCount: number): number
3151
3607
 
3152
3608
  /**
3153
3609
  * The completion menu, rendered inside the composer's subtree directly above
3154
- * the composer band — attached the way Claude-Code anchors its dropdown. Opening
3155
- * it grows the stack downward: the composer stays the last element on screen
3156
- * and everything above (the flushed static transcript, the status line) never
3157
- * moves. Props-only (no lifted state): the menu is a pure view of the input
3610
+ * the composer band — attached the way Claude-Code anchors its dropdown.
3611
+ * Its height is deducted from the live transcript budget so the menu covers
3612
+ * live rows instead of growing the tree and moving the composer/status.
3613
+ * Props-only (no lifted state): the menu is a pure view of the input
3158
3614
  * editor's live completion state, so no cross-component effect ever resyncs
3159
3615
  * it (a state lift here previously deadlocked the menu after a resize).
3160
3616
  */
@@ -3212,7 +3668,7 @@ function CompletionMenu({ active, mention, index, rows, error }: {
3212
3668
  // Scroll affordance: with the full merged catalog (commands + registry +
3213
3669
  // skills) the six-row window rarely shows the tail — count and hint keep
3214
3670
  // the rest discoverable without inflating the menu budget.
3215
- hidden > 0 ? createElement(Text, { key: 'more', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, dim(` … +${hidden} more`)) : undefined,
3671
+ hidden > 0 ? createElement(Text, { key: 'more', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` … +${hidden} more`) : undefined,
3216
3672
  showFooter ? createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, dim(mention ? `↑↓ choose · ${rows.length} items · tab insert` : `↑↓ choose · ${rows.length} items · tab complete`)) : undefined,
3217
3673
  )
3218
3674
  }
@@ -3234,7 +3690,7 @@ interface DraftFile extends FilePathInspection {
3234
3690
  * While a modal (approval / question / model panel) owns the keys, the
3235
3691
  * box passes every key through untouched.
3236
3692
  */
3237
- function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }: {
3693
+ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, submitMode, cycleSubmitMode, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openSearch, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openLanguage, saveLanguage, openHistory, openQueue, openAgents, openSubagent, openTodos, openUsage, openDelete, openDiff, openReviewPicker, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, updateQueued, historyFill, historyConsumed, animations, applyAnimations, applyRainbow, rainbowBurstId, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }: {
3238
3694
  active: boolean
3239
3695
  frozen: boolean
3240
3696
  /** Frozen-band hint naming the surface that owns the keyboard; an empty
@@ -3243,82 +3699,100 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3243
3699
  busy: boolean
3244
3700
  descriptors: readonly CommandDescriptor[]
3245
3701
  skills: readonly SkillRow[]
3246
- dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3247
- steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3702
+ dispatch: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
3703
+ /** Submit as steering into the running turn (see {@link AppProps.steer}). */
3704
+ steer: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
3705
+ /** Delivery mode the next submission uses; Tab on an empty composer flips it. */
3706
+ submitMode: 'queue' | 'steer'
3707
+ /** Flip {@link submitMode} and report the new mode. */
3708
+ cycleSubmitMode: () => void
3248
3709
  /** The full current session identity ('' while pending); the delivery origin. */
3249
3710
  sessionKey: string
3250
- interrupt(): boolean
3251
- quit(): void
3252
- openModel(): void
3253
- openEffort(): void
3254
- openHelp(): void
3255
- openMode(): void
3256
- openPermission(): void
3257
- openResume(): void
3258
- openPlugin(query?: string): void
3711
+ interrupt: () => boolean
3712
+ quit: () => void
3713
+ openModel: () => void
3714
+ openEffort: () => void
3715
+ openHelp: () => void
3716
+ openMode: () => void
3717
+ openPermission: () => void
3718
+ openResume: () => void
3719
+ /** Open the /search panel with an optional seed query. */
3720
+ openSearch: (query: string) => void
3721
+ openPlugin: (query?: string) => void
3259
3722
  /** Open the /update panel (aligned upgrade surface). */
3260
- openUpdate(): void
3723
+ openUpdate: () => void
3261
3724
  /** Open the /schedule reminder panel (read-only catalog). */
3262
- openSchedule(): void
3263
- openJobs(): void
3264
- openStatusline(): void
3265
- openTheme(): void
3266
- openHistory(): void
3725
+ openSchedule: () => void
3726
+ openJobs: () => void
3727
+ openStatusline: () => void
3728
+ openTheme: () => void
3729
+ /** Open the /language picker (bare /language). */
3730
+ openLanguage: () => void
3731
+ /** Apply and persist a language chosen by argument. */
3732
+ saveLanguage: (name: LanguageName) => void
3733
+ openHistory: () => void
3734
+ openQueue: () => void
3267
3735
  /** Open the /agents panel (live subagent feed + transcript entry). */
3268
- openAgents(): void
3736
+ openAgents: () => void
3269
3737
  /** Open the /subagent model panel. */
3270
- openSubagent(): void
3738
+ openSubagent: () => void
3271
3739
  /** Open the /todos subpage (full todo list in one bounded panel). */
3272
- openTodos(): void
3273
- /** Open the /resume picker in delete mode, optionally pre-armed on one id. */
3274
- openDelete(id?: string): void
3275
- openDiff(argument: string): void
3276
- reviewChanges(argument: string): void
3740
+ openTodos: () => void
3741
+ openUsage: () => void
3742
+ /** Open the dedicated /delete picker, optionally pre-armed on one id. */
3743
+ openDelete: (id?: string) => void
3744
+ openDiff: (argument: string) => void
3745
+ reviewChanges: (selection: ReviewSelection) => void
3746
+ /** Open the /review candidate picker (bare /review). */
3747
+ openReviewPicker: () => void
3277
3748
  /** The row id awaiting y/n in this box, when a deletion is pending. */
3278
3749
  deleteConfirm?: string
3279
3750
  /** Confirm the pending deletion (y in the box). */
3280
- confirmDelete(): void
3751
+ confirmDelete: () => void
3281
3752
  /** Cancel the pending deletion (any other key in the box). */
3282
- cancelDelete(): void
3283
- createSession(mode?: string): void
3284
- forkSession(argument: string): void
3285
- cancelSessionSwitch(): boolean
3286
- notify(text: string, tone?: NoticeTone): void
3753
+ cancelDelete: () => void
3754
+ createSession: (mode?: string) => void
3755
+ forkSession: (argument: string) => void
3756
+ cancelSessionSwitch: () => boolean
3757
+ notify: (text: string, tone?: NoticeTone) => void
3287
3758
  /** Apply the Ctrl+R passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
3288
- applyEditorKeys(): Promise<string>
3759
+ applyEditorKeys: () => Promise<string>
3289
3760
  hasNotice: boolean
3290
- dismissNotice(): void
3291
- toggleReasoning(): void
3292
- openVerbose(): void
3293
- clearView(): void
3294
- refresh(): void
3295
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
3296
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
3297
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
3298
- inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
3299
- prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
3300
- cycleMode(): string
3301
- exportTranscript(argument: string): Promise<void>
3302
- renameTitle(argument: string): string
3303
- copyLastResponse(): Promise<string>
3761
+ dismissNotice: () => void
3762
+ toggleReasoning: () => void
3763
+ openVerbose: () => void
3764
+ clearView: () => void
3765
+ refresh: () => void
3766
+ loadMentions: (query: string, signal?: AbortSignal) => Promise<readonly MentionCandidate[]>
3767
+ inspectImages: (paths: readonly string[]) => Promise<readonly ImagePathInspection[]>
3768
+ prepareImages: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly ImageBlock[]>
3769
+ inspectFiles: (paths: readonly string[]) => Promise<readonly FilePathInspection[]>
3770
+ prepareFiles: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly FileBlock[]>
3771
+ cycleMode: () => string
3772
+ exportTranscript: (argument: string) => Promise<void>
3773
+ renameTitle: (argument: string) => string
3774
+ copyLastResponse: () => Promise<string>
3304
3775
  /** Newest-first recall space (persistent + in-session, deduped). */
3305
3776
  recallSpace: readonly string[]
3306
3777
  /** Record one in-session submission (deduped, local only). */
3307
- recordLocal(text: string): void
3778
+ recordLocal: (text: string) => void
3308
3779
  /** Persist one submission to the global history file. */
3309
- recordHistory(text: string): void
3310
- /** Live queued inbox rows; Delete on the empty composer cancels the newest. */
3311
- queued: readonly { messageId: string; target: 'next-turn' | 'next-step'; text: string }[]
3312
- /** Cancel one queued inbox message by identity. */
3313
- cancelQueued(messageId: string): void
3780
+ recordHistory: (text: string) => void
3781
+ /** Next-turn inbox rows, ordered exactly as the durable inbox. */
3782
+ queued: readonly Extract<TranscriptEntry, { kind: 'pending' }>[]
3783
+ updateQueued?: (messageId: string, action: QueueMutation) => void
3314
3784
  /** Accepted /history entry waiting to be placed into the composer. */
3315
3785
  historyFill: { text: string; index: number } | undefined
3316
3786
  /** Marks the accepted entry consumed (called after the fill is applied). */
3317
- historyConsumed(): void
3787
+ historyConsumed: () => void
3318
3788
  /** Whether timed animations run (shimmer, chase, blink, wave). */
3319
3789
  animations: boolean
3320
3790
  /** Apply and report one /animation toggle (App persists through the runner). */
3321
- applyAnimations(enabled: boolean): void
3791
+ applyAnimations: (enabled: boolean) => void
3792
+ /** Reroll or pin the rainbow palette (switches to rainbow if needed). */
3793
+ applyRainbow: (seed?: number) => void
3794
+ /** Monotonic id of the in-flight /rainbow composer burst; 0 means none. */
3795
+ rainbowBurstId: number
3322
3796
  /** DeepSeek easter-egg wave tier of the applied route (null otherwise):
3323
3797
  * official DeepSeek models drive their flash/pro tiers, non-DeepSeek
3324
3798
  * models running an effort above high drive the "Into the Unknown"
@@ -3337,10 +3811,10 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3337
3811
  * background process sharing the console cannot keep it overwritten. */
3338
3812
  tabTitle: string
3339
3813
  /** Reports the editor's current physical row count so the live budget stays exact. */
3340
- onEditorRows(rows: number): void
3814
+ onEditorRows: (rows: number) => void
3341
3815
  /** Reports the open completion menu's physical row count (0 when closed)
3342
3816
  * for the same reason: the dynamic budget must reserve it, not overflow. */
3343
- onMenuRows(rows: number): void
3817
+ onMenuRows: (rows: number) => void
3344
3818
  }): ReactElement {
3345
3819
  const { stdout: inputStdout } = useStdout()
3346
3820
  const columns = inputStdout?.columns ?? 80
@@ -3393,27 +3867,28 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3393
3867
  preferredColumnRef.current = null
3394
3868
  }, [editorColumns])
3395
3869
 
3396
- // A /history panel acceptance lands as a fill: place the sanitized text at
3397
- // the end of the composer and resume recall from that entry.
3870
+ // A /history panel acceptance lands as a fill: append the sanitized text to
3871
+ // the composer and resume recall from that entry. Appending (never
3872
+ // replacing) is what keeps a half-written draft and its prepared
3873
+ // attachments from being destroyed by picking a history entry.
3398
3874
  useEffect(() => {
3399
3875
  if (historyFill === undefined) return
3400
3876
  const safe = sanitizeDraftText(historyFill.text)
3401
- draftImagesRef.current = []
3402
- setDraftImages([])
3403
- draftFilesRef.current = []
3404
- setDraftFiles([])
3405
- valueRef.current = safe
3406
- cursorRef.current = safe.length
3407
- setValue(safe)
3408
- setCursor(safe.length)
3877
+ const current = valueRef.current
3878
+ const joined = appendRecall(current, safe)
3879
+ valueRef.current = joined
3880
+ cursorRef.current = joined.length
3881
+ setValue(joined)
3882
+ setCursor(joined.length)
3409
3883
  resetCursorBlink()
3410
3884
  preferredColumnRef.current = null
3411
3885
  setDismissedMenuValue(undefined)
3412
3886
  recall.current = {
3413
3887
  entries: recallSpace,
3414
3888
  index: historyFill.index,
3415
- savedDraft: safe,
3416
- lastRecalled: safe,
3889
+ // The draft this fill appended to stays reachable: Down walks back to it.
3890
+ savedDraft: current,
3891
+ lastRecalled: joined,
3417
3892
  }
3418
3893
  historyConsumed()
3419
3894
  }, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
@@ -3443,7 +3918,11 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3443
3918
  if (stdin === undefined) return
3444
3919
  const originalRead = stdin.read.bind(stdin)
3445
3920
  const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
3446
- const chunk = originalRead(...args)
3921
+ // `Readable.read` is declared `any`; the assertion names its real result
3922
+ // union once so the normalization and the focus-event stripping below
3923
+ // stay type-checked. Node hands back a Buffer unless an encoding was set,
3924
+ // and null once the stream ends.
3925
+ const chunk = originalRead(...args) as string | Buffer | null
3447
3926
  if (chunk === null) return chunk
3448
3927
  const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
3449
3928
  const input = focusReporting
@@ -3464,9 +3943,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3464
3943
  } as typeof stdin.read
3465
3944
  stdin.read = patchedRead
3466
3945
  return () => {
3467
- stdin.read = originalRead as typeof stdin.read
3946
+ stdin.read = originalRead
3468
3947
  }
3469
- }, [focusReporting, stdin])
3948
+ }, [focusReporting, inputStdout, stdin])
3470
3949
 
3471
3950
  // Keep the navigation's recall space fresh while browsing state survives
3472
3951
  // (new local submissions extend the space; the index stays valid unless
@@ -3480,7 +3959,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3480
3959
  const [completionIndex, setCompletionIndex] = useState(0)
3481
3960
  const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
3482
3961
  const candidates = completionCandidates(value, descriptors, skills)
3483
- const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
3962
+ const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n') && !looksLikePathDraft(value)
3484
3963
 
3485
3964
  // @mention token: the last `@word` on the cursor's line before the cursor.
3486
3965
  const beforeCursor = value.slice(0, cursor)
@@ -3519,7 +3998,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3519
3998
 
3520
3999
  const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
3521
4000
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
3522
- notify(`${inspection.name} is already attached`, 'warning')
4001
+ notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
3523
4002
  return false
3524
4003
  }
3525
4004
  const next = [...draftImagesRef.current, { ...inspection, marker }]
@@ -3538,7 +4017,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3538
4017
  const originalCursor = cursorRef.current
3539
4018
  const total = imagePaths.length + filePaths.length
3540
4019
  if (total === 0) return
3541
- notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
4020
+ notify(t('notice.attachmentsChecking', { count: total, plural: total === 1 ? '' : 's' }))
3542
4021
  void Promise.all([
3543
4022
  imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
3544
4023
  filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
@@ -3559,13 +4038,13 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3559
4038
  markers.push(marker)
3560
4039
  }
3561
4040
  if (imageAdditions.length === 0 && fileAdditions.length === 0) {
3562
- notify('those attachments are already attached', 'warning')
4041
+ notify(t('notice.attachmentsAlready'), 'warning')
3563
4042
  return
3564
4043
  }
3565
4044
  const current = valueRef.current
3566
4045
  const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
3567
4046
  if (anchor === undefined) {
3568
- notify('draft changed at the attachment drop point; drop the files again', 'warning')
4047
+ notify(t('notice.attachmentDraftChanged'), 'warning')
3569
4048
  return
3570
4049
  }
3571
4050
  const at = anchor.start
@@ -3586,9 +4065,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3586
4065
  draftFilesRef.current = nextFiles
3587
4066
  setDraftFiles(nextFiles)
3588
4067
  const count = imageAdditions.length + fileAdditions.length
3589
- notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
4068
+ notify(t('notice.attachmentsReady', { count, plural: count === 1 ? '' : 's' }))
3590
4069
  }, (reason: unknown) => {
3591
- notify(`attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
4070
+ notify(t('notice.attachmentFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
3592
4071
  })
3593
4072
  }
3594
4073
 
@@ -3596,8 +4075,8 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3596
4075
  const requestId = mentionRequestRef.current + 1
3597
4076
  mentionRequestRef.current = requestId
3598
4077
  if (!active || !mentionActive) {
3599
- setMentionRows([])
3600
- setMentionError(undefined)
4078
+ setMentionRows(current => current.length === 0 ? current : [])
4079
+ setMentionError(current => current === undefined ? current : undefined)
3601
4080
  return
3602
4081
  }
3603
4082
  setMentionError(undefined)
@@ -3620,7 +4099,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3620
4099
  clearTimeout(timer)
3621
4100
  controller.abort()
3622
4101
  }
3623
- }, [active, mentionActive, mentionToken?.query])
4102
+ }, [active, loadMentions, mentionActive, mentionToken?.query])
3624
4103
 
3625
4104
  // Codex routes keys to the topmost surface first. Completion therefore
3626
4105
  // remains available while a turn runs, and Esc dismisses it before the
@@ -3656,6 +4135,23 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3656
4135
  // the App's dynamic budget can reserve it instead of overflowing.
3657
4136
  const menuHeightRows = menuActive ? completionMenuRowCount(inputTerminalRows, menuRows.length) : 0
3658
4137
 
4138
+ /**
4139
+ * What accepting the highlighted completion candidate would insert, or ''
4140
+ * when nothing can be accepted (no rows, or an image row that resolves
4141
+ * asynchronously). The Enter branch uses it to tell a real accept from a
4142
+ * no-op that must fall through to submission.
4143
+ */
4144
+ const highlightedCompletion = (): string => {
4145
+ if (mentionActive) {
4146
+ const row = rankedMentionRows[completionIndex % Math.max(1, rankedMentionRows.length)]
4147
+ if (row === undefined) return ''
4148
+ if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) return ''
4149
+ return row.label.startsWith('@') ? row.label : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
4150
+ }
4151
+ const candidate = candidates[completionIndex % Math.max(1, candidates.length)]
4152
+ return candidate === undefined ? '' : `${candidate.label} `
4153
+ }
4154
+
3659
4155
  /** Accept the highlighted completion-menu candidate into the draft. */
3660
4156
  const acceptMenuCandidate = (): void => {
3661
4157
  if (mentionActive && mentionToken !== undefined) {
@@ -3673,7 +4169,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3673
4169
  const current = valueRef.current
3674
4170
  const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
3675
4171
  if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
3676
- notify('draft changed around the image mention; select it again', 'warning')
4172
+ notify(t('notice.imageDraftChanged'), 'warning')
3677
4173
  return
3678
4174
  }
3679
4175
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
@@ -3684,7 +4180,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3684
4180
  setCursor(edit.cursor)
3685
4181
  resetCursorBlink()
3686
4182
  setDismissedMenuValue(edit.value)
3687
- notify(`${inspection.name} is already attached`, 'warning')
4183
+ notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
3688
4184
  return
3689
4185
  }
3690
4186
  const marker = uniqueImageMarker(inspection.name, 'mention')
@@ -3696,9 +4192,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3696
4192
  resetCursorBlink()
3697
4193
  setDismissedMenuValue(edit.value)
3698
4194
  registerDraftImage(inspection, marker)
3699
- notify(`${inspection.name} ready for the next message`)
4195
+ notify(t('notice.imageReady', { name: inspection.name }))
3700
4196
  }, (reason: unknown) => {
3701
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
4197
+ notify(t('notice.imageFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
3702
4198
  })
3703
4199
  setCompletionIndex(0)
3704
4200
  setDismissedMenuValue(undefined)
@@ -3745,6 +4241,18 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3745
4241
  preferredColumnRef.current = null
3746
4242
  setCompletionIndex(0)
3747
4243
  setDismissedMenuValue(undefined)
4244
+ // A file drag (any OS / terminal) often writes the path without
4245
+ // bracketed-paste wrappers, so it lands as ordinary insert. If the whole
4246
+ // draft is one drop path, attach it the same way a copied pathname paste
4247
+ // would.
4248
+ const dropped = parsePastedAttachmentPaths(edit.value)
4249
+ if (dropped.images.length > 0 || dropped.files.length > 0) {
4250
+ valueRef.current = ''
4251
+ cursorRef.current = 0
4252
+ setValue('')
4253
+ setCursor(0)
4254
+ insertDroppedAttachments(dropped.images, dropped.files)
4255
+ }
3748
4256
  }
3749
4257
 
3750
4258
  /** Move the cursor without editing; horizontal moves clear the column preference. */
@@ -3802,7 +4310,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3802
4310
  prepareAbortRef.current = undefined
3803
4311
  setPreparingImages(false)
3804
4312
  dismissNotice()
3805
- notify('image submission cancelled', 'warning')
4313
+ notify(t('notice.imageCancelled'), 'warning')
3806
4314
  }
3807
4315
 
3808
4316
  /** Cross history while an unchanged recalled draft rests its caret on
@@ -3877,10 +4385,18 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3877
4385
  const label = cycleMode()
3878
4386
  if (label !== '') notify(label)
3879
4387
  } catch (error: unknown) {
3880
- notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
4388
+ notify(t('notice.permissionChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
3881
4389
  }
3882
4390
  return
3883
4391
  }
4392
+ // Tab on an EMPTY composer picks how the next submission is delivered:
4393
+ // queue for the next turn, or steer into the turn already running. With a
4394
+ // draft present Tab stays the completion key (handled with the menu
4395
+ // below), so this only claims the keypress when nothing is being typed.
4396
+ if (key.tab && liveValue === '' && !menuActive) {
4397
+ cycleSubmitMode()
4398
+ return
4399
+ }
3884
4400
  // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
3885
4401
  // Alt+R is the zero-config alias: VS Code never intercepts Alt chords,
3886
4402
  // so the toggle stays reachable before /vscode-keys has been applied.
@@ -3926,7 +4442,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3926
4442
  applyEdit(deleteForward(liveValue, liveCursor))
3927
4443
  return
3928
4444
  }
3929
- if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
4445
+ if (busy) notify(t('notice.cancelBeforeExit'), 'warning')
3930
4446
  else quit()
3931
4447
  return
3932
4448
  }
@@ -3952,7 +4468,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3952
4468
  const forwardDelete = rawEditorTokens.current?.some(token =>
3953
4469
  token.kind === 'delete-forward' || token.kind === 'delete-word-forward') === true
3954
4470
  if (forwardDelete) {
3955
- cancelQueued(queued[queued.length - 1]!.messageId)
4471
+ updateQueued?.(queued[queued.length - 1].messageId, { kind: 'remove' })
3956
4472
  return
3957
4473
  }
3958
4474
  }
@@ -3968,8 +4484,19 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3968
4484
  // exactly, in which case Enter submits it (typing a full "/effort" and
3969
4485
  // pressing return must run the command, not re-accept its own text).
3970
4486
  if (menuActive) {
4487
+ // Accepting can be a no-op: the menu is open with no matches yet, the
4488
+ // slash line already spells its candidate, or a mention insertion
4489
+ // repeats the token already in the draft. Enter must reach the submit
4490
+ // path in every one of those cases, or the message cannot be sent.
4491
+ const highlighted = highlightedCompletion()
4492
+ const repeatsToken = mentionActive && mentionToken !== undefined && cursor === liveValue.length
4493
+ && liveValue.slice(mentionToken.start, cursor) === highlighted
4494
+ // The slash rule is a whole-list exact match, not a highlighted-row
4495
+ // comparison: typing `/mode` once the list is open must run the
4496
+ // command even when the highlight happens to rest on `/model`.
3971
4497
  const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
3972
- if (!exactSlash) {
4498
+ const acceptNoop = highlighted === '' || repeatsToken || exactSlash
4499
+ if (!acceptNoop) {
3973
4500
  acceptMenuCandidate()
3974
4501
  return
3975
4502
  }
@@ -3984,7 +4511,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3984
4511
  // Slash semantics with attachments are unchanged: commands cannot
3985
4512
  // carry attachments, so the line goes to the model as a prompt —
3986
4513
  // warn instead of surprising the user with a literal "/export".
3987
- if (isSlashLine(text)) notify('commands cannot carry attachments; the line will be sent to the model as a prompt', 'warning')
4514
+ if (isSlashLine(text)) notify(t('notice.commandAttachments'), 'warning')
3988
4515
  // Attachment prepares resolve asynchronously; the app remounts onto
3989
4516
  // another session in the meantime, and this (old) instance's unmount
3990
4517
  // cleanup runs too late on the microtask timeline. Tag the delivery
@@ -4023,7 +4550,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4023
4550
  }
4024
4551
  recall.current = beginRecall(recallSpace, '')
4025
4552
  const blocks: readonly ContentBlock[] = [...images, ...files]
4026
- if (busy) steer(text, blocks, originSession)
4553
+ if (submitMode === 'steer') steer(text, blocks, originSession)
4027
4554
  else dispatch(text, blocks, originSession)
4028
4555
  }, (reason: unknown) => {
4029
4556
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
@@ -4083,7 +4610,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4083
4610
  if (text === '/copy') {
4084
4611
  void copyLastResponse().then(
4085
4612
  outcome => notify(outcome),
4086
- error => notify(`copy failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
4613
+ error => notify(t('notice.copyFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
4087
4614
  )
4088
4615
  return
4089
4616
  }
@@ -4092,7 +4619,16 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4092
4619
  return
4093
4620
  }
4094
4621
  if (text === '/review' || text.startsWith('/review ')) {
4095
- reviewChanges(text.slice(7))
4622
+ const argument = text.slice(7).trim()
4623
+ if (argument === '') {
4624
+ openReviewPicker()
4625
+ return
4626
+ }
4627
+ try {
4628
+ reviewChanges(parseReviewArgument(argument))
4629
+ } catch (error: unknown) {
4630
+ notify(error instanceof Error ? error.message : String(error), 'warning')
4631
+ }
4096
4632
  return
4097
4633
  }
4098
4634
  if (text === '/model' || text.startsWith('/model ')) {
@@ -4127,6 +4663,10 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4127
4663
  else dispatch(text)
4128
4664
  return
4129
4665
  }
4666
+ if (text === '/search' || text.startsWith('/search ')) {
4667
+ openSearch(text.slice(7).trim())
4668
+ return
4669
+ }
4130
4670
  if (text === '/new' || text.startsWith('/new ')) {
4131
4671
  createSession(text.slice(4).trim() || undefined)
4132
4672
  return
@@ -4159,17 +4699,41 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4159
4699
  openTheme()
4160
4700
  return
4161
4701
  }
4702
+ if (text === '/language' || text.startsWith('/language ')) {
4703
+ const argument = text.slice('/language'.length).trim()
4704
+ if (argument === '') openLanguage()
4705
+ else if (argument === 'en' || argument === 'zh') {
4706
+ saveLanguage(parseLanguageName(argument))
4707
+ notify(t('notice.languageSaved', { name: argument }))
4708
+ refresh()
4709
+ } else notify(t('notice.usage.language'), 'warning')
4710
+ return
4711
+ }
4162
4712
  if (text === '/animation' || text.startsWith('/animation ')) {
4163
4713
  const parsed = parseAnimationsArgument(text.slice('/animation'.length))
4164
4714
  if (parsed === 'toggle') applyAnimations(!animations)
4165
- else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
4715
+ else if (parsed === 'usage') notify(t('notice.usage.animation'), 'info')
4166
4716
  else applyAnimations(parsed.enabled)
4167
4717
  return
4168
4718
  }
4719
+ if (text === '/rainbow' || text.startsWith('/rainbow ')) {
4720
+ const parsed = parseRainbowArgument(text.slice('/rainbow'.length))
4721
+ if (parsed === 'usage') notify(t('notice.usage.rainbow'), 'warning')
4722
+ else applyRainbow(parsed === 'random' ? undefined : parsed.seed)
4723
+ return
4724
+ }
4169
4725
  if (text === '/history') {
4170
4726
  openHistory()
4171
4727
  return
4172
4728
  }
4729
+ if (text === '/queue') {
4730
+ openQueue()
4731
+ return
4732
+ }
4733
+ if (text === '/usage') {
4734
+ openUsage()
4735
+ return
4736
+ }
4173
4737
  if (text === '/agents') {
4174
4738
  openAgents()
4175
4739
  return
@@ -4193,19 +4757,30 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4193
4757
  openDelete(text.slice(7).trim())
4194
4758
  return
4195
4759
  }
4196
- if (busy && !text.startsWith('/')) {
4197
- // A running turn is steered, not blocked: the inbox delivers this
4198
- // text at the next step boundary (Esc/Ctrl+C still cancels outright).
4199
- // Slash lines keep the registry path — commands run out of band.
4200
- steer(text)
4760
+ const slash = slashNameAndArgs(text)
4761
+ if (slash !== undefined && BARE_LOCAL_COMMANDS.has(slash.name) && slash.args !== '') {
4762
+ notify(t('notice.usage.bareCommand', { name: slash.name }), 'warning')
4201
4763
  return
4202
4764
  }
4203
- dispatch(text)
4765
+ // Delivery mode: everything above this point is a local command or a
4766
+ // panel opener and always runs out of band. A real prompt follows the
4767
+ // composer's Tab choice — `steer` joins the running turn, the default
4768
+ // queues it for the next one.
4769
+ if (submitMode === 'steer') steer(text)
4770
+ else dispatch(text)
4771
+ return
4772
+ }
4773
+ // The modified-Enter newline family: Ctrl+J arrives as a bare LF (Ink
4774
+ // names it 'enter', not 'return'), and the kitty layer normalizes
4775
+ // Ctrl/Shift+Enter to the same byte. Alt+Enter reaches here as a bare CR
4776
+ // with no flags — Ink's parser drops the escape and reports no meta, and
4777
+ // plain Enter always carries key.return — so a flagless CR is Alt+Enter.
4778
+ // Only plain Enter submits. app.spec's "modified-Enter family" test pins
4779
+ // this exact parser shape; an Ink upgrade that changes it fails there.
4780
+ if (input === '\n' || input === '\r') {
4781
+ applyEdit(insertText(liveValue, liveCursor, '\n'))
4204
4782
  return
4205
4783
  }
4206
- // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
4207
- // stripping the leading escape. Neither is a multiline shortcut.
4208
- if (input === '\n' || input === '\r') return
4209
4784
  // A fast Tab followed by text can arrive as one readable chunk in an
4210
4785
  // integrated terminal. Accept the candidate first, then apply the
4211
4786
  // remaining characters against the synchronously updated editor refs.
@@ -4216,11 +4791,11 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4216
4791
  return
4217
4792
  }
4218
4793
  if (menuActive && key.upArrow) {
4219
- setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
4794
+ setCompletionIndex(index => stepCompletionIndex(index, -1, menuRows.length))
4220
4795
  return
4221
4796
  }
4222
4797
  if (menuActive && key.downArrow) {
4223
- setCompletionIndex(index => (index + 1) % menuRows.length)
4798
+ setCompletionIndex(index => stepCompletionIndex(index, 1, menuRows.length))
4224
4799
  return
4225
4800
  }
4226
4801
  // Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
@@ -4313,10 +4888,13 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4313
4888
  return
4314
4889
  }
4315
4890
  if (input !== '' && !key.ctrl && !key.meta) {
4316
- // Bracketed-paste wrappers arrive as unknown escape sequences stripped
4317
- // of their ESC. Markers may ride their own chunk or the edges of a
4318
- // content chunk; strip every occurrence and track the open-paste flag
4319
- // so a chunk that is exactly LF inserts instead of submitting.
4891
+ // Bracketed-paste wrappers arrive as escape sequences Ink has stripped
4892
+ // only ONE leading ESC from, so the tail marker still carries its own:
4893
+ // strip both spellings before the payload is read, or a dragged path
4894
+ // reaches the attachment parser with a trailing control byte and fails.
4895
+ // Markers may ride their own chunk or the edges of a content chunk; the
4896
+ // open-paste flag is tracked so a chunk that is exactly LF inserts
4897
+ // instead of submitting.
4320
4898
  let text = input
4321
4899
  if (text.includes(PASTE_START_MARKER)) {
4322
4900
  pasteBracketRef.current = true
@@ -4331,13 +4909,12 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4331
4909
  clearTimeout(timer)
4332
4910
  pasteBracketCancelRef.current = undefined
4333
4911
  }
4334
- text = text.replaceAll(PASTE_START_MARKER, '')
4335
4912
  }
4336
4913
  if (text.includes(PASTE_END_MARKER)) {
4337
4914
  pasteBracketRef.current = false
4338
4915
  pasteBracketCancelRef.current?.()
4339
- text = text.replaceAll(PASTE_END_MARKER, '')
4340
4916
  }
4917
+ text = stripPasteMarkers(text)
4341
4918
  if (text === '') return
4342
4919
  if (text.length > 1) {
4343
4920
  // A path-list paste splits into images and files; prose falls through
@@ -4377,13 +4954,23 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4377
4954
  if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
4378
4955
  }, [animations, waveKey, wavePlayedKey])
4379
4956
  const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
4957
+ const burstKey = rainbowBurstId > 0 ? `rainbow:${rainbowBurstId}` : null
4958
+ const [burstPlayedKey, setBurstPlayedKey] = useState<string | null>(null)
4959
+ useEffect(() => {
4960
+ if (!animations && burstKey !== null && burstKey !== burstPlayedKey) setBurstPlayedKey(burstKey)
4961
+ }, [animations, burstKey, burstPlayedKey])
4962
+ const burstArmed = burstKey !== null && burstKey !== burstPlayedKey
4380
4963
 
4381
4964
  // Every exclusive panel keeps the composer as a stable visual anchor, but
4382
4965
  // freezes it to one row: no menu, multiline wrap, or animation.
4383
4966
  const tierActive = waveTier !== null
4384
4967
  const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
4385
4968
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
4386
- const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
4969
+ const waveGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
4970
+ // Steer mode owns the prompt glyph in every paint path (static band, wave,
4971
+ // rainbow burst), so the mode is visible without reading the placeholder.
4972
+ const promptGlyph = submitMode === 'steer' ? '↳' : waveGlyph
4973
+ const placeholderText = composerPlaceholder(submitMode)
4387
4974
  // The multiline editor model: the sanitized draft hard-wrapped into
4388
4975
  // column-safe physical rows, with the caret mapped to its exact row and
4389
4976
  // column. Computed before the frozen path so the row report below runs
@@ -4421,7 +5008,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4421
5008
  // the reserve from outliving the menu (unmount or inactive handoff).
4422
5009
  useEffect(() => {
4423
5010
  onMenuRows(menuHeightRows)
4424
- return () => onMenuRows(0)
5011
+ return () => {
5012
+ if (menuHeightRows !== 0) onMenuRows(0)
5013
+ }
4425
5014
  }, [menuHeightRows, onMenuRows])
4426
5015
  // The composer band: the old border's three-row footprint repainted as a
4427
5016
  // background-color band (the Codex-style shaded composer strip) — one
@@ -4457,7 +5046,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4457
5046
  return band(createElement(
4458
5047
  Text,
4459
5048
  { backgroundColor: bandBg, wrap: 'truncate-end' },
4460
- createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
5049
+ createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, submitMode === 'steer' ? '↳ ' : busy ? '… ' : `${promptGlyph} `),
4461
5050
  frozenLine,
4462
5051
  bandFill(2 + visibleColumns(frozenLine)),
4463
5052
  ))
@@ -4475,10 +5064,10 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4475
5064
  // spacer or a second blink timer.
4476
5065
  const editorRows: ReactElement[] = []
4477
5066
  for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
4478
- const row = editorViewModel.rows[index]!
5067
+ const row = editorViewModel.rows[index]
4479
5068
  const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
4480
5069
  const placeholder = index === 0 && value === '' && !busy && !preparingImages
4481
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
5070
+ const tail = placeholder ? placeholderText : parts.after
4482
5071
  const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
4483
5072
  editorRows.push(createElement(
4484
5073
  Text,
@@ -4486,16 +5075,18 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4486
5075
  index === 0
4487
5076
  ? preparingImages
4488
5077
  ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
4489
- : busy
4490
- ? createElement(BusyChase, { animated: animations })
4491
- : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
5078
+ : submitMode === 'steer'
5079
+ ? createElement(Text, { color: promptColor, bold: true }, '↳ ')
5080
+ : busy
5081
+ ? createElement(BusyChase, { animated: animations })
5082
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
4492
5083
  : ' ',
4493
5084
  parts.before,
4494
5085
  parts.hasCaret
4495
5086
  ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
4496
5087
  : null,
4497
5088
  placeholder
4498
- ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
5089
+ ? createElement(Text, { dimColor: true }, tail)
4499
5090
  : parts.after,
4500
5091
  bandFill(consumed),
4501
5092
  ))
@@ -4511,26 +5102,47 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4511
5102
  Box,
4512
5103
  { flexDirection: 'column' },
4513
5104
  menu,
4514
- createElement(ComposerWave, {
4515
- key: waveKey ?? 'static',
4516
- tier: waveTier ?? 'deepseek',
4517
- style: waveStyle ?? 'wave',
4518
- active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
4519
- onSettled: () => {
4520
- if (waveKey !== null) setWavePlayedKey(waveKey)
4521
- },
4522
- fallback: band(staticEditor),
4523
- bandWidth,
4524
- bandBg,
4525
- rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
4526
- windowStart: editorWindowStart,
4527
- caretRow: caret.row,
4528
- cursor: clampedCursor,
4529
- caretVisible: cursorVisible,
4530
- value,
4531
- promptGlyph,
4532
- promptColor,
4533
- }),
5105
+ burstArmed
5106
+ ? createElement(ComposerRainbowBurst, {
5107
+ key: burstKey,
5108
+ active: !busy && !preparingImages && animations,
5109
+ onSettled: () => {
5110
+ if (burstKey !== null) setBurstPlayedKey(burstKey)
5111
+ },
5112
+ fallback: band(staticEditor),
5113
+ bandWidth,
5114
+ bandBg,
5115
+ rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
5116
+ windowStart: editorWindowStart,
5117
+ caretRow: caret.row,
5118
+ cursor: clampedCursor,
5119
+ caretVisible: cursorVisible,
5120
+ value,
5121
+ promptGlyph,
5122
+ placeholder: placeholderText,
5123
+ promptColor,
5124
+ })
5125
+ : createElement(ComposerWave, {
5126
+ key: waveKey ?? 'static',
5127
+ tier: waveTier ?? 'deepseek',
5128
+ style: waveStyle ?? 'wave',
5129
+ active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
5130
+ onSettled: () => {
5131
+ if (waveKey !== null) setWavePlayedKey(waveKey)
5132
+ },
5133
+ fallback: band(staticEditor),
5134
+ bandWidth,
5135
+ bandBg,
5136
+ rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
5137
+ windowStart: editorWindowStart,
5138
+ caretRow: caret.row,
5139
+ cursor: clampedCursor,
5140
+ caretVisible: cursorVisible,
5141
+ value,
5142
+ promptGlyph,
5143
+ placeholder: placeholderText,
5144
+ promptColor,
5145
+ }),
4534
5146
  )
4535
5147
  }
4536
5148
 
@@ -4755,7 +5367,13 @@ export function computeSettledRows(
4755
5367
 
4756
5368
  /** The whole terminal app; state arrives via the store, output via Ink. */
4757
5369
  export function App(props: AppProps): ReactElement {
4758
- const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
5370
+ // The stores are closure-backed singletons whose methods never touch `this`,
5371
+ // but a bare method reference still detaches it from its receiver. One stable
5372
+ // wrapper per store keeps both the receiver and the reference identity the
5373
+ // `useSyncExternalStore` contract requires.
5374
+ const subscribeTranscript = useCallback((listener: () => void) => props.store.subscribe(listener), [props.store])
5375
+ const readTranscript = useCallback(() => props.store.getView(), [props.store])
5376
+ const view = useSyncExternalStore(subscribeTranscript, readTranscript)
4759
5377
  // Terminal input anchor: Ink reference-counts raw mode across every active
4760
5378
  // `useInput` hook, so mutually exclusive surfaces (composer <-> approval
4761
5379
  // bar <-> panels) drop the count to zero inside each handoff commit — the
@@ -4777,8 +5395,10 @@ export function App(props: AppProps): ReactElement {
4777
5395
  // process-stable, so one callback per view identity is enough.
4778
5396
  const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands])
4779
5397
  const readSkills = useCallback(() => props.skills.rows, [props.skills])
4780
- const descriptors = useSyncExternalStore(props.commands.subscribe, readDescriptors)
4781
- const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
5398
+ const subscribeCommands = useCallback((listener: () => void) => props.commands.subscribe(listener), [props.commands])
5399
+ const subscribeSkills = useCallback((listener: () => void) => props.skills.subscribe(listener), [props.skills])
5400
+ const descriptors = useSyncExternalStore(subscribeCommands, readDescriptors)
5401
+ const skills = useSyncExternalStore(subscribeSkills, readSkills)
4782
5402
  const [modelLabel, setModelLabel] = useState(props.model)
4783
5403
  const [modelOpen, setModelOpen] = useState(false)
4784
5404
  /** Nested /model stages; only one owns terminal input at a time. */
@@ -4807,13 +5427,17 @@ export function App(props: AppProps): ReactElement {
4807
5427
  * (ordinary turns, image preparation, /animation toggles) never replays. */
4808
5428
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
4809
5429
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
5430
+ const [rainbowBurstId, setRainbowBurstId] = useState(0)
5431
+ const fireRainbowBurst = (): void => {
5432
+ setRainbowBurstId(id => id + 1)
5433
+ }
4810
5434
  // /animation toggle: applies immediately, persists through the runner, and
4811
5435
  // gates every timed leaf (shimmer, chase, blink, wave) for this render.
4812
5436
  const [animations, setAnimations] = useState(props.animations ?? true)
4813
5437
  const applyAnimations = (enabled: boolean): void => {
4814
5438
  setAnimations(enabled)
4815
5439
  props.saveAnimations?.(enabled)
4816
- notify(`animations ${enabled ? 'on' : 'off'}`)
5440
+ notify(t('notice.animationState', { state: enabled ? 'on' : 'off' }))
4817
5441
  }
4818
5442
  const previousModel = useRef<string | undefined>(undefined)
4819
5443
  const previousEffort = useRef<string | undefined>(props.effort)
@@ -4851,14 +5475,22 @@ export function App(props: AppProps): ReactElement {
4851
5475
  const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
4852
5476
  const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
4853
5477
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
5478
+ /** Bumped when /effort's catalog lookup must be ignored (panel closed or superseded). */
5479
+ const effortLookupEpoch = useRef(0)
4854
5480
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
4855
5481
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
4856
5482
  setNotice({ text, tone })
4857
5483
  }, [])
4858
5484
 
5485
+ const {
5486
+ loadModels,
5487
+ loadModelProviders,
5488
+ loadProviderAuthorizations,
5489
+ onBridgeReady,
5490
+ } = props
4859
5491
  useEffect(() => {
4860
- props.onBridgeReady({ notify })
4861
- }, [])
5492
+ onBridgeReady({ notify })
5493
+ }, [notify, onBridgeReady])
4862
5494
  useEffect(() => {
4863
5495
  if (!modelOpen) return
4864
5496
  let cancelled = false
@@ -4867,7 +5499,7 @@ export function App(props: AppProps): ReactElement {
4867
5499
  // Enter the promise chain before invoking the loader so a provider that
4868
5500
  // throws synchronously becomes an in-panel error instead of escaping the
4869
5501
  // React effect and tearing down Ink.
4870
- Promise.resolve().then(() => props.loadModels()).then((loaded) => {
5502
+ Promise.resolve().then(() => loadModels()).then((loaded) => {
4871
5503
  if (!cancelled) setDirectory(loaded)
4872
5504
  }, (error: unknown) => {
4873
5505
  if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
@@ -4875,13 +5507,13 @@ export function App(props: AppProps): ReactElement {
4875
5507
  return () => {
4876
5508
  cancelled = true
4877
5509
  }
4878
- }, [modelOpen, modelLoadEpoch, props.loadModels])
5510
+ }, [loadModels, modelOpen, modelLoadEpoch])
4879
5511
  useEffect(() => {
4880
- if (!modelOpen || props.loadModelProviders === undefined) return
5512
+ if (!modelOpen || loadModelProviders === undefined) return
4881
5513
  let cancelled = false
4882
5514
  setProviderDirectory(undefined)
4883
5515
  setProviderError(undefined)
4884
- Promise.resolve().then(() => props.loadModelProviders!()).then((loaded) => {
5516
+ Promise.resolve().then(() => loadModelProviders()).then((loaded) => {
4885
5517
  if (!cancelled) setProviderDirectory(loaded)
4886
5518
  }, (error: unknown) => {
4887
5519
  if (!cancelled) setProviderError(error instanceof Error ? error.message : String(error))
@@ -4889,13 +5521,13 @@ export function App(props: AppProps): ReactElement {
4889
5521
  return () => {
4890
5522
  cancelled = true
4891
5523
  }
4892
- }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
5524
+ }, [loadModelProviders, modelOpen, modelLoadEpoch])
4893
5525
  useEffect(() => {
4894
- if (!modelOpen || props.loadProviderAuthorizations === undefined) return
5526
+ if (!modelOpen || loadProviderAuthorizations === undefined) return
4895
5527
  let cancelled = false
4896
5528
  setAuthorizationDirectory(undefined)
4897
5529
  setAuthorizationError(undefined)
4898
- Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
5530
+ Promise.resolve().then(() => loadProviderAuthorizations()).then((loaded) => {
4899
5531
  if (!cancelled) setAuthorizationDirectory(loaded)
4900
5532
  }, (error: unknown) => {
4901
5533
  if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
@@ -4903,7 +5535,7 @@ export function App(props: AppProps): ReactElement {
4903
5535
  return () => {
4904
5536
  cancelled = true
4905
5537
  }
4906
- }, [modelOpen, modelLoadEpoch, props.loadProviderAuthorizations])
5538
+ }, [loadProviderAuthorizations, modelOpen, modelLoadEpoch])
4907
5539
  useEffect(() => {
4908
5540
  const subscribe = props.subscribeModelProviders
4909
5541
  if (!modelOpen || subscribe === undefined) return
@@ -4928,24 +5560,39 @@ export function App(props: AppProps): ReactElement {
4928
5560
  // Dedupe for the dynamic-budget tripwire: one warning per distinct shape.
4929
5561
  const budgetWarnRef = useRef<string | undefined>(undefined)
4930
5562
  const [verboseOpen, setVerboseOpen] = useState(false)
5563
+ const [queueOpen, setQueueOpen] = useState(false)
5564
+ /**
5565
+ * How the composer delivers its next submission: `queue` waits for the next
5566
+ * turn, `steer` joins the turn already running. Tab on an empty composer
5567
+ * flips it; the prompt glyph and the placeholder both name the current mode.
5568
+ */
5569
+ const [submitMode, setSubmitMode] = useState<'queue' | 'steer'>('queue')
4931
5570
  const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
5571
+ const [reviewPickerOpen, setReviewPickerOpen] = useState(false)
4932
5572
  const [helpOpen, setHelpOpen] = useState(false)
4933
5573
  const [modeOpen, setModeOpen] = useState(false)
4934
5574
  const [permissionOpen, setPermissionOpen] = useState(false)
4935
5575
  const [resumeOpen, setResumeOpen] = useState(false)
5576
+ const [searchOpen, setSearchOpen] = useState(false)
5577
+ /** /search seed: the query from `/search <text>` (cleared on open). */
5578
+ const [searchSeed, setSearchSeed] = useState('')
4936
5579
  const [pluginOpen, setPluginOpen] = useState(false)
4937
5580
  const [pluginQuery, setPluginQuery] = useState('')
4938
5581
  const [updateOpen, setUpdateOpen] = useState(false)
5582
+ const [updateApplying, setUpdateApplying] = useState(false)
5583
+ useEffect(() => subscribeUpdateApplyRunning(setUpdateApplying), [])
4939
5584
  const [scheduleOpen, setScheduleOpen] = useState(false)
4940
5585
  const [jobsOpen, setJobsOpen] = useState(false)
4941
5586
  const [statuslineOpen, setStatuslineOpen] = useState(false)
4942
5587
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
4943
5588
  const [themeOpen, setThemeOpen] = useState(false)
5589
+ const [languageOpen, setLanguageOpen] = useState(false)
4944
5590
  const [historyOpen, setHistoryOpen] = useState(false)
4945
5591
  const [agentsOpen, setAgentsOpen] = useState(false)
4946
5592
  const [subagentOpen, setSubagentOpen] = useState(false)
4947
5593
  const [todosOpen, setTodosOpen] = useState(false)
4948
- /** /delete state: delete-mode hint plus an optional pre-armed row id. */
5594
+ const [usageOpen, setUsageOpen] = useState(false)
5595
+ /** /delete state: dedicated picker mode plus an optional pre-armed row id. */
4949
5596
  const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
4950
5597
  /** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
4951
5598
  * composer takes the keys, the resume panel yields until it settles. */
@@ -4958,19 +5605,20 @@ export function App(props: AppProps): ReactElement {
4958
5605
  const cancelDelete = useCallback((): void => {
4959
5606
  setDeleteConfirmId(undefined)
4960
5607
  }, [])
5608
+ const deleteSession = props.deleteSession
4961
5609
  const confirmDelete = useCallback((): void => {
4962
5610
  const id = deleteConfirmId
4963
5611
  if (id === undefined) return
4964
5612
  setDeleteConfirmId(undefined)
4965
- void props.deleteSession(id).then(outcome => {
5613
+ void deleteSession(id).then(outcome => {
4966
5614
  notify(outcome)
4967
5615
  // Keep the picker open and reload: a successful deletion must vanish
4968
5616
  // from the list immediately, not look like a no-op.
4969
5617
  setDeleteReloadToken(token => token + 1)
4970
5618
  }, (reason: unknown) => {
4971
- notify(`delete failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
5619
+ notify(t('notice.deleteFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
4972
5620
  })
4973
- }, [deleteConfirmId, props.deleteSession, notify])
5621
+ }, [deleteSession, deleteConfirmId, notify])
4974
5622
  /** The /history panel's accepted entry: text plus its recall-space index. */
4975
5623
  const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
4976
5624
  /** Submissions recorded in this process (Codex local history; persistent file stays in the runner). */
@@ -4988,60 +5636,90 @@ export function App(props: AppProps): ReactElement {
4988
5636
  }, [])
4989
5637
  /** The append-only flush boundary (see `settledEntryCount`): entries below
4990
5638
  * this index are final and ride the `<Static>` scrollback; everything at or
4991
- * beyond stays in the live tree. Pending inbox rows always live at
4992
- * index >= settled, so the queued-inbox scan below only walks the mutable
4993
- * tail instead of the whole history. */
5639
+ * beyond stays in the live tree. */
4994
5640
  const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
4995
- /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). The
4996
- * projection only appends and removes pending rows at index >= settled, so
4997
- * a bounded tail scan replaces an unconditional O(history) filter on every
4998
- * event. */
4999
- const queuedRows = useMemo(() => {
5000
- const rows: Array<Extract<TranscriptEntry, { kind: 'pending' }>> = []
5001
- for (let index = settled; index < view.entries.length; index++) {
5002
- const entry = view.entries[index]
5003
- if (entry.kind === 'pending') rows.push(entry)
5004
- }
5005
- return rows
5006
- }, [view.entries, settled])
5641
+ /** Next-turn rows in durable inbox order: a running tool row can split the
5642
+ * pending rows, so this maps the inbox id list onto the folded entries
5643
+ * instead of scanning the mutable tail. */
5644
+ const queuedRows = useMemo(
5645
+ () => queuedInboxRows(view.entries, view.pending['next-turn']),
5646
+ [view.entries, view.pending],
5647
+ )
5007
5648
  const [refreshEpoch, setRefreshEpoch] = useState(0)
5008
- const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
5009
- const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
5010
- const agentRows = useSyncExternalStore(props.subagents.subscribe, props.subagents.getSnapshot)
5649
+ const subscribeApproval = useCallback((listener: () => void) => props.approval.subscribe(listener), [props.approval])
5650
+ const readApprovalSnapshot = useCallback(() => props.approval.getSnapshot(), [props.approval])
5651
+ const subscribeQuestions = useCallback((listener: () => void) => props.questions.subscribe(listener), [props.questions])
5652
+ const readQuestionSnapshot = useCallback(() => props.questions.getSnapshot(), [props.questions])
5653
+ const subscribeSubagents = useCallback((listener: () => void) => props.subagents.subscribe(listener), [props.subagents])
5654
+ const readAgentRows = useCallback(() => props.subagents.getSnapshot(), [props.subagents])
5655
+ const approvalSnapshot = useSyncExternalStore(subscribeApproval, readApprovalSnapshot)
5656
+ const questionSnapshot = useSyncExternalStore(subscribeQuestions, readQuestionSnapshot)
5657
+ const agentRows = useSyncExternalStore(subscribeSubagents, readAgentRows)
5011
5658
  const approvalPending = approvalSnapshot.pending !== undefined
5012
5659
  const questionPending = questionSnapshot.pending !== undefined
5013
- // While any modal owns the keys, the prompt box passes everything through.
5660
+ /**
5661
+ * Every keyboard-owning surface that is mutually exclusive with the composer,
5662
+ * in precedence order. This ONE list drives the composer gate, the transcript
5663
+ * visibility, the frozen band's hint, and the hand-off when a human approval
5664
+ * or question arrives, so a panel cannot be wired into one of them and
5665
+ * forgotten in the others.
5666
+ */
5667
+ const panelSurfaces: readonly { readonly hint: string; readonly open: boolean; readonly close: () => void }[] = [
5668
+ { hint: 'the diff review', open: diffView !== undefined, close: () => setDiffView(undefined) },
5669
+ { hint: 'the review picker', open: reviewPickerOpen, close: () => setReviewPickerOpen(false) },
5670
+ {
5671
+ hint: '/model',
5672
+ open: modelOpen,
5673
+ close: () => {
5674
+ setModelOpen(false)
5675
+ setProviderOpen(false)
5676
+ setProviderAction(undefined)
5677
+ setEffortFor(undefined)
5678
+ },
5679
+ },
5680
+ { hint: '/help', open: helpOpen, close: () => setHelpOpen(false) },
5681
+ { hint: '/mode', open: modeOpen, close: () => setModeOpen(false) },
5682
+ { hint: '/permission', open: permissionOpen, close: () => setPermissionOpen(false) },
5683
+ { hint: '/resume', open: resumeOpen, close: () => setResumeOpen(false) },
5684
+ { hint: '/search', open: searchOpen, close: () => setSearchOpen(false) },
5685
+ { hint: '/plugin', open: pluginOpen, close: () => setPluginOpen(false) },
5686
+ { hint: '/update', open: updateOpen, close: () => setUpdateOpen(false) },
5687
+ { hint: '/schedule', open: scheduleOpen, close: () => setScheduleOpen(false) },
5688
+ { hint: '/jobs', open: jobsOpen, close: () => setJobsOpen(false) },
5689
+ { hint: '/statusline', open: statuslineOpen, close: () => setStatuslineOpen(false) },
5690
+ { hint: '/theme', open: themeOpen, close: () => setThemeOpen(false) },
5691
+ { hint: '/language', open: languageOpen, close: () => setLanguageOpen(false) },
5692
+ { hint: '/history', open: historyOpen, close: () => setHistoryOpen(false) },
5693
+ { hint: '/queue', open: queueOpen, close: () => setQueueOpen(false) },
5694
+ { hint: '/agents', open: agentsOpen, close: () => setAgentsOpen(false) },
5695
+ { hint: '/subagent', open: subagentOpen, close: () => setSubagentOpen(false) },
5696
+ { hint: '/todos', open: todosOpen, close: () => setTodosOpen(false) },
5697
+ { hint: '/usage', open: usageOpen, close: () => setUsageOpen(false) },
5698
+ ]
5699
+ const panelSurfacesRef = useRef(panelSurfaces)
5700
+ panelSurfacesRef.current = panelSurfaces
5701
+ const openPanel = panelSurfaces.find(surface => surface.open)
5702
+ // The Ctrl+O inspector is the one surface the composer already yields to
5703
+ // through verboseOpen; it rides the same gate without a panel row.
5704
+ const inspectorVisible = verboseOpen && !approvalPending && !questionPending
5705
+ const modalVisible = openPanel !== undefined || inspectorVisible || approvalPending || questionPending
5014
5706
  // While a deletion waits for y/n, the composer takes the keys (the resume
5015
5707
  // panel yields): the confirm is typed IN the input box, not as an invisible
5016
5708
  // panel keypress.
5017
5709
  const inputActive = deleteConfirmId !== undefined
5018
5710
  ? !approvalPending && !questionPending
5019
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5711
+ : !modalVisible
5712
+ const transcriptVisible = !modalVisible
5020
5713
 
5021
- // Human questions outrank local inspectors. Close the lower modal instead
5022
- // of leaving an approval/question visible but keyboard-locked behind it.
5714
+ // Human questions outrank local inspectors. Close every open surface instead
5715
+ // of leaving it visible but keyboard-locked behind the approval.
5023
5716
  useEffect(() => {
5024
5717
  if (!approvalPending && !questionPending) return
5025
- setModelOpen(false)
5026
- setProviderOpen(false)
5027
- setProviderAction(undefined)
5028
- setEffortFor(undefined)
5029
- setHelpOpen(false)
5030
- setModeOpen(false)
5031
- setPermissionOpen(false)
5032
- setResumeOpen(false)
5033
- setPluginOpen(false)
5034
- setUpdateOpen(false)
5035
- setScheduleOpen(false)
5036
- setStatuslineOpen(false)
5037
- setThemeOpen(false)
5038
- setHistoryOpen(false)
5039
- setAgentsOpen(false)
5040
- setSubagentOpen(false)
5041
- setTodosOpen(false)
5718
+ for (const surface of panelSurfacesRef.current) {
5719
+ if (surface.open) surface.close()
5720
+ }
5042
5721
  setDeleteConfirmId(undefined)
5043
5722
  setVerboseOpen(false)
5044
- setDiffView(undefined)
5045
5723
  }, [approvalPending, questionPending])
5046
5724
 
5047
5725
  // Append-only transcript: everything up to the first still-mutable entry
@@ -5085,6 +5763,7 @@ export function App(props: AppProps): ReactElement {
5085
5763
  // One pending synchronized frame covers a debounced resize or explicit
5086
5764
  // source-backed replay. It is closed after the corresponding React commit.
5087
5765
  const synchronizedReplayPending = useRef(false)
5766
+ const resizeBurstHeld = useRef(false)
5088
5767
  useEffect(() => {
5089
5768
  if (appStdout === undefined) return
5090
5769
  let replayTimer: ReturnType<typeof setTimeout> | undefined
@@ -5096,24 +5775,31 @@ export function App(props: AppProps): ReactElement {
5096
5775
  if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
5097
5776
  terminalSizeRef.current = next
5098
5777
 
5099
- // Ink 5 erases by the old logical line count. Once the terminal reflows
5100
- // a full-width border at a new width, that count is no longer enough and
5101
- // stale frames remain visible. Follow Codex's source-backed reflow
5102
- // policy: update live geometry immediately, but wait for the resize
5103
- // burst to settle before one hard reset and one transcript replay at the
5104
- // final width. Replaying Static on every event appends duplicate history.
5778
+ // Hold the visible frame for the whole burst so intermediate Ink
5779
+ // relayouts (new width against still-old Static rows) never flash as
5780
+ // doubled borders. One clear + Static remount still runs after the
5781
+ // burst settles.
5782
+ if (!resizeBurstHeld.current) {
5783
+ resizeBurstHeld.current = true
5784
+ appStdout.write(SYNCHRONIZED_UPDATE_BEGIN)
5785
+ }
5105
5786
  setTerminalSize(next)
5106
5787
  if (replayTimer !== undefined) clearTimeout(replayTimer)
5107
5788
  replayTimer = setTimeout(() => {
5108
5789
  synchronizedReplayPending.current = true
5109
- appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
5790
+ appStdout.write(RESIZE_REFLOW_CLEAR)
5110
5791
  setRefreshEpoch(epoch => epoch + 1)
5792
+ resizeBurstHeld.current = false
5111
5793
  }, RESIZE_REFLOW_DELAY_MS)
5112
5794
  }
5113
5795
  appStdout.on('resize', handleResize)
5114
5796
  return () => {
5115
5797
  appStdout.off('resize', handleResize)
5116
5798
  if (replayTimer !== undefined) clearTimeout(replayTimer)
5799
+ if (resizeBurstHeld.current) {
5800
+ appStdout.write(SYNCHRONIZED_UPDATE_END)
5801
+ resizeBurstHeld.current = false
5802
+ }
5117
5803
  }
5118
5804
  }, [appStdout])
5119
5805
  const terminalRows = terminalSize.rows
@@ -5146,14 +5832,19 @@ export function App(props: AppProps): ReactElement {
5146
5832
  }, [])
5147
5833
  const imeRowsBelowComposer = statusBarRows + 1
5148
5834
  const composerEditorCap = composerMaxRows(terminalRows)
5149
- // Bottom chrome is composer (2 borders + composerRows) + status (up to 2
5150
- // rows) + todo/agents/notice (3) = 8 resting rows, plus the historical
5151
- // 5-row menu reserve: small menus still fit without shrinking the live
5152
- // area (unchanged behavior), and menu rows beyond the reserve are budgeted
5153
- // exactly so the live/streaming area stays strictly below the terminal
5154
- // height as the editor or the menu grows.
5155
- const MENU_RESERVE_ROWS = 5
5156
- const dynamicRows = Math.max(1, terminalRows - 8 - MENU_RESERVE_ROWS - composerGutterRows - (composerRows - 1) - Math.max(0, menuRows - MENU_RESERVE_ROWS))
5835
+ // Pin the composer and status at the bottom: every extra chrome row
5836
+ // (completion menu, notice, todos, agents, extra editor/status rows)
5837
+ // covers live transcript instead of growing the tree.
5838
+ const dynamicRows = liveRegionBudget({
5839
+ terminalRows,
5840
+ composerRows,
5841
+ statusBarRows,
5842
+ menuRows,
5843
+ gutterRows: composerGutterRows,
5844
+ notice: notice !== undefined,
5845
+ todo: transcriptVisible && view.todos.length > 0,
5846
+ agents: transcriptVisible && agentRows.length > 0,
5847
+ })
5157
5848
  const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
5158
5849
  const deepDivingVisible = busy && !streamingActive
5159
5850
  // Terminal tab label: "deepseek" until the session carries a name, then the
@@ -5201,6 +5892,10 @@ export function App(props: AppProps): ReactElement {
5201
5892
  )
5202
5893
  if (liveAudit.warning !== undefined && budgetWarnRef.current !== liveAudit.warning) {
5203
5894
  budgetWarnRef.current = liveAudit.warning
5895
+ // The budget tripwire is a last-resort diagnostic: it fires only when the
5896
+ // live/static allocation already broke its contract, and Ink owns/shadows
5897
+ // console output. Everywhere else the TUI must never write to stdout.
5898
+ // eslint-disable-next-line no-console -- see the tripwire note above
5204
5899
  console.warn(`[dsh-code] ${liveAudit.warning}`)
5205
5900
  }
5206
5901
  const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length
@@ -5208,9 +5903,6 @@ export function App(props: AppProps): ReactElement {
5208
5903
  : visibleLiveLines.slice(-liveAudit.allocation.live)
5209
5904
  const auditedReasoningRows = liveAudit.allocation.reasoning
5210
5905
  const auditedAnswerRows = liveAudit.allocation.answer
5211
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5212
- const inspectorVisible = verboseOpen && !approvalPending && !questionPending
5213
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
5214
5906
  // The surface that currently owns the keyboard, named in the frozen band:
5215
5907
  // an empty composer under a panel must not advertise typing it cannot
5216
5908
  // accept — every key actually feeds the panel (which may or may not
@@ -5219,58 +5911,54 @@ export function App(props: AppProps): ReactElement {
5219
5911
  ? 'the approval prompt'
5220
5912
  : questionPending
5221
5913
  ? 'the question'
5222
- : diffView !== undefined
5223
- ? 'the diff review'
5224
- : modelOpen
5225
- ? '/model'
5226
- : helpOpen
5227
- ? '/help'
5228
- : modeOpen
5229
- ? '/mode'
5230
- : permissionOpen
5231
- ? '/permission'
5232
- : resumeOpen
5233
- ? '/resume'
5234
- : pluginOpen
5235
- ? '/plugin'
5236
- : updateOpen
5237
- ? '/update'
5238
- : scheduleOpen
5239
- ? '/schedule'
5240
- : jobsOpen
5241
- ? '/jobs'
5242
- : statuslineOpen
5243
- ? '/statusline'
5244
- : themeOpen
5245
- ? '/theme'
5246
- : historyOpen
5247
- ? '/history'
5248
- : agentsOpen
5249
- ? '/agents'
5250
- : subagentOpen
5251
- ? '/subagent'
5252
- : todosOpen
5253
- ? '/todos'
5254
- : inspectorVisible
5255
- ? 'history details'
5256
- : undefined
5914
+ : openPanel?.hint ?? (inspectorVisible ? 'history details' : undefined)
5915
+ // One way out of every panel, matching Esc: while a panel owns the keys,
5916
+ // Ctrl+C closes it instead of silently doing nothing. The composer keeps its
5917
+ // own three states (interrupt the turn / clear the draft / quit) whenever no
5918
+ // panel is open, and the approval and question bars keep theirs.
5919
+ useStableInput((input, key) => {
5920
+ if (!(key.ctrl && input === 'c')) return
5921
+ if (approvalPending || questionPending || deleteConfirmId !== undefined) return
5922
+ if (openPanel !== undefined) openPanel.close()
5923
+ else if (inspectorVisible) setVerboseOpen(false)
5924
+ }, true)
5257
5925
  const frozenHint = keyboardOwner === undefined
5258
5926
  ? undefined
5259
- : `keys go to ${keyboardOwner} · esc ${approvalPending ? 'rejects' : questionPending ? 'cancels' : 'closes'}`
5927
+ : t('frozen.keysGoTo', {
5928
+ owner: keyboardOwner,
5929
+ action: approvalPending
5930
+ ? t('frozen.action.rejects')
5931
+ : questionPending
5932
+ ? t('frozen.action.cancels')
5933
+ : updateApplying && updateOpen
5934
+ ? t('frozen.action.waits')
5935
+ : t('frozen.action.closes'),
5936
+ })
5260
5937
  const closeInspector = useCallback((): void => {
5261
5938
  setVerboseOpen(false)
5262
5939
  }, [])
5263
- const refreshScreen = (): void => {
5264
- // Same source-backed clear the resize path uses: reset the scroll region
5265
- // (`\x1b[r`) before wiping screen AND scrollback, then home the cursor.
5266
- // A bare `\x1b[2J\x1b[3J\x1b[H` leaves a previously set scroll region in
5267
- // place, so Ink's next repaint positions against stale bounds — the
5268
- // stale-position flicker where the screen keeps redrawing.
5940
+ const refreshScreen = useCallback((opts?: { wipeScrollback?: boolean }): void => {
5941
+ // Resize / Ctrl+L wipe screen AND scrollback. A history-cap trim remounts
5942
+ // Static at the current width, so native scrollback must stay the user
5943
+ // may be reading messages above the fold.
5944
+ const clear = opts?.wipeScrollback === false ? TRIM_REFLOW_CLEAR : RESIZE_REFLOW_CLEAR
5269
5945
  if (appStdout !== undefined) {
5270
5946
  synchronizedReplayPending.current = true
5271
- appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
5947
+ appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + clear)
5272
5948
  }
5273
5949
  setRefreshEpoch(epoch => epoch + 1)
5950
+ }, [appStdout])
5951
+ const applyRainbow = (seed?: number): void => {
5952
+ // Replace the memoized roll, then setTheme so getPalette() and the
5953
+ // painters pick the new values; persist rainbow as the active theme
5954
+ // so a mid-session /rainbow from dark/light actually sticks. The
5955
+ // source-backed rebuild (same as /theme) repaints Static history too.
5956
+ rerollRainbow(seed)
5957
+ setTheme('rainbow')
5958
+ props.saveTheme?.('rainbow')
5959
+ notify(t('notice.rainbowRolled', { seed: rainbowSeedLabel() }))
5960
+ fireRainbowBurst()
5961
+ refreshScreen()
5274
5962
  }
5275
5963
  useEffect(() => {
5276
5964
  if (!synchronizedReplayPending.current || appStdout === undefined) return
@@ -5288,8 +5976,8 @@ export function App(props: AppProps): ReactElement {
5288
5976
  const settledNeedsTrim = settledRowsCache.current?.needsTrim === true
5289
5977
  useEffect(() => {
5290
5978
  if (!settledNeedsTrim || busy || streamingActive) return
5291
- refreshScreen()
5292
- }, [settledNeedsTrim, busy, streamingActive])
5979
+ refreshScreen({ wipeScrollback: false })
5980
+ }, [busy, refreshScreen, settledNeedsTrim, streamingActive])
5293
5981
 
5294
5982
  const sessionHasImages = useMemo(() => view.entries.some(entry =>
5295
5983
  (entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
@@ -5302,16 +5990,16 @@ export function App(props: AppProps): ReactElement {
5302
5990
  setEffortLabel(effortId)
5303
5991
  const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
5304
5992
  if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
5305
- notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
5993
+ notify(t('notice.modelChangedPlaceholder', { model: selected }), 'warning')
5306
5994
  } else {
5307
- notify(`model next step uses ${selected}`)
5995
+ notify(t('notice.modelNextStep', { model: selected }))
5308
5996
  }
5309
5997
  setModelOpen(false)
5310
5998
  setProviderOpen(false)
5311
5999
  setProviderAction(undefined)
5312
6000
  setEffortFor(undefined)
5313
6001
  } catch (error: unknown) {
5314
- notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
6002
+ notify(t('notice.modelSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
5315
6003
  }
5316
6004
  }
5317
6005
 
@@ -5319,6 +6007,7 @@ export function App(props: AppProps): ReactElement {
5319
6007
  setModelLoadEpoch(epoch => epoch + 1)
5320
6008
  }
5321
6009
  const closeModelSurface = (): void => {
6010
+ effortLookupEpoch.current += 1
5322
6011
  setModelOpen(false)
5323
6012
  setProviderOpen(false)
5324
6013
  setProviderAction(undefined)
@@ -5332,7 +6021,7 @@ export function App(props: AppProps): ReactElement {
5332
6021
  const seen = new Set<string>()
5333
6022
  for (const row of providerDirectory?.rows ?? []) {
5334
6023
  for (const model of row.configuration.models) {
5335
- const raw = (model.extras as Record<string, unknown> | undefined)?.reasoningEfforts
6024
+ const raw = (model.extras)?.reasoningEfforts
5336
6025
  if (!isDeclaredReasoningEfforts(raw)) continue
5337
6026
  const key = row.provider + '/' + model.id
5338
6027
  if (seen.has(key)) continue
@@ -5370,7 +6059,7 @@ export function App(props: AppProps): ReactElement {
5370
6059
  setProviderAction(undefined)
5371
6060
  setProviderOpen(false)
5372
6061
  reloadModelSurfaces()
5373
- notify(`logged in to ${authorization.label}; select a model`)
6062
+ notify(t('notice.loggedIn', { provider: authorization.label }))
5374
6063
  },
5375
6064
  back: () => {
5376
6065
  setProviderAction(undefined)
@@ -5386,7 +6075,7 @@ export function App(props: AppProps): ReactElement {
5386
6075
  setProviderAction(undefined)
5387
6076
  setProviderOpen(true)
5388
6077
  reloadModelSurfaces()
5389
- notify(`logged out from ${authorization.label}`)
6078
+ notify(t('notice.loggedOut', { provider: authorization.label }))
5390
6079
  },
5391
6080
  back: () => setProviderAction(undefined),
5392
6081
  })
@@ -5397,13 +6086,13 @@ export function App(props: AppProps): ReactElement {
5397
6086
  save: props.saveModelProviderConfiguration,
5398
6087
  saveCredential: props.saveModelProviderCredential,
5399
6088
  discover: props.discoverModelProvider
5400
- ?? (async () => { throw new Error('model discovery is unavailable in this profile; enter models by hand') }),
6089
+ ?? (() => Promise.reject(new Error('model discovery is unavailable in this profile; enter models by hand'))),
5401
6090
  done: result => {
5402
6091
  const target = providerAction.target
5403
6092
  setProviderAction(undefined)
5404
6093
  setProviderOpen(true)
5405
6094
  reloadModelSurfaces()
5406
- notify(`provider configuration saved: ${target.displayName}` + (result.key ? ' · API key updated' : ''))
6095
+ notify(t('notice.providerSaved', { provider: target.displayName, suffix: result.key ? ' · API key updated' : '' }))
5407
6096
  },
5408
6097
  back: () => setProviderAction(undefined),
5409
6098
  onExit: closeModelSurface,
@@ -5418,7 +6107,7 @@ export function App(props: AppProps): ReactElement {
5418
6107
  setProviderAction(undefined)
5419
6108
  setProviderOpen(true)
5420
6109
  reloadModelSurfaces()
5421
- notify(`API key removed for ${target.displayName}`)
6110
+ notify(t('notice.apiKeyRemoved', { provider: target.displayName }))
5422
6111
  },
5423
6112
  back: () => setProviderAction(undefined),
5424
6113
  })
@@ -5432,7 +6121,7 @@ export function App(props: AppProps): ReactElement {
5432
6121
  setProviderAction(undefined)
5433
6122
  setProviderOpen(true)
5434
6123
  reloadModelSurfaces()
5435
- notify(`provider removed: ${target.displayName}`)
6124
+ notify(t('notice.providerRemoved', { provider: target.displayName }))
5436
6125
  },
5437
6126
  back: () => setProviderAction(undefined),
5438
6127
  })
@@ -5444,42 +6133,42 @@ export function App(props: AppProps): ReactElement {
5444
6133
  authorizationError,
5445
6134
  onConfigure: (target: ProviderTargetView) => {
5446
6135
  if (props.saveModelProviderConfiguration === undefined) {
5447
- notify('provider configuration is unavailable in this profile', 'warning')
6136
+ notify(t('notice.providerUnavailable'), 'warning')
5448
6137
  return
5449
6138
  }
5450
6139
  setProviderAction({ kind: 'configure', target })
5451
6140
  },
5452
6141
  onUnset: (target: ProviderTargetView) => {
5453
6142
  if (props.unsetModelProviderCredential === undefined) {
5454
- notify('API key removal is unavailable in this profile', 'warning')
6143
+ notify(t('notice.apiKeyUnavailable'), 'warning')
5455
6144
  return
5456
6145
  }
5457
6146
  setProviderAction({ kind: 'unset', target })
5458
6147
  },
5459
6148
  onRemove: (target: ProviderTargetView) => {
5460
6149
  if (props.removeModelProvider === undefined) {
5461
- notify('provider removal is unavailable in this profile', 'warning')
6150
+ notify(t('notice.providerRemovalUnavailable'), 'warning')
5462
6151
  return
5463
6152
  }
5464
6153
  setProviderAction({ kind: 'remove', target })
5465
6154
  },
5466
6155
  onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
5467
6156
  if (busy) {
5468
- notify('provider login is available only while the agent is idle', 'warning')
6157
+ notify(t('notice.loginIdleOnly'), 'warning')
5469
6158
  return
5470
6159
  }
5471
6160
  if (props.beginProviderAuthorization === undefined
5472
6161
  || props.cancelProviderAuthorization === undefined
5473
6162
  || props.openAuthorizationUrl === undefined
5474
6163
  || props.copyTextValue === undefined) {
5475
- notify('provider login is unavailable in this profile', 'warning')
6164
+ notify(t('notice.loginUnavailable'), 'warning')
5476
6165
  return
5477
6166
  }
5478
6167
  setProviderAction({ kind: 'login', target, authorization })
5479
6168
  },
5480
6169
  onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
5481
6170
  if (props.logoutProviderAuthorization === undefined) {
5482
- notify('provider logout is unavailable in this profile', 'warning')
6171
+ notify(t('notice.logoutUnavailable'), 'warning')
5483
6172
  return
5484
6173
  }
5485
6174
  setProviderAction({ kind: 'logout', target, authorization })
@@ -5512,7 +6201,7 @@ export function App(props: AppProps): ReactElement {
5512
6201
  setEffortFor(row)
5513
6202
  return
5514
6203
  }
5515
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
6204
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined
5516
6205
  applyModel(row, effortId)
5517
6206
  },
5518
6207
  ...(props.loadModelProviders === undefined || props.saveModelProviderConfiguration === undefined
@@ -5547,6 +6236,7 @@ export function App(props: AppProps): ReactElement {
5547
6236
  continuationPrefix: ' ',
5548
6237
  dim: true,
5549
6238
  maxRows: auditedReasoningRows,
6239
+ columns: Math.max(1, terminalColumns - 2),
5550
6240
  })
5551
6241
  // The collapsed marker shimmers only while reasoning streams
5552
6242
  // alone: once answer text flows, a periodically re-rendered
@@ -5563,6 +6253,7 @@ export function App(props: AppProps): ReactElement {
5563
6253
  continuationPrefix: ' ',
5564
6254
  dim: true,
5565
6255
  maxRows: auditedReasoningRows,
6256
+ columns: Math.max(1, terminalColumns - 2),
5566
6257
  })
5567
6258
  : undefined,
5568
6259
  view.streaming !== '' && auditedAnswerRows > 0
@@ -5570,7 +6261,7 @@ export function App(props: AppProps): ReactElement {
5570
6261
  StreamTail,
5571
6262
  // The same two-column gutter as settled replies: streamed text
5572
6263
  // lands exactly where the assembled message will render.
5573
- { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
6264
+ { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ', columns: Math.max(1, terminalColumns - 2) },
5574
6265
  busy ? createElement(Caret, { animated: animations }) : undefined,
5575
6266
  )
5576
6267
  : undefined,
@@ -5579,6 +6270,13 @@ export function App(props: AppProps): ReactElement {
5579
6270
  : undefined,
5580
6271
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
5581
6272
  transcriptVisible ? createElement(AgentsLine, { rows: agentRows, total: props.subagents.getTotalSeen() }) : undefined,
6273
+ usageOpen && !approvalPending && !questionPending
6274
+ ? createElement(UsagePanel, {
6275
+ key: props.sessionKey,
6276
+ load: props.loadUsage,
6277
+ close: () => setUsageOpen(false),
6278
+ })
6279
+ : undefined,
5582
6280
  todosOpen && !approvalPending && !questionPending
5583
6281
  ? createElement(MemoTodoListPanel, {
5584
6282
  todos: view.todos,
@@ -5587,6 +6285,14 @@ export function App(props: AppProps): ReactElement {
5587
6285
  },
5588
6286
  })
5589
6287
  : undefined,
6288
+ queueOpen && !approvalPending && !questionPending
6289
+ ? createElement(QueuePanel, {
6290
+ rows: queuedRows,
6291
+ busy,
6292
+ update: props.updateQueued,
6293
+ onClose: () => setQueueOpen(false),
6294
+ })
6295
+ : undefined,
5590
6296
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
5591
6297
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify, interrupt: props.interrupt, summarize: questionPending }),
5592
6298
  modelSurface,
@@ -5607,10 +6313,23 @@ export function App(props: AppProps): ReactElement {
5607
6313
  onClose: () => setDiffView(undefined),
5608
6314
  })
5609
6315
  : undefined,
6316
+ reviewPickerOpen && !approvalPending && !questionPending && props.listReviewBranches !== undefined && props.listReviewCommits !== undefined
6317
+ ? createElement(ReviewPickerPanel, {
6318
+ loadBranches: props.listReviewBranches,
6319
+ loadCommits: props.listReviewCommits,
6320
+ choose: argument => {
6321
+ setReviewPickerOpen(false)
6322
+ props.reviewChanges(argument)
6323
+ },
6324
+ close: () => setReviewPickerOpen(false),
6325
+ })
6326
+ : undefined,
5610
6327
  verboseOpen && !approvalPending && !questionPending
5611
6328
  ? createElement(MemoVerbosePanel, {
5612
6329
  entries: view.entries,
5613
6330
  onClose: closeInspector,
6331
+ columns: terminalColumns,
6332
+ rows: terminalRows,
5614
6333
  })
5615
6334
  : undefined,
5616
6335
  modeOpen && !approvalPending && !questionPending
@@ -5619,9 +6338,9 @@ export function App(props: AppProps): ReactElement {
5619
6338
  load: props.loadPresets,
5620
6339
  select: (id: string) => {
5621
6340
  void props.switchMode(id).then(label => {
5622
- notify(`mode ${label}`)
6341
+ notify(t('notice.modeChangedSimple', { value: label }))
5623
6342
  setModeOpen(false)
5624
- }, (reason: unknown) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error'))
6343
+ }, (reason: unknown) => notify(t('notice.modeSwitchFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error'))
5625
6344
  },
5626
6345
  close: () => setModeOpen(false),
5627
6346
  })
@@ -5633,7 +6352,7 @@ export function App(props: AppProps): ReactElement {
5633
6352
  select: (id: string) => {
5634
6353
  try {
5635
6354
  const selected = props.setPermission(id)
5636
- notify(`permission ${selected}`)
6355
+ notify(t('notice.permissionChangedSimple', { value: selected }))
5637
6356
  setPermissionOpen(false)
5638
6357
  } catch (reason: unknown) {
5639
6358
  notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
@@ -5647,14 +6366,43 @@ export function App(props: AppProps): ReactElement {
5647
6366
  currentCwd: props.workspaceRoot,
5648
6367
  load: props.loadSessions,
5649
6368
  readTranscript: props.loadSessionTranscript,
5650
- requestDelete,
6369
+ requestDelete: resumeDelete.mode ? requestDelete : undefined,
5651
6370
  deleteConfirmId,
5652
6371
  reloadToken: deleteReloadToken,
5653
6372
  deleteMode: resumeDelete.mode,
5654
- select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
6373
+ presetId: resumeDelete.id,
6374
+ select: (row: SessionRow) => {
6375
+ // Defense in depth: the dedicated delete picker must never turn a
6376
+ // selection into a session switch, even if its key routing regresses.
6377
+ if (resumeDelete.mode) return
6378
+ props.switchSession(row)
6379
+ setResumeOpen(false)
6380
+ },
5655
6381
  close: () => setResumeOpen(false),
5656
6382
  })
5657
6383
  : undefined,
6384
+ searchOpen && !approvalPending && !questionPending && props.searchSessions !== undefined
6385
+ ? createElement(SearchPanel, {
6386
+ load: props.searchSessions,
6387
+ initialQuery: searchSeed,
6388
+ select: (row: SearchRow) => {
6389
+ setSearchOpen(false)
6390
+ props.switchSession({
6391
+ id: row.id,
6392
+ createdAt: row.updatedAt,
6393
+ updatedAt: row.updatedAt,
6394
+ cwd: '',
6395
+ workspace: '',
6396
+ subagent: row.subagent,
6397
+ resumable: row.resumable,
6398
+ live: false,
6399
+ persisted: true,
6400
+ preset: '',
6401
+ })
6402
+ },
6403
+ close: () => setSearchOpen(false),
6404
+ })
6405
+ : undefined,
5658
6406
  pluginOpen && !approvalPending && !questionPending
5659
6407
  ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
5660
6408
  : undefined,
@@ -5691,12 +6439,36 @@ export function App(props: AppProps): ReactElement {
5691
6439
  // palette. `auto` stores as requested; detection is a later step.
5692
6440
  setTheme(name)
5693
6441
  props.saveTheme?.(name)
5694
- notify(`theme ${name}`)
6442
+ // Rainbow prints its roll seed so a lucky launch can be reproduced
6443
+ // with RAINBOW_SEED=<seed>.
6444
+ notify(name === 'rainbow'
6445
+ ? t('notice.themeRainbow', { seed: rainbowSeedLabel() })
6446
+ : t('notice.themeSaved', { name }))
5695
6447
  setThemeOpen(false)
6448
+ // The header whale and settled history live in the Static region,
6449
+ // which renders once and would keep the old palette's colors; the
6450
+ // same source-backed rebuild resize and Ctrl+L use repaints the
6451
+ // whole screen (scrollback included) from the new palette.
6452
+ if (name === 'rainbow') fireRainbowBurst()
6453
+ refreshScreen()
5696
6454
  },
5697
6455
  close: () => setThemeOpen(false),
5698
6456
  })
5699
6457
  : undefined,
6458
+ languageOpen && !approvalPending && !questionPending
6459
+ ? createElement(LanguagePanel, {
6460
+ current: getLanguage(),
6461
+ select: (name: LanguageName) => {
6462
+ props.saveLanguage(name)
6463
+ notify(t('notice.languageSaved', { name }))
6464
+ setLanguageOpen(false)
6465
+ // The Static region renders once; the same source-backed rebuild
6466
+ // the theme switch uses repaints translated text everywhere.
6467
+ refreshScreen()
6468
+ },
6469
+ close: () => setLanguageOpen(false),
6470
+ })
6471
+ : undefined,
5700
6472
  historyOpen && !approvalPending && !questionPending
5701
6473
  ? createElement(HistoryPanel, {
5702
6474
  entries: recallSpace,
@@ -5724,15 +6496,15 @@ export function App(props: AppProps): ReactElement {
5724
6496
  // The runner's label already carries the effort suffix
5725
6497
  // (`provider/model@effort`), so no second append here.
5726
6498
  const label = props.setSubagentModel(row, effortId)
5727
- notify(`subagents ${label}`)
6499
+ notify(t('notice.subagentsChanged', { value: label }))
5728
6500
  setSubagentOpen(false)
5729
6501
  } catch (reason: unknown) {
5730
- notify(`subagent model change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
6502
+ notify(t('notice.subagentChangeFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
5731
6503
  }
5732
6504
  },
5733
6505
  inherit: () => {
5734
6506
  props.clearSubagentModel()
5735
- notify('subagents → inherit current model')
6507
+ notify(t('notice.subagentsInherited'))
5736
6508
  setSubagentOpen(false)
5737
6509
  },
5738
6510
  close: () => setSubagentOpen(false),
@@ -5759,11 +6531,18 @@ export function App(props: AppProps): ReactElement {
5759
6531
  descriptors,
5760
6532
  skills,
5761
6533
  dispatch: props.dispatch,
5762
- applyEditorKeys: props.applyEditorKeys,
5763
6534
  steer: props.steer,
6535
+ submitMode,
6536
+ cycleSubmitMode: () => {
6537
+ const next = submitMode === 'queue' ? 'steer' : 'queue'
6538
+ setSubmitMode(next)
6539
+ notify(t(next === 'steer' ? 'notice.submitMode.steer' : 'notice.submitMode.queue'))
6540
+ },
6541
+ applyEditorKeys: props.applyEditorKeys,
5764
6542
  interrupt: props.interrupt,
5765
6543
  quit: props.quit,
5766
6544
  openModel: () => {
6545
+ effortLookupEpoch.current += 1
5767
6546
  setDirectory(undefined)
5768
6547
  setModelError(undefined)
5769
6548
  setProviderDirectory(undefined)
@@ -5786,18 +6565,20 @@ export function App(props: AppProps): ReactElement {
5786
6565
  // never as "the model has no efforts" — the adapter advertises
5787
6566
  // levels for every deepseek model, so "no efforts" is almost
5788
6567
  // always a failed resolveModelInfo, not a fact.
6568
+ const epoch = ++effortLookupEpoch.current
5789
6569
  void props.loadModels().then((loaded) => {
6570
+ if (epoch !== effortLookupEpoch.current) return
5790
6571
  const [provider, model] = modelLabel.split('/')
5791
6572
  const row = loaded.rows.find(candidate => candidate.provider === provider && candidate.model === model)
5792
6573
  ?? loaded.rows.find(candidate => candidate.model === model && candidate.reasoning !== undefined)
5793
6574
  ?? loaded.rows.find(candidate => candidate.model === model)
5794
6575
  if (row === undefined) {
5795
- notify('current model is not in the catalog', 'warning')
6576
+ notify(t('notice.modelMissing'), 'warning')
5796
6577
  return
5797
6578
  }
5798
6579
  const rowTag = `${row.provider}/${row.model}`
5799
6580
  if (loaded.reasoningFailures?.includes(rowTag) === true) {
5800
- notify('reasoning levels temporarily unavailable (capability lookup failed) — try again', 'warning')
6581
+ notify(t('notice.effortUnavailable'), 'warning')
5801
6582
  return
5802
6583
  }
5803
6584
  if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
@@ -5821,20 +6602,34 @@ export function App(props: AppProps): ReactElement {
5821
6602
  openMode: () => setModeOpen(true),
5822
6603
  openPermission: () => setPermissionOpen(true),
5823
6604
  openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
6605
+ openSearch: (query: string) => {
6606
+ if (props.searchSessions === undefined) {
6607
+ notify(t('notice.sessionSearchUnavailable'), 'warning')
6608
+ return
6609
+ }
6610
+ setSearchSeed(query)
6611
+ setSearchOpen(true)
6612
+ },
5824
6613
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
5825
6614
  openUpdate: () => setUpdateOpen(true),
5826
6615
  openSchedule: () => setScheduleOpen(true),
5827
6616
  openJobs: () => setJobsOpen(true),
5828
6617
  openStatusline: () => setStatuslineOpen(true),
5829
6618
  openTheme: () => setThemeOpen(true),
6619
+ openLanguage: () => setLanguageOpen(true),
6620
+ saveLanguage: props.saveLanguage,
5830
6621
  openHistory: () => setHistoryOpen(true),
6622
+ openQueue: () => setQueueOpen(true),
5831
6623
  openAgents: () => setAgentsOpen(true),
5832
6624
  openSubagent: () => setSubagentOpen(true),
5833
6625
  openTodos: () => setTodosOpen(true),
6626
+ openUsage: () => setUsageOpen(true),
5834
6627
  openDelete: (id?: string) => {
5835
6628
  const armed = id === undefined || id === '' ? undefined : id
5836
6629
  setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
5837
- setDeleteConfirmId(armed)
6630
+ // Confirm after the picker resolves the id against the listing —
6631
+ // the argument may be a suffix of the displayed id, not the row key.
6632
+ setDeleteConfirmId(undefined)
5838
6633
  setResumeOpen(true)
5839
6634
  },
5840
6635
  openDiff: (argument: string) => {
@@ -5843,6 +6638,7 @@ export function App(props: AppProps): ReactElement {
5843
6638
  })
5844
6639
  },
5845
6640
  reviewChanges: props.reviewChanges,
6641
+ openReviewPicker: () => setReviewPickerOpen(true),
5846
6642
  deleteConfirm: deleteConfirmId,
5847
6643
  confirmDelete,
5848
6644
  cancelDelete,
@@ -5886,11 +6682,13 @@ export function App(props: AppProps): ReactElement {
5886
6682
  recordLocal,
5887
6683
  recordHistory: props.recordHistory,
5888
6684
  queued: queuedRows,
5889
- cancelQueued: props.cancelQueued,
6685
+ updateQueued: props.updateQueued,
5890
6686
  historyFill,
5891
6687
  historyConsumed,
5892
6688
  animations,
5893
6689
  applyAnimations,
6690
+ applyRainbow,
6691
+ rainbowBurstId,
5894
6692
  waveTier,
5895
6693
  waveStyle,
5896
6694
  maxRows: composerEditorCap,
@@ -5914,6 +6712,7 @@ export function App(props: AppProps): ReactElement {
5914
6712
  },
5915
6713
  stats: view.stats,
5916
6714
  busy,
6715
+ animated: animations,
5917
6716
  columns: terminalColumns,
5918
6717
  items: statuslineItems,
5919
6718
  onRows: handleStatusRows,