dsh-code 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +130 -4
  30. package/lib/types/render/status.d.ts +9 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
package/src/app.ts CHANGED
@@ -26,18 +26,33 @@ 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'
48
+ import { UpdatePanel } from './update-panel.ts'
49
+ import type { LauncherUpdateStatus } from './update.ts'
37
50
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
38
51
  import { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
39
52
  import type { TranscriptStore } from './store.ts'
53
+ import { DEFAULT_TERMINAL_TITLE, sanitizeTerminalTitle, terminalTitleSequence, useTerminalTitle } from './terminal-title.ts'
40
54
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
55
+ import { imeCursorRowsUp, useImeCursorAnchor } from './render/ime-cursor.ts'
41
56
  import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
57
  import {
43
58
  busyChaseFrame,
@@ -55,9 +70,14 @@ import {
55
70
  deepseekWaveWordVisible,
56
71
  deepDivingGradientColor,
57
72
  deepDivingSparkColor,
73
+ flowColor,
58
74
  effortAboveHigh,
59
75
  isOfficialDeepSeekLabel,
60
76
  parseAnimationsArgument,
77
+ RAINBOW_BURST_DURATION_MS,
78
+ RAINBOW_BURST_TICK_MS,
79
+ rainbowBurstColumnBg,
80
+ rainbowSpectrumHue,
61
81
  type DeepseekWaveStyle,
62
82
  type DeepseekWaveTier,
63
83
  } from './render/animations.ts'
@@ -80,7 +100,8 @@ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
80
100
  import type { SkillsView, SkillRow } from './skills.ts'
81
101
  import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
82
102
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
83
- import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, 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'
84
105
  import type { PresetRow } from './presets.ts'
85
106
  import type { PermissionRow } from './permissions.ts'
86
107
  import type { PluginRow } from './plugin-inventory.ts'
@@ -93,7 +114,7 @@ import {
93
114
  type RecallState,
94
115
  } from './history.ts'
95
116
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
96
- import type { GitDiffView } from './git-workflow.ts'
117
+ import { parseReviewArgument, type GitDiffView, type ReviewBranch, type ReviewCommit, type ReviewSelection } from './git-workflow.ts'
97
118
  import {
98
119
  authorizationForProvider,
99
120
  providerAuthorizationStatus,
@@ -144,7 +165,7 @@ const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l'
144
165
  import {
145
166
  layoutStatusBar,
146
167
  parseStatuslineItems,
147
- STATUS_CYCLE_HINT,
168
+ statusCycleHint,
148
169
  STATUS_GROUP_SEPARATOR,
149
170
  STATUS_ITEM_SEPARATOR,
150
171
  STATUS_ROW2_INDENT,
@@ -171,6 +192,7 @@ import {
171
192
  followInspectorCursor,
172
193
  inspectorViewport,
173
194
  layoutGutterRows,
195
+ liveRegionBudget,
174
196
  moveScroll,
175
197
  panelViewport,
176
198
  revealRow,
@@ -178,6 +200,8 @@ import {
178
200
  } from './render/inspector.ts'
179
201
  import {
180
202
  clampLiveAllocation,
203
+ diffLineStyle,
204
+ fillDiffLineBars,
181
205
  lineSegment,
182
206
  markdownLines,
183
207
  settledEntryLines,
@@ -220,37 +244,53 @@ import {
220
244
  export type NoticeTone = 'info' | 'warning' | 'error'
221
245
 
222
246
  /** One source of truth for TUI-owned slash commands in completion and `/help`. */
223
- const LOCAL_COMMANDS = [
224
- { label: '/help', description: 'show this overlay' },
225
- { label: '/model', description: 'switch the model and manage providers' },
226
- { label: '/effort', description: 'adjust reasoning effort for the current model' },
227
- { label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
228
- { label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
229
- { label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
230
- { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
231
- { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
232
- { label: '/plugin', description: 'inspect the live plugin composition' },
233
- { label: '/jobs', description: 'inspect background jobs' },
234
- { label: '/statusline', description: 'customize the status line items' },
235
- { label: '/theme', description: 'switch the color theme' },
236
- { label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
237
- { label: '/history', description: 'search and recall past prompts' },
238
- { label: '/agents', description: 'inspect subagent sessions of this conversation' },
239
- { label: '/todos', description: 'inspect the full todo list' },
240
- { label: '/subagent', description: 'choose the model delegated subagents run on' },
241
- { label: '/vscode-keys', description: 'pass ctrl+r through the vs code terminal' },
242
- { label: '/delete', description: 'delete a session and its subagent threads' },
243
- { label: '/clear', description: 'clear the screen' },
244
- { label: '/export', description: 'export the transcript to markdown (/export [path])' },
245
- { label: '/title', description: 'rename this session (/title <text>)' },
246
- { label: '/copy', description: 'copy the latest assistant response' },
247
- { label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
248
- { label: '/review', description: 'review Git changes under read-only permissions' },
249
- { 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' },
250
284
  ] as const
251
285
 
252
286
  const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
253
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
+
254
294
  /** Props the runner hands the app; callbacks stay owned by the runner. */
255
295
  export interface AppProps {
256
296
  /** Event-fed transcript store for the live session. */
@@ -289,127 +329,151 @@ export interface AppProps {
289
329
  * an attachment prepare resolves after the app remounted onto another
290
330
  * session, and the runner drops the stale delivery then.
291
331
  */
292
- dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
293
- /** Submit steering, with the same stale-delivery guard as {@link dispatch}. */
294
- 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
295
339
  /**
296
340
  * The FULL current session identity ('' while the first session is pending)
297
341
  * — the stale-delivery origin above. Distinct from the short display id.
298
342
  */
299
343
  sessionKey: string
300
344
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
301
- interrupt(): boolean
345
+ interrupt: () => boolean
302
346
  /** Quit: unmount, flush, and request process exit. */
303
- quit(): void
347
+ quit: () => void
304
348
  /** Load the selectable model directory (called when /model opens). */
305
- loadModels(): Promise<ModelDirectory>
349
+ loadModels: () => Promise<ModelDirectory>
306
350
  /** Load @mention candidates for the typed query (files + sessions). */
307
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
351
+ loadMentions: (query: string, signal?: AbortSignal) => Promise<readonly MentionCandidate[]>
308
352
  /** Validate draft image paths without committing attachment objects. */
309
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
353
+ inspectImages: (paths: readonly string[]) => Promise<readonly ImagePathInspection[]>
310
354
  /** Validate, normalize and persist images immediately before submission. */
311
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
355
+ prepareImages: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly ImageBlock[]>
312
356
  /** Validate draft non-image file paths without committing attachment objects. */
313
- inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
357
+ inspectFiles: (paths: readonly string[]) => Promise<readonly FilePathInspection[]>
314
358
  /** Persist non-image files immediately before submission as durable file blocks. */
315
- prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
359
+ prepareFiles: (paths: readonly string[], signal?: AbortSignal) => Promise<readonly FileBlock[]>
316
360
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
317
- selectModel(row: ModelRow, effortId?: string): string
361
+ selectModel: (row: ModelRow, effortId?: string) => string
318
362
  /** The /subagent override label, '' when delegated agents follow the current model. */
319
363
  subagentModel: string
320
364
  /** Apply one /subagent model pick; returns the override label. */
321
- setSubagentModel(row: ModelRow, effortId?: string): string
365
+ setSubagentModel: (row: ModelRow, effortId?: string) => string
322
366
  /** Drop the /subagent override (delegated agents follow the current model). */
323
- clearSubagentModel(): void
367
+ clearSubagentModel: () => void
324
368
  /** Delete one session subtree; resolves with the outcome line. */
325
- deleteSession(id: string): Promise<string>
369
+ deleteSession: (id: string) => Promise<string>
326
370
  /** Load provider/settings/credential facts for the optional /model provider stage. */
327
- loadModelProviders?(): Promise<ProviderSettingsDirectory>
371
+ loadModelProviders?: () => Promise<ProviderSettingsDirectory>
328
372
  /** Subscribe to Harness credential/settings/adapter invalidations while /model is open. */
329
- subscribeModelProviders?(listener: () => void): () => void
373
+ subscribeModelProviders?: (listener: () => void) => () => void
330
374
  /** Store or rotate one provider credential through the Harness credential service. */
331
- saveModelProviderCredential?(target: ProviderTargetView, key: string): Promise<void>
375
+ saveModelProviderCredential?: (target: ProviderTargetView, key: string) => Promise<void>
332
376
  /** Remove one writable provider credential without removing its settings profile. */
333
- unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
377
+ unsetModelProviderCredential?: (target: ProviderTargetView) => Promise<void>
334
378
  /** Remove one user-owned provider profile and its page-managed credential. */
335
- removeModelProvider?(target: ProviderTargetView): Promise<void>
379
+ removeModelProvider?: (target: ProviderTargetView) => Promise<void>
336
380
  /** Save endpoint and explicit model capacities through the provider profile. */
337
- saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
381
+ saveModelProviderConfiguration?: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
338
382
  /**
339
383
  * Interrogate the provider's real endpoint (typed key wins over the stored
340
384
  * credential) for the models it actually serves — the discovery stage of
341
385
  * the provider setup page.
342
386
  */
343
- discoverModelProvider?(
387
+ discoverModelProvider?: (
344
388
  target: ProviderTargetView,
345
389
  request: { readonly apiKey?: string; readonly baseURL?: string },
346
390
  signal?: AbortSignal,
347
- ): Promise<readonly DiscoveredModelView[]>
391
+ ) => Promise<readonly DiscoveredModelView[]>
348
392
  /** Provider authorization flows and value-free stored-record facts. */
349
- loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
350
- subscribeProviderAuthorizations?(listener: () => void): () => void
351
- beginProviderAuthorization?(
393
+ loadProviderAuthorizations?: () => Promise<ProviderAuthorizationDirectory>
394
+ subscribeProviderAuthorizations?: (listener: () => void) => () => void
395
+ beginProviderAuthorization?: (
352
396
  row: ProviderAuthorizationRow,
353
397
  method: string,
354
398
  interaction: AuthorizationInteraction,
355
399
  signal: AbortSignal,
356
- ): Promise<AuthorizationStatus>
357
- cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
358
- logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
359
- openAuthorizationUrl?(url: string): boolean
360
- copyTextValue?(text: string): Promise<void>
361
- /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
362
- cyclePermission(): string
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>
405
+ /** Cycle to the next mode station (Shift+Tab): a permission preset or a plan switch; returns the notice label. */
406
+ cycleMode: () => string
407
+ /** Pre-session plan choice: shows the plan badge before the first session exists. */
408
+ pendingPlan?: boolean
363
409
  /** Select or inspect a permission preset without requiring a pre-existing session. */
364
- setPermission(id: string): string
410
+ setPermission: (id: string) => string
365
411
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
366
- exportTranscript(argument: string): Promise<void>
412
+ exportTranscript: (argument: string) => Promise<void>
367
413
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
368
- renameTitle(argument: string): string
414
+ renameTitle: (argument: string) => string
369
415
  /** Copy the latest complete assistant response; resolves to notice text. */
370
- copyLastResponse(): Promise<string>
416
+ copyLastResponse: () => Promise<string>
371
417
  /** Load a complete read-only Git diff for the file-oriented viewport. */
372
- 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[]>
373
423
  /** Start a model review after applying the read-only permission preset. */
374
- reviewChanges(argument: string): void
424
+ reviewChanges: (selection: ReviewSelection) => void
375
425
  /** Preset/session/plugin kernel operations. */
376
- loadPresets(): Promise<readonly PresetRow[]>
377
- switchMode(id: string): Promise<string>
426
+ loadPresets: () => Promise<readonly PresetRow[]>
427
+ switchMode: (id: string) => Promise<string>
378
428
  /** Load the switchable permission presets for the /permission panel. */
379
- loadPermissions(): Promise<readonly PermissionRow[]>
380
- createSession(mode?: string): void
429
+ loadPermissions: () => Promise<readonly PermissionRow[]>
430
+ createSession: (mode?: string) => void
381
431
  /** Fork the active session at a completed-turn boundary. */
382
- forkSession(argument: string): void
383
- loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
384
- 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[]>
385
443
  /** Load this session's subagent conversations (children by lineage). */
386
- loadSubagents(): Promise<readonly SessionRow[]>
387
- switchSession(row: SessionRow): void
388
- cancelSessionSwitch(): boolean
389
- loadPlugins(): readonly PluginRow[]
444
+ loadSubagents: () => Promise<readonly SessionRow[]>
445
+ switchSession: (row: SessionRow) => void
446
+ cancelSessionSwitch: () => boolean
447
+ loadPlugins: () => readonly PluginRow[]
390
448
  /** Caller-visible background jobs (the host jobs registry, read-only). */
391
- loadJobs(): readonly JobRow[]
449
+ loadJobs: () => readonly JobRow[]
450
+ /** Probe the launcher's aligned update plan (read-only; never installs). */
451
+ probeUpdate: () => Promise<LauncherUpdateStatus>
452
+ /** Run the launcher's aligned update; streams sanitized lines; resolves with the exit code. */
453
+ applyUpdate: (onLine: (line: string) => void, plan?: { readonly dshSpec: string; readonly codeSpec: string; readonly pluginSpecs: readonly string[] }) => Promise<number>
392
454
  /** Registers the app's notice channel with the runner (called once on mount). */
393
- onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
455
+ onBridgeReady: (bridge: { notify: (text: string, tone?: NoticeTone) => void }) => void
394
456
  /** Ordered enabled status items (/statusline config); the runner owns persistence. */
395
457
  statusline: readonly string[]
396
458
  /** Persist a new statusline item set; the runner surfaces IO failures as notices. */
397
- 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
398
462
  /** Apply and persist one /theme selection; the runner owns the theme.json file. */
399
- saveTheme?(name: ThemeName): void
463
+ saveTheme?: (name: ThemeName) => void
400
464
  /** Whether timed animations run at startup (animations.json; on by default
401
465
  * — like parseAnimationsPref, only an explicit false disables them). */
402
466
  animations?: boolean
403
467
  /** Apply and persist one /animation toggle; the runner owns the file. */
404
- saveAnimations?(enabled: boolean): void
468
+ saveAnimations?: (enabled: boolean) => void
405
469
  /** Persistent cross-session input history (oldest first); the runner owns the file. */
406
470
  history: readonly string[]
407
471
  /** Persist one submitted prompt to the global history file. */
408
- recordHistory(text: string): void
409
- /** Cancel one queued inbox message by identity (Delete on the empty composer). */
410
- 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
411
475
  /** Apply the Ctrl+R terminal passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
412
- applyEditorKeys(): Promise<string>
476
+ applyEditorKeys: () => Promise<string>
413
477
  }
414
478
 
415
479
  /** Pad text with spaces to a visible-column target (menu name column). */
@@ -463,7 +527,13 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
463
527
  */
464
528
  function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
465
529
  const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
466
- 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) + ' ')
467
537
  }
468
538
 
469
539
  /** Blinking block caret appended to streaming text; solid when frozen. */
@@ -473,7 +543,7 @@ function Caret({ animated = true }: { animated?: boolean }): ReactElement {
473
543
  }
474
544
 
475
545
  /** One resettable input-caret phase shared by the entire composer. */
476
- function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
546
+ function useCursorBlink(active: boolean): { visible: boolean; reset: () => void } {
477
547
  const [epoch, setEpoch] = useState(0)
478
548
  const [visible, setVisible] = useState(true)
479
549
  useEffect(() => {
@@ -502,6 +572,13 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
502
572
  function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
503
573
  const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
504
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
505
582
  const graphemes = splitGraphemes(text)
506
583
  return createElement(
507
584
  Text,
@@ -515,8 +592,8 @@ function ShimmerLine({ text, animated = true }: { text: string; animated?: boole
515
592
  color: inkColor(!animated
516
593
  ? (sparkle ? palette.brandBright : palette.brandDeep)
517
594
  : sparkle
518
- ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
519
- : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
595
+ ? deepDivingSparkColor(tick, palette.brandDeep, highlight)
596
+ : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
520
597
  bold: sparkle || undefined,
521
598
  },
522
599
  grapheme.text,
@@ -613,6 +690,18 @@ function segmentProps(style: MdSegment['style']): {
613
690
  return { color: undefined, bold: true, italic: true, strikethrough: undefined }
614
691
  case 'strike':
615
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
+ }
616
705
  default:
617
706
  return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
618
707
  }
@@ -625,20 +714,46 @@ function lineStyleProps(style: LineStyle): {
625
714
  italic: boolean | undefined
626
715
  strikethrough: boolean | undefined
627
716
  dimColor: boolean | undefined
717
+ backgroundColor: string | undefined
628
718
  } {
629
719
  switch (style) {
630
720
  case 'brand':
631
- 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 }
632
722
  case 'success':
633
- 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 }
634
724
  case 'error':
635
- 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 }
636
726
  case 'warn':
637
- 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 }
638
728
  case 'dimItalic':
639
- 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
+ }
640
755
  default:
641
- return { ...segmentProps(style), dimColor: undefined }
756
+ return { ...segmentProps(style), dimColor: undefined, backgroundColor: undefined }
642
757
  }
643
758
  }
644
759
 
@@ -662,7 +777,7 @@ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
662
777
  }
663
778
 
664
779
  /** File-oriented, color-coded unified diff viewport. */
665
- function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): ReactElement {
780
+ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose: () => void }): ReactElement {
666
781
  const stdout = useStdout().stdout
667
782
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
668
783
  const [fileIndex, setFileIndex] = useState(0)
@@ -670,15 +785,7 @@ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): R
670
785
  const file = view.files[fileIndex]
671
786
  const lines = useMemo(() => {
672
787
  if (file === undefined) return textLines(' (no changes)', viewport.contentColumns, 'dim')
673
- return file.lines.flatMap(line => styledLines([
674
- lineSegment(line, line.startsWith('+') && !line.startsWith('+++')
675
- ? 'success'
676
- : line.startsWith('-') && !line.startsWith('---')
677
- ? 'error'
678
- : line.startsWith('@@') || line.startsWith('diff --git') || line.startsWith('index ')
679
- ? 'brand'
680
- : 'dim'),
681
- ], viewport.contentColumns))
788
+ return fillDiffLineBars(file.lines.flatMap(line => styledLines([lineSegment(line, diffLineStyle(line))], viewport.contentColumns)), viewport.contentColumns)
682
789
  }, [file, viewport.contentColumns])
683
790
  const visibleScroll = clampScroll(scroll, lines.length, viewport.bodyRows)
684
791
  useInput((input, key) => {
@@ -697,15 +804,16 @@ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): R
697
804
  else if (key.pageUp) setScroll(current => moveScroll(current, -viewport.bodyRows, lines.length, viewport.bodyRows))
698
805
  else if (key.pageDown) setScroll(current => moveScroll(current, viewport.bodyRows, lines.length, viewport.bodyRows))
699
806
  })
700
- 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)
701
809
  return createElement(
702
810
  Box,
703
- { flexDirection: 'column', borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
704
- 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)),
705
813
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
706
814
  createElement(StyledRows, { lines: lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
707
815
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
708
- 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)),
709
817
  )
710
818
  }
711
819
 
@@ -723,6 +831,32 @@ function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
723
831
  * keeps its historical three lines. Short or narrow terminals keep a one-line
724
832
  * form without the kernel line.
725
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
+
726
860
  function Header({ resumed }: { resumed: boolean }): ReactElement {
727
861
  const stdout = useStdout().stdout
728
862
  const rows = stdout?.rows ?? 40
@@ -737,7 +871,7 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
737
871
  const copyWidths = [visibleColumns(title), visibleColumns(slogan), visibleColumns(hint)]
738
872
  if (kernelLine !== undefined) copyWidths.push(visibleColumns(kernelLine))
739
873
  const copyColumns = Math.max(...copyWidths)
740
- const compact = `${title} · ${hint}`
874
+ const compact = kernelLine === undefined ? `${title} · ${hint}` : `${title} · ${kernelLine} · ${hint}`
741
875
  if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 10) {
742
876
  return createElement(
743
877
  Box,
@@ -756,7 +890,10 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
756
890
  createElement(
757
891
  Box,
758
892
  { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
759
- ...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)),
760
897
  ),
761
898
  createElement(
762
899
  Box,
@@ -791,7 +928,7 @@ function todoMark(status: TodoItem['status']): string {
791
928
  function AgentsLine({ rows, total }: { rows: readonly SubagentRow[]; total: number }): ReactElement | undefined {
792
929
  if (rows.length === 0) return undefined
793
930
  const running = rows.filter(row => row.state !== 'done').length
794
- const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
931
+ const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]
795
932
  const mark = newest.state === 'done' ? '✓' : newest.state === 'idle' ? '⏸' : '●'
796
933
  return createElement(
797
934
  Box,
@@ -844,7 +981,7 @@ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose
844
981
  const inProgress = todos.filter(todo => todo.status === 'in_progress').length
845
982
  const pending = todos.length - completed - inProgress
846
983
  const rows = todos.length === 0
847
- ? [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')}`)]
848
985
  : todos.map(todo => createElement(
849
986
  Text,
850
987
  { key: todo.content, dimColor: true, wrap: 'truncate-end' },
@@ -873,40 +1010,202 @@ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose
873
1010
  })
874
1011
 
875
1012
  if (viewport.maxHeight === 0 || viewport.compact) {
876
- 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))
877
1014
  }
878
1015
 
1016
+ const accent = panelAccent('todos', getPalette().brand)
879
1017
  return createElement(
880
1018
  Box,
881
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
882
- 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)),
883
1021
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
884
1022
  ...rows.slice(visibleScroll, visibleScroll + viewport.bodyRows),
885
1023
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
886
- 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))),
887
1025
  )
888
1026
  }
889
1027
 
890
1028
  const MemoTodoListPanel = memo(TodoListPanel)
891
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
+
892
1178
  /**
893
1179
  * Ink props for one status tone: the Codex status-line accent mapping over
894
1180
  * the DeepSeek palette, all blue by design — the status bar speaks only in
895
1181
  * degrees of blue (deep accent, primary figures, model identity, sky
896
1182
  * paths and done states), with amber/red reserved for warnings and errors.
897
1183
  */
898
- function statusToneProps(tone: StatusTone): {
1184
+ function statusToneProps(tone: StatusTone, flowMs?: number): {
899
1185
  color: string | undefined
900
1186
  bold: boolean | undefined
901
1187
  dimColor: boolean | undefined
902
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
+ }
903
1200
  switch (tone) {
904
1201
  case 'model':
905
1202
  // Same tone as the working-directory segment: the model name reads as
906
1203
  // a path fact, not a brand accent.
907
1204
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
908
1205
  case 'live':
909
- 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 }
910
1209
  case 'path':
911
1210
  return { color: inkColor(getPalette().code), bold: undefined, dimColor: undefined }
912
1211
  case 'branch':
@@ -928,6 +1227,10 @@ function statusToneProps(tone: StatusTone): {
928
1227
  return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
929
1228
  case 'success':
930
1229
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
1230
+ // The plan station's dedicated green: the status bar otherwise speaks in
1231
+ // blues, but the fourth cycle station IS a distinct green mode marker.
1232
+ case 'plan':
1233
+ return { color: inkColor(getPalette().success), bold: true, dimColor: undefined }
931
1234
  case 'warn':
932
1235
  return { color: inkColor(getPalette().warn), bold: true, dimColor: undefined }
933
1236
  case 'error':
@@ -964,18 +1267,40 @@ function statusToneProps(tone: StatusTone): {
964
1267
  * the prompt keeps is always hues[0]. */
965
1268
  function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTriple, RgbTriple] {
966
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]]
967
1280
  return tier === 'flash'
968
1281
  ? [palette.brandBright, palette.brand, palette.brandMid]
969
1282
  : [palette.brandBright, palette.code, palette.brandMid]
970
1283
  }
971
1284
 
972
- function StatusLine({ facts, stats, busy, columns, items }: {
1285
+ function StatusLine({ facts, stats, busy, columns, items, onRows, animated }: {
973
1286
  facts: StatusFacts
974
1287
  stats: Parameters<typeof layoutStatusBar>[1]
975
1288
  busy: boolean
976
1289
  columns: number
977
1290
  items: readonly string[]
1291
+ /** Reports the footer's exact physical row count (1 or 2) so the IME
1292
+ * anchor ledger below the composer stays exact. */
1293
+ onRows?: (rows: 1 | 2) => void
1294
+ /** Whether timed animations run (the persisted preference). */
1295
+ animated: boolean
978
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()
979
1304
  const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
980
1305
  busy,
981
1306
  items,
@@ -999,7 +1324,16 @@ function StatusLine({ facts, stats, busy, columns, items }: {
999
1324
  busy,
1000
1325
  columns,
1001
1326
  items,
1327
+ // Labels come from t(); a language switch must rebuild the rows.
1328
+ language,
1002
1329
  ])
1330
+ // The IME anchor below the composer counts every row between the caret and
1331
+ // Ink's parked cursor, so the footer reports its exact row count one-way
1332
+ // (same contract as the composer's row report).
1333
+ const statusRowCount: 1 | 2 = layout.row2.left.length > 0 ? 2 : 1
1334
+ useEffect(() => {
1335
+ onRows?.(statusRowCount)
1336
+ }, [onRows, statusRowCount])
1003
1337
 
1004
1338
  const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
1005
1339
  const leftParts: ReactElement[] = []
@@ -1010,7 +1344,7 @@ function StatusLine({ facts, stats, busy, columns, items }: {
1010
1344
  group.spans.forEach((span, spanIndex) => {
1011
1345
  leftParts.push(createElement(
1012
1346
  Text,
1013
- { 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) },
1014
1348
  span.text,
1015
1349
  ))
1016
1350
  })
@@ -1022,12 +1356,12 @@ function StatusLine({ facts, stats, busy, columns, items }: {
1022
1356
  }
1023
1357
  rightParts.push(createElement(
1024
1358
  Text,
1025
- { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone) },
1359
+ { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) },
1026
1360
  span.text,
1027
1361
  ))
1028
1362
  })
1029
1363
  if (row.hint) {
1030
- 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()))
1031
1365
  }
1032
1366
  // Each row already fits the column budget; truncate-end stays as the
1033
1367
  // terminal-measurement backstop so a drifting cell count clips instead
@@ -1105,9 +1439,9 @@ const APPROVAL_OPTIONS: readonly ApprovalOption[] = [
1105
1439
  function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1106
1440
  snapshot: ApprovalSnapshot
1107
1441
  locked: boolean
1108
- notify(text: string, tone?: NoticeTone): void
1442
+ notify: (text: string, tone?: NoticeTone) => void
1109
1443
  /** Cancel the running turn (Ctrl+C), matching the composer's busy branch. */
1110
- interrupt(): boolean
1444
+ interrupt: () => boolean
1111
1445
  /** Render as the bounded one-line form even on tall terminals (another
1112
1446
  * human-asked surface already owns the full panel budget). */
1113
1447
  summarize?: boolean
@@ -1134,7 +1468,7 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1134
1468
  }
1135
1469
  ask.answer('rejected')
1136
1470
  if (option.key === 'reject-note') {
1137
- notify('rejected — type below what it should do differently (it steers the next step)', 'warning')
1471
+ notify(t('notice.rejected'), 'warning')
1138
1472
  }
1139
1473
  }
1140
1474
 
@@ -1158,35 +1492,35 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1158
1492
  return
1159
1493
  }
1160
1494
  if (key.return) {
1161
- decide(APPROVAL_OPTIONS[cursor]!)
1495
+ decide(APPROVAL_OPTIONS[cursor])
1162
1496
  return
1163
1497
  }
1164
1498
  if (key.escape) {
1165
- decide(APPROVAL_OPTIONS[2]!)
1499
+ decide(APPROVAL_OPTIONS[2])
1166
1500
  return
1167
1501
  }
1168
1502
  if (input === 'y' || input === 'Y') {
1169
- decide(APPROVAL_OPTIONS[0]!)
1503
+ decide(APPROVAL_OPTIONS[0])
1170
1504
  return
1171
1505
  }
1172
1506
  if (input === 'n' || input === 'N') {
1173
- decide(APPROVAL_OPTIONS[1]!)
1507
+ decide(APPROVAL_OPTIONS[1])
1174
1508
  return
1175
1509
  }
1176
1510
  if (input === 'd' || input === 'D') {
1177
- decide(APPROVAL_OPTIONS[2]!)
1511
+ decide(APPROVAL_OPTIONS[2])
1178
1512
  return
1179
1513
  }
1180
1514
  if (/^[1-9]$/u.test(input)) {
1181
1515
  const index = Number(input) - 1
1182
- if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index]!)
1516
+ if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index])
1183
1517
  }
