dsh-code 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +4063 -844
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +100 -63
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +73 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +86 -31
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/inspector.d.ts +26 -0
  25. package/lib/types/render/lines.d.ts +21 -1
  26. package/lib/types/render/markdown.d.ts +1 -1
  27. package/lib/types/render/projection.d.ts +95 -4
  28. package/lib/types/render/status.d.ts +8 -8
  29. package/lib/types/render/text.d.ts +6 -0
  30. package/lib/types/render/usage.d.ts +113 -0
  31. package/lib/types/session-directory.d.ts +17 -0
  32. package/lib/types/startup.d.ts +1 -1
  33. package/lib/types/terminal-title.d.ts +8 -0
  34. package/lib/types/theme-panel.d.ts +2 -2
  35. package/lib/types/theme.d.ts +271 -52
  36. package/lib/types/update-panel.d.ts +5 -5
  37. package/lib/types/update.d.ts +10 -1
  38. package/lib/types/version.d.ts +4 -3
  39. package/package.json +24 -7
  40. package/src/app.ts +1155 -478
  41. package/src/approval.ts +166 -166
  42. package/src/authorization-panel.ts +19 -16
  43. package/src/editor-keys.ts +371 -371
  44. package/src/git-workflow.ts +229 -3
  45. package/src/i18n.ts +68 -0
  46. package/src/index.ts +412 -76
  47. package/src/input-split.ts +3 -3
  48. package/src/kernel-panels.ts +471 -89
  49. package/src/keyboard.ts +5 -4
  50. package/src/language-panel.ts +53 -0
  51. package/src/locales/en.ts +489 -0
  52. package/src/locales/zh.ts +488 -0
  53. package/src/mentions.ts +8 -4
  54. package/src/models.ts +264 -212
  55. package/src/panel-accent.ts +41 -0
  56. package/src/presets.ts +1 -1
  57. package/src/provider-settings.ts +1 -1
  58. package/src/rainbow.ts +208 -0
  59. package/src/render/animations.ts +104 -6
  60. package/src/render/editor.ts +20 -20
  61. package/src/render/export.ts +116 -95
  62. package/src/render/inspector.ts +42 -0
  63. package/src/render/lines.ts +628 -415
  64. package/src/render/markdown.ts +15 -3
  65. package/src/render/projection.ts +429 -19
  66. package/src/render/status.ts +41 -35
  67. package/src/render/text.ts +14 -0
  68. package/src/render/tool-preview.ts +77 -77
  69. package/src/render/usage.ts +430 -0
  70. package/src/render/width.ts +2 -2
  71. package/src/session-directory.ts +8 -6
  72. package/src/session-query.ts +8 -4
  73. package/src/startup.ts +3 -3
  74. package/src/subagents.ts +229 -229
  75. package/src/terminal-title.ts +22 -5
  76. package/src/theme-panel.ts +17 -21
  77. package/src/theme.ts +281 -33
  78. package/src/update-panel.ts +37 -27
  79. package/src/update.ts +19 -3
  80. package/src/version.ts +58 -20
  81. package/src/whale-glyph.ts +23 -23
package/src/app.ts CHANGED
@@ -26,14 +26,25 @@ 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'
46
+ import { LanguagePanel } from './language-panel.ts'
47
+ import { getLanguage, parseLanguageName, t, type LanguageName, type MessageKey } from './i18n.ts'
37
48
  import { UpdatePanel } from './update-panel.ts'
38
49
  import type { LauncherUpdateStatus } from './update.ts'
39
50
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.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'
@@ -97,7 +114,7 @@ import {
97
114
  type RecallState,
98
115
  } from './history.ts'
