dsh-code 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +4063 -844
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +100 -63
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +73 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +86 -31
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/inspector.d.ts +26 -0
  25. package/lib/types/render/lines.d.ts +21 -1
  26. package/lib/types/render/markdown.d.ts +1 -1
  27. package/lib/types/render/projection.d.ts +95 -4
  28. package/lib/types/render/status.d.ts +8 -8
  29. package/lib/types/render/text.d.ts +6 -0
  30. package/lib/types/render/usage.d.ts +113 -0
  31. package/lib/types/session-directory.d.ts +17 -0
  32. package/lib/types/startup.d.ts +1 -1
  33. package/lib/types/terminal-title.d.ts +8 -0
  34. package/lib/types/theme-panel.d.ts +2 -2
  35. package/lib/types/theme.d.ts +271 -52
  36. package/lib/types/update-panel.d.ts +5 -5
  37. package/lib/types/update.d.ts +10 -1
  38. package/lib/types/version.d.ts +4 -3
  39. package/package.json +24 -7
  40. package/src/app.ts +1155 -478
  41. package/src/approval.ts +166 -166
  42. package/src/authorization-panel.ts +19 -16
  43. package/src/editor-keys.ts +371 -371
  44. package/src/git-workflow.ts +229 -3
  45. package/src/i18n.ts +68 -0
  46. package/src/index.ts +412 -76
  47. package/src/input-split.ts +3 -3
  48. package/src/kernel-panels.ts +471 -89
  49. package/src/keyboard.ts +5 -4
  50. package/src/language-panel.ts +53 -0
  51. package/src/locales/en.ts +489 -0
  52. package/src/locales/zh.ts +488 -0
  53. package/src/mentions.ts +8 -4
  54. package/src/models.ts +264 -212
  55. package/src/panel-accent.ts +41 -0
  56. package/src/presets.ts +1 -1
  57. package/src/provider-settings.ts +1 -1
  58. package/src/rainbow.ts +208 -0
  59. package/src/render/animations.ts +104 -6
  60. package/src/render/editor.ts +20 -20
  61. package/src/render/export.ts +116 -95
  62. package/src/render/inspector.ts +42 -0
  63. package/src/render/lines.ts +628 -415
  64. package/src/render/markdown.ts +15 -3
  65. package/src/render/projection.ts +429 -19
  66. package/src/render/status.ts +41 -35
  67. package/src/render/text.ts +14 -0
  68. package/src/render/tool-preview.ts +77 -77
  69. package/src/render/usage.ts +430 -0
  70. package/src/render/width.ts +2 -2
  71. package/src/session-directory.ts +8 -6
  72. package/src/session-query.ts +8 -4
  73. package/src/startup.ts +3 -3
  74. package/src/subagents.ts +229 -229
  75. package/src/terminal-title.ts +22 -5
  76. package/src/theme-panel.ts +17 -21
  77. package/src/theme.ts +281 -33
  78. package/src/update-panel.ts +37 -27
  79. package/src/update.ts +19 -3
  80. package/src/version.ts +58 -20
  81. package/src/whale-glyph.ts +23 -23
@@ -10,12 +10,16 @@ import type { PresetRow } from './presets.ts'
10
10
  import type { PluginRow } from './plugin-inventory.ts'
11
11
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
12
12
  import { formatRelativeTime } from './session-directory.ts'
13
+ import type { ReviewBranch, ReviewCommit, ReviewSelection } from './git-workflow.ts'
13
14
  import { panelViewport, revealRow } from './render/inspector.ts'
14
15
  import { markdownLines, textLines, type LineStyle, type StyledLine } from './render/lines.ts'
16
+ import { usageLines, type UsageView } from './render/usage.ts'
15
17
  import { deleteLastGrapheme } from './render/editor.ts'
16
18
  import { stripPasteMarkers } from './keyboard.ts'
17
19
  import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
18
20
  import { singleLineText, truncateColumns } from './render/text.ts'
21
+ import { panelAccent } from './panel-accent.ts'
22
+ import { t } from './i18n.ts'
19
23
  import { getPalette, inkColor } from './theme.ts'
20
24
 