1184
1518
  }, { isActive: active })
1185
1519
 
1186
1520
  if (pending === undefined) return undefined
1187
1521
  const queuedSuffix = snapshot.queued > 0 ? ` · +${snapshot.queued} queued` : ''
1188
1522
  if (viewport.maxHeight === 0 || viewport.compact || summarize === true) {
1189
- 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))
1190
1524
  }
1191
1525
  // Body budget: title + options + footer consume fixed rows; the command
1192
1526
  // preview shrinks with an explicit overflow marker (Codex's "[… N lines]").
@@ -1205,7 +1539,7 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1205
1539
  createElement(PanelGap, { visible: viewport.gapRows > 0 && body.length > 0 }),
1206
1540
  ...visibleBody.map((line, index) => createElement(StyledRows, { key: `body-${index}`, lines: [line] })),
1207
1541
  ...(overflow > 0
1208
- ? [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))]
1209
1543
  : []),
1210
1544
  ...(body.length > 0 ? [createElement(PanelGap, { visible: viewport.gapRows > 0 })] : []),
1211
1545
  ...APPROVAL_OPTIONS.map((option, index) => {
@@ -1222,8 +1556,8 @@ function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
1222
1556
  )
1223
1557
  }),
1224
1558
  createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(snapshot.answered
1225
- ? 'submitted'
1226
- : '↑↓ choose · enter confirm · y/n/d quick · esc reject', viewport.contentColumns)),
1559
+ ? t('approval.submitted')
1560
+ : t('approval.footer'), viewport.contentColumns)),
1227
1561
  )