99
116
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
100
- import type { GitDiffView } from './git-workflow.ts'
117
+ import { parseReviewArgument, type GitDiffView, type ReviewBranch, type ReviewCommit, type ReviewSelection } from './git-workflow.ts'
101
118
  import {
102
119
  authorizationForProvider,
103
120
  providerAuthorizationStatus,
@@ -148,7 +165,7 @@ const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l'
148
165
  import {
149
166
  layoutStatusBar,
150
167
  parseStatuslineItems,
151
- STATUS_CYCLE_HINT,
168
+ statusCycleHint,
152
169
  STATUS_GROUP_SEPARATOR,
153
170
  STATUS_ITEM_SEPARATOR,
154
171
  STATUS_ROW2_INDENT,
@@ -175,6 +192,7 @@ import {
175
192
  followInspectorCursor,
176
193
  inspectorViewport,
177
194
  layoutGutterRows,
195
+ liveRegionBudget,
178
196
  moveScroll,
179
197
  panelViewport,
180
198
  revealRow,
@@ -182,6 +200,8 @@ import {
182
200
  } from './render/inspector.ts'
183
201
  import {
184
202
  clampLiveAllocation,
203
+ diffLineStyle,
204
+ fillDiffLineBars,
185
205
  lineSegment,
186
206
  markdownLines,
187
207
  settledEntryLines,
@@ -224,39 +244,53 @@ import {
224
244
  export type NoticeTone = 'info' | 'warning' | 'error'
225
245
 
226
246
  /** 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' },
247
+ /** One TUI-owned slash command: label plus its i18n description key. */
248
+ interface LocalCommand { readonly label: string; readonly descriptionKey: MessageKey }
249
+
250
+ const LOCAL_COMMANDS: readonly LocalCommand[] = [
251
+ { label: '/help', descriptionKey: 'cmd.help' },
252
+ { label: '/model', descriptionKey: 'cmd.model' },
253
+ { label: '/effort', descriptionKey: 'cmd.effort' },
254
+ { label: '/mode', descriptionKey: 'cmd.mode' },
255
+ { label: '/permission', descriptionKey: 'cmd.permission' },
256
+ { label: '/new', descriptionKey: 'cmd.new' },
257
+ { label: '/fork', descriptionKey: 'cmd.fork' },
258
+ { label: '/resume', descriptionKey: 'cmd.resume' },
259
+ { label: '/search', descriptionKey: 'cmd.search' },
260
+ { label: '/plugin', descriptionKey: 'cmd.plugin' },
261
+ { label: '/update', descriptionKey: 'cmd.update' },
262
+ { label: '/jobs', descriptionKey: 'cmd.jobs' },
263
+ { label: '/schedule', descriptionKey: 'cmd.schedule' },
264
+ { label: '/statusline', descriptionKey: 'cmd.statusline' },
265
+ { label: '/theme', descriptionKey: 'cmd.theme' },
266
+ { label: '/language', descriptionKey: 'cmd.language' },
267
+ { label: '/rainbow', descriptionKey: 'cmd.rainbow' },
268
+ { label: '/animation', descriptionKey: 'cmd.animation' },
269
+ { label: '/history', descriptionKey: 'cmd.history' },
270
+ { label: '/queue', descriptionKey: 'cmd.queue' },
271
+ { label: '/usage', descriptionKey: 'cmd.usage' },
272
+ { label: '/agents', descriptionKey: 'cmd.agents' },
273
+ { label: '/todos', descriptionKey: 'cmd.todos' },
274
+ { label: '/subagent', descriptionKey: 'cmd.subagent' },
275
+ { label: '/vscode-keys', descriptionKey: 'cmd.vscode-keys' },
276
+ { label: '/delete', descriptionKey: 'cmd.delete' },
277
+ { label: '/clear', descriptionKey: 'cmd.clear' },
278
+ { label: '/export', descriptionKey: 'cmd.export' },
279
+ { label: '/title', descriptionKey: 'cmd.title' },
280
+ { label: '/copy', descriptionKey: 'cmd.copy' },
281
+ { label: '/diff', descriptionKey: 'cmd.diff' },
282
+ { label: '/review', descriptionKey: 'cmd.review' },
283
+ { label: '/quit', descriptionKey: 'cmd.quit' },
256
284
  ] as const
257
285
 
258
286
  const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
259
287
 
288
+ /** One mutation the terminal may request for a pending next-turn inbox item. */
289
+ export type QueueMutation =
290
+ | { readonly kind: 'remove' }
291
+ | { readonly kind: 'edit'; readonly text: string }
292
+ | { readonly kind: 'steer' }
293
+
260
294
  /** Props the runner hands the app; callbacks stay owned by the runner. */
261
295
  export interface AppProps {
262
296
  /** Event-fed transcript store for the live session. */
@@ -295,133 +329,151 @@ export interface AppProps {
295
329
  * an attachment prepare resolves after the app remounted onto another
296
330
  * session, and the runner drops the stale delivery then.
297
331
  */
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
332
+ dispatch: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
333
+ /**
334
+ * Submit one line as steering: it joins the turn already running at its next
335
+ * step boundary instead of waiting for the next turn. Same stale-delivery
336
+ * guard as {@link dispatch}.
337
+ */
338
+ steer: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
301
339
  /**
302
340
  * The FULL current session identity ('' while the first session is pending)
303
341
  * — the stale-delivery origin above. Distinct from the short display id.
304
342
  */
305
343
  sessionKey: string
306
344
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
307
- interrupt(): boolean
345
+ interrupt: () => boolean
308
346
  /** Quit: unmount, flush, and request process exit. */
309
- quit(): void
347
+ quit: () => void
310
348
  /** Load the selectable model directory (called when /model opens). */
311
- loadModels(): Promise<ModelDirectory>
349
+ loadModels: () => Promise<ModelDirectory>
312
350
  /** Load @mention candidates for the typed query (files + sessions). */
313
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
351
+ loadMentions: (query: string, signal?: AbortSignal) => Promise<readonly MentionCandidate[]>
314
352
  /** Validate draft image paths without committing attachment objects. */
315
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
353
+ inspectImages: (paths: readonly string[]) => Promise<readonly ImagePathInspection[]>
316
354
  /** Validate, normalize and persist images immediately before submission. */
317
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
355
+ prepareImages: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly ImageBlock[]>
318
356
  /** Validate draft non-image file paths without committing attachment objects. */
319
- inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
357
+ inspectFiles: (paths: readonly string[]) => Promise<readonly FilePathInspection[]>
320
358
  /** Persist non-image files immediately before submission as durable file blocks. */
321
- prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
359
+ prepareFiles: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly FileBlock[]>
322
360
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
323
- selectModel(row: ModelRow, effortId?: string): string
361
+ selectModel: (row: ModelRow, effortId?: string) => string
324
362
  /** The /subagent override label, '' when delegated agents follow the current model. */
325
363
  subagentModel: string
326
364
  /** Apply one /subagent model pick; returns the override label. */
327
- setSubagentModel(row: ModelRow, effortId?: string): string
365
+ setSubagentModel: (row: ModelRow, effortId?: string) => string
328
366
  /** Drop the /subagent override (delegated agents follow the current model). */
329
- clearSubagentModel(): void
367
+ clearSubagentModel: () => void
330
368
  /** Delete one session subtree; resolves with the outcome line. */
331
- deleteSession(id: string): Promise<string>
369
+ deleteSession: (id: string) => Promise<string>
332
370
  /** Load provider/settings/credential facts for the optional /model provider stage. */
333
- loadModelProviders?(): Promise<ProviderSettingsDirectory>
371
+ loadModelProviders?: () => Promise<ProviderSettingsDirectory>
334
372
  /** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
335
- subscribeModelProviders?(listener: () => void): () => void
373
+ subscribeModelProviders?: (listener: () => void) => () => void
336
374
  /** Store or rotate one provider credential through the Harness credential service. */
337
- saveModelProviderCredential?(target: ProviderTargetView, key: string): Promise<void>
375
+ saveModelProviderCredential?: (target: ProviderTargetView, key: string) => Promise<void>
338
376
  /** Remove one writable provider credential without removing its settings profile. */
339
- unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
377
+ unsetModelProviderCredential?: (target: ProviderTargetView) => Promise<void>
340
378
  /** Remove one user-owned provider profile and its page-managed credential. */
341
- removeModelProvider?(target: ProviderTargetView): Promise<void>
379
+ removeModelProvider?: (target: ProviderTargetView) => Promise<void>
342
380
  /** Save endpoint and explicit model capacities through the provider profile. */
343
- saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
381
+ saveModelProviderConfiguration?: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
344
382
  /**
345
383
  * Interrogate the provider's real endpoint (typed key wins over the stored
346
384
  * credential) for the models it actually serves — the discovery stage of
347
385
  * the provider setup page.
348
386
  */
349
- discoverModelProvider?(
387
+ discoverModelProvider?: (
350
388
  target: ProviderTargetView,
351
389
  request: { readonly apiKey?: string; readonly baseURL?: string },
352
390
  signal?: AbortSignal,
353
- ): Promise<readonly DiscoveredModelView[]>
391
+ ) => Promise<readonly DiscoveredModelView[]>
354
392
  /** Provider authorization flows and value-free stored-record facts. */
355
- loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
356
- subscribeProviderAuthorizations?(listener: () => void): () => void
357
- beginProviderAuthorization?(
393
+ loadProviderAuthorizations?: () => Promise<ProviderAuthorizationDirectory>
394
+ subscribeProviderAuthorizations?: (listener: () => void) => () => void
395
+ beginProviderAuthorization?: (
358
396
  row: ProviderAuthorizationRow,
359
397
  method: string,
360
398
  interaction: AuthorizationInteraction,
361
399
  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>
400
+ ) => Promise<AuthorizationStatus>
401
+ cancelProviderAuthorization?: (row: ProviderAuthorizationRow) => void
402
+ logoutProviderAuthorization?: (row: ProviderAuthorizationRow) => Promise<void>
403
+ openAuthorizationUrl?: (url: string) => boolean
404
+ copyTextValue?: (text: string) => Promise<void>
367
405
  /** Cycle to the next mode station (Shift+Tab): a permission preset or a plan switch; returns the notice label. */
368
- cycleMode(): string
406
+ cycleMode: () => string
369
407
  /** Pre-session plan choice: shows the plan badge before the first session exists. */
370
408
  pendingPlan?: boolean
371
409
  /** Select or inspect a permission preset without requiring a pre-existing session. */
372
- setPermission(id: string): string
410
+ setPermission: (id: string) => string
373
411
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
374
- exportTranscript(argument: string): Promise<void>
412
+ exportTranscript: (argument: string) => Promise<void>
375
413
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
376
- renameTitle(argument: string): string
414
+ renameTitle: (argument: string) => string
377
415
  /** Copy the latest complete assistant response; resolves to notice text. */
378
- copyLastResponse(): Promise<string>
416
+ copyLastResponse: () => Promise<string>
379
417
  /** Load a complete read-only Git diff for the file-oriented viewport. */
380
- loadGitDiff(argument: string): Promise<GitDiffView>
418
+ loadGitDiff: (argument: string) => Promise<GitDiffView>
419
+ /** Local branches for the /review picker (absent: the picker hides the branch phase's list). */
420
+ listReviewBranches?: (signal?: AbortSignal) => Promise<readonly ReviewBranch[]>
421
+ /** Recent commits on the current branch for the /review picker. */
422
+ listReviewCommits?: (signal?: AbortSignal) => Promise<readonly ReviewCommit[]>
381
423
  /** Start a model review after applying the read-only permission preset. */
382
- reviewChanges(argument: string): void
424
+ reviewChanges: (selection: ReviewSelection) => void
383
425
  /** Preset/session/plugin kernel operations. */
384
- loadPresets(): Promise<readonly PresetRow[]>
385
- switchMode(id: string): Promise<string>
426
+ loadPresets: () => Promise<readonly PresetRow[]>
427
+ switchMode: (id: string) => Promise<string>
386
428
  /** Load the switchable permission presets for the /permission panel. */
387
- loadPermissions(): Promise<readonly PermissionRow[]>
388
- createSession(mode?: string): void
429
+ loadPermissions: () => Promise<readonly PermissionRow[]>
430
+ createSession: (mode?: string) => void
389
431
  /** 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>
432
+ forkSession: (argument: string) => void
433
+ loadSessions: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
434
+ loadSessionTranscript: (id: string, signal?: AbortSignal) => Promise<string>
435
+ /** Read the current session's usage blocks (projections plus per-turn fold). */
436
+ loadUsage: () => Promise<UsageView>
437
+ /**
438
+ * Full-text search over every persisted session (the in-process
439
+ * session-query engine). Absent when the deployment disabled the row;
440
+ * /search degrades to a notice instead of opening the panel.
441
+ */
442
+ searchSessions?: (query: string, signal?: AbortSignal) => Promise<readonly SearchRow[]>
393
443
  /** 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[]
444
+ loadSubagents: () => Promise<readonly SessionRow[]>
445
+ switchSession: (row: SessionRow) => void
446
+ cancelSessionSwitch: () => boolean
447
+ loadPlugins: () => readonly PluginRow[]
398
448
  /** Caller-visible background jobs (the host jobs registry, read-only). */
399
- loadJobs(): readonly JobRow[]
449
+ loadJobs: () => readonly JobRow[]
400
450
  /** Probe the launcher's aligned update plan (read-only; never installs). */
401
- probeUpdate(): Promise<LauncherUpdateStatus>
451
+ probeUpdate: () => Promise<LauncherUpdateStatus>
402
452
  /** Run the launcher's aligned update; streams sanitized lines; resolves with the exit code. */
403
- applyUpdate(onLine: (line: string) => void): Promise<number>
453
+ applyUpdate: (onLine: (line: string) => void, plan?: { readonly dshSpec: string; readonly codeSpec: string; readonly pluginSpecs: readonly string[] }) => Promise<number>
404
454
  /** Registers the app's notice channel with the runner (called once on mount). */
405
- onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
455
+ onBridgeReady: (bridge: { notify: (text: string, tone?: NoticeTone) => void }) => void
406
456
  /** Ordered enabled status items (/statusline config); the runner owns persistence. */
407
457
  statusline: readonly string[]
408
458
  /** Persist a new statusline item set; the runner surfaces IO failures as notices. */
409
- saveStatusline(items: readonly string[]): void
459
+ saveStatusline: (items: readonly string[]) => void
460
+ /** Apply and persist one /language selection; the runner owns the language.json file. */
461
+ saveLanguage: (name: LanguageName) => void
410
462
  /** Apply and persist one /theme selection; the runner owns the theme.json file. */
411
- saveTheme?(name: ThemeName): void
463
+ saveTheme?: (name: ThemeName) => void
412
464
  /** Whether timed animations run at startup (animations.json; on by default
413
465
  * — like parseAnimationsPref, only an explicit false disables them). */
414
466
  animations?: boolean
415
467
  /** Apply and persist one /animation toggle; the runner owns the file. */
416
- saveAnimations?(enabled: boolean): void
468
+ saveAnimations?: (enabled: boolean) => void
417
469
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
418
470
  history: readonly string[]
419
471
  /** 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
472
+ recordHistory: (text: string) => void
473
+ /** Mutate one next-turn inbox message; durable inbox splices reconcile the result. */
474
+ updateQueued?: (messageId: string, action: QueueMutation) => void
423
475
  /** Apply the Ctrl+R terminal passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
424
- applyEditorKeys(): Promise<string>
476
+ applyEditorKeys: () => Promise<string>
425
477
  }
426
478
 
427
479
  /** Pad text with spaces to a visible-column target (menu name column). */
@@ -475,7 +527,13 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
475
527
  */
476
528
  function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
477
529
  const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
478
- return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
530
+ // Flowing themes (prismatic, rainbow) ride their anchor walk while busy;
531
+ // every other theme (and the frozen state) keeps the palette's live accent.
532
+ const flow = themeFlow()
533
+ const marker = flow !== undefined && animated
534
+ ? flowColor(tick * BUSY_CHASE_TICK_MS + flow.phaseMs, flow.anchors)
535
+ : getPalette().brandBright
536
+ return createElement(Text, { color: inkColor(marker) }, busyChaseFrame(tick) + ' ')
479
537
  }
480
538
 
481
539
  /** Blinking block caret appended to streaming text; solid when frozen. */
@@ -485,7 +543,7 @@ function Caret({ animated = true }: { animated?: boolean }): ReactElement {
485
543
  }
486
544
 
487
545
  /** One resettable input-caret phase shared by the entire composer. */
488
- function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
546
+ function useCursorBlink(active: boolean): { visible: boolean; reset: () => void } {
489
547
  const [epoch, setEpoch] = useState(0)
490
548
  const [visible, setVisible] = useState(true)
491
549
  useEffect(() => {
@@ -514,6 +572,13 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
514
572
  function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
515
573
  const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
516
574
  const palette = getPalette()
575
+ // Flowing themes walk their anchors for the shimmer highlight so
576
+ // streaming text glows along the spectrum; other themes keep the bright
577
+ // accent.
578
+ const flow = themeFlow()
579
+ const highlight = flow !== undefined && animated
580
+ ? flowColor(tick * DEEP_DIVING_SHIMMER_TICK_MS + flow.phaseMs, flow.anchors)
581
+ : palette.brandBright
517
582
  const graphemes = splitGraphemes(text)
518
583
  return createElement(
519
584
  Text,
@@ -527,8 +592,8 @@ function ShimmerLine({ text, animated = true }: { text: string; animated?: boole
527
592
  color: inkColor(!animated
528
593
  ? (sparkle ? palette.brandBright : palette.brandDeep)
529
594
  : sparkle
530
- ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
531
- : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
595
+ ? deepDivingSparkColor(tick, palette.brandDeep, highlight)
596
+ : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
532
597
  bold: sparkle || undefined,
533
598
  },
534
599
  grapheme.text,
@@ -625,6 +690,18 @@ function segmentProps(style: MdSegment['style']): {
625
690
  return { color: undefined, bold: true, italic: true, strikethrough: undefined }
626
691
  case 'strike':
627
692
  return { color: inkColor(getPalette().dim), bold: undefined, italic: undefined, strikethrough: true }
693
+ case 'diffAdd':
694
+ case 'diffDel':
695
+ // Inline-markdown twin of lineStyleProps' diff cases: tinted rows for
696
+ // ```diff fences rendered through the markdown span path. The diff
697
+ // foreground tokens stay AA-legible both on the row tints (when the
698
+ // background rides along) and on the plain terminal background.
699
+ return {
700
+ color: inkColor(style === 'diffAdd' ? getPalette().diffAddFg : getPalette().diffDelFg),
701
+ bold: undefined,
702
+ italic: undefined,
703
+ strikethrough: undefined,
704
+ }
628
705
  default:
629
706
  return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
630
707
  }
@@ -637,20 +714,46 @@ function lineStyleProps(style: LineStyle): {
637
714
  italic: boolean | undefined
638
715
  strikethrough: boolean | undefined
639
716
  dimColor: boolean | undefined
717
+ backgroundColor: string | undefined
640
718
  } {
641
719
  switch (style) {
642
720
  case 'brand':
643
- return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
721
+ return { color: inkColor(getPalette().brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
644
722
  case 'success':
645
- return { color: inkColor(getPalette().success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
723
+ return { color: inkColor(getPalette().success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
646
724
  case 'error':
647
- return { color: inkColor(getPalette().error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
725
+ return { color: inkColor(getPalette().error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
648
726
  case 'warn':
649
- return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
727
+ return { color: inkColor(getPalette().warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
650
728
  case 'dimItalic':
651
- return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined }
729
+ return { color: inkColor(getPalette().dim), bold: undefined, italic: true, strikethrough: undefined, dimColor: undefined, backgroundColor: undefined }
730
+ // Codex diff rendering: added/removed lines carry a theme tint behind
731
+ // the sign and text, with the AA-tuned diff foreground tokens on top; the
732
+ // depth gate turns this into plain foreground styling on 16-color
733
+ // terminals.
734
+ case 'diffAdd':
735
+ return { color: inkColor(getPalette().diffAddFg), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: diffBackground('diffAdd') }
736
+ case 'diffDel':
737
+ return { color: inkColor(getPalette().diffDelFg), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined, backgroundColor: diffBackground('diffDel') }
738
+ // Prompt rows: one full-width bar per delivery kind. The tint comes from
739
+ // the row token and the foreground from its AA-tuned twin; the depth gate
740
+ // inside rowBackground degrades this to foreground-only on 16-color
741
+ // terminals, matching the diff rows.
742
+ case 'promptRow':
743
+ case 'promptQueuedRow':
744
+ case 'promptSteeredRow': {
745
+ const tokens = promptRowTokens(style === 'promptQueuedRow' ? 'queued' : style === 'promptSteeredRow' ? 'steered' : undefined)
746
+ return {
747
+ color: inkColor(getPalette()[tokens.fg]),
748
+ bold: undefined,
749
+ italic: undefined,
750
+ strikethrough: undefined,
751
+ dimColor: undefined,
752
+ backgroundColor: rowBackground(tokens.fg),
753
+ }
754
+ }
652
755
  default:
653
- return { ...segmentProps(style), dimColor: undefined }
756
+ return { ...segmentProps(style), dimColor: undefined, backgroundColor: undefined }
654
757
  }
655
758
  }
656
759
 
@@ -674,7 +777,7 @@ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
674
777
  }
675
778
 
676
779
  /** File-oriented, color-coded unified diff viewport. */
677
- function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): ReactElement {
780
+ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose: () => void }): ReactElement {
678
781
  const stdout = useStdout().stdout
679
782
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
680
783
  const [fileIndex, setFileIndex] = useState(0)
@@ -682,15 +785,7 @@ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): R
682
785
  const file = view.files[fileIndex]
683
786
  const lines = useMemo(() => {
684
787
  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))
788
+ return fillDiffLineBars(file.lines.flatMap(line => styledLines([lineSegment(line, diffLineStyle(line))], viewport.contentColumns)), viewport.contentColumns)
694
789
  }, [file, viewport.contentColumns])
695
790
  const visibleScroll = clampScroll(scroll, lines.length, viewport.bodyRows)
696
791
  useInput((input, key) => {
@@ -709,15 +804,16 @@ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): R
709
804
  else if (key.pageUp) setScroll(current => moveScroll(current, -viewport.bodyRows, lines.length, viewport.bodyRows))
710
805
  else if (key.pageDown) setScroll(current => moveScroll(current, viewport.bodyRows, lines.length, viewport.bodyRows))
711
806
  })
712
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length} files · esc/q close`, viewport.contentColumns))
807
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.diff.compact', { title: view.title, files: view.files.length }), viewport.contentColumns))
808
+ const accent = panelAccent('diff', getPalette().dim, getPalette().brand)
713
809
  return createElement(
714
810
  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)),
811
+ { flexDirection: 'column', borderStyle: 'round', borderColor: inkColor(accent.border), paddingX: 1 },
812
+ 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
813
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
718
814
  createElement(StyledRows, { lines: lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
719
815
  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)),
816
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.diff.footer'), viewport.contentColumns)),
721
817
  )
722
818
  }
723
819
 
@@ -735,6 +831,32 @@ function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
735
831
  * keeps its historical three lines. Short or narrow terminals keep a one-line
736
832
  * form without the kernel line.
737
833
  */
834
+ /**
835
+ * One whale-glyph row painted as a frozen seven-color spectrum (column 0
836
+ * red, column 25 violet). Spaces stay uncolored so the silhouette punches
837
+ * through; adjacent same-hue blocks merge into one span.
838
+ */
839
+ function rainbowGlyphRow(row: string, rowKey: number): ReactElement {
840
+ const span = Math.max(1, WHALE_GLYPH_COLUMNS - 1)
841
+ const children: ReactElement[] = []
842
+ let start = 0
843
+ while (start < row.length) {
844
+ if (row[start] === ' ') {
845
+ let end = start + 1
846
+ while (end < row.length && row[end] === ' ') end += 1
847
+ children.push(createElement(Text, { key: start }, row.slice(start, end)))
848
+ start = end
849
+ continue
850
+ }
851
+ const color = inkColor(rainbowSpectrumHue(start / span))
852
+ let end = start + 1
853
+ while (end < row.length && row[end] !== ' ' && inkColor(rainbowSpectrumHue(end / span)) === color) end += 1
854
+ children.push(createElement(Text, { key: start, color }, row.slice(start, end)))
855
+ start = end
856
+ }
857
+ return createElement(Text, { key: rowKey }, ...children)
858
+ }
859
+
738
860
  function Header({ resumed }: { resumed: boolean }): ReactElement {
739
861
  const stdout = useStdout().stdout
740
862
  const rows = stdout?.rows ?? 40
@@ -749,7 +871,7 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
749
871
  const copyWidths = [visibleColumns(title), visibleColumns(slogan), visibleColumns(hint)]
750
872
  if (kernelLine !== undefined) copyWidths.push(visibleColumns(kernelLine))
751
873
  const copyColumns = Math.max(...copyWidths)
752
- const compact = `${title} · ${hint}`
874
+ const compact = kernelLine === undefined ? `${title} · ${hint}` : `${title} · ${kernelLine} · ${hint}`
753
875
  if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 10) {
754
876
  return createElement(
755
877
  Box,
@@ -768,7 +890,10 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
768
890
  createElement(
769
891
  Box,
770
892
  { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
771
- ...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(getPalette().brand) }, row)),
893
+ ...WHALE_GLYPH.map((row, index) =>
894
+ isRainbow()
895
+ ? rainbowGlyphRow(row, index)
896
+ : createElement(Text, { key: index, color: inkColor(getPalette().brand) }, row)),
772
897
  ),
773
898
  createElement(
774
899
  Box,
@@ -803,7 +928,7 @@ function todoMark(status: TodoItem['status']): string {
803
928
  function AgentsLine({ rows, total }: { rows: readonly SubagentRow[]; total: number }): ReactElement | undefined {
804
929
  if (rows.length === 0) return undefined
805
930
  const running = rows.filter(row => row.state !== 'done').length
806
- const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
931
+ const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]
807
932
  const mark = newest.state === 'done' ? '✓' : newest.state === 'idle' ? '⏸' : '●'
808
933
  return createElement(
809
934
  Box,
@@ -856,7 +981,7 @@ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose
856
981
  const inProgress = todos.filter(todo => todo.status === 'in_progress').length
857
982
  const pending = todos.length - completed - inProgress
858
983
  const rows = todos.length === 0
859
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no todos yet')]
984
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.todos.empty')}`)]
860
985
  : todos.map(todo => createElement(
861
986
  Text,
862
987
  { key: todo.content, dimColor: true, wrap: 'truncate-end' },
@@ -885,40 +1010,202 @@ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose
885
1010
  })
886
1011
 
887
1012
  if (viewport.maxHeight === 0 || viewport.compact) {
888
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('todos · esc/q close', viewport.contentColumns))
1013
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.todos.compact'), viewport.contentColumns))
889
1014
  }
890
1015
 
1016
+ const accent = panelAccent('todos', getPalette().brand)
891
1017
  return createElement(
892
1018
  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)),
1019
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
1020
+ 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
1021
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
896
1022
  ...rows.slice(visibleScroll, visibleScroll + viewport.bodyRows),
897
1023
  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))),
1024
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(t('panel.todos.footer'), viewport.contentColumns))),
899
1025
  )
900
1026
  }
901
1027
 
902
1028
  const MemoTodoListPanel = memo(TodoListPanel)
903
1029
 
1030
+ /** Rows in the exact next-turn inbox order, never transcript append order. */
1031
+ export function queuedInboxRows(
1032
+ entries: readonly TranscriptEntry[],
1033
+ ids: readonly string[],
1034
+ ): readonly Extract<TranscriptEntry, { kind: 'pending' }>[] {
1035
+ const byId = new Map<string, Extract<TranscriptEntry, { kind: 'pending' }>>()
1036
+ for (const entry of entries) {
1037
+ if (entry.kind === 'pending' && entry.target === 'next-turn') byId.set(entry.messageId, entry)
1038
+ }
1039
+ return ids.flatMap(id => {
1040
+ const row = byId.get(id)
1041
+ return row === undefined ? [] : [row]
1042
+ })
1043
+ }
1044
+
1045
+ /** A bounded, keyboard-owned management surface for the durable next-turn inbox. */
1046
+ function QueuePanel({ rows, busy, update, onClose }: {
1047
+ rows: readonly Extract<TranscriptEntry, { kind: 'pending' }>[]
1048
+ busy: boolean
1049
+ update?: (messageId: string, action: QueueMutation) => void
1050
+ onClose: () => void
1051
+ }): ReactElement {
1052
+ const stdout = useStdout().stdout
1053
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1054
+ const [selected, setSelected] = useState(0)
1055
+ const [scroll, setScroll] = useState(0)
1056
+ const [editing, setEditing] = useState<{ messageId: string; text: string; cursor: number } | undefined>(undefined)
1057
+ // Ink can deliver a following key before its effect swaps the input
1058
+ // listener after an edit-mode render; this ref keeps the editor's key
1059
+ // stream coherent while the visible state catches up.
1060
+ const editingRef = useRef(editing)
1061
+ const current = rows[selected]
1062
+ const visibleScroll = revealRow(clampScroll(scroll, rows.length, viewport.bodyRows), selected, rows.length, viewport.bodyRows)
1063
+ const move = (delta: number): void => {
1064
+ setSelected(current => Math.max(0, Math.min(rows.length - 1, current + delta)))
1065
+ }
1066
+
1067
+ useEffect(() => {
1068
+ setSelected(current => Math.max(0, Math.min(rows.length - 1, current)))
1069
+ if (editing !== undefined && !rows.some(row => row.messageId === editing.messageId)) {
1070
+ editingRef.current = undefined
1071
+ setEditing(undefined)
1072
+ }
1073
+ }, [rows, editing])
1074
+ useEffect(() => {
1075
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
1076
+ }, [visibleScroll, scroll])
1077
+
1078
+ useStableInput((input, key) => {
1079
+ const activeEdit = editingRef.current
1080
+ if (activeEdit !== undefined) {
1081
+ if (key.escape) {
1082
+ editingRef.current = undefined
1083
+ setEditing(undefined)
1084
+ return
1085
+ }
1086
+ if (key.return) {
1087
+ if (activeEdit.text.trim() !== '') update?.(activeEdit.messageId, { kind: 'edit', text: activeEdit.text })
1088
+ editingRef.current = undefined
1089
+ setEditing(undefined)
1090
+ return
1091
+ }
1092
+ if (key.leftArrow) {
1093
+ const next = { ...activeEdit, cursor: moveCursorBy(activeEdit.text, activeEdit.cursor, -1) }
1094
+ editingRef.current = next
1095
+ setEditing(next)
1096
+ return
1097
+ }
1098
+ if (key.rightArrow) {
1099
+ const next = { ...activeEdit, cursor: moveCursorBy(activeEdit.text, activeEdit.cursor, 1) }
1100
+ editingRef.current = next
1101
+ setEditing(next)
1102
+ return
1103
+ }
1104
+ // Ink 5 reports 0x7F (backspace) and the forward-delete sequence as the
1105
+ // same `key.delete`, so a bare Delete binding here would erase on a
1106
+ // habitual Backspace. This management surface keeps `d` as its only
1107
+ // removal key instead of guessing which byte arrived.
1108
+ if (key.backspace || key.delete) {
1109
+ const edit = deleteBackward(activeEdit.text, activeEdit.cursor)
1110
+ const next = { ...activeEdit, text: edit.value, cursor: edit.cursor }
1111
+ editingRef.current = next
1112
+ setEditing(next)
1113
+ return
1114
+ }
1115
+ if (input !== '' && !key.ctrl && !key.meta) {
1116
+ const edit = insertText(activeEdit.text, activeEdit.cursor, input)
1117
+ const next = { ...activeEdit, text: edit.value, cursor: edit.cursor }
1118
+ editingRef.current = next
1119
+ setEditing(next)
1120
+ }
1121
+ return
1122
+ }
1123
+ if (key.escape || input === 'q') {
1124
+ onClose()
1125
+ return
1126
+ }
1127
+ if (key.upArrow) move(-1)
1128
+ else if (key.downArrow) move(1)
1129
+ else if (key.pageUp) move(-Math.max(1, viewport.bodyRows - 1))
1130
+ else if (key.pageDown) move(Math.max(1, viewport.bodyRows - 1))
1131
+ else if (input === 'g') setSelected(0)
1132
+ else if (input === 'G') setSelected(Math.max(0, rows.length - 1))
1133
+ else if (input === 'e' && current !== undefined) {
1134
+ // Text is editable on every row: an edit rewrites what the user typed
1135
+ // and carries the row's attachments through untouched, which is exactly
1136
+ // what the row's read-only attachment marker promises.
1137
+ const next = { messageId: current.messageId, text: current.text, cursor: current.text.length }
1138
+ editingRef.current = next
1139
+ setEditing(next)
1140
+ }
1141
+ else if (input === 'd' && current !== undefined) update?.(current.messageId, { kind: 'remove' })
1142
+ else if (key.return && current !== undefined && busy) update?.(current.messageId, { kind: 'steer' })
1143
+ }, true)
1144
+
1145
+ if (viewport.maxHeight === 0 || viewport.compact) {
1146
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.queue.compact'), viewport.contentColumns))
1147
+ }
1148
+ const body = rows.length === 0
1149
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.queue.empty'), viewport.contentColumns))]
1150
+ : rows.map((row, index) => {
1151
+ const selectedRow = index === selected
1152
+ const suffix = (row.images?.length ?? 0) + (row.files?.length ?? 0) > 0 ? ` ${t('panel.queue.attachments')}` : ''
1153
+ if (editing?.messageId === row.messageId) {
1154
+ const before = editing.text.slice(0, editing.cursor)
1155
+ const caret = editing.text.slice(editing.cursor, editing.cursor + 1) || ' '
1156
+ const after = editing.text.slice(editing.cursor + caret.length)
1157
+ return createElement(Text, { key: row.messageId, color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(`✎ ${before}[${caret}]${after}`, viewport.contentColumns))
1158
+ }
1159
+ 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))
1160
+ })
1161
+ const footer = editing !== undefined
1162
+ ? t('panel.queue.editFooter')
1163
+ : busy
1164
+ ? t('panel.queue.footerBusy')
1165
+ : t('panel.queue.footerIdle')
1166
+ const accent = panelAccent('queue', getPalette().brand)
1167
+ return createElement(
1168
+ Box,
1169
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
1170
+ 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)),
1171
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1172
+ ...body.slice(visibleScroll, visibleScroll + viewport.bodyRows),
1173
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1174
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(footer, viewport.contentColumns)),
1175
+ )
1176
+ }
1177
+
904
1178
  /**
905
1179
  * Ink props for one status tone: the Codex status-line accent mapping over
906
1180
  * the DeepSeek palette, all blue by design — the status bar speaks only in
907
1181
  * degrees of blue (deep accent, primary figures, model identity, sky
908
1182
  * paths and done states), with amber/red reserved for warnings and errors.
909
1183
  */
910
- function statusToneProps(tone: StatusTone): {
1184
+ function statusToneProps(tone: StatusTone, flowMs?: number): {
911
1185
  color: string | undefined
912
1186
  bold: boolean | undefined
913
1187
  dimColor: boolean | undefined
914
1188
  } {
1189
+ if (isRainbow()) {
1190
+ // Carnival roll: every tone carries its rolled color (adjacent tones in
1191
+ // canonical on-screen order never match, the row boundary included);
1192
+ // the live dot rides the flow walk while busy. Bold emphasis carries
1193
+ // the semantic hierarchy so randomness never hides importance.
1194
+ const emphasized = tone === 'model' || tone === 'success' || tone === 'plan' || tone === 'warn' || tone === 'error'
1195
+ const color = tone === 'live' && flowMs !== undefined
1196
+ ? flowColor(flowMs, themeFlow()?.anchors ?? FLOW_ANCHORS)
1197
+ : rainbowRoll().toneColors[tone]
1198
+ return { color: inkColor(color), bold: emphasized || undefined, dimColor: undefined }
1199
+ }
915
1200
  switch (tone) {
916
1201
  case 'model':
917
1202
  // Same tone as the working-directory segment: the model name reads as
918
1203
  // a path fact, not a brand accent.
919
1204
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
920
1205
  case 'live':
921
- return { color: inkColor(getPalette().brandBright), bold: undefined, dimColor: undefined }
1206
+ // With a flow sample (flowing theme while busy), the live dot rides
1207
+ // the anchor walk; otherwise the palette's live accent.
1208
+ return { color: inkColor(flowMs === undefined ? getPalette().brandBright : flowColor(flowMs, themeFlow()?.anchors ?? FLOW_ANCHORS)), bold: undefined, dimColor: undefined }
922
1209
  case 'path':
923
1210
  return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
924
1211
  case 'branch':
@@ -980,12 +1267,22 @@ function statusToneProps(tone: StatusTone): {
980
1267
  * the prompt keeps is always hues[0]. */
981
1268
  function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTriple, RgbTriple] {
982
1269
  const palette = getPalette()
1270
+ // Rainbow waves pick three SPECTRALLY SPREAD anchors from the rolled
1271
+ // pool — spectral neighbors would wash into one hue across the band.
1272
+ if (isRainbow()) {
1273
+ const anchors = rainbowRoll().flowAnchors
1274
+ const stride = Math.max(1, Math.floor(anchors.length / 3))
1275
+ return [anchors[0], anchors[stride], anchors[stride * 2]]
1276
+ }
1277
+ // Prismatic waves ride the flow anchors for both tiers — the model-switch
1278
+ // easter egg becomes a violet→fuchsia→cyan sweep.
1279
+ if (isPrismatic()) return [FLOW_ANCHORS[0], FLOW_ANCHORS[1], FLOW_ANCHORS[2]]
983
1280
  return tier === 'flash'
984
1281
  ? [palette.brandBright, palette.brand, palette.brandMid]
985
1282
  : [palette.brandBright, palette.code, palette.brandMid]
986
1283
  }
987
1284
 
988
- function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1285
+ function StatusLine({ facts, stats, busy, columns, items, onRows, animated }: {
989
1286
  facts: StatusFacts
990
1287
  stats: Parameters<typeof layoutStatusBar>[1]
991
1288
  busy: boolean
@@ -994,7 +1291,16 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
994
1291
  /** Reports the footer's exact physical row count (1 or 2) so the IME
995
1292
  * anchor ledger below the composer stays exact. */
996
1293
  onRows?: (rows: 1 | 2) => void
1294
+ /** Whether timed animations run (the persisted preference). */
1295
+ animated: boolean
997
1296
  }): ReactElement {
1297
+ // Flowing-theme busy flow: the identity cluster's live dot cycles the
1298
+ // anchor walk while a turn runs; static themes never start the timer.
1299
+ const flow = themeFlow()
1300
+ const flowActive = animated && busy && flow !== undefined
1301
+ const flowTick = useFrames(BUSY_CHASE_TICK_MS, flowActive)
1302
+ const flowMs = flowActive ? flowTick * BUSY_CHASE_TICK_MS + (flow?.phaseMs ?? 0) : undefined
1303
+ const language = getLanguage()
998
1304
  const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
999
1305
  busy,
1000
1306
  items,
@@ -1018,6 +1324,8 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1018
1324
  busy,
1019
1325
  columns,
1020
1326
  items,
1327
+ // Labels come from t(); a language switch must rebuild the rows.
1328
+ language,
1021
1329
  ])
1022
1330
  // The IME anchor below the composer counts every row between the caret and
1023
1331
  // Ink's parked cursor, so the footer reports its exact row count one-way
@@ -1036,7 +1344,7 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1036
1344
  group.spans.forEach((span, spanIndex) => {
1037
1345
  leftParts.push(createElement(
1038
1346
  Text,
1039
- { key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone) },
1347
+ { key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) },
1040
1348
  span.text,
1041
1349
  ))
1042
1350
  })
@@ -1048,12 +1356,12 @@ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
1048
1356
  }
1049
1357
  rightParts.push(createElement(
1050
1358
  Text,
1051
- { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone) },
1359
+ { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) },
1052
1360
  span.text,
1053
1361
  ))