21
25
  interface ListFrameProps {
@@ -40,9 +44,9 @@ function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
40
44
  /** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
41
45
  * toggle lives in the footer), direct-typing panels keep the plain prompt. */
42
46
  function searchLine(searching: boolean | undefined, query: string): string {
43
- if (searching === true) return `search: ${query === '' ? 'type to filter · esc stops' : query}`
44
- if (searching === false) return query === '' ? '' : `search: ${query}`
45
- return `search: ${query === '' ? 'type to filter' : query}`
47
+ if (searching === true) return query === '' ? t('panel.searchIdleStop') : `${t('panel.searchPrefix')}${query}`
48
+ if (searching === false) return query === '' ? '' : `${t('panel.searchPrefix')}${query}`
49
+ return query === '' ? t('panel.searchIdle') : `${t('panel.searchPrefix')}${query}`
46
50
  }
47
51
 
48
52
  function ListFrame(props: ListFrameProps): ReactElement {
@@ -72,10 +76,11 @@ function ListFrame(props: ListFrameProps): ReactElement {
72
76
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
73
77
  const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
74
78
  const visible = stateRows.slice(offset, offset + bodyRows)
79
+ const accent = panelAccent('kernel-list', getPalette().dim, getPalette().brandBright)
75
80
  return createElement(
76
81
  Box,
77
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
78
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
82
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
83
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
79
84
  createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
80
85
  ...visible.map((row, index) => {
81
86
  const absolute = offset + index
@@ -105,9 +110,9 @@ export function editQuery(query: string, input: string, key: { backspace?: boole
105
110
 
106
111
  export function ModePanel({ current, load, select, close }: {
107
112
  current: string
108
- load(): Promise<readonly PresetRow[]>
109
- select(id: string): void
110
- close(): void
113
+ load: () => Promise<readonly PresetRow[]>
114
+ select: (id: string) => void
115
+ close: () => void
111
116
  }): ReactElement {
112
117
  const [rows, setRows] = useState<readonly PresetRow[]>([])
113
118
  const [query, setQuery] = useState('')
@@ -133,22 +138,22 @@ export function ModePanel({ current, load, select, close }: {
133
138
  // Empty/loading/filtered-out lists have no row at the cursor: a bare
134
139
  // `?.broken === undefined` check passes on undefined and crashes the
135
140
  // process on the `!.id` access (PermissionPanel guards this correctly).
136
- if (key.return && visible[cursor] !== undefined && visible[cursor]!.broken === undefined) return select(visible[cursor]!.id)
141
+ if (key.return && visible[cursor] !== undefined && visible[cursor].broken === undefined) return select(visible[cursor].id)
137
142
  const next = editQuery(query, input, key)
138
143
  if (next !== undefined) { setQuery(next); setCursor(0) }
139
144
  })
140
145
  return createElement(ListFrame, {
141
- title: `/mode · current ${current}`,
146
+ title: t('panel.mode.title', { current }),
142
147
  rows: visible.map(row => ({ key: row.id, disabled: row.broken !== undefined, text: `${row.id === current ? '●' : '○'} ${row.name ?? row.id} · ${row.description ?? row.trust}${row.broken === undefined ? '' : ` · broken: ${row.broken}`}` })),
143
- cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
148
+ cursor, loading, error, query, footer: t('panel.footer.chooseSwitch'),
144
149
  })
145
150
  }
146
151
 
147
152
  export function PermissionPanel({ current, load, select, close }: {
148
153
  current: string
149
- load(): Promise<readonly PermissionRow[]>
150
- select(id: string): void
151
- close(): void
154
+ load: () => Promise<readonly PermissionRow[]>
155
+ select: (id: string) => void
156
+ close: () => void
152
157
  }): ReactElement {
153
158
  const [rows, setRows] = useState<readonly PermissionRow[]>([])
154
159
  const [query, setQuery] = useState('')
@@ -171,18 +176,18 @@ export function PermissionPanel({ current, load, select, close }: {
171
176
  if (input === 'r' && query === '') return refresh()
172
177
  if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
173
178
  if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
174
- if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
179
+ if (key.return && visible[cursor] !== undefined) return select(visible[cursor].id)
175
180
  const next = editQuery(query, input, key)
176
181
  if (next !== undefined) { setQuery(next); setCursor(0) }
177
182
  })
178
183
  return createElement(ListFrame, {
179
- title: `/permission · current ${current}`,
184
+ title: t('panel.permission.title', { current }),
180
185
  rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
181
- cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
186
+ cursor, loading, error, query, footer: t('panel.footer.chooseSelect'),
182
187
  })
183
188
  }
184
189
 
185
- export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
190
+ export function PluginPanel({ load, close, initialQuery = '' }: { load: () => readonly PluginRow[]; close: () => void; initialQuery?: string }): ReactElement {
186
191
  const [epoch, setEpoch] = useState(0)
187
192
  const [query, setQuery] = useState(initialQuery)
188
193
  const [cursor, setCursor] = useState(0)
@@ -201,12 +206,12 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
201
206
  if (next !== undefined) { setQuery(next); setCursor(0) }
202
207
  })
203
208
  return createElement(ListFrame, {
204
- title: '/plugin · loader inspector',
209
+ title: t('panel.plugin.title'),
205
210
  rows: rows.map((row, index) => ({
206
211
  key: row.entryId,
207
212
  disabled: !row.enabled,
208
213
  text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
209
- })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
214
+ })), cursor, loading: false, query, footer: t('panel.footer.inspectDetails'),
210
215
  })
211
216
  }
212
217
 
@@ -252,7 +257,7 @@ const JOB_MARK: Record<JobRow['status'], string> = {
252
257
  * interval dies with the panel). Cancel stays upstream-only; an absent
253
258
  * registry renders as the plain empty state (a harmless missing service).
254
259
  */
255
- export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close(): void }): ReactElement {
260
+ export function JobsPanel({ load, close }: { load: () => readonly JobRow[]; close: () => void }): ReactElement {
256
261
  const [, setRefresh] = useState(0)
257
262
  const [cursor, setCursor] = useState(0)
258
263
  const [, setTick] = useState(0)
@@ -269,29 +274,29 @@ export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close():
269
274
  })
270
275
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
271
276
  return createElement(ListFrame, {
272
- title: `/jobs · background tasks · ${rows.length}`,
277
+ title: t('panel.jobs.title', { count: rows.length }),
273
278
  rows: rows.map(row => ({
274
279
  key: row.id,
275
280
  text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
276
281
  })),
277
- cursor, loading: false, query: '', searching: false, footer: '↑↓ inspect · r refresh · esc close',
282
+ cursor, loading: false, query: '', searching: false, footer: t('panel.footer.inspectRefresh'),
278
283
  })
279
284
  }
280
285
 
281
286
  export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
282
287
  currentCwd: string
283
- load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
284
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
285
- select(row: SessionRow): void
288
+ load: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
289
+ readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
290
+ select: (row: SessionRow) => void
286
291
  /** Arm the composer-based delete confirm for one row (App owns the keys). */
287
- requestDelete?(row: SessionRow): void
292
+ requestDelete?: (row: SessionRow) => void
288
293
  /** The row id awaiting y/n in the composer, when any (App-owned). */
289
294
  deleteConfirmId?: string
290
295
  /** Bump to reload the listing (e.g. after a deletion). */
291
296
  reloadToken?: number
292
297
  /** Opened via /delete: hint-first delete mode. */
293
298
  deleteMode?: boolean
294
- close(): void
299
+ close: () => void
295
300
  }): ReactElement {
296
301
  // Codex resume-picker default: the CURRENT directory's root sessions; the
297
302
  // cwd filter widens to all only on request (the old default leaked every
@@ -358,12 +363,12 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
358
363
  if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
359
364
  if (input === 'g') return setCursor(0)
360
365
  if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
361
- if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
366
+ if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor])
362
367
  if (input === 'e' && rows[cursor] !== undefined) {
363
- return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
368
+ return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id)
364
369
  }