1228
1562
  }
1229
1563
 
@@ -1596,21 +1930,21 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1596
1930
 
1597
1931
  if (pending === undefined || question === undefined) return undefined
1598
1932
  if (viewport.maxHeight === 0 || viewport.compact) {
1599
- 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))
1600
1934
  }
1601
1935
  const footerBase = submitted
1602
- ? 'submitted'
1936
+ ? t('question.submitted')
1603
1937
  : mode === 'custom'
1604
1938
  ? options.length === 0
1605
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1606
- : '↑↓/pgup/pgdn scroll · type answer · enter submit · tab/esc or empty backspace: options'
1939
+ ? t('question.customNoOptions')
1940
+ : t('question.customOptions')
1607
1941
  : options.length === 0
1608
- ? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
1942
+ ? t('question.customNoOptions')
1609
1943
  : isMulti
1610
- ? '↑↓ choose · pgup/pgdn scroll · space/1-9 toggle · enter submit · c custom · esc interrupt'
1611
- : '↑↓ choose · pgup/pgdn scroll · 1-9 pick · enter submit · c custom · esc interrupt'
1944
+ ? t('question.multiOptions')
1945
+ : t('question.singleOptions')
1612
1946
  const footer = pending.request.questions.length > 1 && !submitted
1613
- ? `${footerBase} · ←→/ctrl+p/n switch question`
1947
+ ? `${footerBase}${t('question.switch')}`
1614
1948
  : footerBase
1615
1949
  return createElement(
1616
1950
  Box,
@@ -1618,12 +1952,12 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1618
1952
  createElement(
1619
1953
  Text,
1620
1954
  { color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep), bold: true, wrap: 'truncate-end' },
1621
- 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),
1622
1956
  ),
1623
1957
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1624
1958
  createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
1625
1959
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1626
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1960
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
1627
1961
  )
1628
1962
  }
1629
1963
 
@@ -1633,43 +1967,65 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1633
1967
  error: string | undefined
1634
1968
  /** `provider/model` label of the applied model: the cursor lands on it once. */
1635
1969
  current?: string
1636
- onSelect(row: ModelRow): void
1637
- onProviders?(): void
1638
- onRetry(): void
1639
- onClose(): void
1970
+ onSelect: (row: ModelRow) => void
1971
+ onProviders?: () => void
1972
+ onRetry: () => void
1973
+ onClose: () => void
1640
1974
  }): ReactElement {
1975
+ const [query, setQuery] = useState('')
1641
1976
  const [cursor, setCursor] = useState(0)
1642
1977
  const stdout = useStdout().stdout
1643
1978
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1644
1979
  const rows = directory?.rows ?? []
1980
+ // Direct-typing filter over provider and model names (the /mode contract):
1981
+ // printable keys edit the query, so a long directory is searchable without
1982
+ // a separate search mode. With a query active, q/r/g/G stop acting as
1983
+ // commands and become query text instead.
1984
+ const filtered = useMemo(() => {
1985
+ if (query === '') return rows
1986
+ const needle = query.toLowerCase()
1987
+ return rows.filter(row => `${row.provider} ${row.providerName ?? ''} ${row.model} ${row.modelName}`.toLowerCase().includes(needle))
1988
+ }, [rows, query])
1645
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 }
1646
1997
 
1647
1998
  useEffect(() => {
1648
1999
  // Open ON the applied model (Codex resumes the previous pick): the first
1649
2000
  // non-empty directory positions the cursor once, never on later refreshes.
1650
2001
  if (positioned.current || rows.length === 0 || current === undefined) {
1651
- if (rows.length === 0) {
2002
+ if (filtered.length === 0) {
1652
2003
  if (cursor !== 0) setCursor(0)
1653
2004
  return
1654
2005
  }
1655
- if (cursor >= rows.length) setCursor(rows.length - 1)
2006
+ if (cursor >= filtered.length) setCursor(filtered.length - 1)
1656
2007
  return
1657
2008
  }
1658
2009
  const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
1659
2010
  if (index >= 0) {
1660
2011
  positioned.current = true
1661
- setCursor(index)
1662
- } else if (cursor >= rows.length) {
1663
- setCursor(Math.max(0, rows.length - 1))
2012
+ // Position within the ACTIVE filter: the full-row index means nothing
2013
+ // when the query already narrowed the list while the directory loaded
2014
+ // (a late resolve must not place the cursor outside `filtered`).
2015
+ const filteredIndex = filtered.indexOf(rows[index])
2016
+ setCursor(filteredIndex >= 0 ? filteredIndex : 0)
2017
+ } else if (cursor >= filtered.length) {
2018
+ setCursor(Math.max(0, filtered.length - 1))
1664
2019
  }
1665
- }, [rows, cursor, current])
2020
+ }, [rows, filtered, cursor, current])
1666
2021
 
1667
2022
  useInput((input, key) => {
1668
- if (key.escape || input === 'q') {
2023
+ const { filtered: list, query: text, cursor: at } = liveRef.current
2024
+ if (key.escape || (input === 'q' && text === '')) {
1669
2025
  onClose()
1670
2026
  return
1671
2027
  }
1672
- if (input === 'r') {
2028
+ if (input === 'r' && text === '') {
1673
2029
  onRetry()
1674
2030
  return
1675
2031
  }
@@ -1682,13 +2038,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1682
2038
  onClose()
1683
2039
  return
1684
2040
  }
1685
- if (rows.length === 0) return
2041
+ const next = editQuery(text, input, key)
2042
+ if (next !== undefined) {
2043
+ setQuery(next)
2044
+ setCursor(0)
2045
+ return
2046
+ }
2047
+ if (list.length === 0) return
1686
2048
  if (key.upArrow) {
1687
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
2049
+ setCursor(at > 0 ? at - 1 : list.length - 1)
1688
2050
  return
1689
2051
  }
1690
2052
  if (key.downArrow) {
1691
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
2053
+ setCursor(at < list.length - 1 ? at + 1 : 0)
1692
2054
  return
1693
2055
  }
1694
2056
  if (key.pageUp) {
@@ -1696,36 +2058,31 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1696
2058
  return
1697
2059
  }
1698
2060
  if (key.pageDown) {
1699
- setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
2061
+ setCursor(current => Math.min(list.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1700
2062
  return
1701
2063
  }
1702
- if (input === 'g') {
1703
- setCursor(0)
1704
- return
1705
- }
1706
- if (input === 'G') {
1707
- setCursor(rows.length - 1)
1708
- return
1709
- }
1710
- if (key.return && rows[cursor] !== undefined) {
1711
- onSelect(rows[cursor])
2064
+ if (key.return && list[at] !== undefined) {
2065
+ onSelect(list[at])
1712
2066
  }
1713
2067
  })
1714
2068
 
1715
2069
  if (viewport.maxHeight === 0 || viewport.compact) {
1716
2070
  const providers = onProviders === undefined ? '' : ' · tab providers'
1717
- const state = rows.length === 0
2071
+ const state = filtered.length === 0
1718
2072
  ? directory === undefined && error === undefined
1719
- ? 'loading'
2073
+ ? t('panel.model.loading')
1720
2074
  : error !== undefined
1721
- ? 'error'
1722
- : 'no models'
1723
- : `❯ ${rows[cursor]?.modelName ?? rows[cursor]?.model ?? ''}`
1724
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model · ${state}${providers} · r retry · esc/q close`, viewport.contentColumns))
2075
+ ? t('panel.model.error')
2076
+ : query === '' ? t('panel.model.noModels') : t('panel.model.compactNoMatch', { query: singleLineText(query) })
2077
+ : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ''}`
2078
+ const tail = query === ''
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))
1725
2082
  }
1726
2083
 
1727
2084
  const stateRows: ReactElement[] = directory === undefined && error === undefined
1728
- ? [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')}`)]
1729
2086
  : error !== undefined
1730
2087
  ? [createElement(
1731
2088
  Text,
@@ -1738,27 +2095,32 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1738
2095
  : [createElement(
1739
2096
  Text,
1740
2097
  { key: 'failures', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1741
- truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
2098
+ truncateColumns(` ${t('panel.provider.failure', { providers: directory?.failures.join(', ') ?? '' })}`, viewport.contentColumns),
1742
2099
  )]),
1743
2100
  ...(rows.length === 0
1744
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
1745
- : []),
2101
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.noModels')}`)]
2102
+ : filtered.length === 0
2103
+ ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('panel.model.noMatch', { query: singleLineText(query) })}`, viewport.contentColumns))]
2104
+ : []),
1746
2105
  ]
1747
2106
  // Measurement and rendering share the same physical-row budget: state
1748
2107
  // messages consume body rows before selectable entries, as in Codex's
1749
2108
  // list-selection views.
1750
2109
  const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1751
2110
  const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1752
- const first = selectionWindow(cursor, rows.length, rowBudget)
1753
- const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
2111
+ const first = selectionWindow(cursor, filtered.length, rowBudget)
2112
+ const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
2113
+ const accent = panelAccent('model', getPalette().brand)
1754
2114
  return createElement(
1755
2115
  Box,
1756
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1757
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, 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)),
1758
2120
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1759
2121
  ...visibleStateRows,
1760
2122
  ...visible.map((row) => {
1761
- const index = rows.indexOf(row)
2123
+ const index = filtered.indexOf(row)
1762
2124
  const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
1763
2125
  const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
1764
2126
  return createElement(
@@ -1772,21 +2134,23 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1772
2134
  )
1773
2135
  }),
1774
2136
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1775
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · tab providers'} · r retry · esc/q 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))),
1776
2140
  )
1777
2141
  }
1778
2142
 
1779
2143
  /** Compact provider-state copy; only value-free credential facts cross this boundary. */
1780
2144
  function providerStateLabel(row: ProviderTargetView): string {
1781
- const route = row.active ? 'active' : 'dormant'
2145
+ const route = row.active ? t('panel.provider.active') : t('panel.provider.dormant')
1782
2146
  const credential = row.credential
1783
- 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') })
1784
2148
  if (credential?.kind === 'facts') {
1785
- if (!credential.configured) return `${route} · key missing`
1786
- const source = credential.source === undefined ? 'configured' : singleLineText(credential.source)
1787
- 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')}`}` })
1788
2152
  }
1789
- 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') })
1790
2154
  }
1791
2155
 
1792
2156
  /** The provider-management stage reached from /model with `a`. */
@@ -1795,15 +2159,15 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1795
2159
  error: string | undefined
1796
2160
  authorizations: ProviderAuthorizationDirectory | undefined
1797
2161
  authorizationError: string | undefined
1798
- onConfigure(target: ProviderTargetView): void
1799
- onUnset(target: ProviderTargetView): void
1800
- onRemove(target: ProviderTargetView): void
1801
- onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1802
- onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1803
- onRetry(): void
1804
- 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
1805
2169
  /** Leave the whole /model flow (Ctrl+C), not just this stage. */
1806
- onExit(): void
2170
+ onExit: () => void
1807
2171
  }): ReactElement {
1808
2172
  const stdout = useStdout().stdout
1809
2173
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -1865,9 +2229,9 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1865
2229
  if (input === 'd') {
1866
2230
  const facts = target.credential
1867
2231
  if (facts?.kind !== 'facts' || !facts.configured) {
1868
- setActionError('this provider has no configured API key to remove')
2232
+ setActionError(t('panel.provider.noConfiguredKey'))
1869
2233
  } else if (!facts.writable) {
1870
- setActionError('this API key is supplied read-only by the environment')
2234
+ setActionError(t('panel.provider.readOnlyKey'))
1871
2235
  } else {
1872
2236
  onUnset(target)
1873
2237
  }
@@ -1875,7 +2239,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1875
2239
  }
1876
2240
  if (input === 'x') {
1877
2241
  if (!target.removable) {
1878
- setActionError('this provider profile is not removable')
2242
+ setActionError(t('panel.provider.notRemovable'))
1879
2243
  } else {
1880
2244
  onRemove(target)
1881
2245
  }
@@ -1883,14 +2247,14 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1883
2247
  }
1884
2248
  const authorization = authorizationForProvider(authorizations, target.provider)
1885
2249
  if (input === 'l' || input === 'L') {
1886
- if (authorization === undefined) setActionError('this provider offers no interactive login flow')
1887
- 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'))
1888
2252
  else onLogin(target, authorization)
1889
2253
  return
1890
2254
  }
1891
2255
  if (input === 'o' || input === 'O') {
1892
- if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
1893
- 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'))
1894
2258
  else onLogout(target, authorization)
1895
2259
  return
1896
2260
  }
@@ -1899,7 +2263,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1899
2263
  // the configuration surface behind an undiscoverable chord.
1900
2264
  if (key.return) {
1901
2265
  if (target.settingsNs.length === 0) {
1902
- setActionError('this provider is not managed by Harness settings')
2266
+ setActionError(t('panel.provider.notManaged'))
1903
2267
  } else {
1904
2268
  onConfigure(target)
1905
2269
  }
@@ -1907,10 +2271,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1907
2271
  }, true)
1908
2272
 
1909
2273
  if (viewport.maxHeight === 0 || viewport.compact) {
1910
- 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))
1911
2275
  }
1912
2276
  const stateRows: ReactElement[] = directory === undefined && error === undefined
1913
- ? [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')}`)]
1914
2278
  : error !== undefined
1915
2279
  ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
1916
2280
  : [
@@ -1924,14 +2288,14 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1924
2288
  )),