1054
1362
  })
1055
1363
  if (row.hint) {
1056
- rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, STATUS_CYCLE_HINT))
1364
+ rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, statusCycleHint()))
1057
1365
  }
1058
1366
  // Each row already fits the column budget; truncate-end stays as the
1059
1367
  // terminal-measurement backstop so a drifting cell count clips instead
@@ -1131,9 +1439,9 @@ const APPROVAL_OPTIONS: readonly ApprovalOption[] = [
1131
1439
  function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1132
1440
  snapshot: ApprovalSnapshot
1133
1441
  locked: boolean
1134
- notify(text: string, tone?: NoticeTone): void
1442
+ notify: (text: string, tone?: NoticeTone) => void
1135
1443
  /** Cancel the running turn (Ctrl+C), matching the composer's busy branch. */
1136
- interrupt(): boolean
1444
+ interrupt: () => boolean
1137
1445
  /** Render as the bounded one-line form even on tall terminals (another
1138
1446
  * human-asked surface already owns the full panel budget). */
1139
1447
  summarize?: boolean
@@ -1160,7 +1468,7 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1160
1468
  }
1161
1469
  ask.answer('rejected')
1162
1470
  if (option.key === 'reject-note') {
1163
- notify('rejected — type below what it should do differently (it steers the next step)', 'warning')
1471
+ notify(t('notice.rejected'), 'warning')
1164
1472
  }
1165
1473
  }
1166
1474
 
@@ -1184,35 +1492,35 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1184
1492
  return
1185
1493
  }
1186
1494
  if (key.return) {
1187
- decide(APPROVAL_OPTIONS[cursor]!)
1495
+ decide(APPROVAL_OPTIONS[cursor])
1188
1496
  return
1189
1497
  }
1190
1498
  if (key.escape) {
1191
- decide(APPROVAL_OPTIONS[2]!)
1499
+ decide(APPROVAL_OPTIONS[2])
1192
1500
  return
1193
1501
  }
1194
1502
  if (input === 'y' || input === 'Y') {
1195
- decide(APPROVAL_OPTIONS[0]!)
1503
+ decide(APPROVAL_OPTIONS[0])
1196
1504
  return
1197
1505
  }
1198
1506
  if (input === 'n' || input === 'N') {
1199
- decide(APPROVAL_OPTIONS[1]!)
1507
+ decide(APPROVAL_OPTIONS[1])
1200
1508
  return
1201
1509
  }
1202
1510
  if (input === 'd' || input === 'D') {
1203
- decide(APPROVAL_OPTIONS[2]!)
1511
+ decide(APPROVAL_OPTIONS[2])
1204
1512
  return
1205
1513
  }
1206
1514
  if (/^[1-9]$/u.test(input)) {
1207
1515
  const index = Number(input) - 1
1208
- if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index]!)
1516
+ if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index])
1209
1517
  }
1210
1518
  }, { isActive: active })
1211
1519
 
1212
1520
  if (pending === undefined) return undefined
1213
1521
  const queuedSuffix = snapshot.queued > 0 ? ` · +${snapshot.queued} queued` : ''
1214
1522
  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))
1523
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('approval.compact', { queued: queuedSuffix }), viewport.contentColumns))
1216
1524
  }
1217
1525
  // Body budget: title + options + footer consume fixed rows; the command
1218
1526
  // preview shrinks with an explicit overflow marker (Codex's "[… N lines]").
@@ -1231,7 +1539,7 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1231
1539
  createElement(PanelGap, { visible: viewport.gapRows > 0 && body.length > 0 }),
1232
1540
  ...visibleBody.map((line, index) => createElement(StyledRows, { key: `body-${index}`, lines: [line] })),
1233
1541
  ...(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))]
1542
+ ? [createElement(Text, { key: 'overflow', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('approval.overflow', { count: overflow }), viewport.contentColumns))]
1235
1543
  : []),
1236
1544
  ...(body.length > 0 ? [createElement(PanelGap, { visible: viewport.gapRows > 0 })] : []),
1237
1545
  ...APPROVAL_OPTIONS.map((option, index) => {
@@ -1248,8 +1556,8 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1248
1556
  )
1249
1557
  }),
1250
1558
  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)),
1559
+ ? t('approval.submitted')
1560
+ : t('approval.footer'), viewport.contentColumns)),
1253
1561
  )
1254
1562
  }
1255
1563
 
@@ -1622,21 +1930,21 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1622
1930
 
1623
1931
  if (pending === undefined || question === undefined) return undefined
1624
1932
  if (viewport.maxHeight === 0 || viewport.compact) {
1625
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
1933
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? t('question.compact.plan') : t('question.compact.normal'), viewport.contentColumns))
1626
1934
  }
1627
1935
  const footerBase = submitted
1628
- ? 'submitted'
1936
+ ? t('question.submitted')
1629
1937
  : mode === 'custom'
1630
1938
  ? 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'
1939
+ ? t('question.customNoOptions')
1940
+ : t('question.customOptions')
1633
1941
  : options.length === 0
1634
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1942
+ ? t('question.customNoOptions')
1635
1943
  : 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'
1944
+ ? t('question.multiOptions')
1945
+ : t('question.singleOptions')
1638
1946
  const footer = pending.request.questions.length > 1 && !submitted
1639
- ? `${footerBase} · ←→/ctrl+p/n switch question`
1947
+ ? `${footerBase}${t('question.switch')}`
1640
1948
  : footerBase
1641
1949
  return createElement(
1642
1950
  Box,
@@ -1644,12 +1952,12 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1644
1952
  createElement(
1645
1953
  Text,
1646
1954
  { 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),
1955
+ 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
1956
  ),
1649
1957
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1650
1958
  createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
1651
1959
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1652
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1960
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1653
1961
  )
1654
1962
  }
1655
1963
 
@@ -1659,10 +1967,10 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1659
1967
  error: string | undefined
1660
1968
  /** `provider/model` label of the applied model: the cursor lands on it once. */
1661
1969
  current?: string
1662
- onSelect(row: ModelRow): void
1663
- onProviders?(): void
1664
- onRetry(): void
1665
- onClose(): void
1970
+ onSelect: (row: ModelRow) => void
1971
+ onProviders?: () => void
1972
+ onRetry: () => void
1973
+ onClose: () => void
1666
1974
  }): ReactElement {
1667
1975
  const [query, setQuery] = useState('')
1668
1976
  const [cursor, setCursor] = useState(0)
@@ -1679,6 +1987,13 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1679
1987
  return rows.filter(row => `${row.provider} ${row.providerName ?? ''} ${row.model} ${row.modelName}`.toLowerCase().includes(needle))
1680
1988
  }, [rows, query])
1681
1989
  const positioned = useRef(false)
1990
+ // Latest-value ref: Ink re-subscribes useInput when its effect flushes,
1991
+ // which can lag a committed render (a directory that just landed paints
1992
+ // before the subscription swaps). A keystroke in that window would meet a
1993
+ // stale closure — Enter died as an empty-filter no-op right after "2 of 4
1994
+ // match" painted. The handler reads render-fresh values through the ref.
1995
+ const liveRef = useRef({ filtered, query, cursor })
1996
+ liveRef.current = { filtered, query, cursor }
1682
1997
 
1683
1998
  useEffect(() => {
1684
1999
  // Open ON the applied model (Codex resumes the previous pick): the first
@@ -1697,7 +2012,7 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1697
2012
  // Position within the ACTIVE filter: the full-row index means nothing
1698
2013
  // when the query already narrowed the list while the directory loaded
1699
2014
  // (a late resolve must not place the cursor outside `filtered`).
1700
- const filteredIndex = filtered.indexOf(rows[index]!)
2015
+ const filteredIndex = filtered.indexOf(rows[index])
1701
2016
  setCursor(filteredIndex >= 0 ? filteredIndex : 0)
1702
2017
  } else if (cursor >= filtered.length) {
1703
2018
  setCursor(Math.max(0, filtered.length - 1))
@@ -1705,11 +2020,12 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1705
2020
  }, [rows, filtered, cursor, current])
1706
2021
 