365
370
  if (input === 't' && rows[cursor] !== undefined) {
366
- const row = rows[cursor]!
371
+ const row = rows[cursor]
367
372
  transcriptLoad.current?.abort()
368
373
  setTranscript({ id: row.id })
369
374
  const controller = new AbortController()
@@ -374,11 +379,11 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
374
379
  )
375
380
  return
376
381
  }
377
- if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
382
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor])
378
383
  }, { isActive: transcript === undefined })
379
384
  if (transcript !== undefined) {
380
385
  return createElement(DocumentPanel, {
381
- title: `transcript · ${transcript.id}`,
386
+ title: t('panel.document.title', { id: transcript.id }),
382
387
  text: transcript.text,
383
388
  error: transcript.error,
384
389
  close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
@@ -395,7 +400,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
395
400
  disabled: !row.resumable,
396
401
  text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${formatRelativeTime(row.updatedAt ?? row.createdAt, now)} · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
397
402
  })), cursor, loading, error, query: options.query, searching,
398
- footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
403
+ footer: t('panel.footer.resume'),
399
404
  })
400
405
  }
401
406
 
@@ -440,7 +445,7 @@ function DocumentPanel({ title, text, error, close }: {
440
445
  title: string
441
446
  text?: string
442
447
  error?: string
443
- close(): void
448
+ close: () => void
444
449
  }): ReactElement {
445
450
  const stdout = useStdout().stdout
446
451
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -455,18 +460,19 @@ function DocumentPanel({ title, text, error, close }: {
455
460
  if (input === 'g') return setScroll(0)
456
461
  if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
457
462
  })
458
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
463
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('document.compact'), viewport.contentColumns))
459
464
  const body: readonly StyledLine[] = error !== undefined
460
465
  ? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
461
466
  : text === undefined
462
- ? textLines('loading transcript…', viewport.contentColumns, 'dim')
467
+ ? textLines(t('document.loading'), viewport.contentColumns, 'dim')
463
468
  : lines.slice(scroll, scroll + viewport.bodyRows)
469
+ const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright)
464
470
  return createElement(
465
471
  Box,
466
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
467
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
472
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
473
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
468
474
  createElement(DocumentRows, { lines: body }),
469
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)),
475
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('document.footer', { from: lines.length === 0 ? 0 : scroll + 1, to: Math.min(lines.length, scroll + viewport.bodyRows), total: lines.length }), viewport.contentColumns)),
470
476
  )
471
477
  }
472
478
 