1925
2289
  ...(authorizationError === undefined
1926
2290
  ? []
1927
- : [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))]),
1928
2292
  ...(authorizations?.failures ?? []).map((failure, index) => createElement(
1929
2293
  Text,
1930
2294
  { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1931
2295
  truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1932
2296
  )),
1933
2297
  ...(rows.length === 0
1934
- ? [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')}`)]
1935
2299
  : []),
1936
2300
  ]
1937
2301
  const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
@@ -1942,7 +2306,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1942
2306
  const itemRows: ReactElement[] = []
1943
2307
  for (let display = first; display < first + rowBudget && display < displayLength; display += 1) {
1944
2308
  if (hasSeparator && display === configuredCount) {
1945
- 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)))
1946
2310
  continue
1947
2311
  }
1948
2312
  const index = hasSeparator && display > configuredCount ? display - 1 : display
@@ -1966,15 +2330,16 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1966
2330
  truncateColumns((index === cursor ? '❯ ' : ' ') + displayText(label), viewport.contentColumns),
1967
2331
  ))
1968
2332
  }
2333
+ const accent = panelAccent('model-providers', getPalette().brand)
1969
2334
  return createElement(
1970
2335
  Box,
1971
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1972
- 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)),
1973
2338
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1974
2339
  ...visibleStateRows,
1975
2340
  ...itemRows,
1976
2341
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1977
- 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)),
1978
2343
  )
1979
2344
  }
1980
2345
 
@@ -2002,14 +2367,14 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2002
2367
  target: ProviderTargetView
2003
2368
  /** Models with declared efforts (settings first, catalog-advertised after) a model row can copy from. */
2004
2369
  effortDonors: readonly EffortDonor[]
2005
- save(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
2370
+ save: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
2006
2371
  saveCredential: ((target: ProviderTargetView, key: string) => Promise<void>) | undefined
2007
- 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[]>
2008
2373
  /** Report a successful save so the surface can notice the key rotation. */
2009
- done(result: { readonly key: boolean }): void
2010
- back(): void
2374
+ done: (result: { readonly key: boolean }) => void
2375
+ back: () => void
2011
2376
  /** Leave the whole /model flow (Ctrl+C), not just this page. */
2012
- onExit(): void
2377
+ onExit: () => void
2013
2378
  }): ReactElement {
2014
2379
  const stdout = useStdout().stdout
2015
2380
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2063,7 +2428,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2063
2428
 
2064
2429
  /** Compact declaration summary for the row label: count, off, or inherit. */
2065
2430
  const effortsSummary = (model: ProviderModelSettings): string => {
2066
- const raw = (model.extras as Record<string, unknown> | undefined)?.reasoningEfforts
2431
+ const raw = (model.extras)?.reasoningEfforts
2067
2432
  if (raw === false) return 'off'
2068
2433
  if (isDeclaredReasoningEfforts(raw)) return String(Object.keys(raw).length)
2069
2434
  return '~'
@@ -2216,7 +2581,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2216
2581
  }
2217
2582
  if (input === 'e' && selected !== undefined) {
2218
2583
  setError(undefined)
2219
- setEffDraft(serializeReasoningEfforts((selected.extras as Record<string, unknown> | undefined)?.reasoningEfforts))
2584
+ setEffDraft(serializeReasoningEfforts((selected.extras)?.reasoningEfforts))
2220
2585
  setEffEditing(true)
2221
2586
  return
2222
2587
  }
@@ -2244,19 +2609,20 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2244
2609
  // Never hide a live input surface: one visible row keeps the escape
2245
2610
  // route honest on extremely short terminals (the three fixed rows - key,
2246
2611
  // url, add-by-id - cannot fit below a three-row body).
2247
- 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))
2248
2613
  }
2249
2614
  if (page === 'donor') {
2250
2615
  const stateRow = donorRows.length === 0
2251
- ? 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))
2252
- : 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))
2253
2618
  const donorBudget = Math.max(0, viewport.bodyRows - 2)
2254
2619
  const donorFirst = selectionWindow(donorIndex, donorRows.length, donorBudget)
2255
2620
  const donorVisible = donorRows.slice(donorFirst, donorFirst + donorBudget)
2621
+ const accent = panelAccent('model-efforts', getPalette().brand)
2256
2622
  return createElement(
2257
2623
  Box,
2258
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2259
- 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)),
2260
2626
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2261
2627
  stateRow,
2262
2628
  ...donorVisible.map((donor, index) => {
@@ -2265,7 +2631,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2265
2631
  return createElement(Text, { key: donor.provider + '/' + donor.id, color: active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns))
2266
2632
  }),
2267
2633
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2268
- 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)),
2269
2635
  )
2270
2636
  }
2271
2637
  if (page === 'discover') {
@@ -2307,7 +2673,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2307
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)))
2308
2674
  continue
2309
2675
  }
2310
- const model = models[index]!
2676
+ const model = models[index]
2311
2677
  const active = index === cursor
2312
2678
  const context = model.contextWindow === undefined ? '-' : String(model.contextWindow)
2313
2679
  const output = model.maxTokens === undefined ? '-' : String(model.maxTokens)
@@ -2317,10 +2683,11 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2317
2683
  : ' in:' + (active && field === 'ctx' ? '[' + context + ']' : context) + ' out:' + (active && field === 'out' ? '[' + output + ']' : output) + ' eff:' + effortsSummary(model)
2318
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)))
2319
2685
  }
2686
+ const accent = panelAccent('model-configure', getPalette().brand)
2320
2687
  return createElement(
2321
2688
  Box,
2322
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2323
- 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)),
2324
2691
  // The adapter's configuration diagnostic heads the editor: the provider
2325
2692
  // is here precisely because it stayed listed for repair.
2326
2693
  ...(target.diagnostic === undefined ? [] : [createElement(
@@ -2334,7 +2701,7 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
2334
2701
  ...stateRows,
2335
2702
  ...modelRows,
2336
2703
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2337
- 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)),
2338
2705
  )
2339
2706
  }
2340
2707
 
@@ -2350,11 +2717,11 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2350
2717
  baseURL: string
2351
2718
  apiKey: string
2352
2719
  configured: readonly string[]
2353
- discover(target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal): Promise<readonly DiscoveredModelView[]>
2354
- onAdopt(models: readonly DiscoveredModelView[]): void
2355
- 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
2356
2723
  /** Leave the whole /model flow (Ctrl+C). */
2357
- onExit(): void
2724
+ onExit: () => void
2358
2725
  }): ReactElement {
2359
2726
  const stdout = useStdout().stdout
2360
2727
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2415,24 +2782,25 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2415
2782
  }
2416
2783
  }, true)
2417
2784
  if (viewport.maxHeight === 0) {
2418
- 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))
2419
2786
  }
2420
2787
  const stateRows = loading
2421
- ? [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))]
2422
2789
  : error !== undefined
2423
2790
  ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(' ' + error, viewport.contentColumns))]
2424
2791
  : rows.length === 0
2425
- ? [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))]
2426
- : [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))]
2427
2794
  // One spare row keeps the panel strictly below maxHeight even with the
2428
2795
  // gap collapsed (the at-equality regime makes Ink rewrite Static).
2429
2796
  const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 1)
2430
2797
  const first = selectionWindow(cursor, rows.length, rowBudget)
2431
2798
  const visible = rows.slice(first, first + rowBudget)
2799
+ const accent = panelAccent('model-discover', getPalette().brand)
2432
2800
  return createElement(
2433
2801
  Box,
2434
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2435
- 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)),
2436
2804
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2437
2805
  ...stateRows,
2438
2806
  ...visible.map((model, index) => {
@@ -2444,7 +2812,7 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2444
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))
2445
2813
  }),
2446
2814
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2447
- 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)),
2448
2816
  )
2449
2817
  }
2450
2818
 
@@ -2452,9 +2820,9 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2452
2820
  function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
2453
2821
  target: ProviderTargetView
2454
2822
  kind: 'credential' | 'provider'
2455
- confirm(target: ProviderTargetView): Promise<void>
2456
- done(): void
2457
- back(): void
2823
+ confirm: (target: ProviderTargetView) => Promise<void>
2824
+ done: () => void
2825
+ back: () => void
2458
2826
  }): ReactElement {
2459
2827
  const stdout = useStdout().stdout
2460
2828
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -2512,7 +2880,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2512
2880
  skills: readonly SkillRow[]
2513
2881
  commandError: string | undefined
2514
2882
  skillError: string | undefined
2515
- onClose(): void
2883
+ onClose: () => void
2516
2884
  }): ReactElement {
2517
2885
  const stdout = useStdout().stdout
2518
2886
  const columns = stdout?.columns ?? 80
@@ -2522,19 +2890,19 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2522
2890
  const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
2523
2891
  const row = (label: string, description: string): ReactElement => createElement(
2524
2892
  Text,
2525
- { dimColor: true, wrap: 'truncate-end' },
2526
- ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
2893
+ { color: inkColor(getPalette().dim), wrap: 'truncate-end' },
2894
+ ` ${padColumns(label, nameWidth)}${truncateColumns(displayText(description), descBudget)}`,
2527
2895
  )
2528
2896
  const content: ReactElement[] = [
2529
- createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
2530
- createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
2531
- createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
2532
- createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl/alt+r thinking · shift+tab permission preset'),
2533
- createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
2534
- createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
2535
- 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')}`),
2536
2904
  createElement(Text, { key: 'commands-gap' }, ' '),
2537
- 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')),
2538
2906
  ...(commandError === undefined
2539
2907
  ? []
2540
2908
  : [createElement(
@@ -2545,18 +2913,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2545
2913
  ...LOCAL_COMMANDS.map(command => createElement(
2546
2914
  Box,
2547
2915
  { key: `local-${command.label.slice(1)}` },
2548
- row(command.label, command.description),
2916
+ row(command.label, t(command.descriptionKey)),
2549
2917
  )),
2550
2918
  ...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
2551
2919
  Text,
2552
- { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
2553
- ` ${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)}`,
2554
2922
  )),
2555
2923
  ...(skills.length === 0 && skillError === undefined
2556
2924
  ? []
2557
2925
  : [
2558
2926
  createElement(Text, { key: 'skills-gap' }, ' '),
2559
- 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')),
2560
2928
  ]),
2561
2929
  ...(skillError === undefined
2562
2930
  ? []
@@ -2567,8 +2935,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2567
2935
  )]),
2568
2936
  ...skills.map(skill => createElement(
2569
2937
  Text,
2570
- { key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
2571
- ` ${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)}`,
2572
2940
  )),
2573
2941
  ]
2574
2942
  const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
@@ -2594,17 +2962,18 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2594
2962
  })
2595
2963
 
2596
2964
  if (viewport.maxHeight === 0 || viewport.compact) {
2597
- 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))
2598
2966
  }
2599
2967
 
2968
+ const accent = panelAccent('help', getPalette().brand)
2600
2969
  return createElement(
2601
2970
  Box,
2602
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
2603
- 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)),
2604
2973
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2605
2974
  ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
2606
2975
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2607
- 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))),
2608
2977
  )
2609
2978
  }
2610
2979
 
@@ -2614,7 +2983,8 @@ function verboseLine(text: string, columns: number): string {
2614
2983
  }
2615
2984
 
2616
2985
  /** The empty-composer placeholder text (shared by the static and wave paths). */
2617
- const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2986
+ const composerPlaceholder = (mode: 'queue' | 'steer'): string =>
2987
+ t(mode === 'steer' ? 'composer.placeholderSteer' : 'composer.placeholder')
2618
2988
 
2619
2989
  /** One physical cell of the wave-painted composer row: a char plus styles. */