1707
2022
  useInput((input, key) => {
1708
- if (key.escape || (input === 'q' && query === '')) {
2023
+ const { filtered: list, query: text, cursor: at } = liveRef.current
2024
+ if (key.escape || (input === 'q' && text === '')) {
1709
2025
  onClose()
1710
2026
  return
1711
2027
  }
1712
- if (input === 'r' && query === '') {
2028
+ if (input === 'r' && text === '') {
1713
2029
  onRetry()
1714
2030
  return
1715
2031
  }
@@ -1722,19 +2038,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1722
2038
  onClose()
1723
2039
  return
1724
2040
  }
1725
- const next = editQuery(query, input, key)
2041
+ const next = editQuery(text, input, key)
1726
2042
  if (next !== undefined) {
1727
2043
  setQuery(next)
1728
2044
  setCursor(0)
1729
2045
  return
1730
2046
  }
1731
- if (filtered.length === 0) return
2047
+ if (list.length === 0) return
1732
2048
  if (key.upArrow) {
1733
- setCursor(cursor > 0 ? cursor - 1 : filtered.length - 1)
2049
+ setCursor(at > 0 ? at - 1 : list.length - 1)
1734
2050
  return
1735
2051
  }
1736
2052
  if (key.downArrow) {
1737
- setCursor(cursor < filtered.length - 1 ? cursor + 1 : 0)
2053
+ setCursor(at < list.length - 1 ? at + 1 : 0)
1738
2054
  return
1739
2055
  }
1740
2056
  if (key.pageUp) {
@@ -1742,11 +2058,11 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1742
2058
  return
1743
2059
  }
1744
2060
  if (key.pageDown) {
1745
- setCursor(current => Math.min(filtered.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
2061
+ setCursor(current => Math.min(list.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1746
2062
  return
1747
2063
  }
1748
- if (key.return && filtered[cursor] !== undefined) {
1749
- onSelect(filtered[cursor])
2064
+ if (key.return && list[at] !== undefined) {
2065
+ onSelect(list[at])
1750
2066
  }
1751
2067
  })
1752
2068
 
@@ -1754,19 +2070,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1754
2070
  const providers = onProviders === undefined ? '' : ' · tab providers'
1755
2071
  const state = filtered.length === 0
1756
2072
  ? directory === undefined && error === undefined
1757
- ? 'loading'
2073
+ ? t('panel.model.loading')
1758
2074
  : error !== undefined
1759
- ? 'error'
1760
- : query === '' ? 'no models' : `no match for '${singleLineText(query)}'`
2075
+ ? t('panel.model.error')
2076
+ : query === '' ? t('panel.model.noModels') : t('panel.model.compactNoMatch', { query: singleLineText(query) })
1761
2077
  : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ''}`
1762
2078
  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))
2079
+ ? t('panel.model.footer.filter')
2080
+ : t('panel.model.footer.filtered')
2081
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.model.compact', { state, providers, tail }), viewport.contentColumns))
1766
2082
  }
1767
2083
 
1768
2084
  const stateRows: ReactElement[] = directory === undefined && error === undefined
1769
- ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ' loading models…')]
2085
+ ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.loading')}`)]
1770
2086
  : error !== undefined
1771
2087
  ? [createElement(
1772
2088
  Text,
@@ -1779,12 +2095,12 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1779
2095
  : [createElement(
1780
2096
  Text,
1781
2097
  { key: 'failures', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1782
- truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
2098
+ truncateColumns(` ${t('panel.provider.failure', { providers: directory?.failures.join(', ') ?? '' })}`, viewport.contentColumns),
1783
2099
  )]),
1784
2100
  ...(rows.length === 0
1785
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
2101
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.noModels')}`)]
1786
2102
  : filtered.length === 0
1787
- ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` no models match '${singleLineText(query)}'`, viewport.contentColumns))]
2103
+ ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('panel.model.noMatch', { query: singleLineText(query) })}`, viewport.contentColumns))]
1788
2104
  : []),
1789
2105
  ]
1790
2106
  // Measurement and rendering share the same physical-row budget: state
@@ -1794,12 +2110,13 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1794
2110
  const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1795
2111
  const first = selectionWindow(cursor, filtered.length, rowBudget)
1796
2112
  const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
2113
+ const accent = panelAccent('model', getPalette().brand)
1797
2114
  return createElement(
1798
2115
  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)),
2116
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2117
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(query === ''
2118
+ ? rows.length === 0 ? t('panel.model.title') : t('panel.model.titleCount', { index: cursor + 1, total: rows.length })
2119
+ : t('panel.model.titleMatches', { filtered: filtered.length, total: rows.length, query: singleLineText(query) }), viewport.contentColumns)),
1803
2120
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1804
2121
  ...visibleStateRows,
1805
2122
  ...visible.map((row) => {
@@ -1817,23 +2134,23 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1817
2134
  )
1818
2135
  }),
1819
2136
  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))),
2137
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(query === ''
2138
+ ? t('panel.model.footer.select', { providers: onProviders === undefined ? '' : ` · ${t('panel.model.providers')}` })
2139
+ : t('panel.model.footer.filteredSelect'), viewport.contentColumns))),
1823
2140
  )
1824
2141
  }
1825
2142
 
1826
2143
  /** Compact provider-state copy; only value-free credential facts cross this boundary. */
1827
2144
  function providerStateLabel(row: ProviderTargetView): string {
1828
- const route = row.active ? 'active' : 'dormant'
2145
+ const route = row.active ? t('panel.provider.active') : t('panel.provider.dormant')
1829
2146
  const credential = row.credential
1830
- if (credential?.kind === 'error') return `${route} · key status unavailable`
2147
+ if (credential?.kind === 'error') return t('panel.provider.state', { route, value: t('panel.provider.keyStatusUnavailable') })
1831
2148
  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'}`
2149
+ if (!credential.configured) return t('panel.provider.state', { route, value: t('panel.provider.noKey') })
2150
+ const source = credential.source === undefined ? t('panel.provider.configured') : singleLineText(credential.source)
2151
+ return t('panel.provider.state', { route, value: `${t('panel.provider.key', { value: source })}${credential.writable ? '' : ` · ${t('panel.provider.readOnly')}`}` })
1835
2152
  }
1836
- return `${route} · ${row.configured ? 'provider auth' : 'not configured'}`
2153
+ return t('panel.provider.state', { route, value: row.configured ? t('panel.provider.authConfigured') : t('panel.provider.noLogin') })
1837
2154
  }
1838
2155
 
1839
2156
  /** The provider-management stage reached from /model with `a`. */
@@ -1842,15 +2159,15 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1842
2159
  error: string | undefined
1843
2160
  authorizations: ProviderAuthorizationDirectory | undefined
1844
2161
  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
2162
+ onConfigure: (target: ProviderTargetView) => void
2163
+ onUnset: (target: ProviderTargetView) => void
2164
+ onRemove: (target: ProviderTargetView) => void
2165
+ onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => void
2166
+ onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => void
2167
+ onRetry: () => void
2168
+ onBack: () => void
1852
2169
  /** Leave the whole /model flow (Ctrl+C), not just this stage. */
1853
- onExit(): void
2170
+ onExit: () => void
1854
2171
  }): ReactElement {
1855
2172
  const stdout = useStdout().stdout
1856
2173
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -1912,9 +2229,9 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1912
2229
  if (input === 'd') {
1913
2230
  const facts = target.credential
1914
2231
  if (facts?.kind !== 'facts' || !facts.configured) {
1915
- setActionError('this provider has no configured API key to remove')
2232
+ setActionError(t('panel.provider.noConfiguredKey'))
1916
2233
  } else if (!facts.writable) {
1917
- setActionError('this API key is supplied read-only by the environment')
2234
+ setActionError(t('panel.provider.readOnlyKey'))
1918
2235
  } else {
1919
2236
  onUnset(target)
1920
2237
  }
@@ -1922,7 +2239,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1922
2239
  }
1923
2240
  if (input === 'x') {
1924
2241
  if (!target.removable) {
1925
- setActionError('this provider profile is not removable')
2242
+ setActionError(t('panel.provider.notRemovable'))
1926
2243
  } else {
1927
2244
  onRemove(target)
1928
2245
  }
@@ -1930,14 +2247,14 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1930
2247
  }
1931
2248
  const authorization = authorizationForProvider(authorizations, target.provider)
1932
2249
  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')
2250
+ if (authorization === undefined) setActionError(t('panel.provider.noLoginFlow'))
2251
+ else if (authorization.inFlight) setActionError(t('panel.provider.loginRunning'))
1935
2252
  else onLogin(target, authorization)
1936
2253
  return
1937
2254
  }
1938
2255
  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')
2256
+ if (authorization === undefined || !authorization.record.configured) setActionError(t('panel.provider.noLoginRecord'))
2257
+ else if (!authorization.record.writable) setActionError(t('panel.provider.readOnlyLogin'))
1941
2258
  else onLogout(target, authorization)
1942
2259
  return
1943
2260
  }
@@ -1946,7 +2263,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1946
2263
  // the configuration surface behind an undiscoverable chord.
1947
2264
  if (key.return) {
1948
2265
  if (target.settingsNs.length === 0) {
1949
- setActionError('this provider is not managed by Harness settings')
2266
+ setActionError(t('panel.provider.notManaged'))
1950
2267
  } else {
1951
2268
  onConfigure(target)
1952
2269
  }
@@ -1954,10 +2271,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1954
2271
  }, true)
1955
2272
 
1956
2273
  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))
2274
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.providers.compact'), viewport.contentColumns))
1958
2275
  }
1959
2276
  const stateRows: ReactElement[] = directory === undefined && error === undefined
1960
- ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' loading providers…')]
2277
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` ${t('panel.provider.loading')}`)]
1961
2278
  : error !== undefined
1962
2279
  ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
1963
2280
  : [
@@ -1971,14 +2288,14 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1971
2288
  )),
1972
2289
  ...(authorizationError === undefined
1973
2290
  ? []
1974
- : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
2291
+ : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.provider.loginStatusUnavailable', { message: singleLineText(authorizationError) })}`, viewport.contentColumns))]),
1975
2292
  ...(authorizations?.failures ?? []).map((failure, index) => createElement(
1976
2293
  Text,
1977
2294
  { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1978
2295
  truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1979
2296
  )),
1980
2297
  ...(rows.length === 0
1981
- ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
2298
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` ${t('panel.provider.empty')}`)]
1982
2299
  : []),
1983
2300
  ]
1984
2301
  const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
@@ -1989,7 +2306,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1989
2306
  const itemRows: ReactElement[] = []
1990
2307
  for (let display = first; display < first + rowBudget && display < displayLength; display += 1) {
1991
2308
  if (hasSeparator && display === configuredCount) {
1992
- itemRows.push(createElement(Text, { key: 'separator', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ── not configured ──', viewport.contentColumns)))
2309
+ itemRows.push(createElement(Text, { key: 'separator', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.provider.notConfiguredDivider')}`, viewport.contentColumns)))
1993
2310
  continue
1994
2311
  }
1995
2312
  const index = hasSeparator && display > configuredCount ? display - 1 : display
@@ -2013,15 +2330,16 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
2013
2330
  truncateColumns((index === cursor ? '❯ ' : ' ') + displayText(label), viewport.contentColumns),
2014
2331
  ))
2015
2332
  }
2333
+ const accent = panelAccent('model-providers', getPalette().brand)
2016
2334
  return createElement(
2017
2335
  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)),
2336
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2337
+ 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
2338
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2021
2339
  ...visibleStateRows,
2022
2340
  ...itemRows,
2023
2341
  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)),
2342
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.providers.footer'), viewport.contentColumns)),
2025
2343
  )
2026
2344
  }
2027
2345
 
@@ -2049,14 +2367,14 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2049
2367
  target: ProviderTargetView
2050
2368
  /** Models with declared efforts (settings first, catalog-advertised after) a model row can copy from. */
2051
2369
  effortDonors: readonly EffortDonor[]
2052
- save(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
2370
+ save: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
2053
2371
  saveCredential: ((target: ProviderTargetView, key: string) => Promise<void>) | undefined
2054
- discover(target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal): Promise<readonly DiscoveredModelView[]>
2372
+ discover: (target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal) => Promise<readonly DiscoveredModelView[]>
2055
2373
  /** Report a successful save so the surface can notice the key rotation. */
2056
- done(result: { readonly key: boolean }): void
2057
- back(): void
2374
+ done: (result: { readonly key: boolean }) => void
2375
+ back: () => void
2058
2376
  /** Leave the whole /model flow (Ctrl+C), not just this page. */
2059
- onExit(): void
2377
+ onExit: () => void
2060
2378
  }): ReactElement {
2061
2379
  const stdout = useStdout().stdout
2062
2380
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2110,7 +2428,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2110
2428
 
2111
2429
  /** Compact declaration summary for the row label: count, off, or inherit. */
2112
2430
  const effortsSummary = (model: ProviderModelSettings): string => {
2113
- const raw = (model.extras as Record<string, unknown> | undefined)?.reasoningEfforts
2431
+ const raw = (model.extras)?.reasoningEfforts
2114
2432
  if (raw === false) return 'off'
2115
2433
  if (isDeclaredReasoningEfforts(raw)) return String(Object.keys(raw).length)
2116
2434
  return '~'
@@ -2263,7 +2581,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2263
2581
  }
2264
2582
  if (input === 'e' && selected !== undefined) {
2265
2583
  setError(undefined)
2266
- setEffDraft(serializeReasoningEfforts((selected.extras as Record<string, unknown> | undefined)?.reasoningEfforts))
2584
+ setEffDraft(serializeReasoningEfforts((selected.extras)?.reasoningEfforts))
2267
2585
  setEffEditing(true)
2268
2586
  return
2269
2587
  }
@@ -2291,19 +2609,20 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2291
2609
  // Never hide a live input surface: one visible row keeps the escape
2292
2610
  // route honest on extremely short terminals (the three fixed rows - key,
2293
2611
  // 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))
2612
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.setup.compact'), viewport.contentColumns))
2295
2613
  }
2296
2614
  if (page === 'donor') {
2297
2615
  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))
2616
+ ? createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.setup.noDonors')}`, viewport.contentColumns))
2617
+ : createElement(Text, { key: 'hint', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.setup.copyInto', { id: displayText(selected?.id ?? '') })}`, viewport.contentColumns))
2300
2618
  const donorBudget = Math.max(0, viewport.bodyRows - 2)
2301
2619
  const donorFirst = selectionWindow(donorIndex, donorRows.length, donorBudget)
2302
2620
  const donorVisible = donorRows.slice(donorFirst, donorFirst + donorBudget)
2621
+ const accent = panelAccent('model-efforts', getPalette().brand)
2303
2622
  return createElement(
2304
2623
  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)),
2624
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2625
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.setup.copyTitle'), viewport.contentColumns)),
2307
2626
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2308
2627
  stateRow,
2309
2628
  ...donorVisible.map((donor, index) => {
@@ -2312,7 +2631,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2312
2631
  return createElement(Text, { key: donor.provider + '/' + donor.id, color: active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns))
2313
2632
  }),
2314
2633
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2315
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter copy · esc back', viewport.contentColumns)),
2634
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.setup.copyFooter'), viewport.contentColumns)),
2316
2635
  )
2317
2636
  }
2318
2637
  if (page === 'discover') {
@@ -2354,7 +2673,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2354
2673
  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
2674
  continue
2356
2675
  }
2357
- const model = models[index]!
2676
+ const model = models[index]
2358
2677
  const active = index === cursor
2359
2678
  const context = model.contextWindow === undefined ? '-' : String(model.contextWindow)
2360
2679
  const output = model.maxTokens === undefined ? '-' : String(model.maxTokens)
@@ -2364,10 +2683,11 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2364
2683
  : ' in:' + (active && field === 'ctx' ? '[' + context + ']' : context) + ' out:' + (active && field === 'out' ? '[' + output + ']' : output) + ' eff:' + effortsSummary(model)
2365
2684
  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
2685
  }
2686
+ const accent = panelAccent('model-configure', getPalette().brand)
2367
2687
  return createElement(
2368
2688
  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)),
2689
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2690
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.setup.title', { provider: target.displayName }), viewport.contentColumns)),
2371
2691
  // The adapter's configuration diagnostic heads the editor: the provider
2372
2692
  // is here precisely because it stayed listed for repair.
2373
2693
  ...(target.diagnostic === undefined ? [] : [createElement(
@@ -2381,7 +2701,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2381
2701
  ...stateRows,
2382
2702
  ...modelRows,
2383
2703
  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)),
2704
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.setup.footer'), viewport.contentColumns)),
2385
2705
  )
2386
2706
  }
2387
2707
 
@@ -2397,11 +2717,11 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2397
2717
  baseURL: string
2398
2718
  apiKey: string
2399
2719
  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
2720
+ discover: (target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal) => Promise<readonly DiscoveredModelView[]>
2721
+ onAdopt: (models: readonly DiscoveredModelView[]) => void
2722
+ back: () => void
2403
2723
  /** Leave the whole /model flow (Ctrl+C). */
2404
- onExit(): void
2724
+ onExit: () => void
2405
2725
  }): ReactElement {
2406
2726
  const stdout = useStdout().stdout
2407
2727
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2462,24 +2782,25 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2462
2782
  }
2463
2783
  }, true)
2464
2784
  if (viewport.maxHeight === 0) {
2465
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('model discovery · terminal too small · esc back', viewport.contentColumns))
2785
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.compact'), viewport.contentColumns))
2466
2786
  }
2467
2787
  const stateRows = loading
2468
- ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' discovering models…', viewport.contentColumns))]
2788
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.loading')}`, viewport.contentColumns))]
2469
2789
  : error !== undefined
2470
2790
  ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(' ' + error, viewport.contentColumns))]
2471
2791
  : 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))]
2792
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.empty')}`, viewport.contentColumns))]
2793
+ : [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
2794
  // One spare row keeps the panel strictly below maxHeight even with the
2475
2795
  // gap collapsed (the at-equality regime makes Ink rewrite Static).
2476
2796
  const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 1)
2477
2797
  const first = selectionWindow(cursor, rows.length, rowBudget)
2478
2798
  const visible = rows.slice(first, first + rowBudget)
2799
+ const accent = panelAccent('model-discover', getPalette().brand)
2479
2800
  return createElement(
2480
2801
  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)),
2802
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2803
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.title', { provider: target.displayName }), viewport.contentColumns)),
2483
2804
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2484
2805
  ...stateRows,
2485
2806
  ...visible.map((model, index) => {
@@ -2491,7 +2812,7 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2491
2812
  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
2813
  }),
2493
2814
  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)),
2815
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.footer'), viewport.contentColumns)),
2495
2816
  )
2496
2817
  }
2497
2818
 
@@ -2499,9 +2820,9 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2499
2820
  function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
2500
2821
  target: ProviderTargetView
2501
2822
  kind: 'credential' | 'provider'
2502
- confirm(target: ProviderTargetView): Promise<void>
2503
- done(): void
2504
- back(): void
2823
+ confirm: (target: ProviderTargetView) => Promise<void>
2824
+ done: () => void
2825
+ back: () => void
2505
2826
  }): ReactElement {
2506
2827
  const stdout = useStdout().stdout
2507
2828
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2559,7 +2880,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2559
2880
  skills: readonly SkillRow[]
2560
2881
  commandError: string | undefined
2561
2882
  skillError: string | undefined
2562
- onClose(): void
2883
+ onClose: () => void
2563
2884
  }): ReactElement {
2564
2885
  const stdout = useStdout().stdout
2565
2886
  const columns = stdout?.columns ?? 80
@@ -2569,19 +2890,19 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2569
2890
  const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
2570
2891
  const row = (label: string, description: string): ReactElement => createElement(
2571
2892
  Text,
2572
- { dimColor: true, wrap: 'truncate-end' },
2573
- ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
2893
+ { color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2894
+ ` ${padColumns(label, nameWidth)}${truncateColumns(displayText(description), descBudget)}`,
2574
2895
  )
2575
2896
  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'),
2897
+ createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, t('help.keysTitle')),
2898
+ createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.submit')}`),
2899
+ createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.mentions')}`),
2900
+ createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.inspector')}`),
2901
+ createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.cancel')}`),
2902
+ createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.queue')}`),
2903
+ createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ` ${t('help.key.edit')}`),
2583
2904
  createElement(Text, { key: 'commands-gap' }, ' '),