@@ -480,8 +486,8 @@ export function HistoryPanel({ entries, fill, close }: {
480
486
  /** Newest-first recall entries (persistent + in-session, deduped). */
481
487
  entries: readonly string[]
482
488
  /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
483
- fill(text: string, index: number): void
484
- close(): void
489
+ fill: (text: string, index: number) => void
490
+ close: () => void
485
491
  }): ReactElement {
486
492
  const [query, setQuery] = useState('')
487
493
  const [cursor, setCursor] = useState(0)
@@ -497,14 +503,18 @@ export function HistoryPanel({ entries, fill, close }: {
497
503
  }
498
504
  if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
499
505
  if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
500
- if (input === 'g') return setCursor(0)
501
- if (input === 'G') return setCursor(matches.length - 1)
502
- if (key.backspace) {
506
+ // g/G stay vim-style jumps only on an empty query (the /mode contract):
507
+ // mid-filter they are query text, so filters like 'Fix' or 'grep' survive.
508
+ if (input === 'g' && query === '') return setCursor(0)
509
+ if (input === 'G' && query === '') return setCursor(matches.length - 1)
510
+ if (key.backspace || key.delete) {
503
511
  setQuery(current => deleteLastGrapheme(current))
504
512
  setCursor(0)
505
513
  return
506
514
  }
507
- if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
515
+ // Ink reports single uppercase letters and shifted symbols ('!', '@')
516
+ // with key.shift set; only ctrl/meta mark real command input.
517
+ if (input !== '' && !key.ctrl && !key.meta) {
508
518
  const text = stripPasteMarkers(input)
509
519
  if (text !== '') {
510
520
  setQuery(current => (current + text).slice(0, 120))
@@ -516,21 +526,22 @@ export function HistoryPanel({ entries, fill, close }: {
516
526
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
517
527
  if (viewport.maxHeight === 0 || viewport.compact) {
518
528
  const picked = matches[cursor]
519
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · ' + (picked === undefined ? 'no matching prompts' : singleLineText(picked)) + ' · esc close', viewport.contentColumns))
529
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/history · ${picked === undefined ? t('history.compact.none') : singleLineText(picked)} · ${t('panel.close')}`, viewport.contentColumns))
520
530
  }
521
531
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
522
532
  const offset = revealRow(0, cursor, matches.length, bodyRows)
523
533
  const visible = matches.slice(offset, offset + bodyRows)
524
534
  const header = query === ''
525
- ? `/history · ${entries.length} prompts · type to filter`
526
- : `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
535
+ ? t('history.title.prompts', { count: entries.length })
536
+ : t('history.title.match', { matches: matches.length, count: entries.length, query: truncateColumns(singleLineText(query), Math.max(6, viewport.contentColumns - 34)) })
537
+ const accent = panelAccent('history', getPalette().dim, getPalette().brandBright)
527
538
  return createElement(
528
539
  Box,
529
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
530
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
531
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
540
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
541
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
542
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.filterHint', { state: query === '' ? t('history.filterEmpty') : t('history.filterQuery', { query: singleLineText(query) }) }), viewport.contentColumns)),
532
543
  ...(visible.length === 0
533
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
544
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.empty'), viewport.contentColumns))]
534
545
  : visible.map((entry, index) => {
535
546
  const absolute = offset + index
536
547
  const selected = absolute === cursor
@@ -544,7 +555,313 @@ export function HistoryPanel({ entries, fill, close }: {
544
555
  truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
545
556
  )
546
557
  })),
547
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
558
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.footer'), viewport.contentColumns)),
559
+ )
560
+ }
561
+
562
+ /**
563
+ * The /review candidate picker (Codex's preset popup): bare /review opens a
564
+ * four-way preset — review uncommitted changes, pick a base branch, pick a
565
+ * recent commit, or type a custom focus. Branch and commit phases are
566
+ * type-to-filter lists; every selection resolves to the same /review
567
+ * argument string the direct command accepts.
568
+ */
569
+ export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }: {
570
+ loadBranches: (signal?: AbortSignal) => Promise<readonly ReviewBranch[]>
571
+ loadCommits: (signal?: AbortSignal) => Promise<readonly ReviewCommit[]>
572
+ /** Run the review for one picker selection. */
573
+ choose: (selection: ReviewSelection) => void
574
+ close: () => void
575
+ }): ReactElement {
576
+ const [phase, setPhase] = useState<'preset' | 'branches' | 'commits' | 'custom'>('preset')
577
+ const [cursor, setCursor] = useState(0)
578
+ const [query, setQuery] = useState('')
579
+ const [rows, setRows] = useState<readonly (ReviewBranch | ReviewCommit)[]>([])
580
+ const [loading, setLoading] = useState(false)
581
+ const [error, setError] = useState<string>()
582
+ const loadRef = useRef<AbortController>()
583
+ const now = useMemo(() => Date.now(), [rows])
584
+
585
+ useEffect(() => {
586
+ if (phase !== 'branches' && phase !== 'commits') return undefined
587
+ loadRef.current?.abort()
588
+ const controller = new AbortController()
589
+ loadRef.current = controller
590
+ setLoading(true)
591
+ setError(undefined)
592
+ const load = phase === 'branches'
593
+ ? (signal?: AbortSignal) => loadBranches(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
594
+ : (signal?: AbortSignal) => loadCommits(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
595
+ void Promise.resolve().then(() => load(controller.signal)).then(list => {
596
+ if (controller.signal.aborted) return
597
+ setLoading(false)
598
+ setRows(list)
599
+ setCursor(0)
600
+ }, reason => {
601
+ if (controller.signal.aborted) return
602
+ setLoading(false)
603
+ setRows([])
604
+ setError(reason instanceof Error ? reason.message : String(reason))
605
+ })
606
+ return () => controller.abort()
607
+ // eslint-disable-next-line react-hooks/exhaustive-deps
608
+ }, [phase])
609
+
610
+ const filtered = useMemo(() => {
611
+ if (phase !== 'branches' && phase !== 'commits') return []
612
+ const needle = query.toLowerCase()
613
+ if (needle === '') return rows
614
+ return rows.filter(row => {
615
+ const hay = 'name' in row ? row.name : `${row.sha} ${row.title}`
616
+ return hay.toLowerCase().includes(needle)
617
+ })
618
+ }, [phase, rows, query])
619
+
620
+ const submit = (selection: ReviewSelection): void => {
621
+ choose(selection)
622
+ }
623
+
624
+ useInput((input, key) => {
625
+ if (key.escape || (input === 'q' && query === '' && phase !== 'custom')) {
626
+ if (phase !== 'preset') {
627
+ setPhase('preset')
628
+ setQuery('')
629
+ setCursor(0)
630
+ return
631
+ }
632
+ return close()
633
+ }
634
+ if (key.ctrl && input === 'c') return close()
635
+ if (phase === 'custom' || ((phase === 'branches' || phase === 'commits') && query !== '')) {
636
+ const next = editQuery(query, input, key)
637
+ if (next !== undefined) {
638
+ setQuery(next)
639
+ setCursor(0)
640
+ return
641
+ }
642
+ }
643
+ // The row budget per phase: the preset list is fixed at four rows, the
644
+ // branch/commit lists clamp to their filtered length.
645
+ const rowCount = phase === 'preset' ? 4 : filtered.length
646
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
647
+ if (key.downArrow) return setCursor(value => Math.min(Math.max(0, rowCount - 1), value + 1))
648
+ if (key.return) {
649
+ if (phase === 'preset') {
650
+ if (cursor === 0) return submit({ kind: 'uncommitted' })
651
+ if (cursor === 1) return setPhase('branches')
652
+ if (cursor === 2) return setPhase('commits')
653
+ return setPhase('custom')
654
+ }
655
+ if (phase === 'branches' || phase === 'commits') {
656
+ const row = filtered[cursor]
657
+ if (row !== undefined) submit('name' in row ? { kind: 'base-branch', branch: row.name } : { kind: 'commit', sha: row.sha })
658
+ return
659
+ }
660
+ if (query.trim() !== '') submit({ kind: 'custom', instructions: query.trim() })
661
+ return
662
+ }
663
+ })
664
+
665
+ const stdout = useStdout().stdout
666
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
667
+ if (viewport.maxHeight === 0 || viewport.compact) {
668
+ const state = phase === 'preset' ? 'pick a review target' : loading ? 'loading…' : error !== undefined ? `error: ${error}` : `${filtered.length} candidates`
669
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/review · ${state} · ${t('panel.close')}`, viewport.contentColumns))
670
+ }
671
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
672
+ const offset = revealRow(0, cursor, phase === 'preset' ? 4 : filtered.length, bodyRows)
673
+ const header = phase === 'preset'
674
+ ? t('review.picker.title')
675
+ : phase === 'branches'
676
+ ? loading ? t('review.picker.loadingBranches') : error !== undefined ? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) }) : t('review.picker.branches', { filtered: filtered.length, total: rows.length })
677
+ : phase === 'commits'
678
+ ? loading ? t('review.picker.loadingCommits') : error !== undefined ? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) }) : t('review.picker.commits', { filtered: filtered.length, total: rows.length })
679
+ : t('review.picker.customHint')
680
+ const accent = panelAccent('review-picker', getPalette().dim, getPalette().brandBright)
681
+ return createElement(
682
+ Box,
683
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
684
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
685
+ ...(phase === 'preset'
686
+ ? [
687
+ t('review.picker.uncommitted'),
688
+ t('review.picker.branch'),
689
+ t('review.picker.commit'),
690
+ t('review.picker.custom'),
691
+ ].map((label, index) => {
692
+ const absolute = offset + index
693
+ const selected = absolute === cursor
694
+ return createElement(
695
+ Text,
696
+ { key: `preset-${index}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
697
+ truncateColumns(`${selected ? '› ' : ' '}${label}`, viewport.contentColumns),
698
+ )
699
+ })
700
+ : phase === 'custom'
701
+ ? [createElement(Text, { key: 'custom-input', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${query === '' ? t('review.picker.customHint') : singleLineText(query)}`, viewport.contentColumns))]
702
+ : filtered.length === 0
703
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${loading ? t('review.picker.loading') : error !== undefined ? t('review.picker.loadFailed') : query === '' ? t('review.picker.empty') : t('review.picker.noMatch')}`, viewport.contentColumns))]
704
+ : filtered.slice(offset, offset + bodyRows).map((row, index) => {
705
+ const absolute = offset + index
706
+ const selected = absolute === cursor
707
+ const label = 'name' in row ? row.name : `${row.sha.slice(0, 7)} · ${formatRelativeTime(row.at, now)} · ${row.title}`
708
+ return createElement(
709
+ Text,
710
+ { key: `row-${absolute}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
711
+ truncateColumns(`${selected ? '› ' : ' '}${singleLineText(label)}`, viewport.contentColumns),
712
+ )
713
+ })),
714
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('review.picker.footer'), viewport.contentColumns)),
715
+ )
716
+ }
717
+
718
+ /** One cross-session full-text search hit mapped from the session-query engine. */
719
+ export interface SearchRow {
720
+ /** Session id (Enter resumes it through the switch machinery). */
721
+ readonly id: string
722
+ /** Display label: session title or the short id form. */
723
+ readonly label: string
724
+ /** Secondary facts line (workspace · preset markers). */
725
+ readonly detail: string
726
+ /** Bounded plain-text excerpt around the strongest match. */
727
+ readonly snippet: string
728
+ /** Match timestamp (relative labels derive from it). */
729
+ readonly updatedAt: number
730
+ /** Whether the hit is a delegated subagent conversation (not resumable). */
731
+ readonly subagent: boolean
732
+ /** Whether Enter may switch into it. */
733
+ readonly resumable: boolean
734
+ }
735
+
736
+ /**
737
+ * The /search panel: full-text search over every persisted session through
738
+ * the in-process session-query engine (the same corpus the model's
739
+ * session_search tool reads). Type a query, Enter searches, Enter again
740
+ * resumes the hit; the query line edits like every kernel panel.
741
+ */
742
+ export function SearchPanel({ load, select, initialQuery = '', close }: {
743
+ load: (query: string, signal?: AbortSignal) => Promise<readonly SearchRow[]>
744
+ select: (row: SearchRow) => void
745
+ initialQuery?: string
746
+ close: () => void
747
+ }): ReactElement {
748
+ const [query, setQuery] = useState(initialQuery)
749
+ const [rows, setRows] = useState<readonly SearchRow[]>([])
750
+ const [cursor, setCursor] = useState(0)
751
+ const [loading, setLoading] = useState(false)
752
+ const [error, setError] = useState<string>()
753
+ const [searched, setSearched] = useState('')
754
+ const searchRef = useRef<AbortController>()
755
+ const run = (next: string): void => {
756
+ const trimmed = next.trim()
757
+ if (trimmed === '') return
758
+ searchRef.current?.abort()
759
+ const controller = new AbortController()
760
+ searchRef.current = controller
761
+ setLoading(true)
762
+ setError(undefined)
763
+ void Promise.resolve().then(() => load(trimmed, controller.signal)).then(hits => {
764
+ if (controller.signal.aborted) return
765
+ setLoading(false)
766
+ setRows(hits)
767
+ setCursor(0)
768
+ setSearched(next)
769
+ }, reason => {
770
+ if (controller.signal.aborted) return
771
+ setLoading(false)
772
+ // Stale results must not stay interactive under an error header: a
773
+ // later Enter re-runs the query instead of resuming an old hit.
774
+ setRows([])
775
+ setCursor(0)
776
+ setError(reason instanceof Error ? reason.message : String(reason))
777
+ })
778
+ }
779
+ // An /search <query> invocation searches immediately with its argument.
780
+ useEffect(() => {
781
+ if (initialQuery.trim() !== '') run(initialQuery)
782
+ return () => searchRef.current?.abort()
783
+ // eslint-disable-next-line react-hooks/exhaustive-deps
784
+ }, [])
785
+ useInput((input, key) => {
786
+ if (key.escape || (input === 'q' && query === '')) return close()
787
+ if (key.ctrl && input === 'c') return close()
788
+ if (key.backspace || key.delete) {
789
+ setQuery(current => deleteLastGrapheme(current))
790
+ setCursor(0)
791
+ return
792
+ }
793
+ const next = editQuery(query, input, key)
794
+ if (next !== undefined) {
795
+ setQuery(next)
796
+ setCursor(0)
797
+ return
798
+ }
799
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
800
+ if (key.downArrow) return setCursor(value => Math.min(rows.length - 1, value + 1))
801
+ if (key.return) {
802
+ // A changed query searches; the SAME query re-runs when the previous
803
+ // pass failed or produced nothing (Enter is then the refresh key).
804
+ const stale = error !== undefined || rows.length === 0
805
+ if (query.trim() !== '' && (query.trim() !== searched.trim() || stale)) {
806
+ run(query)
807
+ return
808
+ }
809
+ // Non-resumable hits (subagent conversations) keep the panel open:
810
+ // Enter must not trade the visible results for a rejected switch.
811
+ const row = rows[cursor]
812
+ if (row !== undefined && row.resumable) select(row)
813
+ return
814
+ }
815
+ })
816
+ const stdout = useStdout().stdout
817
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
818
+ const now = useMemo(() => Date.now(), [rows, searched])
819
+ if (viewport.maxHeight === 0 || viewport.compact) {
820
+ const state = loading ? 'searching…' : error !== undefined ? `error: ${error}` : rows.length === 0 ? 'no results yet' : `❯ ${rows[cursor]?.label ?? ''}`
821
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/search · ${state} · ${t('panel.close')}`, viewport.contentColumns))
822
+ }
823
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
824
+ const offset = revealRow(0, cursor, rows.length, bodyRows)
825
+ const visible = rows.slice(offset, offset + bodyRows)
826
+ const header = error !== undefined
827
+ ? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) })
828
+ : loading
829
+ ? t('search.compact.searching')
830
+ : searched === ''
831
+ ? t('search.title.type')
832
+ : rows.length === 1
833
+ ? t('search.title.hits', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
834
+ : t('search.title.hitsPlural', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
835
+ const accent = panelAccent('search', getPalette().dim, getPalette().brandBright)
836
+ return createElement(
837
+ Box,
838
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
839
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
840
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.hint', { state: query === '' ? t('search.hintEmpty') : singleLineText(query) }), viewport.contentColumns)),
841
+ ...(visible.length === 0
842
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(searched === '' ? ` ${t('search.empty.idle')}` : ` ${t('search.empty.noHits', { loading: loading ? '…' : '' })}`, viewport.contentColumns))]
843
+ : visible.flatMap((row, index) => {
844
+ const absolute = offset + index
845
+ const selected = absolute === cursor
846
+ return [
847
+ createElement(
848
+ Text,
849
+ {
850
+ key: `search-${absolute}`,
851
+ color: selected ? inkColor(getPalette().brandBright) : undefined,
852
+ dimColor: row.subagent,
853
+ wrap: 'truncate-end',
854
+ },
855
+ truncateColumns(`${selected ? '› ' : ' '}${row.subagent ? '↳ ' : ''}${singleLineText(row.label)} · ${formatRelativeTime(row.updatedAt, now)}${row.detail === '' ? '' : ` · ${row.detail}`}${row.resumable ? '' : ' · read-only'}`, viewport.contentColumns),
856
+ ),
857
+ createElement(
858
+ Text,
859
+ { key: `search-snippet-${absolute}`, dimColor: true, wrap: 'truncate-end' },
860
+ truncateColumns(` ⎿ ${singleLineText(row.snippet)}`, viewport.contentColumns),
861
+ ),
862
+ ]
863
+ })),
864
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.footer'), viewport.contentColumns)),
548
865
  )
549
866
  }
550
867
 
@@ -556,8 +873,8 @@ export function HistoryPanel({ entries, fill, close }: {
556
873
  */
557
874
  export function StatuslinePanel({ enabled, change, close }: {
558
875
  enabled: readonly StatusItemId[]
559
- change(items: readonly StatusItemId[]): void
560
- close(): void
876
+ change: (items: readonly StatusItemId[]) => void
877
+ close: () => void
561
878
  }): ReactElement {
562
879
  // Working state: the full catalog in display order (enabled entries in
563
880
  // their configured positions, disabled ones trailing canonically) plus
@@ -578,7 +895,7 @@ export function StatuslinePanel({ enabled, change, close }: {
578
895
  if (target < 0 || target >= order.length) return
579
896
  const next = [...order]
580
897
  const [item] = next.splice(cursor, 1)
581
- next.splice(target, 0, item!)
898
+ next.splice(target, 0, item)
582
899
  commit(next, on)
583
900
  setCursor(target)
584
901
  }
@@ -607,16 +924,17 @@ export function StatuslinePanel({ enabled, change, close }: {
607
924
  const stdout = useStdout().stdout
608
925
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
609
926
  if (viewport.maxHeight === 0 || viewport.compact) {
610
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
927
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.statusline.compact'), viewport.contentColumns))
611
928
  }
612
929
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
613
930
  const offset = revealRow(0, cursor, order.length, bodyRows)
614
931
  const visible = order.slice(offset, offset + bodyRows)
615
932
  const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
933
+ const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright)
616
934
  return createElement(
617
935
  Box,
618
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
619
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
936
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
937
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('statusline.title'), viewport.contentColumns)),
620
938
  ...visible.map((id, index) => {
621
939
  const absolute = offset + index
622
940
  const selected = absolute === cursor
@@ -632,7 +950,7 @@ export function StatuslinePanel({ enabled, change, close }: {
632
950
  truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
633
951
  )
634
952
  }),
635
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
953
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.footer.statusline'), viewport.contentColumns)),
636
954
  )
637
955
  }
638
956
 
@@ -655,11 +973,11 @@ export function EffortPanel({ row, current, select, back, onExit }: {
655
973
  /** Effective effort currently in force ('' when none), for the ● mark. */
656
974
  current: string | undefined
657
975
  /** Accept one advertised effort id, or '' for the provider default. */
658
- select(effortId: string): void
976
+ select: (effortId: string) => void
659
977
  /** Return to the model list without applying. */
660
- back(): void
978
+ back: () => void
661
979
  /** Leave the whole /model flow (Ctrl+C). */
662
- onExit(): void
980
+ onExit: () => void
663
981
  }): ReactElement {
664
982
  const advertised = row.reasoning?.efforts ?? []
665
983
  const empty = row.reasoning === undefined || advertised.length === 0
@@ -706,21 +1024,21 @@ export function EffortPanel({ row, current, select, back, onExit }: {
706
1024
  return
707
1025
  }
708
1026
  if (key.return && rows[cursor] !== undefined) {
709
- select(rows[cursor]!.id)
1027
+ select(rows[cursor].id)
710
1028
  }
711
1029
  })
712
1030
  return createElement(ListFrame, {
713
- title: `/model effort for ${row.providerName} · ${row.modelName}`,
1031
+ title: t('panel.effort.title', { provider: row.providerName, model: row.modelName }),
714
1032
  rows: empty
715
- ? [{ key: 'empty', disabled: true, text: 'this model advertises no reasoning effort levels — the provider default applies' }]
1033
+ ? [{ key: 'empty', disabled: true, text: t('panel.effort.empty') }]
716
1034
  : rows.map(effort => ({
717
1035
  key: effort.id,
718
- text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
1036
+ text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ` · ${t('panel.default')}` : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
719
1037
  })),
720
1038
  cursor,
721
1039
  loading: false,
722
1040
  query: '',
723
- footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
1041
+ footer: empty ? t('panel.footer.effortEmpty') : t('panel.footer.effort'),
724
1042
  })
725
1043
  }
726
1044
 
@@ -745,10 +1063,10 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
745
1063
  /** Live feed rows (child sessions observed this process). */
746
1064
  live: readonly SubagentRow[]
747
1065
  /** Load this session's persisted child sessions by lineage. */
748
- load(): Promise<readonly SessionRow[]>
1066
+ load: () => Promise<readonly SessionRow[]>
749
1067
  /** Read one child session's full transcript as markdown. */
750
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
751
- close(): void
1068
+ readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
1069
+ close: () => void
752
1070
  }): ReactElement {
753
1071
  const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
754
1072
  const [error, setError] = useState<string>()
@@ -819,7 +1137,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
819
1137
  }, { isActive: transcript === undefined })
820
1138
  if (transcript !== undefined) {
821
1139
  return createElement(DocumentPanel, {
822
- title: `subagent · ${transcript.id.slice(-12)}`,
1140
+ title: t('panel.subagent.transcriptTitle', { id: transcript.id.slice(-12) }),
823
1141
  text: transcript.text,
824
1142
  error: transcript.error,
825
1143
  close: () => {
@@ -829,7 +1147,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
829
1147
  })
830
1148
  }
831
1149
  return createElement(ListFrame, {
832
- title: `/agents · ${live.length} live · ${rows.length} total`,
1150
+ title: t('panel.agents.title', { live: live.length, total: rows.length }),
833
1151
  rows: rows.map(row => ({
834
1152
  key: row.id,
835
1153
  text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
@@ -838,7 +1156,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
838
1156
  loading,
839
1157
  ...error === undefined ? {} : { error },
840
1158
  query: '',
841
- footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
1159
+ footer: t('panel.footer.agents'),
842
1160
  })
843
1161
  }
844
1162
 
@@ -857,12 +1175,12 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
857
1175
  export function SubagentPanel({ current, load, pick, inherit, close }: {
858
1176
  /** Display label of the override in force, '' when following the current model. */
859
1177
  current: string
860
- load(): Promise<ModelDirectory>
1178
+ load: () => Promise<ModelDirectory>
861
1179
  /** Apply one model (with an advertised effort, when picked) as the override. */
862
- pick(row: ModelRow, effortId?: string): void
1180
+ pick: (row: ModelRow, effortId?: string) => void
863
1181
  /** Drop the override: subagents follow the current model again. */
864
- inherit(): void
865
- close(): void
1182
+ inherit: () => void
1183
+ close: () => void
866
1184
  }): ReactElement {
867
1185
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
868
1186
  const [error, setError] = useState<string>()
@@ -906,7 +1224,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
906
1224
  setEffortFor(row)
907
1225
  return
908
1226
  }
909
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
1227
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined
910
1228
  pick(row, effortId)
911
1229
  }
912
1230
  })
@@ -920,9 +1238,9 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
920
1238
  })
921
1239
  }