2620
2990
  interface ComposerCell {
@@ -2647,9 +3017,9 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
2647
3017
  const spans: ReactElement[] = []
2648
3018
  let start = 0
2649
3019
  while (start < cells.length) {
2650
- const cell = cells[start]!
3020
+ const cell = cells[start]
2651
3021
  let end = start + 1
2652
- while (end < cells.length && sameCellStyle(cells[end]!, cell)) end += 1
3022
+ while (end < cells.length && sameCellStyle(cells[end], cell)) end += 1
2653
3023
  spans.push(createElement(
2654
3024
  Text,
2655
3025
  {
@@ -2672,7 +3042,7 @@ function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): numb
2672
3042
  let column = 0
2673
3043
  for (let index = 0; index < cells.length; index += 1) {
2674
3044
  if (column === target) return index
2675
- column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
3045
+ column += cells[index].width ?? visibleColumns(cells[index].char)
2676
3046
  if (column > target) return undefined
2677
3047
  }
2678
3048
  return undefined
@@ -2745,11 +3115,13 @@ interface ComposerWaveProps {
2745
3115
  value: string
2746
3116
  /** Tier prompt glyph and accent color (persistent, like Codex's charge). */
2747
3117
  promptGlyph: string
3118
+ /** Empty-composer placeholder for the delivery mode in force. */
3119
+ placeholder: string
2748
3120
  promptColor: string
2749
3121
  /** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
2750
3122
  * cancelled by the gate, or unmounted (a modal panel froze the composer) —
2751
3123
  * so Input's played-key latch survives the leaf's unmount/remount cycle. */
2752
- onSettled(): void
3124
+ onSettled: () => void
2753
3125
  }
2754
3126
 
2755
3127
  /**
@@ -2817,7 +3189,7 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2817
3189
  }
2818
3190
  for (const span of splitGraphemes(parts.before)) push(span.text)
2819
3191
  if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
2820
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3192
+ const tail = placeholder ? props.placeholder : parts.after
2821
3193
  for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
2822
3194
  while (usedColumns < props.bandWidth) push(' ')
2823
3195
 
@@ -2826,9 +3198,9 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2826
3198
  const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
2827
3199
  const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
2828
3200
  const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
2829
- 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))) {
2830
3202
  for (let at = 0; at < word.length; at += 1) {
2831
- const cell = cells[indices[at]!]!
3203
+ const cell = cells[indices[at]!]
2832
3204
  cell.char = word[at]!
2833
3205
  cell.width = 1
2834
3206
  cell.color = inkColor(deepseekWaveWordHue(at, hues))
@@ -2840,11 +3212,11 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2840
3212
  if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
2841
3213
  const spark = deepseekWaveSpark(tick)
2842
3214
  const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
2843
- if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
2844
- cells[lastIndex]!.char = spark
2845
- cells[lastIndex]!.color = props.promptColor
2846
- cells[lastIndex]!.bold = true
2847
- 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
2848
3220
  }
2849
3221
  }
2850
3222
  return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
@@ -2858,13 +3230,104 @@ function ComposerWave(props: ComposerWaveProps): ReactElement {
2858
3230
  )
2859
3231
  }
2860
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
+
2861
3324
  /**
2862
3325
  * The Ctrl+O transcript inspector: one selected durable entry at a time,
2863
3326
  * with independent history selection and content scrolling. The complete
2864
3327
  * retained entry is converted to physical rows, but only one viewport slice
2865
3328
  * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
2866
3329
  */
2867
- function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
3330
+ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose: () => void }): ReactElement {
2868
3331
  const stdout = useStdout().stdout
2869
3332
  const columns = stdout?.columns ?? 80
2870
3333
  const rows = stdout?.rows ?? 30
@@ -2958,14 +3421,15 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
2958
3421
  return createElement(
2959
3422
  Text,
2960
3423
  { wrap: 'truncate-end' },
2961
- truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
3424
+ truncateColumns(t('panel.verbose.compact'), viewport.contentColumns),
2962
3425
  )
2963
3426
  }
2964
3427
 
2965
3428
  const title = entries.length === 0
2966
3429
  ? 'history details · empty'
2967
- : `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}`
2968
3431
  const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
3432
+ const accent = panelAccent('history-inspector', getPalette().brand)
2969
3433
  return createElement(
2970
3434
  Box,
2971
3435
  {
@@ -2973,11 +3437,11 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
2973
3437
  width: viewport.outerColumns,
2974
3438
  paddingX: 1,
2975
3439
  borderStyle: 'round',
2976
- borderColor: inkColor(getPalette().brand),
3440
+ borderColor: inkColor(accent.border),
2977
3441
  },
2978
3442
  createElement(
2979
3443
  Text,
2980
- { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
3444
+ { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' },
2981
3445
  truncateColumns(title, viewport.contentColumns),
2982
3446
  ),
2983
3447
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
@@ -2991,8 +3455,8 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
2991
3455
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
2992
3456
  createElement(
2993
3457
  Text,
2994
- { dimColor: true, wrap: 'truncate-end' },
2995
- 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)),
2996
3460
  ),
2997
3461
  )
2998
3462
  }
@@ -3039,7 +3503,7 @@ export function completionCandidates(
3039
3503
  ): readonly CompletionCandidate[] {
3040
3504
  if (!value.startsWith('/')) return []
3041
3505
  const prefix = value.slice(1).split(' ')[0] ?? ''
3042
- 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' }))
3043
3507
  // Local commands shadow registry names (e.g. the TUI-local /permission works
3044
3508
  // before any session exists, while the registry child needs one), so
3045
3509
  // collisions cannot render two rows with the same key.
@@ -3104,10 +3568,10 @@ function completionMenuRowCount(terminalRows: number, rowCount: number): number
3104
3568
 
3105
3569
  /**
3106
3570
  * The completion menu, rendered inside the composer's subtree directly above
3107
- * the composer band — attached the way Claude-Code anchors its dropdown. Opening
3108
- * it grows the stack downward: the composer stays the last element on screen
3109
- * and everything above (the flushed static transcript, the status line) never
3110
- * 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
3111
3575
  * editor's live completion state, so no cross-component effect ever resyncs
3112
3576
  * it (a state lift here previously deadlocked the menu after a resize).
3113
3577
  */
@@ -3165,7 +3629,7 @@ function CompletionMenu({ active, mention, index, rows, error }: {
3165
3629
  // Scroll affordance: with the full merged catalog (commands + registry +
3166
3630
  // skills) the six-row window rarely shows the tail — count and hint keep
3167
3631
  // the rest discoverable without inflating the menu budget.
3168
- 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,
3169
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,
3170
3634
  )
3171
3635
  }
@@ -3187,84 +3651,109 @@ interface DraftFile extends FilePathInspection {
3187
3651
  * While a modal (approval / question / model panel) owns the keys, the
3188
3652
  * box passes every key through untouched.
3189
3653
  */
3190
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, 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, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, 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 }: {
3191
3655
  active: boolean
3192
3656
  frozen: boolean
3657
+ /** Frozen-band hint naming the surface that owns the keyboard; an empty
3658
+ * draft otherwise advertises typing that the composer cannot accept. */
3659
+ frozenHint?: string
3193
3660
  busy: boolean
3194
3661
  descriptors: readonly CommandDescriptor[]
3195
3662
  skills: readonly SkillRow[]
3196
- dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
3197
- 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
3198
3670
  /** The full current session identity ('' while pending); the delivery origin. */
3199
3671
  sessionKey: string
3200
- interrupt(): boolean
3201
- quit(): void
3202
- openModel(): void
3203
- openEffort(): void
3204
- openHelp(): void
3205
- openMode(): void
3206
- openPermission(): void
3207
- openResume(): void
3208
- openPlugin(query?: string): void
3209
- openJobs(): void
3210
- openStatusline(): void
3211
- openTheme(): void
3212
- openHistory(): 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
3683
+ /** Open the /update panel (aligned upgrade surface). */
3684
+ openUpdate: () => void
3685
+ /** Open the /schedule reminder panel (read-only catalog). */
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
3213
3696
  /** Open the /agents panel (live subagent feed + transcript entry). */
3214
- openAgents(): void
3697
+ openAgents: () => void
3215
3698
  /** Open the /subagent model panel. */
3216
- openSubagent(): void
3699
+ openSubagent: () => void
3217
3700
  /** Open the /todos subpage (full todo list in one bounded panel). */
3218
- openTodos(): void
3701
+ openTodos: () => void
3702
+ openUsage: () => void
3219
3703
  /** Open the /resume picker in delete mode, optionally pre-armed on one id. */
3220
- openDelete(id?: string): void
3221
- openDiff(argument: string): void
3222
- 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
3223
3709
  /** The row id awaiting y/n in this box, when a deletion is pending. */
3224
3710
  deleteConfirm?: string
3225
3711
  /** Confirm the pending deletion (y in the box). */
3226
- confirmDelete(): void
3712
+ confirmDelete: () => void
3227
3713
  /** Cancel the pending deletion (any other key in the box). */
3228
- cancelDelete(): void
3229
- createSession(mode?: string): void
3230
- forkSession(argument: string): void
3231
- cancelSessionSwitch(): boolean
3232
- 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
3233
3719
  /** Apply the Ctrl+R passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
3234
- applyEditorKeys(): Promise<string>
3720
+ applyEditorKeys: () => Promise<string>
3235
3721
  hasNotice: boolean
3236
- dismissNotice(): void
3237
- toggleReasoning(): void
3238
- openVerbose(): void
3239
- clearView(): void
3240
- refresh(): void
3241
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
3242
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
3243
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
3244
- inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
3245
- prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
3246
- cyclePermission(): string
3247
- exportTranscript(argument: string): Promise<void>
3248
- renameTitle(argument: string): string
3249
- 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>
3250
3736
  /** Newest-first recall space (persistent + in-session, deduped). */
3251
3737
  recallSpace: readonly string[]
3252
3738
  /** Record one in-session submission (deduped, local only). */
3253
- recordLocal(text: string): void
3739
+ recordLocal: (text: string) => void
3254
3740
  /** Persist one submission to the global history file. */
3255
- recordHistory(text: string): void
3256
- /** Live queued inbox rows; Delete on the empty composer cancels the newest. */
3257
- queued: readonly { messageId: string; target: 'next-turn' | 'next-step'; text: string }[]
3258
- /** Cancel one queued inbox message by identity. */
3259
- 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
3260
3745
  /** Accepted /history entry waiting to be placed into the composer. */
3261
3746
  historyFill: { text: string; index: number } | undefined
3262
3747
  /** Marks the accepted entry consumed (called after the fill is applied). */
3263
- historyConsumed(): void
3748
+ historyConsumed: () => void
3264
3749
  /** Whether timed animations run (shimmer, chase, blink, wave). */
3265
3750
  animations: boolean
3266
3751
  /** Apply and report one /animation toggle (App persists through the runner). */
3267
- 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
3268
3757
  /** DeepSeek easter-egg wave tier of the applied route (null otherwise):
3269
3758
  * official DeepSeek models drive their flash/pro tiers, non-DeepSeek
3270
3759
  * models running an effort above high drive the "Into the Unknown"
@@ -3275,14 +3764,25 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3275
3764
  waveStyle: DeepseekWaveStyle | null
3276
3765
  /** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
3277
3766
  maxRows: number
3767
+ /** Terminal rows below the composer the editor does not own: the status
3768
+ * footer and Ink's parked cursor row. The IME anchor adds these to the
3769
+ * caret's in-band offset to reach that parked position. */
3770
+ anchorRowsBelow: number
3771
+ /** The managed terminal tab label; re-asserted on terminal focus-in so a
3772
+ * background process sharing the console cannot keep it overwritten. */
3773
+ tabTitle: string
3278
3774
  /** Reports the editor's current physical row count so the live budget stays exact. */
3279
- onEditorRows(rows: number): void
3775
+ onEditorRows: (rows: number) => void
3280
3776
  /** Reports the open completion menu's physical row count (0 when closed)
3281
3777
  * for the same reason: the dynamic budget must reserve it, not overflow. */
3282
- onMenuRows(rows: number): void
3778
+ onMenuRows: (rows: number) => void
3283
3779
  }): ReactElement {
3284
- const columns = useStdout().stdout?.columns ?? 80
3285
- const inputTerminalRows = useStdout().stdout?.rows ?? 30
3780
+ const { stdout: inputStdout } = useStdout()
3781
+ const columns = inputStdout?.columns ?? 80
3782
+ const inputTerminalRows = inputStdout?.rows ?? 30
3783
+ // The managed tab label, kept current for the focus-in re-assert below.
3784
+ const tabTitleRef = useRef(tabTitle)
3785
+ tabTitleRef.current = tabTitle
3286
3786
  const editorColumns = Math.max(1, columns - 6)
3287
3787
  const stdin = useStdin().stdin
3288
3788
  const focusReporting = isVsCodeTerminalEnv()
@@ -3378,12 +3878,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3378
3878
  if (stdin === undefined) return
3379
3879
  const originalRead = stdin.read.bind(stdin)
3380
3880
  const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
3381
- 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
3382
3886
  if (chunk === null) return chunk
3383
3887
  const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
3384
3888
  const input = focusReporting
3385
3889
  ? stripTerminalFocusEvents(normalized, focused => {
3386
3890
  terminalFocusedRef.current = focused
3891
+ // Focus-in re-asserts the managed tab label on both channels: a
3892
+ // background process sharing this console (a test-runner worker,
3893
+ // for example) may have overwritten the console title while the
3894
+ // terminal was unfocused.
3895
+ if (focused && inputStdout !== undefined) {
3896
+ inputStdout.write(terminalTitleSequence(tabTitleRef.current))
3897
+ process.title = sanitizeTerminalTitle(tabTitleRef.current)
3898
+ }
3387
3899
  })
3388
3900
  : normalized
3389
3901
  rawEditorTokens.current = tokenizeRawEditorChunk(input)
@@ -3391,7 +3903,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3391
3903
  } as typeof stdin.read
3392
3904
  stdin.read = patchedRead
3393
3905
  return () => {
3394
- stdin.read = originalRead as typeof stdin.read
3906
+ stdin.read = originalRead
3395
3907
  }
3396
3908
  }, [focusReporting, stdin])
3397
3909
 
@@ -3446,7 +3958,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3446
3958
 
3447
3959
  const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
3448
3960
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
3449
- notify(`${inspection.name} is already attached`, 'warning')
3961
+ notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
3450
3962
  return false
3451
3963
  }
3452
3964
  const next = [...draftImagesRef.current, { ...inspection, marker }]
@@ -3465,7 +3977,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3465
3977
  const originalCursor = cursorRef.current
3466
3978
  const total = imagePaths.length + filePaths.length
3467
3979
  if (total === 0) return
3468
- notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
3980
+ notify(t('notice.attachmentsChecking', { count: total, plural: total === 1 ? '' : 's' }))
3469
3981
  void Promise.all([
3470
3982
  imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
3471
3983
  filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
@@ -3486,13 +3998,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3486
3998
  markers.push(marker)
3487
3999
  }
3488
4000
  if (imageAdditions.length === 0 && fileAdditions.length === 0) {
3489
- notify('those attachments are already attached', 'warning')
4001
+ notify(t('notice.attachmentsAlready'), 'warning')
3490
4002
  return
3491
4003
  }
3492
4004
  const current = valueRef.current
3493
4005
  const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
3494
4006
  if (anchor === undefined) {
3495
- notify('draft changed at the attachment drop point; drop the files again', 'warning')
4007
+ notify(t('notice.attachmentDraftChanged'), 'warning')
3496
4008
  return
3497
4009
  }
3498
4010
  const at = anchor.start
@@ -3513,9 +4025,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3513
4025
  draftFilesRef.current = nextFiles
3514
4026
  setDraftFiles(nextFiles)
3515
4027
  const count = imageAdditions.length + fileAdditions.length
3516
- notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
4028
+ notify(t('notice.attachmentsReady', { count, plural: count === 1 ? '' : 's' }))
3517
4029
  }, (reason: unknown) => {
3518
- 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')
3519
4031
  })
3520
4032
  }
3521
4033
 
@@ -3600,7 +4112,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3600
4112
  const current = valueRef.current
3601
4113
  const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
3602
4114
  if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
3603
- notify('draft changed around the image mention; select it again', 'warning')
4115
+ notify(t('notice.imageDraftChanged'), 'warning')
3604
4116
  return
3605
4117
  }
3606
4118
  if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
@@ -3611,7 +4123,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3611
4123
  setCursor(edit.cursor)
3612
4124
  resetCursorBlink()
3613
4125
  setDismissedMenuValue(edit.value)
3614
- notify(`${inspection.name} is already attached`, 'warning')
4126
+ notify(t('notice.attachmentAlready', { name: inspection.name }), 'warning')
3615
4127
  return
3616
4128
  }
3617
4129
  const marker = uniqueImageMarker(inspection.name, 'mention')
@@ -3623,9 +4135,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3623
4135
  resetCursorBlink()
3624
4136
  setDismissedMenuValue(edit.value)
3625
4137
  registerDraftImage(inspection, marker)
3626
- notify(`${inspection.name} ready for the next message`)
4138
+ notify(t('notice.imageReady', { name: inspection.name }))
3627
4139
  }, (reason: unknown) => {
3628
- 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')
3629
4141
  })
3630
4142
  setCompletionIndex(0)
3631
4143
  setDismissedMenuValue(undefined)
@@ -3729,23 +4241,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3729
4241
  prepareAbortRef.current = undefined
3730
4242
  setPreparingImages(false)
3731
4243
  dismissNotice()
3732
- notify('image submission cancelled', 'warning')
4244
+ notify(t('notice.imageCancelled'), 'warning')
3733
4245
  }
3734
4246
 