2584
- createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
2905
+ createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, t('help.commandsTitle')),
2585
2906
  ...(commandError === undefined
2586
2907
  ? []
2587
2908
  : [createElement(
@@ -2592,18 +2913,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2592
2913
  ...LOCAL_COMMANDS.map(command => createElement(
2593
2914
  Box,
2594
2915
  { key: `local-${command.label.slice(1)}` },
2595
- row(command.label, command.description),
2916
+ row(command.label, t(command.descriptionKey)),
2596
2917
  )),
2597
2918
  ...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
2598
2919
  Text,
2599
- { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
2600
- ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
2920
+ { key: `command-${descriptor.name}`, color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2921
+ ` ${padColumns(`/${descriptor.name}`, nameWidth)}${truncateColumns(displayText(descriptor.description), descBudget)}`,
2601
2922
  )),
2602
2923
  ...(skills.length === 0 && skillError === undefined
2603
2924
  ? []
2604
2925
  : [
2605
2926
  createElement(Text, { key: 'skills-gap' }, ' '),
2606
- createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills'),
2927
+ createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, t('help.skillsTitle')),
2607
2928
  ]),
2608
2929
  ...(skillError === undefined
2609
2930
  ? []
@@ -2614,8 +2935,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2614
2935
  )]),
2615
2936
  ...skills.map(skill => createElement(
2616
2937
  Text,
2617
- { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
2618
- ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
2938
+ { key: `skill-${skill.name}`, color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2939
+ ` ${padColumns(`/${skill.name}`, nameWidth)}${truncateColumns(displayText(skill.description), descBudget)}`,
2619
2940
  )),
2620
2941
  ]
2621
2942
  const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
@@ -2641,17 +2962,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2641
2962
  })
2642
2963
 
2643
2964
  if (viewport.maxHeight === 0 || viewport.compact) {
2644
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
2965
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('help.compact'), viewport.contentColumns))
2645
2966
  }
2646
2967
 
2968
+ const accent = panelAccent('help', getPalette().brand)
2647
2969
  return createElement(
2648
2970
  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)),
2971
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
2972
+ 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
2973
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2652
2974
  ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
2653
2975
  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))),
2976
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(t('help.footer'), viewport.contentColumns))),
2655
2977
  )
2656
2978
  }
2657
2979
 
@@ -2661,7 +2983,8 @@ function verboseLine(text: string, columns: number): string {
2661
2983
  }
2662
2984
 
2663
2985
  /** The empty-composer placeholder text (shared by the static and wave paths). */
2664
- const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2986
+ const composerPlaceholder = (mode: 'queue' | 'steer'): string =>
2987
+ t(mode === 'steer' ? 'composer.placeholderSteer' : 'composer.placeholder')
2665
2988
 
2666
2989
  /** One physical cell of the wave-painted composer row: a char plus styles. */
2667
2990
  interface ComposerCell {
@@ -2694,9 +3017,9 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
2694
3017
  const spans: ReactElement[] = []
2695
3018
  let start = 0
2696
3019
  while (start < cells.length) {
2697
- const cell = cells[start]!
3020
+ const cell = cells[start]
2698
3021
  let end = start + 1
2699
- while (end < cells.length && sameCellStyle(cells[end]!, cell)) end += 1
3022
+ while (end < cells.length && sameCellStyle(cells[end], cell)) end += 1
2700
3023
  spans.push(createElement(
2701
3024
  Text,
2702
3025
  {
@@ -2719,7 +3042,7 @@ function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): numb
2719
3042
  let column = 0
2720
3043
  for (let index = 0; index < cells.length; index += 1) {
2721
3044
  if (column === target) return index
2722
- column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
3045
+ column += cells[index].width ?? visibleColumns(cells[index].char)
2723
3046
  if (column > target) return undefined
2724
3047
  }
2725
3048
  return undefined
@@ -2792,11 +3115,13 @@ interface ComposerWaveProps {
2792
3115
  value: string
2793
3116
  /** Tier prompt glyph and accent color (persistent, like Codex's charge). */
2794
3117
  promptGlyph: string
3118
+ /** Empty-composer placeholder for the delivery mode in force. */
3119
+ placeholder: string
2795
3120
  promptColor: string
2796
3121
  /** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
2797
3122
  * cancelled by the gate, or unmounted (a modal panel froze the composer) —
2798
3123
  * so Input's played-key latch survives the leaf's unmount/remount cycle. */
2799
- onSettled(): void
3124
+ onSettled: () => void
2800
3125
  }
2801
3126
 
2802
3127
  /**
@@ -2864,7 +3189,7 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2864
3189
  }
2865
3190
  for (const span of splitGraphemes(parts.before)) push(span.text)
2866
3191
  if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
2867
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3192
+ const tail = placeholder ? props.placeholder : parts.after
2868
3193
  for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
2869
3194
  while (usedColumns < props.bandWidth) push(' ')
2870
3195
 
@@ -2873,9 +3198,9 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2873
3198
  const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
2874
3199
  const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
2875
3200
  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))) {
3201
+ if (indices.every(index => index !== undefined && (cells[index].char === ' ' || cells[index].dim === true))) {
2877
3202
  for (let at = 0; at < word.length; at += 1) {
2878
- const cell = cells[indices[at]!]!
3203
+ const cell = cells[indices[at]!]
2879
3204
  cell.char = word[at]!
2880
3205
  cell.width = 1
2881
3206
  cell.color = inkColor(deepseekWaveWordHue(at, hues))
@@ -2887,11 +3212,11 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2887
3212
  if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
2888
3213
  const spark = deepseekWaveSpark(tick)
2889
3214
  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
3215
+ if (spark !== null && lastIndex !== undefined && cells[lastIndex].char === ' ') {
3216
+ cells[lastIndex].char = spark
3217
+ cells[lastIndex].color = props.promptColor
3218
+ cells[lastIndex].bold = true
3219
+ cells[lastIndex].dim = false
2895
3220
  }
2896
3221
  }
2897
3222
  return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
@@ -2905,13 +3230,104 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2905
3230
  )
2906
3231
  }
2907
3232
 
3233
+ /**
3234
+ * The /rainbow celebration leaf: a FIXED seven-color ribbon that slides
3235
+ * across the three-row composer band. Same cell model as ComposerWave so
3236
+ * CJK/emoji stay atomic; no wordmark, no sparkles — the spectrum is the
3237
+ * show. Strictly one-shot per burst id (Input latches onSettled).
3238
+ */
3239
+ function ComposerRainbowBurst(props: Omit<ComposerWaveProps, 'tier' | 'style'>): ReactElement {
3240
+ const durationMs = RAINBOW_BURST_DURATION_MS
3241
+ const { tick, done } = useWaveFrames(props.active, durationMs)
3242
+ const settledRef = useRef(false)
3243
+ const onSettledRef = useRef(props.onSettled)
3244
+ onSettledRef.current = props.onSettled
3245
+ const settle = (): void => {
3246
+ if (settledRef.current) return
3247
+ settledRef.current = true
3248
+ onSettledRef.current()
3249
+ }
3250
+ useEffect(() => {
3251
+ if (done) settle()
3252
+ }, [done])
3253
+ useEffect(() => () => {
3254
+ settle()
3255
+ }, [])
3256
+ if (!props.active || done || tick * RAINBOW_BURST_TICK_MS >= durationMs) return props.fallback
3257
+ const bandRgb = getPalette().composerBand
3258
+ const totalBandRows = props.rows.length + 2
3259
+ const burstBg = (row: number, column: number): string => {
3260
+ const rgb = rainbowBurstColumnBg(tick, column, props.bandWidth, bandRgb, row, totalBandRows)
3261
+ return rgb === null ? props.bandBg : inkColor(rgb)
3262
+ }
3263
+ const blankBandRow = (row: number): ReactElement => {
3264
+ const blanks: ComposerCell[] = []
3265
+ for (let column = 0; column < props.bandWidth; column += 1) {
3266
+ blanks.push({ char: ' ', width: 1, backgroundColor: burstBg(row, column) })
3267
+ }
3268
+ return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
3269
+ }
3270
+ const editorBurstRows = props.rows.map((row, visibleIndex) => {
3271
+ const sourceIndex = props.windowStart + visibleIndex
3272
+ const bandRow = visibleIndex + 1
3273
+ const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
3274
+ const placeholder = sourceIndex === 0 && props.value === ''
3275
+ const cells: ComposerCell[] = []
3276
+ let usedColumns = 0
3277
+ const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
3278
+ const width = visibleColumns(char)
3279
+ cells.push({ char, width, backgroundColor: burstBg(bandRow, usedColumns), ...extra })
3280
+ usedColumns += width
3281
+ }
3282
+ if (sourceIndex === 0) {
3283
+ push(props.promptGlyph, { color: props.promptColor, bold: true })
3284
+ push(' ', { color: props.promptColor })
3285
+ } else {
3286
+ push(' ')
3287
+ push(' ')
3288
+ }
3289
+ for (const span of splitGraphemes(parts.before)) push(span.text)
3290
+ if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
3291
+ const tail = placeholder ? props.placeholder : parts.after
3292
+ for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
3293
+ while (usedColumns < props.bandWidth) push(' ')
3294
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
3295
+ })
3296
+ return createElement(
3297
+ Box,
3298
+ { flexDirection: 'column', width: props.bandWidth },
3299
+ blankBandRow(0),
3300
+ ...editorBurstRows,
3301
+ blankBandRow(totalBandRows - 1),
3302
+ )
3303
+ }
3304
+
3305
+ /** One-word kind label per entry, so the inspector's ←→ walk names what
3306
+ * each step is instead of leaving the reader to infer it from the body. */
3307
+ function entryKindLabel(entry: TranscriptEntry | undefined): string {
3308
+ switch (entry?.kind) {
3309
+ case 'user': return 'user prompt'
3310
+ case 'pending': return 'queued prompt'
3311
+ case 'assistant': return 'reply'
3312
+ case 'tool': return 'tool call'
3313
+ case 'command': return 'command'
3314
+ case 'error': return 'turn error'
3315
+ case 'turn-marker': return 'turn end'
3316
+ case 'compaction': return 'compaction'
3317
+ case 'retry': return 'retry'
3318
+ case 'files': return 'files changed'
3319
+ case 'workflow': return 'workflow run'
3320
+ default: return 'empty'
3321
+ }
3322
+ }
3323
+
2908
3324
  /**
2909
3325
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
2910
3326
  * with independent history selection and content scrolling. The complete
2911
3327
  * retained entry is converted to physical rows, but only one viewport slice
2912
3328
  * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
2913
3329
  */
2914
- function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
3330
+ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose: () => void }): ReactElement {
2915
3331
  const stdout = useStdout().stdout
2916
3332
  const columns = stdout?.columns ?? 80
2917
3333
  const rows = stdout?.rows ?? 30
@@ -3005,14 +3421,15 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
3005
3421
  return createElement(
3006
3422
  Text,
3007
3423
  { wrap: 'truncate-end' },
3008
- truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
3424
+ truncateColumns(t('panel.verbose.compact'), viewport.contentColumns),
3009
3425
  )
3010
3426
  }
3011
3427
 
3012
3428
  const title = entries.length === 0
3013
3429
  ? '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}`
3430
+ : `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
3431
  const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
3432
+ const accent = panelAccent('history-inspector', getPalette().brand)
3016
3433
  return createElement(
3017
3434
  Box,
3018
3435
  {
@@ -3020,11 +3437,11 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
3020
3437
  width: viewport.outerColumns,
3021
3438
  paddingX: 1,
3022
3439
  borderStyle: 'round',
3023
- borderColor: inkColor(getPalette().brand),
3440
+ borderColor: inkColor(accent.border),
3024
3441
  },
3025
3442
  createElement(
3026
3443
  Text,
3027
- { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
3444
+ { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' },
3028
3445
  truncateColumns(title, viewport.contentColumns),
3029
3446
  ),
3030
3447
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -3038,8 +3455,8 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
3038
3455
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
3039
3456
  createElement(
3040
3457
  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)),
3458
+ { wrap: 'truncate-end' },
3459
+ dim(truncateColumns(t('panel.verbose.footer'), viewport.contentColumns)),
3043
3460
  ),
3044
3461
  )
3045
3462
  }
@@ -3086,7 +3503,7 @@ export function completionCandidates(
3086
3503
  ): readonly CompletionCandidate[] {
3087
3504
  if (!value.startsWith('/')) return []
3088
3505
  const prefix = value.slice(1).split(' ')[0] ?? ''
3089
- const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
3506
+ const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ label: command.label, description: t(command.descriptionKey), origin: 'command' }))
3090
3507
  // Local commands shadow registry names (e.g. the TUI-local /permission works
3091
3508
  // before any session exists, while the registry child needs one), so
3092
3509
  // collisions cannot render two rows with the same key.
@@ -3151,10 +3568,10 @@ function completionMenuRowCount(terminalRows: number, rowCount: number): number
3151
3568
 
3152
3569
  /**
3153
3570
  * 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
3571
+ * the composer band — attached the way Claude-Code anchors its dropdown.
3572
+ * Its height is deducted from the live transcript budget so the menu covers
3573
+ * live rows instead of growing the tree and moving the composer/status.
3574
+ * Props-only (no lifted state): the menu is a pure view of the input
3158
3575
  * editor's live completion state, so no cross-component effect ever resyncs
3159
3576
  * it (a state lift here previously deadlocked the menu after a resize).
3160
3577
  */
@@ -3212,7 +3629,7 @@ function CompletionMenu({ active, mention, index, rows, error }: {
3212
3629
  // Scroll affordance: with the full merged catalog (commands + registry +
3213
3630
  // skills) the six-row window rarely shows the tail — count and hint keep
3214
3631
  // 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,
3632
+ hidden > 0 ? createElement(Text, { key: 'more', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` … +${hidden} more`) : undefined,
3216
3633
  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
3634
  )
3218
3635
  }
@@ -3234,7 +3651,7 @@ interface DraftFile extends FilePathInspection {
3234
3651
  * While a modal (approval / question / model panel) owns the keys, the
3235
3652
  * box passes every key through untouched.
3236
3653
  */
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 }: {
3654
+ 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
3655
  active: boolean
3239
3656
  frozen: boolean
3240
3657
  /** Frozen-band hint naming the surface that owns the keyboard; an empty
@@ -3243,82 +3660,100 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3243
3660
  busy: boolean
3244
3661
  descriptors: readonly CommandDescriptor[]
3245
3662
  skills: readonly SkillRow[]
3246
- dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3247
- steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3663
+ dispatch: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
3664
+ /** Submit as steering into the running turn (see {@link AppProps.steer}). */
3665
+ steer: (text: string, attachments?: readonly ContentBlock[], origin?: string) => void
3666
+ /** Delivery mode the next submission uses; Tab on an empty composer flips it. */
3667
+ submitMode: 'queue' | 'steer'
3668
+ /** Flip {@link submitMode} and report the new mode. */
3669
+ cycleSubmitMode: () => void
3248
3670
  /** The full current session identity ('' while pending); the delivery origin. */
3249
3671
  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
3672
+ interrupt: () => boolean
3673
+ quit: () => void
3674
+ openModel: () => void
3675
+ openEffort: () => void
3676
+ openHelp: () => void
3677
+ openMode: () => void
3678
+ openPermission: () => void
3679
+ openResume: () => void
3680
+ /** Open the /search panel with an optional seed query. */
3681
+ openSearch: (query: string) => void
3682
+ openPlugin: (query?: string) => void
3259
3683
  /** Open the /update panel (aligned upgrade surface). */
3260
- openUpdate(): void
3684
+ openUpdate: () => void
3261
3685
  /** Open the /schedule reminder panel (read-only catalog). */
3262
- openSchedule(): void
3263
- openJobs(): void
3264
- openStatusline(): void
3265
- openTheme(): void
3266
- openHistory(): void
3686
+ openSchedule: () => void
3687
+ openJobs: () => void
3688
+ openStatusline: () => void
3689
+ openTheme: () => void
3690
+ /** Open the /language picker (bare /language). */
3691
+ openLanguage: () => void
3692
+ /** Apply and persist a language chosen by argument. */
3693
+ saveLanguage: (name: LanguageName) => void
3694
+ openHistory: () => void
3695
+ openQueue: () => void
3267
3696
  /** Open the /agents panel (live subagent feed + transcript entry). */
3268
- openAgents(): void
3697
+ openAgents: () => void
3269
3698
  /** Open the /subagent model panel. */
3270
- openSubagent(): void
3699
+ openSubagent: () => void
3271
3700
  /** Open the /todos subpage (full todo list in one bounded panel). */
3272
- openTodos(): void
3701
+ openTodos: () => void
3702
+ openUsage: () => void
3273
3703
  /** 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
3704
+ openDelete: (id?: string) => void
3705
+ openDiff: (argument: string) => void
3706
+ reviewChanges: (selection: ReviewSelection) => void
3707
+ /** Open the /review candidate picker (bare /review). */
3708
+ openReviewPicker: () => void
3277
3709
  /** The row id awaiting y/n in this box, when a deletion is pending. */
3278
3710
  deleteConfirm?: string
3279
3711
  /** Confirm the pending deletion (y in the box). */
3280
- confirmDelete(): void
3712
+ confirmDelete: () => void
3281
3713
  /** 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
3714
+ cancelDelete: () => void
3715
+ createSession: (mode?: string) => void
3716
+ forkSession: (argument: string) => void
3717
+ cancelSessionSwitch: () => boolean
3718
+ notify: (text: string, tone?: NoticeTone) => void
3287
3719
  /** Apply the Ctrl+R passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
3288
- applyEditorKeys(): Promise<string>
3720
+ applyEditorKeys: () => Promise<string>
3289
3721
  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>
3722
+ dismissNotice: () => void
3723
+ toggleReasoning: () => void
3724
+ openVerbose: () => void
3725
+ clearView: () => void
3726
+ refresh: () => void
3727
+ loadMentions: (query: string, signal?: AbortSignal) => Promise<readonly MentionCandidate[]>
3728
+ inspectImages: (paths: readonly string[]) => Promise<readonly ImagePathInspection[]>
3729
+ prepareImages: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly ImageBlock[]>
3730
+ inspectFiles: (paths: readonly string[]) => Promise<readonly FilePathInspection[]>
3731
+ prepareFiles: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly FileBlock[]>
3732
+ cycleMode: () => string
3733
+ exportTranscript: (argument: string) => Promise<void>
3734
+ renameTitle: (argument: string) => string
3735
+ copyLastResponse: () => Promise<string>
3304
3736
  /** Newest-first recall space (persistent + in-session, deduped). */
3305
3737
  recallSpace: readonly string[]
3306
3738
  /** Record one in-session submission (deduped, local only). */
3307
- recordLocal(text: string): void
3739
+ recordLocal: (text: string) => void
3308
3740
  /** 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
3741
+ recordHistory: (text: string) => void
3742
+ /** Next-turn inbox rows, ordered exactly as the durable inbox. */
3743
+ queued: readonly Extract<TranscriptEntry, { kind: 'pending' }>[]
3744
+ updateQueued?: (messageId: string, action: QueueMutation) => void
3314
3745
  /** Accepted /history entry waiting to be placed into the composer. */
3315
3746
  historyFill: { text: string; index: number } | undefined
3316
3747
  /** Marks the accepted entry consumed (called after the fill is applied). */
3317
- historyConsumed(): void
3748
+ historyConsumed: () => void
3318
3749
  /** Whether timed animations run (shimmer, chase, blink, wave). */
3319
3750
  animations: boolean
3320
3751
  /** Apply and report one /animation toggle (App persists through the runner). */
3321
- applyAnimations(enabled: boolean): void
3752
+ applyAnimations: (enabled: boolean) => void
3753
+ /** Reroll or pin the rainbow palette (switches to rainbow if needed). */
3754
+ applyRainbow: (seed?: number) => void
3755
+ /** Monotonic id of the in-flight /rainbow composer burst; 0 means none. */
3756
+ rainbowBurstId: number
3322
3757
  /** DeepSeek easter-egg wave tier of the applied route (null otherwise):
3323
3758
  * official DeepSeek models drive their flash/pro tiers, non-DeepSeek
3324
3759
  * models running an effort above high drive the "Into the Unknown"
@@ -3337,10 +3772,10 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3337
3772
  * background process sharing the console cannot keep it overwritten. */
3338
3773
  tabTitle: string
3339
3774
  /** Reports the editor's current physical row count so the live budget stays exact. */
3340
- onEditorRows(rows: number): void
3775
+ onEditorRows: (rows: number) => void
3341
3776
  /** Reports the open completion menu's physical row count (0 when closed)
3342
3777
  * for the same reason: the dynamic budget must reserve it, not overflow. */
3343
- onMenuRows(rows: number): void
3778
+ onMenuRows: (rows: number) => void
3344
3779
  }): ReactElement {
3345
3780
  const { stdout: inputStdout } = useStdout()
3346
3781
  const columns = inputStdout?.columns ?? 80
@@ -3443,7 +3878,11 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3443
3878
  if (stdin === undefined) return
3444
3879
  const originalRead = stdin.read.bind(stdin)
3445
3880
  const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
3446
- const chunk = originalRead(...args)
3881
+ // `Readable.read` is declared `any`; the assertion names its real result
3882
+ // union once so the normalization and the focus-event stripping below
3883
+ // stay type-checked. Node hands back a Buffer unless an encoding was set,
3884
+ // and null once the stream ends.
3885
+ const chunk = originalRead(...args) as string | Buffer | null
3447
3886
  if (chunk === null) return chunk
3448
3887
  const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
3449
3888
  const input = focusReporting
@@ -3464,7 +3903,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3464
3903
  } as typeof stdin.read
3465
3904
  stdin.read = patchedRead
3466
3905
  return () => {
3467
- stdin.read = originalRead as typeof stdin.read
3906
+ stdin.read = originalRead
3468
3907
  }
3469
3908
  }, [focusReporting, stdin])
3470
3909
 
@@ -3519,7 +3958,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3519
3958
 
3520
3959
  const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
3521
3960
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
3522
- notify(`${inspection.name} is already attached`, 'warning')
3961
+ notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
3523
3962
  return false
3524
3963
  }
3525
3964
  const next = [...draftImagesRef.current, { ...inspection, marker }]
@@ -3538,7 +3977,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3538
3977
  const originalCursor = cursorRef.current
3539
3978
  const total = imagePaths.length + filePaths.length
3540
3979
  if (total === 0) return
3541
- notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
3980
+ notify(t('notice.attachmentsChecking', { count: total, plural: total === 1 ? '' : 's' }))
3542
3981
  void Promise.all([
3543
3982
  imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
3544
3983
  filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
@@ -3559,13 +3998,13 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3559
3998
  markers.push(marker)
3560
3999
  }
3561
4000
  if (imageAdditions.length === 0 && fileAdditions.length === 0) {
3562
- notify('those attachments are already attached', 'warning')
4001
+ notify(t('notice.attachmentsAlready'), 'warning')
3563
4002
  return
3564
4003
  }
3565
4004
  const current = valueRef.current
3566
4005
  const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
3567
4006
  if (anchor === undefined) {
3568
- notify('draft changed at the attachment drop point; drop the files again', 'warning')
4007
+ notify(t('notice.attachmentDraftChanged'), 'warning')
3569
4008
  return
3570
4009
  }
3571
4010
  const at = anchor.start
@@ -3586,9 +4025,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3586
4025
  draftFilesRef.current = nextFiles
3587
4026
  setDraftFiles(nextFiles)
3588
4027
  const count = imageAdditions.length + fileAdditions.length
3589
- notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
4028
+ notify(t('notice.attachmentsReady', { count, plural: count === 1 ? '' : 's' }))
3590
4029
  }, (reason: unknown) => {
3591
- notify(`attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
4030
+ notify(t('notice.attachmentFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
3592
4031
  })
3593
4032
  }