922
1240
  return createElement(ListFrame, {
923
- title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
1241
+ title: `${t('panel.subagent.title')}${current === '' ? '' : t('panel.subagent.override', { value: current })}`,
924
1242
  rows: [
925
- { key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
1243
+ { key: '__inherit__', text: `${current === '' ? '●' : '○'} ${t('panel.subagent.inherit')}` },
926
1244
  ...rows.map(row => ({
927
1245
  key: `${row.provider}/${row.model}`,
928
1246
  text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
@@ -932,7 +1250,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
932
1250
  loading,
933
1251
  ...error === undefined ? {} : { error },
934
1252
  query: '',
935
- footer: '↑↓ choose · enter apply · r refresh · esc close',
1253
+ footer: t('panel.footer.subagent'),
936
1254
  })
937
1255
  }
938
1256
 
@@ -981,7 +1299,7 @@ export function scheduleDisplayRows(rows: readonly ScheduleRow[], now: number):
981
1299
  }))
982
1300
  }
983
1301
 
984
- export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[]; close(): void }): ReactElement {
1302
+ export function SchedulePanel({ rows, close }: { rows: () => readonly ScheduleRow[]; close: () => void }): ReactElement {
985
1303
  const [, setTick] = useState(0)
986
1304
  useEffect(() => {
987
1305
  const id = setInterval(() => setTick(value => value + 1), 1_000)
@@ -994,23 +1312,87 @@ export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[];
994
1312
  if (key.escape || input === 'q') return close()
995
1313
  })
996
1314
  if (viewport.maxHeight === 0 || viewport.compact) {
997
- const summary = display.length === 0 ? 'no active reminders' : singleLineText(display[0]!.text)
1315
+ const summary = display.length === 0 ? t('panel.schedule.none') : singleLineText(display[0].text)
998
1316
  return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns))
999
1317
  }