3735
- /** Move through visual rows first, then cross history at the true edge. */
4247
+ /** Cross history while an unchanged recalled draft rests its caret on
4248
+ * either text edge; between the edges (or inside ordinary drafts) the
4249
+ * arrows move through visual rows first. */
3736
4250
  const navigateVertical = (direction: -1 | 1): void => {
3737
4251
  const currentValue = valueRef.current
3738
4252
  const currentCursor = cursorRef.current
3739
- const model = editorModel(currentValue, editorColumns)
3740
- const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
3741
- const next = moveCursorVertically(model, currentCursor, preferred, direction)
3742
- if (next !== currentCursor) {
3743
- cursorRef.current = next
3744
- setCursor(next)
3745
- resetCursorBlink()
3746
- preferredColumnRef.current = preferred
3747
- return
3748
- }
4253
+ // History owns the arrows while an unchanged recalled draft rests its
4254
+ // caret on either text edge (start or end). Everywhere else - edited
4255
+ // drafts, interior carets, ordinary typing - the arrows move through
4256
+ // visual rows as plain editing.
3749
4257
  if (recall.current.entries.length > 0
3750
4258
  && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
3751
4259
  const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
@@ -3757,10 +4265,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3757
4265
  setValue(safe)
3758
4266
  setCursor(safe.length)
3759
4267
  preferredColumnRef.current = null
3760
- setDismissedMenuValue(undefined)
4268
+ // Suppress the completion menu for the recalled text: a recalled
4269
+ // command would otherwise reopen the menu, whose Up/Down navigation
4270
+ // then traps the walk before it reaches older history entries. Any
4271
+ // edit re-opens the menu; submitting resets the dismissal.
4272
+ setDismissedMenuValue(safe)
3761
4273
  }
4274
+ resetCursorBlink()
4275
+ return
4276
+ }
4277
+ const model = editorModel(currentValue, editorColumns)
4278
+ const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
4279
+ const next = moveCursorVertically(model, currentCursor, preferred, direction)
4280
+ if (next !== currentCursor) {
4281
+ cursorRef.current = next
4282
+ setCursor(next)
4283
+ resetCursorBlink()
4284
+ preferredColumnRef.current = preferred
3762
4285
  }
3763
- resetCursorBlink()
3764
4286
  }
3765
4287
 
3766
4288
  useStableInput((input, key) => {
@@ -3787,16 +4309,25 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3787
4309
  }
3788
4310
  return
3789
4311
  }
3790
- // Shift+Tab cycles the permission preset (Claude-Code convention).
4312
+ // Shift+Tab cycles the mode stations: permission presets, then the
4313
+ // plan station when the composition offers it (Claude-Code convention).
3791
4314
  if (key.tab && key.shift) {
3792
4315
  try {
3793
- const next = cyclePermission()
3794
- if (next !== '') notify(`permission → ${next}`)
4316
+ const label = cycleMode()
4317
+ if (label !== '') notify(label)
3795
4318
  } catch (error: unknown) {
3796
- 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')
3797
4320
  }
3798
4321
  return
3799
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
+ }
3800
4331
  // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
3801
4332
  // Alt+R is the zero-config alias: VS Code never intercepts Alt chords,
3802
4333
  // so the toggle stays reachable before /vscode-keys has been applied.
@@ -3842,7 +4373,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3842
4373
  applyEdit(deleteForward(liveValue, liveCursor))
3843
4374
  return
3844
4375
  }
3845
- if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
4376
+ if (busy) notify(t('notice.cancelBeforeExit'), 'warning')
3846
4377
  else quit()
3847
4378
  return
3848
4379
  }
@@ -3868,7 +4399,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3868
4399
  const forwardDelete = rawEditorTokens.current?.some(token =>
3869
4400
  token.kind === 'delete-forward' || token.kind === 'delete-word-forward') === true
3870
4401
  if (forwardDelete) {
3871
- cancelQueued(queued[queued.length - 1]!.messageId)
4402
+ updateQueued?.(queued[queued.length - 1].messageId, { kind: 'remove' })
3872
4403
  return
3873
4404
  }
3874
4405
  }
@@ -3900,7 +4431,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3900
4431
  // Slash semantics with attachments are unchanged: commands cannot
3901
4432
  // carry attachments, so the line goes to the model as a prompt —
3902
4433
  // warn instead of surprising the user with a literal "/export".
3903
- 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')
3904
4435
  // Attachment prepares resolve asynchronously; the app remounts onto
3905
4436
  // another session in the meantime, and this (old) instance's unmount
3906
4437
  // cleanup runs too late on the microtask timeline. Tag the delivery
@@ -3939,7 +4470,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3939
4470
  }
3940
4471
  recall.current = beginRecall(recallSpace, '')
3941
4472
  const blocks: readonly ContentBlock[] = [...images, ...files]
3942
- if (busy) steer(text, blocks, originSession)
4473
+ if (submitMode === 'steer') steer(text, blocks, originSession)
3943
4474
  else dispatch(text, blocks, originSession)
3944
4475
  }, (reason: unknown) => {
3945
4476
  if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
@@ -3958,13 +4489,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3958
4489
  setDismissedMenuValue(undefined)
3959
4490
  if (trimmed === '') return
3960
4491
  dismissNotice()
3961
- // Global recall records non-slash submissions only (slash lines are
3962
- // commands, not prompts) Codex record_local_submission semantics;
3963
- // the submission resets any active recall browsing.
3964
- if (!text.startsWith('/')) {
3965
- recordLocal(text)
3966
- recordHistory(text)
3967
- }
4492
+ // Global recall records every submission - prompts and typed slash
4493
+ // commands share one history, so Up/Down and /history recall commands
4494
+ // exactly like prompts; the submission resets any active recall
4495
+ // browsing.
4496
+ recordLocal(text)
4497
+ recordHistory(text)
3968
4498
  recall.current = beginRecall(recallSpace, '')
3969
4499
  if (text === '/quit') {
3970
4500
  quit()
@@ -4009,7 +4539,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4009
4539
  return
4010
4540
  }
4011
4541
  if (text === '/review' || text.startsWith('/review ')) {
4012
- 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
+ }
4013
4552
  return
4014
4553
  }
4015
4554
  if (text === '/model' || text.startsWith('/model ')) {
@@ -4044,6 +4583,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4044
4583
  else dispatch(text)
4045
4584
  return
4046
4585
  }
4586
+ if (text === '/search' || text.startsWith('/search ')) {
4587
+ openSearch(text.slice(7).trim())
4588
+ return
4589
+ }
4047
4590
  if (text === '/new' || text.startsWith('/new ')) {
4048
4591
  createSession(text.slice(4).trim() || undefined)
4049
4592
  return
@@ -4056,6 +4599,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4056
4599
  openPlugin(text.slice(7).trim())
4057
4600
  return
4058
4601
  }
4602
+ if (text === '/update') {
4603
+ openUpdate()
4604
+ return
4605
+ }
4606
+ if (text === '/schedule') {
4607
+ openSchedule()
4608
+ return
4609
+ }
4059
4610
  if (text === '/jobs' || text.startsWith('/jobs ')) {
4060
4611
  openJobs()
4061
4612
  return
@@ -4068,17 +4619,41 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4068
4619
  openTheme()
4069
4620
  return
4070
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
+ }
4071
4632
  if (text === '/animation' || text.startsWith('/animation ')) {
4072
4633
  const parsed = parseAnimationsArgument(text.slice('/animation'.length))
4073
4634
  if (parsed === 'toggle') applyAnimations(!animations)
4074
- else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
4635
+ else if (parsed === 'usage') notify(t('notice.usage.animation'), 'info')
4075
4636
  else applyAnimations(parsed.enabled)
4076
4637
  return
4077
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
+ }
4078
4645
  if (text === '/history') {
4079
4646
  openHistory()
4080
4647
  return
4081
4648
  }
4649
+ if (text === '/queue') {
4650
+ openQueue()
4651
+ return
4652
+ }
4653
+ if (text === '/usage') {
4654
+ openUsage()
4655
+ return
4656
+ }
4082
4657
  if (text === '/agents') {
4083
4658
  openAgents()
4084
4659
  return
@@ -4102,19 +4677,25 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4102
4677
  openDelete(text.slice(7).trim())
4103
4678
  return
4104
4679
  }
4105
- if (busy && !text.startsWith('/')) {
4106
- // A running turn is steered, not blocked: the inbox delivers this
4107
- // text at the next step boundary (Esc/Ctrl+C still cancels outright).
4108
- // Slash lines keep the registry path — commands run out of band.
4109
- steer(text)
4110
- return
4111
- }
4112
- 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'))
4113
4697
  return
4114
4698
  }
4115
- // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
4116
- // stripping the leading escape. Neither is a multiline shortcut.
4117
- if (input === '\n' || input === '\r') return
4118
4699
  // A fast Tab followed by text can arrive as one readable chunk in an
4119
4700
  // integrated terminal. Accept the candidate first, then apply the
4120
4701
  // remaining characters against the synchronously updated editor refs.
@@ -4286,13 +4867,23 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4286
4867
  if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
4287
4868
  }, [animations, waveKey, wavePlayedKey])
4288
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
4289
4876
 
4290
4877
  // Every exclusive panel keeps the composer as a stable visual anchor, but
4291
4878
  // freezes it to one row: no menu, multiline wrap, or animation.
4292
4879
  const tierActive = waveTier !== null
4293
4880
  const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
4294
4881
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
4295
- 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)
4296
4887
  // The multiline editor model: the sanitized draft hard-wrapped into
4297
4888
  // column-safe physical rows, with the caret mapped to its exact row and
4298
4889
  // column. Computed before the frozen path so the row report below runs
@@ -4315,6 +4906,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4315
4906
  useEffect(() => {
4316
4907
  onEditorRows(editorRowCount)
4317
4908
  }, [editorRowCount, onEditorRows])
4909
+ // IME anchor: park the real terminal cursor on the caret cell while the
4910
+ // composer accepts input. IME composition and candidate windows anchor to
4911
+ // that real cursor cell, which otherwise sits below the status row where
4912
+ // Ink leaves it, so Chinese input never appears at the caret. Frozen bands
4913
+ // release the anchor; the wrapper keeps Ink's relative erase ledger exact.
4914
+ const caretRowInWindow = Math.max(0, Math.min(caret.row - editorWindowStart, editorWindowRows - 1))
4915
+ useImeCursorAnchor(
4916
+ !frozen,
4917
+ imeCursorRowsUp({ editorWindowRows, caretRowInWindow, rowsBelowComposer: anchorRowsBelow }),
4918
+ 2 + caret.column,
4919
+ )
4318
4920
  // The menu's physical rows ride the same one-way report; the cleanup keeps
4319
4921
  // the reserve from outliving the menu (unmount or inactive handoff).
4320
4922
  useEffect(() => {
@@ -4350,12 +4952,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4350
4952
  ))
4351
4953
  }
4352
4954
  const frozenLine = value === ''
4353
- ? 'type a message'
4955
+ ? frozenHint ?? 'type a message'
4354
4956
  : verboseLine(value, Math.max(1, columns - 6))
4355
4957
  return band(createElement(
4356
4958
  Text,
4357
4959
  { backgroundColor: bandBg, wrap: 'truncate-end' },
4358
- createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
4960
+ createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, submitMode === 'steer' ? '↳ ' : busy ? '… ' : `${promptGlyph} `),
4359
4961
  frozenLine,
4360
4962
  bandFill(2 + visibleColumns(frozenLine)),
4361
4963
  ))
@@ -4373,10 +4975,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4373
4975
  // spacer or a second blink timer.
4374
4976
  const editorRows: ReactElement[] = []
4375
4977
  for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
4376
- const row = editorViewModel.rows[index]!
4978
+ const row = editorViewModel.rows[index]
4377
4979
  const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
4378
4980
  const placeholder = index === 0 && value === '' && !busy && !preparingImages
4379
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
4981
+ const tail = placeholder ? placeholderText : parts.after
4380
4982
  const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