3594
4033
 
@@ -3673,7 +4112,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3673
4112
  const current = valueRef.current
3674
4113
  const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
3675
4114
  if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
3676
- notify('draft changed around the image mention; select it again', 'warning')
4115
+ notify(t('notice.imageDraftChanged'), 'warning')
3677
4116
  return
3678
4117
  }
3679
4118
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
@@ -3684,7 +4123,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3684
4123
  setCursor(edit.cursor)
3685
4124
  resetCursorBlink()
3686
4125
  setDismissedMenuValue(edit.value)
3687
- notify(`${inspection.name} is already attached`, 'warning')
4126
+ notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
3688
4127
  return
3689
4128
  }
3690
4129
  const marker = uniqueImageMarker(inspection.name, 'mention')
@@ -3696,9 +4135,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3696
4135
  resetCursorBlink()
3697
4136
  setDismissedMenuValue(edit.value)
3698
4137
  registerDraftImage(inspection, marker)
3699
- notify(`${inspection.name} ready for the next message`)
4138
+ notify(t('notice.imageReady', { name: inspection.name }))
3700
4139
  }, (reason: unknown) => {
3701
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
4140
+ notify(t('notice.imageFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
3702
4141
  })
3703
4142
  setCompletionIndex(0)
3704
4143
  setDismissedMenuValue(undefined)
@@ -3802,7 +4241,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3802
4241
  prepareAbortRef.current = undefined
3803
4242
  setPreparingImages(false)
3804
4243
  dismissNotice()
3805
- notify('image submission cancelled', 'warning')
4244
+ notify(t('notice.imageCancelled'), 'warning')
3806
4245
  }
3807
4246
 
3808
4247
  /** Cross history while an unchanged recalled draft rests its caret on
@@ -3877,10 +4316,18 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3877
4316
  const label = cycleMode()
3878
4317
  if (label !== '') notify(label)
3879
4318
  } catch (error: unknown) {
3880
- notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
4319
+ notify(t('notice.permissionChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
3881
4320
  }
3882
4321
  return
3883
4322
  }
4323
+ // Tab on an EMPTY composer picks how the next submission is delivered:
4324
+ // queue for the next turn, or steer into the turn already running. With a
4325
+ // draft present Tab stays the completion key (handled with the menu
4326
+ // below), so this only claims the keypress when nothing is being typed.
4327
+ if (key.tab && liveValue === '' && !menuActive) {
4328
+ cycleSubmitMode()
4329
+ return
4330
+ }
3884
4331
  // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
3885
4332
  // Alt+R is the zero-config alias: VS Code never intercepts Alt chords,
3886
4333
  // so the toggle stays reachable before /vscode-keys has been applied.
@@ -3926,7 +4373,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3926
4373
  applyEdit(deleteForward(liveValue, liveCursor))
3927
4374
  return
3928
4375
  }
3929
- if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
4376
+ if (busy) notify(t('notice.cancelBeforeExit'), 'warning')
3930
4377
  else quit()
3931
4378
  return
3932
4379
  }
@@ -3952,7 +4399,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3952
4399
  const forwardDelete = rawEditorTokens.current?.some(token =>
3953
4400
  token.kind === 'delete-forward' || token.kind === 'delete-word-forward') === true
3954
4401
  if (forwardDelete) {
3955
- cancelQueued(queued[queued.length - 1]!.messageId)
4402
+ updateQueued?.(queued[queued.length - 1].messageId, { kind: 'remove' })
3956
4403
  return
3957
4404
  }
3958
4405
  }
@@ -3984,7 +4431,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
3984
4431
  // Slash semantics with attachments are unchanged: commands cannot
3985
4432
  // carry attachments, so the line goes to the model as a prompt —
3986
4433
  // 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')
4434
+ if (isSlashLine(text)) notify(t('notice.commandAttachments'), 'warning')
3988
4435
  // Attachment prepares resolve asynchronously; the app remounts onto
3989
4436
  // another session in the meantime, and this (old) instance's unmount
3990
4437
  // cleanup runs too late on the microtask timeline. Tag the delivery
@@ -4023,7 +4470,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4023
4470
  }
4024
4471
  recall.current = beginRecall(recallSpace, '')
4025
4472
  const blocks: readonly ContentBlock[] = [...images, ...files]
4026
- if (busy) steer(text, blocks, originSession)
4473
+ if (submitMode === 'steer') steer(text, blocks, originSession)
4027
4474
  else dispatch(text, blocks, originSession)
4028
4475
  }, (reason: unknown) => {
4029
4476
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
@@ -4092,7 +4539,16 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4092
4539
  return
4093
4540
  }
4094
4541
  if (text === '/review' || text.startsWith('/review ')) {
4095
- reviewChanges(text.slice(7))
4542
+ const argument = text.slice(7).trim()
4543
+ if (argument === '') {
4544
+ openReviewPicker()
4545
+ return
4546
+ }
4547
+ try {
4548
+ reviewChanges(parseReviewArgument(argument))
4549
+ } catch (error: unknown) {
4550
+ notify(error instanceof Error ? error.message : String(error), 'warning')
4551
+ }
4096
4552
  return
4097
4553
  }
4098
4554
  if (text === '/model' || text.startsWith('/model ')) {
@@ -4127,6 +4583,10 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4127
4583
  else dispatch(text)
4128
4584
  return
4129
4585
  }
4586
+ if (text === '/search' || text.startsWith('/search ')) {
4587
+ openSearch(text.slice(7).trim())
4588
+ return
4589
+ }
4130
4590
  if (text === '/new' || text.startsWith('/new ')) {
4131
4591
  createSession(text.slice(4).trim() || undefined)
4132
4592
  return
@@ -4159,17 +4619,41 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4159
4619
  openTheme()
4160
4620
  return
4161
4621
  }
4622
+ if (text === '/language' || text.startsWith('/language ')) {
4623
+ const argument = text.slice('/language'.length).trim()
4624
+ if (argument === '') openLanguage()
4625
+ else if (argument === 'en' || argument === 'zh') {
4626
+ saveLanguage(parseLanguageName(argument))
4627
+ notify(t('notice.languageSaved', { name: argument }))
4628
+ refresh()
4629
+ } else notify(t('notice.usage.language'), 'warning')
4630
+ return
4631
+ }
4162
4632
  if (text === '/animation' || text.startsWith('/animation ')) {
4163
4633
  const parsed = parseAnimationsArgument(text.slice('/animation'.length))
4164
4634
  if (parsed === 'toggle') applyAnimations(!animations)
4165
- else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
4635
+ else if (parsed === 'usage') notify(t('notice.usage.animation'), 'info')
4166
4636
  else applyAnimations(parsed.enabled)
4167
4637
  return
4168
4638
  }
4639
+ if (text === '/rainbow' || text.startsWith('/rainbow ')) {
4640
+ const parsed = parseRainbowArgument(text.slice('/rainbow'.length))
4641
+ if (parsed === 'usage') notify(t('notice.usage.rainbow'), 'warning')
4642
+ else applyRainbow(parsed === 'random' ? undefined : parsed.seed)
4643
+ return
4644
+ }
4169
4645
  if (text === '/history') {
4170
4646
  openHistory()
4171
4647
  return
4172
4648
  }
4649
+ if (text === '/queue') {
4650
+ openQueue()
4651
+ return
4652
+ }
4653
+ if (text === '/usage') {
4654
+ openUsage()
4655
+ return
4656
+ }
4173
4657
  if (text === '/agents') {
4174
4658
  openAgents()
4175
4659
  return
@@ -4193,19 +4677,25 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4193
4677
  openDelete(text.slice(7).trim())
4194
4678
  return
4195
4679
  }
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)
4201
- return
4202
- }
4203
- dispatch(text)
4680
+ // Delivery mode: everything above this point is a local command or a
4681
+ // panel opener and always runs out of band. A real prompt follows the
4682
+ // composer's Tab choice `steer` joins the running turn, the default
4683
+ // queues it for the next one.
4684
+ if (submitMode === 'steer') steer(text)
4685
+ else dispatch(text)
4686
+ return
4687
+ }
4688
+ // The modified-Enter newline family: Ctrl+J arrives as a bare LF (Ink
4689
+ // names it 'enter', not 'return'), and the kitty layer normalizes
4690
+ // Ctrl/Shift+Enter to the same byte. Alt+Enter reaches here as a bare CR
4691
+ // with no flags — Ink's parser drops the escape and reports no meta, and
4692
+ // plain Enter always carries key.return — so a flagless CR is Alt+Enter.
4693
+ // Only plain Enter submits. app.spec's "modified-Enter family" test pins
4694
+ // this exact parser shape; an Ink upgrade that changes it fails there.
4695
+ if (input === '\n' || input === '\r') {
4696
+ applyEdit(insertText(liveValue, liveCursor, '\n'))
4204
4697
  return
4205
4698
  }
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
4699
  // A fast Tab followed by text can arrive as one readable chunk in an
4210
4700
  // integrated terminal. Accept the candidate first, then apply the
4211
4701
  // remaining characters against the synchronously updated editor refs.
@@ -4377,13 +4867,23 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4377
4867
  if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
4378
4868
  }, [animations, waveKey, wavePlayedKey])
4379
4869
  const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
4870
+ const burstKey = rainbowBurstId > 0 ? `rainbow:${rainbowBurstId}` : null
4871
+ const [burstPlayedKey, setBurstPlayedKey] = useState<string | null>(null)
4872
+ useEffect(() => {
4873
+ if (!animations && burstKey !== null && burstKey !== burstPlayedKey) setBurstPlayedKey(burstKey)
4874
+ }, [animations, burstKey, burstPlayedKey])
4875
+ const burstArmed = burstKey !== null && burstKey !== burstPlayedKey
4380
4876
 
4381
4877
  // Every exclusive panel keeps the composer as a stable visual anchor, but
4382
4878
  // freezes it to one row: no menu, multiline wrap, or animation.
4383
4879
  const tierActive = waveTier !== null
4384
4880
  const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
4385
4881
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
4386
- const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
4882
+ const waveGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
4883
+ // Steer mode owns the prompt glyph in every paint path (static band, wave,
4884
+ // rainbow burst), so the mode is visible without reading the placeholder.
4885
+ const promptGlyph = submitMode === 'steer' ? '↳' : waveGlyph
4886
+ const placeholderText = composerPlaceholder(submitMode)
4387
4887
  // The multiline editor model: the sanitized draft hard-wrapped into
4388
4888
  // column-safe physical rows, with the caret mapped to its exact row and
4389
4889
  // column. Computed before the frozen path so the row report below runs
@@ -4457,7 +4957,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4457
4957
  return band(createElement(
4458
4958
  Text,
4459
4959
  { backgroundColor: bandBg, wrap: 'truncate-end' },
4460
- createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
4960
+ createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, submitMode === 'steer' ? '↳ ' : busy ? '… ' : `${promptGlyph} `),
4461
4961
  frozenLine,
4462
4962
  bandFill(2 + visibleColumns(frozenLine)),
4463
4963
  ))
@@ -4475,10 +4975,10 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4475
4975
  // spacer or a second blink timer.
4476
4976
  const editorRows: ReactElement[] = []
4477
4977
  for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
4478
- const row = editorViewModel.rows[index]!
4978
+ const row = editorViewModel.rows[index]
4479
4979
  const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
4480
4980
  const placeholder = index === 0 && value === '' && !busy && !preparingImages
4481
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
4981
+ const tail = placeholder ? placeholderText : parts.after
4482
4982
  const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
4483
4983
  editorRows.push(createElement(
4484
4984
  Text,
@@ -4486,16 +4986,18 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4486
4986
  index === 0
4487
4987
  ? preparingImages
4488
4988
  ? 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} `)
4989
+ : submitMode === 'steer'
4990
+ ? createElement(Text, { color: promptColor, bold: true }, '↳ ')
4991
+ : busy
4992
+ ? createElement(BusyChase, { animated: animations })
4993
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
4492
4994
  : ' ',
4493
4995
  parts.before,
4494
4996
  parts.hasCaret
4495
4997
  ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
4496
4998
  : null,
4497
4999
  placeholder
4498
- ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
5000
+ ? createElement(Text, { dimColor: true }, tail)
4499
5001
  : parts.after,
4500
5002
  bandFill(consumed),
4501
5003
  ))
@@ -4511,26 +5013,47 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
4511
5013
  Box,
4512
5014
  { flexDirection: 'column' },
4513
5015
  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
- }),
5016
+ burstArmed
5017
+ ? createElement(ComposerRainbowBurst, {
5018
+ key: burstKey,
5019
+ active: !busy && !preparingImages && animations,
5020
+ onSettled: () => {
5021
+ if (burstKey !== null) setBurstPlayedKey(burstKey)
5022
+ },
5023
+ fallback: band(staticEditor),
5024
+ bandWidth,
5025
+ bandBg,
5026
+ rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
5027
+ windowStart: editorWindowStart,
5028
+ caretRow: caret.row,
5029
+ cursor: clampedCursor,
5030
+ caretVisible: cursorVisible,
5031
+ value,
5032
+ promptGlyph,
5033
+ placeholder: placeholderText,
5034
+ promptColor,
5035
+ })
5036
+ : createElement(ComposerWave, {
5037
+ key: waveKey ?? 'static',
5038
+ tier: waveTier ?? 'deepseek',
5039
+ style: waveStyle ?? 'wave',
5040
+ active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
5041
+ onSettled: () => {
5042
+ if (waveKey !== null) setWavePlayedKey(waveKey)
5043
+ },
5044
+ fallback: band(staticEditor),
5045
+ bandWidth,
5046
+ bandBg,
5047
+ rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
5048
+ windowStart: editorWindowStart,
5049
+ caretRow: caret.row,
5050
+ cursor: clampedCursor,
5051
+ caretVisible: cursorVisible,
5052
+ value,
5053
+ promptGlyph,
5054
+ placeholder: placeholderText,
5055
+ promptColor,
5056
+ }),
4534
5057
  )
4535
5058
  }
4536
5059
 
@@ -4755,7 +5278,13 @@ export function computeSettledRows(
4755
5278
 
4756
5279
  /** The whole terminal app; state arrives via the store, output via Ink. */