1000
1318
  const budget = Math.max(1, viewport.bodyRows)
1001
1319
  const visible = display.slice(0, budget)
1002
1320
  const hidden = display.length - visible.length
1321
+ const accent = panelAccent('schedule', getPalette().dim, getPalette().brandBright)
1003
1322
  return createElement(
1004
1323
  Box,
1005
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
1006
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(`/schedule · ${display.length} active reminder${display.length === 1 ? '' : 's'}`, viewport.contentColumns)),
1324
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
1325
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(display.length === 1 ? t('schedule.title', { count: display.length }) : t('schedule.titlePlural', { count: display.length }), viewport.contentColumns)),
1007
1326
  ...(display.length === 0
1008
- ? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no active reminders — the model creates them with schedule_create', viewport.contentColumns))]
1327
+ ? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('schedule.empty')}`, viewport.contentColumns))]
1009
1328
  : visible.map(row => createElement(Text, {
1010
1329
  key: row.key,
1011
1330
  color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
1012
1331
  wrap: 'truncate-end',
1013
1332
  }, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns)))),
1014
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`esc/q close${hidden > 0 ? ` · +${hidden} more` : ''} · the model schedules via schedule_create`, viewport.contentColumns)),
1333
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.schedule.footer', { more: hidden > 0 ? t('panel.schedule.more', { count: hidden }) : '' }), viewport.contentColumns)),
1334
+ )
1335
+ }
1336
+
1337
+ /**
1338
+ * The /usage panel: the session's provider-reported token totals, its context
1339
+ * pressure and estimated composition, and the exact per-turn accounting, in
1340
+ * one bounded scrollable surface. Read-only — Esc or q closes it.
1341
+ */
1342
+ export function UsagePanel({ load, close }: {
1343
+ /** Read the current session's usage blocks from the mounted projections. */
1344
+ load: () => Promise<UsageView>
1345
+ close: () => void
1346
+ }): ReactElement {
1347
+ const stdout = useStdout().stdout
1348
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1349
+ const [scroll, setScroll] = useState(0)
1350
+ const [view, setView] = useState<UsageView>()
1351
+ const [error, setError] = useState<string>()
1352
+ // The panel opens on the loading row and swaps in the numbers when the
1353
+ // loader settles: materializing the projection units folds the whole log,
1354
+ // and that must not run inside the keystroke that opened the panel.
1355
+ useEffect(() => {
1356
+ let live = true
1357
+ Promise.resolve().then(load).then(
1358
+ loaded => {
1359
+ if (live) setView(loaded)
1360
+ },
1361
+ reason => {
1362
+ if (live) setError(reason instanceof Error ? reason.message : String(reason))
1363
+ },
1364
+ )
1365
+ return () => {
1366
+ live = false
1367
+ }
1368
+ }, [load])
1369
+ const lines = useMemo(
1370
+ () => view === undefined ? [] : usageLines(view, viewport.contentColumns),
1371
+ [view, viewport.contentColumns],
1372
+ )
1373
+ useInput((input, key) => {
1374
+ if (key.escape || input === 'q') return close()
1375
+ if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
1376
+ if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
1377
+ if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
1378
+ if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
1379
+ if (input === 'g') return setScroll(0)
1380
+ if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
1381
+ })
1382
+ if (viewport.compact) {
1383
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.usage.compact'), viewport.contentColumns))
1384
+ }
1385
+ const body: readonly StyledLine[] = error !== undefined
1386
+ ? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
1387
+ : view === undefined
1388
+ ? textLines(t('panel.loading'), viewport.contentColumns, 'dim')
1389
+ : lines.slice(scroll, scroll + viewport.bodyRows)
1390
+ const accent = panelAccent('usage', getPalette().dim, getPalette().brandBright)
1391
+ return createElement(
1392
+ Box,
1393
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
1394
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('panel.usage.title'), viewport.contentColumns)),
1395
+ createElement(DocumentRows, { lines: body }),
1396
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.usage.footer'), viewport.contentColumns)),
1015
1397
  )
1016
1398
  }