4381
4983
  editorRows.push(createElement(
4382
4984
  Text,
@@ -4384,16 +4986,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4384
4986
  index === 0
4385
4987
  ? preparingImages
4386
4988
  ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
4387
- : busy
4388
- ? createElement(BusyChase, { animated: animations })
4389
- : 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} `)
4390
4994
  : ' ',
4391
4995
  parts.before,
4392
4996
  parts.hasCaret
4393
4997
  ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
4394
4998
  : null,
4395
4999
  placeholder
4396
- ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
5000
+ ? createElement(Text, { dimColor: true }, tail)
4397
5001
  : parts.after,
4398
5002
  bandFill(consumed),
4399
5003
  ))
@@ -4409,26 +5013,47 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4409
5013
  Box,
4410
5014
  { flexDirection: 'column' },
4411
5015
  menu,
4412
- createElement(ComposerWave, {
4413
- key: waveKey ?? 'static',
4414
- tier: waveTier ?? 'deepseek',
4415
- style: waveStyle ?? 'wave',
4416
- active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
4417
- onSettled: () => {
4418
- if (waveKey !== null) setWavePlayedKey(waveKey)
4419
- },
4420
- fallback: band(staticEditor),
4421
- bandWidth,
4422
- bandBg,
4423
- rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
4424
- windowStart: editorWindowStart,
4425
- caretRow: caret.row,
4426
- cursor: clampedCursor,
4427
- caretVisible: cursorVisible,
4428
- value,
4429
- promptGlyph,
4430
- promptColor,
4431
- }),
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
+ }),
4432
5057
  )
4433
5058
  }
4434
5059
 
@@ -4653,7 +5278,13 @@ export function computeSettledRows(
4653
5278
 
4654
5279
  /** The whole terminal app; state arrives via the store, output via Ink. */
4655
5280
  export function App(props: AppProps): ReactElement {
4656
- 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)
4657
5288
  // Terminal input anchor: Ink reference-counts raw mode across every active
4658
5289
  // `useInput` hook, so mutually exclusive surfaces (composer <-> approval
4659
5290
  // bar <-> panels) drop the count to zero inside each handoff commit — the
@@ -4675,8 +5306,10 @@ export function App(props: AppProps): ReactElement {
4675
5306
  // process-stable, so one callback per view identity is enough.
4676
5307
  const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands])
4677
5308
  const readSkills = useCallback(() => props.skills.rows, [props.skills])
4678
- const descriptors = useSyncExternalStore(props.commands.subscribe, readDescriptors)
4679
- 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)
4680
5313
  const [modelLabel, setModelLabel] = useState(props.model)
4681
5314
  const [modelOpen, setModelOpen] = useState(false)
4682
5315
  /** Nested /model stages; only one owns terminal input at a time. */
@@ -4705,13 +5338,17 @@ export function App(props: AppProps): ReactElement {
4705
5338
  * (ordinary turns, image preparation, /animation toggles) never replays. */
4706
5339
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
4707
5340
  const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
5341
+ const [rainbowBurstId, setRainbowBurstId] = useState(0)
5342
+ const fireRainbowBurst = (): void => {
5343
+ setRainbowBurstId(id => id + 1)
5344
+ }
4708
5345
  // /animation toggle: applies immediately, persists through the runner, and
4709
5346
  // gates every timed leaf (shimmer, chase, blink, wave) for this render.
4710
5347
  const [animations, setAnimations] = useState(props.animations ?? true)
4711
5348
  const applyAnimations = (enabled: boolean): void => {
4712
5349
  setAnimations(enabled)
4713
5350
  props.saveAnimations?.(enabled)
4714
- notify(`animations ${enabled ? 'on' : 'off'}`)
5351
+ notify(t('notice.animationState', { state: enabled ? 'on' : 'off' }))
4715
5352
  }
4716
5353
  const previousModel = useRef<string | undefined>(undefined)
4717
5354
  const previousEffort = useRef<string | undefined>(props.effort)
@@ -4826,21 +5463,36 @@ export function App(props: AppProps): ReactElement {
4826
5463
  // Dedupe for the dynamic-budget tripwire: one warning per distinct shape.
4827
5464
  const budgetWarnRef = useRef<string | undefined>(undefined)
4828
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')
4829
5473
  const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
5474
+ const [reviewPickerOpen, setReviewPickerOpen] = useState(false)
4830
5475
  const [helpOpen, setHelpOpen] = useState(false)
4831
5476
  const [modeOpen, setModeOpen] = useState(false)
4832
5477
  const [permissionOpen, setPermissionOpen] = useState(false)
4833
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('')
4834
5482
  const [pluginOpen, setPluginOpen] = useState(false)
4835
5483
  const [pluginQuery, setPluginQuery] = useState('')
5484
+ const [updateOpen, setUpdateOpen] = useState(false)
5485
+ const [scheduleOpen, setScheduleOpen] = useState(false)
4836
5486
  const [jobsOpen, setJobsOpen] = useState(false)
4837
5487
  const [statuslineOpen, setStatuslineOpen] = useState(false)
4838
5488
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
4839
5489
  const [themeOpen, setThemeOpen] = useState(false)
5490
+ const [languageOpen, setLanguageOpen] = useState(false)
4840
5491
  const [historyOpen, setHistoryOpen] = useState(false)
4841
5492
  const [agentsOpen, setAgentsOpen] = useState(false)
4842
5493
  const [subagentOpen, setSubagentOpen] = useState(false)
4843
5494
  const [todosOpen, setTodosOpen] = useState(false)
5495
+ const [usageOpen, setUsageOpen] = useState(false)
4844
5496
  /** /delete state: delete-mode hint plus an optional pre-armed row id. */
4845
5497
  const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
4846
5498
  /** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
@@ -4864,7 +5516,7 @@ export function App(props: AppProps): ReactElement {
4864
5516
  // from the list immediately, not look like a no-op.
4865
5517
  setDeleteReloadToken(token => token + 1)
4866
5518
  }, (reason: unknown) => {
4867
- 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')
4868
5520
  })
4869
5521
  }, [deleteConfirmId, props.deleteSession, notify])
4870
5522
  /** The /history panel's accepted entry: text plus its recall-space index. */
@@ -4884,26 +5536,25 @@ export function App(props: AppProps): ReactElement {
4884
5536
  }, [])
4885
5537
  /** The append-only flush boundary (see `settledEntryCount`): entries below
4886
5538
  * this index are final and ride the `<Static>` scrollback; everything at or
4887
- * beyond stays in the live tree. Pending inbox rows always live at
4888
- * index >= settled, so the queued-inbox scan below only walks the mutable
4889
- * tail instead of the whole history. */
5539
+ * beyond stays in the live tree. */
4890
5540
  const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
4891
- /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). The
4892
- * projection only appends and removes pending rows at index >= settled, so
4893
- * a bounded tail scan replaces an unconditional O(history) filter on every
4894
- * event. */
4895
- const queuedRows = useMemo(() => {
4896
- const rows: Array<Extract<TranscriptEntry, { kind: 'pending' }>> = []
4897
- for (let index = settled; index < view.entries.length; index++) {
4898
- const entry = view.entries[index]
4899
- if (entry.kind === 'pending') rows.push(entry)
4900
- }
4901
- return rows
4902
- }, [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
+ )
4903
5548
  const [refreshEpoch, setRefreshEpoch] = useState(0)
4904
- const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
4905
- const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
4906
- 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)
4907
5558
  const approvalPending = approvalSnapshot.pending !== undefined
4908
5559
  const questionPending = questionSnapshot.pending !== undefined
4909
5560
  // While any modal owns the keys, the prompt box passes everything through.
@@ -4912,7 +5563,8 @@ export function App(props: AppProps): ReactElement {
4912
5563
  // panel keypress.
4913
5564
  const inputActive = deleteConfirmId !== undefined
4914
5565
  ? !approvalPending && !questionPending
4915
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !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
4916
5568
 
4917
5569
  // Human questions outrank local inspectors. Close the lower modal instead
4918
5570
  // of leaving an approval/question visible but keyboard-locked behind it.
@@ -4927,9 +5579,12 @@ export function App(props: AppProps): ReactElement {
4927
5579
  setPermissionOpen(false)
4928
5580
  setResumeOpen(false)
4929
5581
  setPluginOpen(false)
5582
+ setUpdateOpen(false)
5583
+ setScheduleOpen(false)
4930
5584
  setStatuslineOpen(false)
4931
5585
  setThemeOpen(false)
4932
5586
  setHistoryOpen(false)
5587
+ setQueueOpen(false)
4933
5588
  setAgentsOpen(false)
4934
5589
  setSubagentOpen(false)
4935
5590
  setTodosOpen(false)
@@ -4979,6 +5634,7 @@ export function App(props: AppProps): ReactElement {
4979
5634
  // One pending synchronized frame covers a debounced resize or explicit
4980
5635
  // source-backed replay. It is closed after the corresponding React commit.
4981
5636
  const synchronizedReplayPending = useRef(false)
5637
+ const resizeBurstHeld = useRef(false)
4982
5638
  useEffect(() => {
4983
5639
  if (appStdout === undefined) return
4984
5640
  let replayTimer: ReturnType<typeof setTimeout> | undefined
@@ -4990,24 +5646,31 @@ export function App(props: AppProps): ReactElement {
4990
5646
  if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
4991
5647
  terminalSizeRef.current = next
4992
5648
 
4993
- // Ink 5 erases by the old logical line count. Once the terminal reflows
4994
- // a full-width border at a new width, that count is no longer enough and
4995
- // stale frames remain visible. Follow Codex's source-backed reflow
4996
- // policy: update live geometry immediately, but wait for the resize
4997
- // burst to settle before one hard reset and one transcript replay at the
4998
- // 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
+ }
4999
5657
  setTerminalSize(next)
5000
5658
  if (replayTimer !== undefined) clearTimeout(replayTimer)
5001
5659
  replayTimer = setTimeout(() => {
5002
5660
  synchronizedReplayPending.current = true
5003
- appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
5661
+ appStdout.write(RESIZE_REFLOW_CLEAR)
5004
5662
  setRefreshEpoch(epoch => epoch + 1)
5663
+ resizeBurstHeld.current = false
5005
5664
  }, RESIZE_REFLOW_DELAY_MS)
5006
5665
  }
5007
5666
  appStdout.on('resize', handleResize)
5008
5667
  return () => {
5009
5668
  appStdout.off('resize', handleResize)
5010
5669
  if (replayTimer !== undefined) clearTimeout(replayTimer)
5670
+ if (resizeBurstHeld.current) {
5671
+ appStdout.write(SYNCHRONIZED_UPDATE_END)
5672
+ resizeBurstHeld.current = false
5673
+ }
5011
5674
  }
5012
5675
  }, [appStdout])
5013
5676
  const terminalRows = terminalSize.rows
@@ -5029,17 +5692,36 @@ export function App(props: AppProps): ReactElement {
5029
5692
  const handleMenuRows = useCallback((rows: number): void => {
5030
5693
  setMenuRows(current => (current === rows ? current : rows))
5031
5694
  }, [])
5695
+ // The status footer's exact row count, reported one-way by StatusLine (the
5696
+ // second row renders only while it has content). The IME cursor anchor
5697
+ // counts every row between the composer caret and Ink's parked cursor: the
5698
+ // status footer plus Ink's own below-frame row. The gutter rows sit ABOVE
5699
+ // the composer and never enter this distance.
5700
+ const [statusBarRows, setStatusBarRows] = useState<1 | 2>(1)
5701
+ const handleStatusRows = useCallback((rows: 1 | 2): void => {
5702
+ setStatusBarRows(current => (current === rows ? current : rows))
5703
+ }, [])
5704
+ const imeRowsBelowComposer = statusBarRows + 1
5032
5705
  const composerEditorCap = composerMaxRows(terminalRows)
5033
- // Bottom chrome is composer (2 borders + composerRows) + status (up to 2
5034
- // rows) + todo/agents/notice (3) = 8 resting rows, plus the historical
5035
- // 5-row menu reserve: small menus still fit without shrinking the live
5036
- // area (unchanged behavior), and menu rows beyond the reserve are budgeted
5037
- // exactly so the live/streaming area stays strictly below the terminal
5038
- // height as the editor or the menu grows.
5039
- const MENU_RESERVE_ROWS = 5
5040
- 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
+ })
5041
5719
  const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
5042
5720
  const deepDivingVisible = busy && !streamingActive
5721
+ // Terminal tab label: "deepseek" until the session carries a name, then the
5722
+ // session title; cleared on unmount so the host shell regains its default.
5723
+ const tabTitle = view.title === '' ? DEFAULT_TERMINAL_TITLE : view.title
5724
+ useTerminalTitle(tabTitle)
5043
5725
  const allLiveLines = useMemo(
5044
5726
  () => view.entries.slice(settled).flatMap(
5045
5727
  // Width shrinks with the real terminal (no 10-column floor: on a
@@ -5081,6 +5763,10 @@ export function App(props: AppProps): ReactElement {
5081
5763
  )
5082
5764
  if (liveAudit.warning !== undefined && budgetWarnRef.current !== liveAudit.warning) {
5083
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
5084
5770
  console.warn(`[dsh-code] ${liveAudit.warning}`)
5085
5771
  }
5086
5772
  const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length
@@ -5088,9 +5774,60 @@ export function App(props: AppProps): ReactElement {
5088
5774
  : visibleLiveLines.slice(-liveAudit.allocation.live)
5089
5775
  const auditedReasoningRows = liveAudit.allocation.reasoning
5090
5776
  const auditedAnswerRows = liveAudit.allocation.answer
5091
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5092
5777
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
5093
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || 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
5779
+ // The surface that currently owns the keyboard, named in the frozen band:
5780
+ // an empty composer under a panel must not advertise typing it cannot
5781
+ // accept — every key actually feeds the panel (which may or may not
5782
+ // filter with it), so the honest hint names the owner and the way out.
5783
+ const keyboardOwner = approvalPending
5784
+ ? 'the approval prompt'
5785
+ : questionPending
5786
+ ? 'the question'
5787
+ : diffView !== undefined
5788
+ ? 'the diff review'
5789
+ : reviewPickerOpen
5790
+ ? 'the review picker'
5791
+ : modelOpen
5792
+ ? '/model'
5793
+ : helpOpen
5794
+ ? '/help'
5795
+ : modeOpen
5796
+ ? '/mode'
5797
+ : permissionOpen
5798
+ ? '/permission'
5799
+ : resumeOpen
5800
+ ? '/resume'
5801
+ : pluginOpen
5802
+ ? '/plugin'
5803
+ : updateOpen
5804
+ ? '/update'
5805
+ : scheduleOpen
5806
+ ? '/schedule'
5807
+ : jobsOpen
5808
+ ? '/jobs'
5809
+ : statuslineOpen
5810
+ ? '/statusline'
5811
+ : themeOpen
5812
+ ? '/theme'
5813
+ : languageOpen
5814
+ ? '/language'
5815
+ : historyOpen
5816
+ ? '/history'
5817
+ : agentsOpen
5818
+ ? '/agents'
5819
+ : subagentOpen
5820
+ ? '/subagent'
5821
+ : todosOpen
5822
+ ? '/todos'
5823
+ : usageOpen
5824
+ ? '/usage'
5825
+ : inspectorVisible
5826
+ ? 'history details'
5827
+ : undefined
5828
+ const frozenHint = keyboardOwner === undefined
5829
+ ? undefined
5830
+ : `keys go to ${keyboardOwner} · esc ${approvalPending ? 'rejects' : questionPending ? 'cancels' : 'closes'}`
5094
5831
  const closeInspector = useCallback((): void => {
5095
5832
  setVerboseOpen(false)
5096
5833
  }, [])
@@ -5106,6 +5843,18 @@ export function App(props: AppProps): ReactElement {
5106
5843
  }
5107
5844
  setRefreshEpoch(epoch => epoch + 1)
5108
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
+ }
5109
5858
  useEffect(() => {
5110
5859
  if (!synchronizedReplayPending.current || appStdout === undefined) return
5111
5860
  synchronizedReplayPending.current = false
@@ -5136,16 +5885,16 @@ export function App(props: AppProps): ReactElement {
5136
5885
  setEffortLabel(effortId)
5137
5886
  const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
5138
5887
  if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
5139
- notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
5888
+ notify(t('notice.modelChangedPlaceholder', { model: selected }), 'warning')
5140
5889
  } else {
5141
- notify(`model next step uses ${selected}`)
5890
+ notify(t('notice.modelNextStep', { model: selected }))
5142
5891
  }
5143
5892
  setModelOpen(false)
5144
5893
  setProviderOpen(false)
5145
5894
  setProviderAction(undefined)
5146
5895
  setEffortFor(undefined)
5147
5896
  } catch (error: unknown) {
5148
- 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')
5149
5898
  }
5150
5899
  }
5151
5900
 
@@ -5166,7 +5915,7 @@ export function App(props: AppProps): ReactElement {
5166
5915
  const seen = new Set<string>()
5167
5916
  for (const row of providerDirectory?.rows ?? []) {
5168
5917
  for (const model of row.configuration.models) {
5169
- const raw = (model.extras as Record<string, unknown> | undefined)?.reasoningEfforts
5918
+ const raw = (model.extras)?.reasoningEfforts
5170
5919
  if (!isDeclaredReasoningEfforts(raw)) continue
5171
5920
  const key = row.provider + '/' + model.id
5172
5921
  if (seen.has(key)) continue
@@ -5204,7 +5953,7 @@ export function App(props: AppProps): ReactElement {
5204
5953
  setProviderAction(undefined)
5205
5954
  setProviderOpen(false)
5206
5955
  reloadModelSurfaces()
5207
- notify(`logged in to ${authorization.label}; select a model`)
5956
+ notify(t('notice.loggedIn', { provider: authorization.label }))
5208
5957
  },
5209
5958
  back: () => {
5210
5959
  setProviderAction(undefined)
@@ -5220,7 +5969,7 @@ export function App(props: AppProps): ReactElement {
5220
5969
  setProviderAction(undefined)
5221
5970
  setProviderOpen(true)
5222
5971
  reloadModelSurfaces()
5223
- notify(`logged out from ${authorization.label}`)
5972
+ notify(t('notice.loggedOut', { provider: authorization.label }))
5224
5973
  },
5225
5974
  back: () => setProviderAction(undefined),
5226
5975
  })
@@ -5231,13 +5980,13 @@ export function App(props: AppProps): ReactElement {
5231
5980
  save: props.saveModelProviderConfiguration,
5232
5981
  saveCredential: props.saveModelProviderCredential,
5233
5982
  discover: props.discoverModelProvider
5234
- ?? (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'))),
5235
5984
  done: result => {
5236
5985
  const target = providerAction.target
5237
5986
  setProviderAction(undefined)
5238
5987
  setProviderOpen(true)
5239
5988
  reloadModelSurfaces()
5240
- 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' : '' }))
5241
5990
  },
5242
5991
  back: () => setProviderAction(undefined),
5243
5992
  onExit: closeModelSurface,
@@ -5252,7 +6001,7 @@ export function App(props: AppProps): ReactElement {
5252
6001
  setProviderAction(undefined)
5253
6002
  setProviderOpen(true)
5254
6003
  reloadModelSurfaces()
5255
- notify(`API key removed for ${target.displayName}`)
6004
+ notify(t('notice.apiKeyRemoved', { provider: target.displayName }))
5256
6005
  },
5257
6006
  back: () => setProviderAction(undefined),
5258
6007
  })
@@ -5266,7 +6015,7 @@ export function App(props: AppProps): ReactElement {
5266
6015
  setProviderAction(undefined)
5267
6016
  setProviderOpen(true)
5268
6017
  reloadModelSurfaces()
5269
- notify(`provider removed: ${target.displayName}`)
6018
+ notify(t('notice.providerRemoved', { provider: target.displayName }))
5270
6019
  },
5271
6020
  back: () => setProviderAction(undefined),
5272
6021
  })
@@ -5278,42 +6027,42 @@ export function App(props: AppProps): ReactElement {
5278
6027
  authorizationError,
5279
6028
  onConfigure: (target: ProviderTargetView) => {
5280
6029
  if (props.saveModelProviderConfiguration === undefined) {
5281
- notify('provider configuration is unavailable in this profile', 'warning')
6030
+ notify(t('notice.providerUnavailable'), 'warning')
5282
6031
  return
5283
6032
  }
5284
6033
  setProviderAction({ kind: 'configure', target })
5285
6034
  },
5286
6035
  onUnset: (target: ProviderTargetView) => {
5287
6036
  if (props.unsetModelProviderCredential === undefined) {
5288
- notify('API key removal is unavailable in this profile', 'warning')
6037
+ notify(t('notice.apiKeyUnavailable'), 'warning')
5289
6038
  return
5290
6039
  }
5291
6040
  setProviderAction({ kind: 'unset', target })
5292
6041
  },
5293
6042
  onRemove: (target: ProviderTargetView) => {
5294
6043
  if (props.removeModelProvider === undefined) {
5295
- notify('provider removal is unavailable in this profile', 'warning')
6044
+ notify(t('notice.providerRemovalUnavailable'), 'warning')
5296
6045
  return
5297
6046
  }
5298
6047
  setProviderAction({ kind: 'remove', target })
5299
6048
  },
5300
6049
  onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
5301
6050
  if (busy) {
5302
- notify('provider login is available only while the agent is idle', 'warning')
6051
+ notify(t('notice.loginIdleOnly'), 'warning')
5303
6052
  return
5304
6053
  }
5305
6054
  if (props.beginProviderAuthorization === undefined
5306
6055
  || props.cancelProviderAuthorization === undefined
5307
6056
  || props.openAuthorizationUrl === undefined
5308
6057
  || props.copyTextValue === undefined) {
5309
- notify('provider login is unavailable in this profile', 'warning')
6058
+ notify(t('notice.loginUnavailable'), 'warning')
5310
6059
  return
5311
6060
  }
5312
6061
  setProviderAction({ kind: 'login', target, authorization })
5313
6062
  },
5314
6063
  onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
5315
6064
  if (props.logoutProviderAuthorization === undefined) {
5316
- notify('provider logout is unavailable in this profile', 'warning')
6065
+ notify(t('notice.logoutUnavailable'), 'warning')
5317
6066
  return
5318
6067
  }
5319
6068
  setProviderAction({ kind: 'logout', target, authorization })
@@ -5346,7 +6095,7 @@ export function App(props: AppProps): ReactElement {
5346
6095
  setEffortFor(row)
5347
6096
  return
5348
6097
  }
5349
- 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
5350
6099
  applyModel(row, effortId)
5351
6100
  },
5352
6101
  ...(props.loadModelProviders === undefined || props.saveModelProviderConfiguration === undefined
@@ -5413,6 +6162,13 @@ export function App(props: AppProps): ReactElement {
5413
6162
  : undefined,
5414
6163
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
5415
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,
5416
6172
  todosOpen && !approvalPending && !questionPending
5417
6173
  ? createElement(MemoTodoListPanel, {
5418
6174
  todos: view.todos,
@@ -5421,6 +6177,14 @@ export function App(props: AppProps): ReactElement {
5421
6177
  },
5422
6178
  })
5423
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,
5424
6188
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
5425
6189
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify, interrupt: props.interrupt, summarize: questionPending }),
5426
6190
  modelSurface,
@@ -5441,6 +6205,17 @@ export function App(props: AppProps): ReactElement {
5441
6205
  onClose: () => setDiffView(undefined),
5442
6206
  })
5443
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,
5444
6219
  verboseOpen && !approvalPending && !questionPending
5445
6220
  ? createElement(MemoVerbosePanel, {
5446
6221
  entries: view.entries,
@@ -5453,9 +6228,9 @@ export function App(props: AppProps): ReactElement {
5453
6228
  load: props.loadPresets,
5454
6229
  select: (id: string) => {
5455
6230
  void props.switchMode(id).then(label => {
5456
- notify(`mode ${label}`)
6231
+ notify(t('notice.modeChangedSimple', { value: label }))
5457
6232
  setModeOpen(false)
5458
- }, (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'))
5459
6234
  },
5460
6235
  close: () => setModeOpen(false),
5461
6236
  })
@@ -5467,7 +6242,7 @@ export function App(props: AppProps): ReactElement {
5467
6242
  select: (id: string) => {
5468
6243
  try {
5469
6244
  const selected = props.setPermission(id)
5470
- notify(`permission ${selected}`)
6245
+ notify(t('notice.permissionChangedSimple', { value: selected }))
5471
6246
  setPermissionOpen(false)
5472
6247
  } catch (reason: unknown) {
5473
6248
  notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
@@ -5489,9 +6264,42 @@ export function App(props: AppProps): ReactElement {
5489
6264
  close: () => setResumeOpen(false),
5490
6265
  })
5491
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,
5492
6289
  pluginOpen && !approvalPending && !questionPending
5493
6290
  ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
5494
6291
  : undefined,
6292
+ updateOpen && !approvalPending && !questionPending
6293
+ ? createElement(UpdatePanel, {
6294
+ probe: props.probeUpdate,
6295
+ apply: props.applyUpdate,
6296
+ notify: (text: string, tone?: NoticeTone) => notify(text, tone),
6297
+ close: () => setUpdateOpen(false),
6298
+ })
6299
+ : undefined,
6300
+ scheduleOpen && !approvalPending && !questionPending
6301
+ ? createElement(SchedulePanel, { rows: () => view.schedules, close: () => setScheduleOpen(false) })
6302
+ : undefined,
5495
6303
  jobsOpen && !approvalPending && !questionPending
5496
6304
  ? createElement(JobsPanel, { load: props.loadJobs, close: () => setJobsOpen(false) })
5497
6305
  : undefined,
@@ -5514,12 +6322,36 @@ export function App(props: AppProps): ReactElement {
5514
6322
  // palette. `auto` stores as requested; detection is a later step.
5515
6323
  setTheme(name)
5516
6324
  props.saveTheme?.(name)
5517
- 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 }))
5518
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()
5519
6337
  },
5520
6338
  close: () => setThemeOpen(false),
5521
6339
  })
5522
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,
5523
6355
  historyOpen && !approvalPending && !questionPending
5524
6356
  ? createElement(HistoryPanel, {
5525
6357
  entries: recallSpace,
@@ -5547,15 +6379,15 @@ export function App(props: AppProps): ReactElement {
5547
6379
  // The runner's label already carries the effort suffix
5548
6380
  // (`provider/model@effort`), so no second append here.
5549
6381
  const label = props.setSubagentModel(row, effortId)
5550
- notify(`subagents ${label}`)
6382
+ notify(t('notice.subagentsChanged', { value: label }))
5551
6383
  setSubagentOpen(false)
5552
6384
  } catch (reason: unknown) {
5553
- 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')
5554
6386
  }
5555
6387
  },
5556
6388
  inherit: () => {
5557
6389
  props.clearSubagentModel()
5558
- notify('subagents → inherit current model')
6390
+ notify(t('notice.subagentsInherited'))
5559
6391
  setSubagentOpen(false)
5560
6392
  },
5561
6393
  close: () => setSubagentOpen(false),
@@ -5577,12 +6409,19 @@ export function App(props: AppProps): ReactElement {
5577
6409
  createElement(Input, {
5578
6410
  active: inputActive,
5579
6411
  frozen: modalVisible,
6412
+ frozenHint,
5580
6413
  busy,
5581
6414
  descriptors,
5582
6415
  skills,
5583
6416
  dispatch: props.dispatch,
5584
- applyEditorKeys: props.applyEditorKeys,
5585
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,
5586
6425
  interrupt: props.interrupt,
5587
6426
  quit: props.quit,
5588
6427
  openModel: () => {
@@ -5614,12 +6453,12 @@ export function App(props: AppProps): ReactElement {
5614
6453
  ?? loaded.rows.find(candidate => candidate.model === model && candidate.reasoning !== undefined)
5615
6454
  ?? loaded.rows.find(candidate => candidate.model === model)
5616
6455
  if (row === undefined) {
5617
- notify('current model is not in the catalog', 'warning')
6456
+ notify(t('notice.modelMissing'), 'warning')
5618
6457
  return
5619
6458
  }
5620
6459
  const rowTag = `${row.provider}/${row.model}`
5621
6460
  if (loaded.reasoningFailures?.includes(rowTag) === true) {
5622
- notify('reasoning levels temporarily unavailable (capability lookup failed) — try again', 'warning')
6461
+ notify(t('notice.effortUnavailable'), 'warning')
5623
6462
  return
5624
6463
  }
5625
6464
  if (row.reasoning === undefined || row.reasoning.efforts.length === 0) {
@@ -5643,14 +6482,28 @@ export function App(props: AppProps): ReactElement {
5643
6482
  openMode: () => setModeOpen(true),
5644
6483
  openPermission: () => setPermissionOpen(true),
5645
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
+ },
5646
6493
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
6494
+ openUpdate: () => setUpdateOpen(true),
6495
+ openSchedule: () => setScheduleOpen(true),
5647
6496
  openJobs: () => setJobsOpen(true),
5648
6497
  openStatusline: () => setStatuslineOpen(true),
5649
6498
  openTheme: () => setThemeOpen(true),
6499
+ openLanguage: () => setLanguageOpen(true),
6500
+ saveLanguage: props.saveLanguage,
5650
6501
  openHistory: () => setHistoryOpen(true),
6502
+ openQueue: () => setQueueOpen(true),
5651
6503
  openAgents: () => setAgentsOpen(true),
5652
6504
  openSubagent: () => setSubagentOpen(true),
5653
6505
  openTodos: () => setTodosOpen(true),
6506
+ openUsage: () => setUsageOpen(true),
5654
6507
  openDelete: (id?: string) => {
5655
6508
  const armed = id === undefined || id === '' ? undefined : id
5656
6509
  setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
@@ -5663,6 +6516,7 @@ export function App(props: AppProps): ReactElement {
5663
6516
  })
5664
6517
  },
5665
6518
  reviewChanges: props.reviewChanges,
6519
+ openReviewPicker: () => setReviewPickerOpen(true),
5666
6520
  deleteConfirm: deleteConfirmId,
5667
6521
  confirmDelete,
5668
6522
  cancelDelete,
@@ -5698,7 +6552,7 @@ export function App(props: AppProps): ReactElement {
5698
6552
  inspectFiles: props.inspectFiles,
5699
6553
  prepareFiles: props.prepareFiles,
5700
6554
  sessionKey: props.sessionKey,
5701
- cyclePermission: props.cyclePermission,
6555
+ cycleMode: props.cycleMode,
5702
6556
  exportTranscript: props.exportTranscript,
5703
6557
  renameTitle: props.renameTitle,
5704
6558
  copyLastResponse: props.copyLastResponse,
@@ -5706,14 +6560,18 @@ export function App(props: AppProps): ReactElement {
5706
6560
  recordLocal,
5707
6561
  recordHistory: props.recordHistory,
5708
6562
  queued: queuedRows,
5709
- cancelQueued: props.cancelQueued,
6563
+ updateQueued: props.updateQueued,
5710
6564
  historyFill,
5711
6565
  historyConsumed,
5712
6566
  animations,
5713
6567
  applyAnimations,
6568
+ applyRainbow,
6569
+ rainbowBurstId,
5714
6570
  waveTier,
5715
6571
  waveStyle,
5716
6572
  maxRows: composerEditorCap,
6573
+ anchorRowsBelow: imeRowsBelowComposer,
6574
+ tabTitle,
5717
6575
  onEditorRows: handleEditorRows,
5718
6576
  onMenuRows: handleMenuRows,
5719
6577
  }),
@@ -5725,15 +6583,17 @@ export function App(props: AppProps): ReactElement {
5725
6583
  branch: props.branch,
5726
6584
  sessionId: props.sessionId,
5727
6585
  title: view.title,
5728
- plan: view.plan,
6586
+ plan: view.plan || props.pendingPlan === true,
5729
6587
  permission: view.permission !== '' ? view.permission : props.permission,
5730
6588
  sandbox: view.sandbox,
5731
6589
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
5732
6590
  },
5733
6591
  stats: view.stats,
5734
6592
  busy,
6593
+ animated: animations,
5735
6594
  columns: terminalColumns,
5736
6595
  items: statuslineItems,
6596
+ onRows: handleStatusRows,
5737
6597
  }),
5738
6598
  ),
5739
6599
  )