4757
5280
  export function App(props: AppProps): ReactElement {
4758
- const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
5281
+ // The stores are closure-backed singletons whose methods never touch `this`,
5282
+ // but a bare method reference still detaches it from its receiver. One stable
5283
+ // wrapper per store keeps both the receiver and the reference identity the
5284
+ // `useSyncExternalStore` contract requires.
5285
+ const subscribeTranscript = useCallback((listener: () => void) => props.store.subscribe(listener), [props.store])
5286
+ const readTranscript = useCallback(() => props.store.getView(), [props.store])
5287
+ const view = useSyncExternalStore(subscribeTranscript, readTranscript)
4759
5288
  // Terminal input anchor: Ink reference-counts raw mode across every active
4760
5289
  // `useInput` hook, so mutually exclusive surfaces (composer <-> approval
4761
5290
  // bar <-> panels) drop the count to zero inside each handoff commit — the
@@ -4777,8 +5306,10 @@ export function App(props: AppProps): ReactElement {
4777
5306
  // process-stable, so one callback per view identity is enough.
4778
5307
  const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands])
4779
5308
  const readSkills = useCallback(() => props.skills.rows, [props.skills])
4780
- const descriptors = useSyncExternalStore(props.commands.subscribe, readDescriptors)
4781
- const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
5309
+ const subscribeCommands = useCallback((listener: () => void) => props.commands.subscribe(listener), [props.commands])
5310
+ const subscribeSkills = useCallback((listener: () => void) => props.skills.subscribe(listener), [props.skills])
5311
+ const descriptors = useSyncExternalStore(subscribeCommands, readDescriptors)
5312
+ const skills = useSyncExternalStore(subscribeSkills, readSkills)
4782
5313
  const [modelLabel, setModelLabel] = useState(props.model)
4783
5314
  const [modelOpen, setModelOpen] = useState(false)
4784
5315
  /** Nested /model stages; only one owns terminal input at a time. */
@@ -4807,13 +5338,17 @@ export function App(props: AppProps): ReactElement {
4807
5338
  * (ordinary turns, image preparation, /animation toggles) never replays. */
4808
5339
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
4809
5340
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
5341
+ const [rainbowBurstId, setRainbowBurstId] = useState(0)
5342
+ const fireRainbowBurst = (): void => {
5343
+ setRainbowBurstId(id => id + 1)
5344
+ }
4810
5345
  // /animation toggle: applies immediately, persists through the runner, and
4811
5346
  // gates every timed leaf (shimmer, chase, blink, wave) for this render.
4812
5347
  const [animations, setAnimations] = useState(props.animations ?? true)
4813
5348
  const applyAnimations = (enabled: boolean): void => {
4814
5349
  setAnimations(enabled)
4815
5350
  props.saveAnimations?.(enabled)
4816
- notify(`animations ${enabled ? 'on' : 'off'}`)
5351
+ notify(t('notice.animationState', { state: enabled ? 'on' : 'off' }))
4817
5352
  }
4818
5353
  const previousModel = useRef<string | undefined>(undefined)
4819
5354
  const previousEffort = useRef<string | undefined>(props.effort)
@@ -4928,11 +5463,22 @@ export function App(props: AppProps): ReactElement {
4928
5463
  // Dedupe for the dynamic-budget tripwire: one warning per distinct shape.
4929
5464
  const budgetWarnRef = useRef<string | undefined>(undefined)
4930
5465
  const [verboseOpen, setVerboseOpen] = useState(false)
5466
+ const [queueOpen, setQueueOpen] = useState(false)
5467
+ /**
5468
+ * How the composer delivers its next submission: `queue` waits for the next
5469
+ * turn, `steer` joins the turn already running. Tab on an empty composer
5470
+ * flips it; the prompt glyph and the placeholder both name the current mode.
5471
+ */
5472
+ const [submitMode, setSubmitMode] = useState<'queue' | 'steer'>('queue')
4931
5473
  const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
5474
+ const [reviewPickerOpen, setReviewPickerOpen] = useState(false)
4932
5475
  const [helpOpen, setHelpOpen] = useState(false)
4933
5476
  const [modeOpen, setModeOpen] = useState(false)
4934
5477
  const [permissionOpen, setPermissionOpen] = useState(false)
4935
5478
  const [resumeOpen, setResumeOpen] = useState(false)
5479
+ const [searchOpen, setSearchOpen] = useState(false)
5480
+ /** /search seed: the query from `/search <text>` (cleared on open). */
5481
+ const [searchSeed, setSearchSeed] = useState('')
4936
5482
  const [pluginOpen, setPluginOpen] = useState(false)
4937
5483
  const [pluginQuery, setPluginQuery] = useState('')
4938
5484
  const [updateOpen, setUpdateOpen] = useState(false)
@@ -4941,10 +5487,12 @@ export function App(props: AppProps): ReactElement {
4941
5487
  const [statuslineOpen, setStatuslineOpen] = useState(false)
4942
5488
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
4943
5489
  const [themeOpen, setThemeOpen] = useState(false)
5490
+ const [languageOpen, setLanguageOpen] = useState(false)
4944
5491
  const [historyOpen, setHistoryOpen] = useState(false)
4945
5492
  const [agentsOpen, setAgentsOpen] = useState(false)
4946
5493
  const [subagentOpen, setSubagentOpen] = useState(false)
4947
5494
  const [todosOpen, setTodosOpen] = useState(false)
5495
+ const [usageOpen, setUsageOpen] = useState(false)
4948
5496
  /** /delete state: delete-mode hint plus an optional pre-armed row id. */
4949
5497
  const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
4950
5498
  /** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
@@ -4968,7 +5516,7 @@ export function App(props: AppProps): ReactElement {
4968
5516
  // from the list immediately, not look like a no-op.
4969
5517
  setDeleteReloadToken(token => token + 1)
4970
5518
  }, (reason: unknown) => {
4971
- notify(`delete failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
5519
+ notify(t('notice.deleteFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
4972
5520
  })
4973
5521
  }, [deleteConfirmId, props.deleteSession, notify])
4974
5522
  /** The /history panel's accepted entry: text plus its recall-space index. */
@@ -4988,26 +5536,25 @@ export function App(props: AppProps): ReactElement {
4988
5536
  }, [])
4989
5537
  /** The append-only flush boundary (see `settledEntryCount`): entries below
4990
5538
  * 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. */
5539
+ * beyond stays in the live tree. */
4994
5540
  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])
5541
+ /** Next-turn rows in durable inbox order: a running tool row can split the
5542
+ * pending rows, so this maps the inbox id list onto the folded entries
5543
+ * instead of scanning the mutable tail. */
5544
+ const queuedRows = useMemo(
5545
+ () => queuedInboxRows(view.entries, view.pending['next-turn']),
5546
+ [view.entries, view.pending],
5547
+ )
5007
5548
  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)
5549
+ const subscribeApproval = useCallback((listener: () => void) => props.approval.subscribe(listener), [props.approval])
5550
+ const readApprovalSnapshot = useCallback(() => props.approval.getSnapshot(), [props.approval])
5551
+ const subscribeQuestions = useCallback((listener: () => void) => props.questions.subscribe(listener), [props.questions])
5552
+ const readQuestionSnapshot = useCallback(() => props.questions.getSnapshot(), [props.questions])
5553
+ const subscribeSubagents = useCallback((listener: () => void) => props.subagents.subscribe(listener), [props.subagents])
5554
+ const readAgentRows = useCallback(() => props.subagents.getSnapshot(), [props.subagents])
5555
+ const approvalSnapshot = useSyncExternalStore(subscribeApproval, readApprovalSnapshot)
5556
+ const questionSnapshot = useSyncExternalStore(subscribeQuestions, readQuestionSnapshot)
5557
+ const agentRows = useSyncExternalStore(subscribeSubagents, readAgentRows)
5011
5558
  const approvalPending = approvalSnapshot.pending !== undefined
5012
5559
  const questionPending = questionSnapshot.pending !== undefined
5013
5560
  // While any modal owns the keys, the prompt box passes everything through.
@@ -5016,7 +5563,8 @@ export function App(props: AppProps): ReactElement {
5016
5563
  // panel keypress.
5017
5564
  const inputActive = deleteConfirmId !== undefined
5018
5565
  ? !approvalPending && !questionPending
5019
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5566
+ : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending
5567
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending
5020
5568
 
5021
5569
  // Human questions outrank local inspectors. Close the lower modal instead
5022
5570
  // of leaving an approval/question visible but keyboard-locked behind it.
@@ -5036,6 +5584,7 @@ export function App(props: AppProps): ReactElement {
5036
5584
  setStatuslineOpen(false)
5037
5585
  setThemeOpen(false)
5038
5586
  setHistoryOpen(false)
5587
+ setQueueOpen(false)
5039
5588
  setAgentsOpen(false)
5040
5589
  setSubagentOpen(false)
5041
5590
  setTodosOpen(false)
@@ -5085,6 +5634,7 @@ export function App(props: AppProps): ReactElement {
5085
5634
  // One pending synchronized frame covers a debounced resize or explicit
5086
5635
  // source-backed replay. It is closed after the corresponding React commit.
5087
5636
  const synchronizedReplayPending = useRef(false)
5637
+ const resizeBurstHeld = useRef(false)
5088
5638
  useEffect(() => {
5089
5639
  if (appStdout === undefined) return
5090
5640
  let replayTimer: ReturnType<typeof setTimeout> | undefined
@@ -5096,24 +5646,31 @@ export function App(props: AppProps): ReactElement {
5096
5646
  if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
5097
5647
  terminalSizeRef.current = next
5098
5648
 
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.
5649
+ // Hold the visible frame for the whole burst so intermediate Ink
5650
+ // relayouts (new width against still-old Static rows) never flash as
5651
+ // doubled borders. One clear + Static remount still runs after the
5652
+ // burst settles.
5653
+ if (!resizeBurstHeld.current) {
5654
+ resizeBurstHeld.current = true
5655
+ appStdout.write(SYNCHRONIZED_UPDATE_BEGIN)
5656
+ }
5105
5657
  setTerminalSize(next)
5106
5658
  if (replayTimer !== undefined) clearTimeout(replayTimer)
5107
5659
  replayTimer = setTimeout(() => {
5108
5660
  synchronizedReplayPending.current = true
5109
- appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
5661
+ appStdout.write(RESIZE_REFLOW_CLEAR)
5110
5662
  setRefreshEpoch(epoch => epoch + 1)
5663
+ resizeBurstHeld.current = false
5111
5664
  }, RESIZE_REFLOW_DELAY_MS)
5112
5665
  }
5113
5666
  appStdout.on('resize', handleResize)
5114
5667
  return () => {
5115
5668
  appStdout.off('resize', handleResize)
5116
5669
  if (replayTimer !== undefined) clearTimeout(replayTimer)
5670
+ if (resizeBurstHeld.current) {
5671
+ appStdout.write(SYNCHRONIZED_UPDATE_END)
5672
+ resizeBurstHeld.current = false
5673
+ }
5117
5674
  }
5118
5675
  }, [appStdout])
5119
5676
  const terminalRows = terminalSize.rows
@@ -5146,14 +5703,19 @@ export function App(props: AppProps): ReactElement {
5146
5703
  }, [])
5147
5704
  const imeRowsBelowComposer = statusBarRows + 1
5148
5705
  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))
5706
+ // Pin the composer and status at the bottom: every extra chrome row
5707
+ // (completion menu, notice, todos, agents, extra editor/status rows)
5708
+ // covers live transcript instead of growing the tree.
5709
+ const dynamicRows = liveRegionBudget({
5710
+ terminalRows,
5711
+ composerRows,
5712
+ statusBarRows,
5713
+ menuRows,
5714
+ gutterRows: composerGutterRows,
5715
+ notice: notice !== undefined,
5716
+ todo: transcriptVisible && view.todos.length > 0,
5717
+ agents: transcriptVisible && agentRows.length > 0,
5718
+ })
5157
5719
  const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
5158
5720
  const deepDivingVisible = busy && !streamingActive
5159
5721
  // Terminal tab label: "deepseek" until the session carries a name, then the
@@ -5201,6 +5763,10 @@ export function App(props: AppProps): ReactElement {
5201
5763
  )
5202
5764
  if (liveAudit.warning !== undefined && budgetWarnRef.current !== liveAudit.warning) {
5203
5765
  budgetWarnRef.current = liveAudit.warning
5766
+ // The budget tripwire is a last-resort diagnostic: it fires only when the
5767
+ // live/static allocation already broke its contract, and Ink owns/shadows
5768
+ // console output. Everywhere else the TUI must never write to stdout.
5769
+ // eslint-disable-next-line no-console -- see the tripwire note above
5204
5770
  console.warn(`[dsh-code] ${liveAudit.warning}`)
5205
5771
  }
5206
5772
  const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length
@@ -5208,9 +5774,8 @@ export function App(props: AppProps): ReactElement {
5208
5774
  : visibleLiveLines.slice(-liveAudit.allocation.live)
5209
5775
  const auditedReasoningRows = liveAudit.allocation.reasoning
5210
5776
  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
5777
  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
5778
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || languageOpen || historyOpen || queueOpen || agentsOpen || subagentOpen || todosOpen || usageOpen || inspectorVisible || diffView !== undefined || reviewPickerOpen || approvalPending || questionPending
5214
5779
  // The surface that currently owns the keyboard, named in the frozen band:
5215
5780
  // an empty composer under a panel must not advertise typing it cannot
5216
5781
  // accept — every key actually feeds the panel (which may or may not
@@ -5221,6 +5786,8 @@ export function App(props: AppProps): ReactElement {
5221
5786
  ? 'the question'
5222
5787
  : diffView !== undefined
5223
5788
  ? 'the diff review'
5789
+ : reviewPickerOpen
5790
+ ? 'the review picker'
5224
5791
  : modelOpen
5225
5792
  ? '/model'
5226
5793
  : helpOpen
@@ -5243,6 +5810,8 @@ export function App(props: AppProps): ReactElement {
5243
5810
  ? '/statusline'
5244
5811
  : themeOpen
5245
5812
  ? '/theme'
5813
+ : languageOpen
5814
+ ? '/language'
5246
5815
  : historyOpen
5247
5816
  ? '/history'
5248
5817
  : agentsOpen
@@ -5251,9 +5820,11 @@ export function App(props: AppProps): ReactElement {
5251
5820
  ? '/subagent'
5252
5821
  : todosOpen
5253
5822
  ? '/todos'
5254
- : inspectorVisible
5255
- ? 'history details'
5256
- : undefined
5823
+ : usageOpen
5824
+ ? '/usage'
5825
+ : inspectorVisible
5826
+ ? 'history details'
5827
+ : undefined
5257
5828
  const frozenHint = keyboardOwner === undefined
5258
5829
  ? undefined
5259
5830
  : `keys go to ${keyboardOwner} · esc ${approvalPending ? 'rejects' : questionPending ? 'cancels' : 'closes'}`
@@ -5272,6 +5843,18 @@ export function App(props: AppProps): ReactElement {
5272
5843
  }
5273
5844
  setRefreshEpoch(epoch => epoch + 1)
5274
5845
  }
5846
+ const applyRainbow = (seed?: number): void => {
5847
+ // Replace the memoized roll, then setTheme so getPalette() and the
5848
+ // painters pick the new values; persist rainbow as the active theme
5849
+ // so a mid-session /rainbow from dark/light actually sticks. The
5850
+ // source-backed rebuild (same as /theme) repaints Static history too.
5851
+ rerollRainbow(seed)
5852
+ setTheme('rainbow')
5853
+ props.saveTheme?.('rainbow')
5854
+ notify(t('notice.rainbowRolled', { seed: rainbowSeedLabel() }))
5855
+ fireRainbowBurst()
5856
+ refreshScreen()
5857
+ }
5275
5858
  useEffect(() => {
5276
5859
  if (!synchronizedReplayPending.current || appStdout === undefined) return
5277
5860
  synchronizedReplayPending.current = false
@@ -5302,16 +5885,16 @@ export function App(props: AppProps): ReactElement {
5302
5885
  setEffortLabel(effortId)
5303
5886
  const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
5304
5887
  if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
5305
- notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
5888
+ notify(t('notice.modelChangedPlaceholder', { model: selected }), 'warning')
5306
5889
  } else {
5307
- notify(`model next step uses ${selected}`)
5890
+ notify(t('notice.modelNextStep', { model: selected }))
5308
5891
  }
5309
5892
  setModelOpen(false)
5310
5893
  setProviderOpen(false)
5311
5894
  setProviderAction(undefined)
5312
5895
  setEffortFor(undefined)
5313
5896
  } catch (error: unknown) {
5314
- notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
5897
+ notify(t('notice.modelSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
5315
5898
  }
5316
5899
  }
5317
5900
 
@@ -5332,7 +5915,7 @@ export function App(props: AppProps): ReactElement {
5332
5915
  const seen = new Set<string>()
5333
5916
  for (const row of providerDirectory?.rows ?? []) {
5334
5917
  for (const model of row.configuration.models) {
5335
- const raw = (model.extras as Record<string, unknown> | undefined)?.reasoningEfforts
5918
+ const raw = (model.extras)?.reasoningEfforts
5336
5919
  if (!isDeclaredReasoningEfforts(raw)) continue
5337
5920
  const key = row.provider + '/' + model.id
5338
5921
  if (seen.has(key)) continue
@@ -5370,7 +5953,7 @@ export function App(props: AppProps): ReactElement {
5370
5953
  setProviderAction(undefined)
5371
5954
  setProviderOpen(false)
5372
5955
  reloadModelSurfaces()
5373
- notify(`logged in to ${authorization.label}; select a model`)
5956
+ notify(t('notice.loggedIn', { provider: authorization.label }))
5374
5957
  },
5375
5958
  back: () => {
5376
5959
  setProviderAction(undefined)
@@ -5386,7 +5969,7 @@ export function App(props: AppProps): ReactElement {
5386
5969
  setProviderAction(undefined)
5387
5970
  setProviderOpen(true)
5388
5971
  reloadModelSurfaces()
5389
- notify(`logged out from ${authorization.label}`)
5972
+ notify(t('notice.loggedOut', { provider: authorization.label }))
5390
5973
  },
5391
5974
  back: () => setProviderAction(undefined),
5392
5975
  })
@@ -5397,13 +5980,13 @@ export function App(props: AppProps): ReactElement {
5397
5980
  save: props.saveModelProviderConfiguration,
5398
5981
  saveCredential: props.saveModelProviderCredential,
5399
5982
  discover: props.discoverModelProvider
5400
- ?? (async () => { throw new Error('model discovery is unavailable in this profile; enter models by hand') }),
5983
+ ?? (() => Promise.reject(new Error('model discovery is unavailable in this profile; enter models by hand'))),
5401
5984
  done: result => {
5402
5985
  const target = providerAction.target
5403
5986
  setProviderAction(undefined)
5404
5987
  setProviderOpen(true)
5405
5988
  reloadModelSurfaces()
5406
- notify(`provider configuration saved: ${target.displayName}` + (result.key ? ' · API key updated' : ''))
5989
+ notify(t('notice.providerSaved', { provider: target.displayName, suffix: result.key ? ' · API key updated' : '' }))
5407
5990
  },
5408
5991
  back: () => setProviderAction(undefined),
5409
5992
  onExit: closeModelSurface,
@@ -5418,7 +6001,7 @@ export function App(props: AppProps): ReactElement {
5418
6001
  setProviderAction(undefined)
5419
6002
  setProviderOpen(true)
5420
6003
  reloadModelSurfaces()
5421
- notify(`API key removed for ${target.displayName}`)
6004
+ notify(t('notice.apiKeyRemoved', { provider: target.displayName }))
5422
6005
  },
5423
6006
  back: () => setProviderAction(undefined),
5424
6007
  })
@@ -5432,7 +6015,7 @@ export function App(props: AppProps): ReactElement {
5432
6015
  setProviderAction(undefined)
5433
6016
  setProviderOpen(true)
5434
6017
  reloadModelSurfaces()
5435
- notify(`provider removed: ${target.displayName}`)
6018
+ notify(t('notice.providerRemoved', { provider: target.displayName }))
5436
6019
  },
5437
6020
  back: () => setProviderAction(undefined),
5438
6021
  })
@@ -5444,42 +6027,42 @@ export function App(props: AppProps): ReactElement {
5444
6027
  authorizationError,
5445
6028
  onConfigure: (target: ProviderTargetView) => {
5446
6029
  if (props.saveModelProviderConfiguration === undefined) {
5447
- notify('provider configuration is unavailable in this profile', 'warning')
6030
+ notify(t('notice.providerUnavailable'), 'warning')
5448
6031
  return
5449
6032
  }
5450
6033
  setProviderAction({ kind: 'configure', target })
5451
6034
  },
5452
6035
  onUnset: (target: ProviderTargetView) => {
5453
6036
  if (props.unsetModelProviderCredential === undefined) {
5454
- notify('API key removal is unavailable in this profile', 'warning')
6037
+ notify(t('notice.apiKeyUnavailable'), 'warning')
5455
6038
  return
5456
6039
  }
5457
6040
  setProviderAction({ kind: 'unset', target })
5458
6041
  },
5459
6042
  onRemove: (target: ProviderTargetView) => {
5460
6043
  if (props.removeModelProvider === undefined) {
5461
- notify('provider removal is unavailable in this profile', 'warning')
6044
+ notify(t('notice.providerRemovalUnavailable'), 'warning')
5462
6045
  return
5463
6046
  }
5464
6047
  setProviderAction({ kind: 'remove', target })
5465
6048
  },
5466
6049
  onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
5467
6050
  if (busy) {
5468
- notify('provider login is available only while the agent is idle', 'warning')
6051
+ notify(t('notice.loginIdleOnly'), 'warning')
5469
6052
  return
5470
6053
  }
5471
6054
  if (props.beginProviderAuthorization === undefined
5472
6055
  || props.cancelProviderAuthorization === undefined
5473
6056
  || props.openAuthorizationUrl === undefined
5474
6057
  || props.copyTextValue === undefined) {
5475
- notify('provider login is unavailable in this profile', 'warning')
6058
+ notify(t('notice.loginUnavailable'), 'warning')
5476
6059
  return
5477
6060
  }
5478
6061
  setProviderAction({ kind: 'login', target, authorization })
5479
6062
  },
5480
6063
  onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
5481
6064
  if (props.logoutProviderAuthorization === undefined) {
5482
- notify('provider logout is unavailable in this profile', 'warning')
6065
+ notify(t('notice.logoutUnavailable'), 'warning')
5483
6066
  return
5484
6067
  }
5485
6068
  setProviderAction({ kind: 'logout', target, authorization })
@@ -5512,7 +6095,7 @@ export function App(props: AppProps): ReactElement {
5512
6095
  setEffortFor(row)
5513
6096
  return
5514
6097
  }
5515
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
6098
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined
5516
6099
  applyModel(row, effortId)
5517
6100
  },
5518
6101
  ...(props.loadModelProviders === undefined || props.saveModelProviderConfiguration === undefined
@@ -5579,6 +6162,13 @@ export function App(props: AppProps): ReactElement {
5579
6162
  : undefined,
5580
6163
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
5581
6164
  transcriptVisible ? createElement(AgentsLine, { rows: agentRows, total: props.subagents.getTotalSeen() }) : undefined,
6165
+ usageOpen && !approvalPending && !questionPending
6166
+ ? createElement(UsagePanel, {
6167
+ key: props.sessionKey,
6168
+ load: props.loadUsage,
6169
+ close: () => setUsageOpen(false),
6170
+ })
6171
+ : undefined,
5582
6172
  todosOpen && !approvalPending && !questionPending
5583
6173
  ? createElement(MemoTodoListPanel, {
5584
6174
  todos: view.todos,
@@ -5587,6 +6177,14 @@ export function App(props: AppProps): ReactElement {
5587
6177
  },
5588
6178
  })
5589
6179
  : undefined,
6180
+ queueOpen && !approvalPending && !questionPending
6181
+ ? createElement(QueuePanel, {
6182
+ rows: queuedRows,
6183
+ busy,
6184
+ update: props.updateQueued,
6185
+ onClose: () => setQueueOpen(false),
6186
+ })
6187
+ : undefined,
5590
6188
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
5591
6189
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify, interrupt: props.interrupt, summarize: questionPending }),
5592
6190
  modelSurface,
@@ -5607,6 +6205,17 @@ export function App(props: AppProps): ReactElement {
5607
6205
  onClose: () => setDiffView(undefined),
5608
6206
  })
5609
6207
  : undefined,
6208
+ reviewPickerOpen && !approvalPending && !questionPending && props.listReviewBranches !== undefined && props.listReviewCommits !== undefined
6209
+ ? createElement(ReviewPickerPanel, {
6210
+ loadBranches: props.listReviewBranches,
6211
+ loadCommits: props.listReviewCommits,
6212
+ choose: argument => {
6213
+ setReviewPickerOpen(false)
6214
+ props.reviewChanges(argument)
6215
+ },
6216
+ close: () => setReviewPickerOpen(false),
6217
+ })
6218
+ : undefined,
5610
6219
  verboseOpen && !approvalPending && !questionPending
5611
6220
  ? createElement(MemoVerbosePanel, {
5612
6221
  entries: view.entries,
@@ -5619,9 +6228,9 @@ export function App(props: AppProps): ReactElement {
5619
6228
  load: props.loadPresets,
5620
6229
  select: (id: string) => {
5621
6230
  void props.switchMode(id).then(label => {
5622
- notify(`mode ${label}`)
6231
+ notify(t('notice.modeChangedSimple', { value: label }))
5623
6232
  setModeOpen(false)
5624
- }, (reason: unknown) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error'))
6233
+ }, (reason: unknown) => notify(t('notice.modeSwitchFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error'))
5625
6234
  },
5626
6235
  close: () => setModeOpen(false),
5627
6236
  })
@@ -5633,7 +6242,7 @@ export function App(props: AppProps): ReactElement {
5633
6242
  select: (id: string) => {
5634
6243
  try {
5635
6244
  const selected = props.setPermission(id)
5636
- notify(`permission ${selected}`)
6245
+ notify(t('notice.permissionChangedSimple', { value: selected }))
5637
6246
  setPermissionOpen(false)
5638
6247
  } catch (reason: unknown) {
5639
6248
  notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
@@ -5655,6 +6264,28 @@ export function App(props: AppProps): ReactElement {
5655
6264
  close: () => setResumeOpen(false),
5656
6265
  })
5657
6266
  : undefined,
6267
+ searchOpen && !approvalPending && !questionPending && props.searchSessions !== undefined
6268
+ ? createElement(SearchPanel, {
6269
+ load: props.searchSessions,
6270
+ initialQuery: searchSeed,
6271
+ select: (row: SearchRow) => {
6272
+ setSearchOpen(false)
6273
+ props.switchSession({
6274
+ id: row.id,
6275
+ createdAt: row.updatedAt,
6276
+ updatedAt: row.updatedAt,
6277
+ cwd: '',
6278
+ workspace: '',
6279
+ subagent: row.subagent,
6280
+ resumable: row.resumable,
6281
+ live: false,
6282
+ persisted: true,
6283
+ preset: '',
6284
+ })
6285
+ },
6286
+ close: () => setSearchOpen(false),
6287
+ })
6288
+ : undefined,
5658
6289
  pluginOpen && !approvalPending && !questionPending
5659
6290
  ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
5660
6291
  : undefined,
@@ -5691,12 +6322,36 @@ export function App(props: AppProps): ReactElement {
5691
6322
  // palette. `auto` stores as requested; detection is a later step.
5692
6323
  setTheme(name)
5693
6324
  props.saveTheme?.(name)
5694
- notify(`theme ${name}`)
6325
+ // Rainbow prints its roll seed so a lucky launch can be reproduced
6326
+ // with RAINBOW_SEED=<seed>.
6327
+ notify(name === 'rainbow'
6328
+ ? t('notice.themeRainbow', { seed: rainbowSeedLabel() })
6329
+ : t('notice.themeSaved', { name }))
5695
6330
  setThemeOpen(false)
6331
+ // The header whale and settled history live in the Static region,
6332
+ // which renders once and would keep the old palette's colors; the
6333
+ // same source-backed rebuild resize and Ctrl+L use repaints the
6334
+ // whole screen (scrollback included) from the new palette.
6335
+ if (name === 'rainbow') fireRainbowBurst()
6336
+ refreshScreen()
5696
6337
  },
5697
6338
  close: () => setThemeOpen(false),
5698
6339
  })
5699
6340
  : undefined,
6341
+ languageOpen && !approvalPending && !questionPending
6342
+ ? createElement(LanguagePanel, {
6343
+ current: getLanguage(),
6344
+ select: (name: LanguageName) => {
6345
+ props.saveLanguage(name)
6346
+ notify(t('notice.languageSaved', { name }))
6347
+ setLanguageOpen(false)
6348
+ // The Static region renders once; the same source-backed rebuild
6349
+ // the theme switch uses repaints translated text everywhere.
6350
+ refreshScreen()
6351
+ },
6352
+ close: () => setLanguageOpen(false),
6353
+ })
6354
+ : undefined,
5700
6355
  historyOpen && !approvalPending && !questionPending
5701
6356
  ? createElement(HistoryPanel, {
5702
6357
  entries: recallSpace,
@@ -5724,15 +6379,15 @@ export function App(props: AppProps): ReactElement {
5724
6379
  // The runner's label already carries the effort suffix
5725
6380
  // (`provider/model@effort`), so no second append here.
5726
6381
  const label = props.setSubagentModel(row, effortId)
5727
- notify(`subagents ${label}`)
6382
+ notify(t('notice.subagentsChanged', { value: label }))
5728
6383
  setSubagentOpen(false)
5729
6384
  } catch (reason: unknown) {
5730
- notify(`subagent model change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
6385
+ notify(t('notice.subagentChangeFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
5731
6386
  }
5732
6387
  },
5733
6388
  inherit: () => {
5734
6389
  props.clearSubagentModel()
5735
- notify('subagents → inherit current model')
6390
+ notify(t('notice.subagentsInherited'))
5736
6391
  setSubagentOpen(false)
5737
6392
  },
5738
6393
  close: () => setSubagentOpen(false),
@@ -5759,8 +6414,14 @@ export function App(props: AppProps): ReactElement {
5759
6414
  descriptors,
5760
6415
  skills,
5761
6416
  dispatch: props.dispatch,
5762
- applyEditorKeys: props.applyEditorKeys,
5763
6417
  steer: props.steer,
6418
+ submitMode,
6419
+ cycleSubmitMode: () => {
6420
+ const next = submitMode === 'queue' ? 'steer' : 'queue'
6421
+ setSubmitMode(next)
6422
+ notify(t(next === 'steer' ? 'notice.submitMode.steer' : 'notice.submitMode.queue'))
6423
+ },
6424
+ applyEditorKeys: props.applyEditorKeys,
5764
6425
  interrupt: props.interrupt,
5765
6426
  quit: props.quit,
5766
6427
  openModel: () => {
@@ -5792,12 +6453,12 @@ export function App(props: AppProps): ReactElement {
5792
6453
  ?? loaded.rows.find(candidate => candidate.model === model && candidate.reasoning !== undefined)
5793
6454
  ?? loaded.rows.find(candidate => candidate.model === model)
5794
6455
  if (row === undefined) {
5795
- notify('current model is not in the catalog', 'warning')
6456
+ notify(t('notice.modelMissing'), 'warning')
5796
6457
  return
5797
6458
  }
5798
6459
  const rowTag = `${row.provider}/${row.model}`
5799
6460
  if (loaded.reasoningFailures?.includes(rowTag) === true) {
5800
- notify('reasoning levels temporarily unavailable (capability lookup failed) — try again', 'warning')
6461
+ notify(t('notice.effortUnavailable'), 'warning')
5801
6462
  return
5802
6463
  }
5803
6464
  if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
@@ -5821,16 +6482,28 @@ export function App(props: AppProps): ReactElement {
5821
6482
  openMode: () => setModeOpen(true),
5822
6483
  openPermission: () => setPermissionOpen(true),
5823
6484
  openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
6485
+ openSearch: (query: string) => {
6486
+ if (props.searchSessions === undefined) {
6487
+ notify(t('notice.sessionSearchUnavailable'), 'warning')
6488
+ return
6489
+ }
6490
+ setSearchSeed(query)
6491
+ setSearchOpen(true)
6492
+ },
5824
6493
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
5825
6494
  openUpdate: () => setUpdateOpen(true),
5826
6495
  openSchedule: () => setScheduleOpen(true),
5827
6496
  openJobs: () => setJobsOpen(true),
5828
6497
  openStatusline: () => setStatuslineOpen(true),
5829
6498
  openTheme: () => setThemeOpen(true),
6499
+ openLanguage: () => setLanguageOpen(true),
6500
+ saveLanguage: props.saveLanguage,
5830
6501
  openHistory: () => setHistoryOpen(true),
6502
+ openQueue: () => setQueueOpen(true),
5831
6503
  openAgents: () => setAgentsOpen(true),
5832
6504
  openSubagent: () => setSubagentOpen(true),
5833
6505
  openTodos: () => setTodosOpen(true),
6506
+ openUsage: () => setUsageOpen(true),
5834
6507
  openDelete: (id?: string) => {
5835
6508
  const armed = id === undefined || id === '' ? undefined : id
5836
6509
  setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
@@ -5843,6 +6516,7 @@ export function App(props: AppProps): ReactElement {
5843
6516
  })
5844
6517
  },
5845
6518
  reviewChanges: props.reviewChanges,
6519
+ openReviewPicker: () => setReviewPickerOpen(true),
5846
6520
  deleteConfirm: deleteConfirmId,
5847
6521
  confirmDelete,
5848
6522
  cancelDelete,
@@ -5886,11 +6560,13 @@ export function App(props: AppProps): ReactElement {
5886
6560
  recordLocal,
5887
6561
  recordHistory: props.recordHistory,
5888
6562
  queued: queuedRows,
5889
- cancelQueued: props.cancelQueued,
6563
+ updateQueued: props.updateQueued,
5890
6564
  historyFill,
5891
6565
  historyConsumed,
5892
6566
  animations,
5893
6567
  applyAnimations,
6568
+ applyRainbow,
6569
+ rainbowBurstId,
5894
6570
  waveTier,
5895
6571
  waveStyle,
5896
6572
  maxRows: composerEditorCap,
@@ -5914,6 +6590,7 @@ export function App(props: AppProps): ReactElement {
5914
6590
  },
5915
6591
  stats: view.stats,
5916
6592
  busy,
6593
+ animated: animations,
5917
6594
  columns: terminalColumns,
5918
6595
  items: statuslineItems,
5919
6596
  onRows: handleStatusRows,