dsh-code 1.0.7 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +5002 -1061
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
  9. package/lib/types/app.d.ts +120 -63
  10. package/lib/types/attachments.d.ts +16 -7
  11. package/lib/types/authorization-panel.d.ts +3 -3
  12. package/lib/types/git-workflow.d.ts +91 -2
  13. package/lib/types/history.d.ts +10 -0
  14. package/lib/types/i18n.d.ts +39 -0
  15. package/lib/types/index.d.ts +74 -2
  16. package/lib/types/input-split.d.ts +1 -1
  17. package/lib/types/kernel-panels.d.ts +89 -32
  18. package/lib/types/language-panel.d.ts +12 -0
  19. package/lib/types/locales/en.d.ts +498 -0
  20. package/lib/types/locales/zh.d.ts +9 -0
  21. package/lib/types/mentions.d.ts +7 -3
  22. package/lib/types/models.d.ts +14 -0
  23. package/lib/types/panel-accent.d.ts +28 -0
  24. package/lib/types/rainbow.d.ts +69 -0
  25. package/lib/types/render/animations.d.ts +42 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +95 -4
  30. package/lib/types/render/status.d.ts +12 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +42 -1
  34. package/lib/types/session-switch.d.ts +8 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +8 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +27 -6
  40. package/lib/types/update.d.ts +10 -1
  41. package/lib/types/version.d.ts +6 -3
  42. package/package.json +26 -7
  43. package/src/app.ts +1426 -627
  44. package/src/approval.ts +166 -166
  45. package/src/attachments.ts +65 -19
  46. package/src/authorization-panel.ts +24 -18
  47. package/src/editor-keys.ts +371 -371
  48. package/src/fork.ts +11 -7
  49. package/src/git-workflow.ts +229 -3
  50. package/src/history.ts +14 -0
  51. package/src/i18n.ts +68 -0
  52. package/src/index.ts +503 -128
  53. package/src/input-split.ts +27 -7
  54. package/src/kernel-panels.ts +528 -113
  55. package/src/keyboard.ts +5 -4
  56. package/src/language-panel.ts +53 -0
  57. package/src/locales/en.ts +538 -0
  58. package/src/locales/zh.ts +537 -0
  59. package/src/mentions.ts +8 -4
  60. package/src/models.ts +264 -212
  61. package/src/panel-accent.ts +41 -0
  62. package/src/presets.ts +1 -1
  63. package/src/provider-settings.ts +1 -1
  64. package/src/rainbow.ts +218 -0
  65. package/src/render/animations.ts +104 -6
  66. package/src/render/editor.ts +20 -20
  67. package/src/render/export.ts +116 -95
  68. package/src/render/inspector.ts +42 -0
  69. package/src/render/lines.ts +628 -415
  70. package/src/render/markdown.ts +15 -3
  71. package/src/render/projection.ts +429 -19
  72. package/src/render/status.ts +119 -62
  73. package/src/render/text.ts +15 -0
  74. package/src/render/tool-preview.ts +77 -77
  75. package/src/render/usage.ts +430 -0
  76. package/src/render/width.ts +2 -2
  77. package/src/session-directory.ts +90 -9
  78. package/src/session-query.ts +8 -4
  79. package/src/session-switch.ts +14 -0
  80. package/src/startup.ts +3 -3
  81. package/src/store.ts +19 -1
  82. package/src/subagents.ts +229 -229
  83. package/src/terminal-title.ts +22 -5
  84. package/src/theme-panel.ts +17 -21
  85. package/src/theme.ts +281 -33
  86. package/src/update-panel.ts +148 -31
  87. package/src/update.ts +19 -3
  88. package/src/version.ts +63 -20
  89. package/src/whale-glyph.ts +23 -23
@@ -1,6 +1,6 @@
1
1
  /** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
2
2
 
3
- import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
3
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
4
4
  import { Box, Text, useInput, useStdout } from 'ink'
5
5
  import type { ModelDirectory, ModelRow } from './models.ts'
6
6
  import type { SubagentRow } from './subagents.ts'
@@ -9,13 +9,17 @@ import type { PermissionRow } from './permissions.ts'
9
9
  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
- import { formatRelativeTime } from './session-directory.ts'
12
+ import { formatRelativeTime, matchSessionRow } 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
- import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
19
+ import { DEFAULT_STATUSLINE_ITEMS, localizedStatusItems, 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,22 +110,22 @@ 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('')
114
119
  const [cursor, setCursor] = useState(0)
115
120
  const [loading, setLoading] = useState(true)
116
121
  const [error, setError] = useState<string>()
117
- const refresh = (): void => {
122
+ const refresh = useCallback((): void => {
118
123
  setLoading(true); setError(undefined)
119
124
  Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
120
125
  setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
121
126
  })
122
- }
123
- useEffect(refresh, [])
127
+ }, [load])
128
+ useEffect(refresh, [refresh])
124
129
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
125
130
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
126
131
  useInput((input, key) => {
@@ -133,35 +138,35 @@ 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('')
155
160
  const [cursor, setCursor] = useState(0)
156
161
  const [loading, setLoading] = useState(true)
157
162
  const [error, setError] = useState<string>()
158
- const refresh = (): void => {
163
+ const refresh = useCallback((): void => {
159
164
  setLoading(true); setError(undefined)
160
165
  Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
161
166
  setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
162
167
  })
163
- }
164
- useEffect(refresh, [])
168
+ }, [load])
169
+ useEffect(refresh, [refresh])
165
170
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
166
171
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
167
172
  useInput((input, key) => {
@@ -171,23 +176,24 @@ 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)
189
194
  const [expanded, setExpanded] = useState(false)
190
- const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the panel's explicit registry refresh trigger
196
+ const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, load, query])
191
197
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
192
198
  useInput((input, key) => {
193
199
  if (key.escape) return close()
@@ -201,12 +207,12 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
201
207
  if (next !== undefined) { setQuery(next); setCursor(0) }
202
208
  })
203
209
  return createElement(ListFrame, {
204
- title: '/plugin · loader inspector',
210
+ title: t('panel.plugin.title'),
205
211
  rows: rows.map((row, index) => ({
206
212
  key: row.entryId,
207
213
  disabled: !row.enabled,
208
214
  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',
215
+ })), cursor, loading: false, query, footer: t('panel.footer.inspectDetails'),
210
216
  })
211
217
  }
212
218
 
@@ -252,7 +258,7 @@ const JOB_MARK: Record<JobRow['status'], string> = {
252
258
  * interval dies with the panel). Cancel stays upstream-only; an absent
253
259
  * registry renders as the plain empty state (a harmless missing service).
254
260
  */
255
- export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close(): void }): ReactElement {
261
+ export function JobsPanel({ load, close }: { load: () => readonly JobRow[]; close: () => void }): ReactElement {
256
262
  const [, setRefresh] = useState(0)
257
263
  const [cursor, setCursor] = useState(0)
258
264
  const [, setTick] = useState(0)
@@ -269,34 +275,42 @@ export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close():
269
275
  })
270
276
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
271
277
  return createElement(ListFrame, {
272
- title: `/jobs · background tasks · ${rows.length}`,
278
+ title: t('panel.jobs.title', { count: rows.length }),
273
279
  rows: rows.map(row => ({
274
280
  key: row.id,
275
281
  text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
276
282
  })),
277
- cursor, loading: false, query: '', searching: false, footer: '↑↓ inspect · r refresh · esc close',
283
+ cursor, loading: false, query: '', searching: false, footer: t('panel.footer.inspectRefresh'),
278
284
  })
279
285
  }
280
286
 
281
- export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
287
+ export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, presetId, close }: {
282
288
  currentCwd: string
283
- load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
284
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
285
- select(row: SessionRow): void
289
+ load: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
290
+ readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
291
+ select: (row: SessionRow) => void
286
292
  /** Arm the composer-based delete confirm for one row (App owns the keys). */
287
- requestDelete?(row: SessionRow): void
293
+ requestDelete?: (row: SessionRow) => void
288
294
  /** The row id awaiting y/n in the composer, when any (App-owned). */
289
295
  deleteConfirmId?: string
290
296
  /** Bump to reload the listing (e.g. after a deletion). */
291
297
  reloadToken?: number
292
298
  /** Opened via /delete: hint-first delete mode. */
293
299
  deleteMode?: boolean
294
- close(): void
300
+ /** `/delete <id>` argument to resolve after the listing loads. */
301
+ presetId?: string
302
+ close: () => void
295
303
  }): ReactElement {
296
304
  // Codex resume-picker default: the CURRENT directory's root sessions; the
297
305
  // cwd filter widens to all only on request (the old default leaked every
298
306
  // directory's sessions into what read as a current-directory view).
299
- const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' })
307
+ const [options, setOptions] = useState<SessionDirectoryOptions>({
308
+ sessions: presetId === undefined || presetId === '' ? 'roots' : 'all',
309
+ cwd: presetId === undefined || presetId === '' ? 'current' : 'all',
310
+ sort: 'newest',
311
+ currentCwd,
312
+ query: '',
313
+ })
300
314
  const [focus, setFocus] = useState(0)
301
315
  const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
302
316
  const [rows, setRows] = useState<readonly SessionRow[]>([])
@@ -308,6 +322,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
308
322
  /** Ctrl+F-gated search: typing filters only while searching (codex). */
309
323
  const [searching, setSearching] = useState(false)
310
324
  /** Reference clock pinned per row render, so relative times never drift mid-list. */
325
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when the listing or its options change
311
326
  const now = useMemo(() => Date.now(), [rows, options])
312
327
  const transcriptLoad = useRef<AbortController>()
313
328
  useEffect(() => () => transcriptLoad.current?.abort(), [])
@@ -320,8 +335,22 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
320
335
  if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
321
336
  })
322
337
  return () => controller.abort()
323
- }, [options, reloadToken])
338
+ }, [load, options, reloadToken])
324
339
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
340
+ const presetArmed = useRef(false)
341
+ useEffect(() => {
342
+ if (presetArmed.current || presetId === undefined || presetId === '' || rows.length === 0 || requestDelete === undefined) return
343
+ try {
344
+ const row = matchSessionRow(rows, presetId)
345
+ presetArmed.current = true
346
+ const index = rows.findIndex(candidate => candidate.id === row.id)
347
+ if (index >= 0) setCursor(index)
348
+ requestDelete(row)
349
+ } catch (reason: unknown) {
350
+ presetArmed.current = true
351
+ setError(reason instanceof Error ? reason.message : String(reason))
352
+ }
353
+ }, [rows, presetId, requestDelete])
325
354
  const cycle = (): void => {
326
355
  if (focus === 3) {
327
356
  setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
@@ -358,12 +387,11 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
358
387
  if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
359
388
  if (input === 'g') return setCursor(0)
360
389
  if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
361
- if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
362
390
  if (input === 'e' && rows[cursor] !== undefined) {
363
- return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
391
+ return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id)
364
392
  }
365
393
  if (input === 't' && rows[cursor] !== undefined) {
366
- const row = rows[cursor]!
394
+ const row = rows[cursor]
367
395
  transcriptLoad.current?.abort()
368
396
  setTranscript({ id: row.id })
369
397
  const controller = new AbortController()
@@ -374,11 +402,17 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
374
402
  )
375
403
  return
376
404
  }
377
- if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
405
+ if (key.return && rows[cursor] !== undefined) {
406
+ if (deleteMode) {
407
+ if (requestDelete !== undefined && !rows[cursor].live) requestDelete(rows[cursor])
408
+ return
409
+ }
410
+ if (rows[cursor].resumable) select(rows[cursor])
411
+ }
378
412
  }, { isActive: transcript === undefined })
379
413
  if (transcript !== undefined) {
380
414
  return createElement(DocumentPanel, {
381
- title: `transcript · ${transcript.id}`,
415
+ title: t('panel.document.title', { id: transcript.id }),
382
416
  text: transcript.text,
383
417
  error: transcript.error,
384
418
  close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
@@ -387,15 +421,17 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
387
421
  const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
388
422
  const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
389
423
  return createElement(ListFrame, {
390
- title: deleteConfirmId === undefined
391
- ? `/resume${deleteMode ? ' delete mode' : ''}${searching ? ' searching' : ''} · ${toolbar}`
392
- : `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
424
+ title: deleteConfirmId !== undefined
425
+ ? t('panel.resume.deleteTitle', { target: pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id })
426
+ : deleteMode
427
+ ? t('panel.delete.title', { search: searching ? ` — ${t('panel.searching')}` : '', toolbar })
428
+ : t('panel.resume.title', { mode: '', search: searching ? ` — ${t('panel.searching')}` : '', toolbar }),
393
429
  rows: rows.map(row => ({
394
430
  key: row.id,
395
- disabled: !row.resumable,
431
+ disabled: deleteMode ? row.live : !row.resumable,
396
432
  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
433
  })), cursor, loading, error, query: options.query, searching,
398
- footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
434
+ footer: t(deleteMode ? 'panel.footer.delete' : 'panel.footer.resume'),
399
435
  })
400
436
  }
401
437
 
@@ -440,7 +476,7 @@ function DocumentPanel({ title, text, error, close }: {
440
476
  title: string
441
477
  text?: string
442
478
  error?: string
443
- close(): void
479
+ close: () => void
444
480
  }): ReactElement {
445
481
  const stdout = useStdout().stdout
446
482
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -455,18 +491,19 @@ function DocumentPanel({ title, text, error, close }: {
455
491
  if (input === 'g') return setScroll(0)
456
492
  if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
457
493
  })
458
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
494
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('document.compact'), viewport.contentColumns))
459
495
  const body: readonly StyledLine[] = error !== undefined
460
496
  ? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
461
497
  : text === undefined
462
- ? textLines('loading transcript…', viewport.contentColumns, 'dim')
498
+ ? textLines(t('document.loading'), viewport.contentColumns, 'dim')
463
499
  : lines.slice(scroll, scroll + viewport.bodyRows)
500
+ const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright)
464
501
  return createElement(
465
502
  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)),
503
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
504
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
468
505
  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)),
506
+ 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
507
  )
471
508
  }
472
509
 
@@ -480,8 +517,8 @@ export function HistoryPanel({ entries, fill, close }: {
480
517
  /** Newest-first recall entries (persistent + in-session, deduped). */
481
518
  entries: readonly string[]
482
519
  /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
483
- fill(text: string, index: number): void
484
- close(): void
520
+ fill: (text: string, index: number) => void
521
+ close: () => void
485
522
  }): ReactElement {
486
523
  const [query, setQuery] = useState('')
487
524
  const [cursor, setCursor] = useState(0)
@@ -497,14 +534,18 @@ export function HistoryPanel({ entries, fill, close }: {
497
534
  }
498
535
  if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
499
536
  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) {
537
+ // g/G stay vim-style jumps only on an empty query (the /mode contract):
538
+ // mid-filter they are query text, so filters like 'Fix' or 'grep' survive.
539
+ if (input === 'g' && query === '') return setCursor(0)
540
+ if (input === 'G' && query === '') return setCursor(matches.length - 1)
541
+ if (key.backspace || key.delete) {
503
542
  setQuery(current => deleteLastGrapheme(current))
504
543
  setCursor(0)
505
544
  return
506
545
  }
507
- if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
546
+ // Ink reports single uppercase letters and shifted symbols ('!', '@')
547
+ // with key.shift set; only ctrl/meta mark real command input.
548
+ if (input !== '' && !key.ctrl && !key.meta) {
508
549
  const text = stripPasteMarkers(input)
509
550
  if (text !== '') {
510
551
  setQuery(current => (current + text).slice(0, 120))
@@ -516,21 +557,22 @@ export function HistoryPanel({ entries, fill, close }: {
516
557
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
517
558
  if (viewport.maxHeight === 0 || viewport.compact) {
518
559
  const picked = matches[cursor]
519
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · ' + (picked === undefined ? 'no matching prompts' : singleLineText(picked)) + ' · esc close', viewport.contentColumns))
560
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/history · ${picked === undefined ? t('history.compact.none') : singleLineText(picked)} · ${t('panel.close')}`, viewport.contentColumns))
520
561
  }
521
562
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
522
563
  const offset = revealRow(0, cursor, matches.length, bodyRows)
523
564
  const visible = matches.slice(offset, offset + bodyRows)
524
565
  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)}'`
566
+ ? t('history.title.prompts', { count: entries.length })
567
+ : t('history.title.match', { matches: matches.length, count: entries.length, query: truncateColumns(singleLineText(query), Math.max(6, viewport.contentColumns - 34)) })
568
+ const accent = panelAccent('history', getPalette().dim, getPalette().brandBright)
527
569
  return createElement(
528
570
  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)),
571
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
572
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
573
+ 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
574
  ...(visible.length === 0
533
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
575
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.empty'), viewport.contentColumns))]
534
576
  : visible.map((entry, index) => {
535
577
  const absolute = offset + index
536
578
  const selected = absolute === cursor
@@ -544,7 +586,315 @@ export function HistoryPanel({ entries, fill, close }: {
544
586
  truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
545
587
  )
546
588
  })),
547
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
589
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.footer'), viewport.contentColumns)),
590
+ )
591
+ }
592
+
593
+ /**
594
+ * The /review candidate picker (Codex's preset popup): bare /review opens a
595
+ * four-way preset — review uncommitted changes, pick a base branch, pick a
596
+ * recent commit, or type a custom focus. Branch and commit phases are
597
+ * type-to-filter lists; every selection resolves to the same /review
598
+ * argument string the direct command accepts.
599
+ */
600
+ export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }: {
601
+ loadBranches: (signal?: AbortSignal) => Promise<readonly ReviewBranch[]>
602
+ loadCommits: (signal?: AbortSignal) => Promise<readonly ReviewCommit[]>
603
+ /** Run the review for one picker selection. */
604
+ choose: (selection: ReviewSelection) => void
605
+ close: () => void
606
+ }): ReactElement {
607
+ const [phase, setPhase] = useState<'preset' | 'branches' | 'commits' | 'custom'>('preset')
608
+ const [cursor, setCursor] = useState(0)
609
+ const [query, setQuery] = useState('')
610
+ const [rows, setRows] = useState<readonly (ReviewBranch | ReviewCommit)[]>([])
611
+ const [loading, setLoading] = useState(false)
612
+ const [error, setError] = useState<string>()
613
+ const loadRef = useRef<AbortController>()
614
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when review rows change
615
+ const now = useMemo(() => Date.now(), [rows])
616
+
617
+ useEffect(() => {
618
+ if (phase !== 'branches' && phase !== 'commits') return undefined
619
+ loadRef.current?.abort()
620
+ const controller = new AbortController()
621
+ loadRef.current = controller
622
+ setLoading(true)
623
+ setError(undefined)
624
+ const load = phase === 'branches'
625
+ ? (signal?: AbortSignal) => loadBranches(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
626
+ : (signal?: AbortSignal) => loadCommits(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
627
+ void Promise.resolve().then(() => load(controller.signal)).then(list => {
628
+ if (controller.signal.aborted) return
629
+ setLoading(false)
630
+ setRows(list)
631
+ setCursor(0)
632
+ }, reason => {
633
+ if (controller.signal.aborted) return
634
+ setLoading(false)
635
+ setRows([])
636
+ setError(reason instanceof Error ? reason.message : String(reason))
637
+ })
638
+ return () => controller.abort()
639
+ // eslint-disable-next-line react-hooks/exhaustive-deps
640
+ }, [phase])
641
+
642
+ const filtered = useMemo(() => {
643
+ if (phase !== 'branches' && phase !== 'commits') return []
644
+ const needle = query.toLowerCase()
645
+ if (needle === '') return rows
646
+ return rows.filter(row => {
647
+ const hay = 'name' in row ? row.name : `${row.sha} ${row.title}`
648
+ return hay.toLowerCase().includes(needle)
649
+ })
650
+ }, [phase, rows, query])
651
+
652
+ const submit = (selection: ReviewSelection): void => {
653
+ choose(selection)
654
+ }
655
+
656
+ useInput((input, key) => {
657
+ if (key.escape || (input === 'q' && query === '' && phase !== 'custom')) {
658
+ if (phase !== 'preset') {
659
+ setPhase('preset')
660
+ setQuery('')
661
+ setCursor(0)
662
+ return
663
+ }
664
+ return close()
665
+ }
666
+ if (key.ctrl && input === 'c') return close()
667
+ if (phase === 'custom' || ((phase === 'branches' || phase === 'commits') && query !== '')) {
668
+ const next = editQuery(query, input, key)
669
+ if (next !== undefined) {
670
+ setQuery(next)
671
+ setCursor(0)
672
+ return
673
+ }
674
+ }
675
+ // The row budget per phase: the preset list is fixed at four rows, the
676
+ // branch/commit lists clamp to their filtered length.
677
+ const rowCount = phase === 'preset' ? 4 : filtered.length
678
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
679
+ if (key.downArrow) return setCursor(value => Math.min(Math.max(0, rowCount - 1), value + 1))
680
+ if (key.return) {
681
+ if (phase === 'preset') {
682
+ if (cursor === 0) return submit({ kind: 'uncommitted' })
683
+ if (cursor === 1) return setPhase('branches')
684
+ if (cursor === 2) return setPhase('commits')
685
+ return setPhase('custom')
686
+ }
687
+ if (phase === 'branches' || phase === 'commits') {
688
+ const row = filtered[cursor]
689
+ if (row !== undefined) submit('name' in row ? { kind: 'base-branch', branch: row.name } : { kind: 'commit', sha: row.sha })
690
+ return
691
+ }
692
+ if (query.trim() !== '') submit({ kind: 'custom', instructions: query.trim() })
693
+ return
694
+ }
695
+ })
696
+
697
+ const stdout = useStdout().stdout
698
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
699
+ if (viewport.maxHeight === 0 || viewport.compact) {
700
+ const state = phase === 'preset' ? 'pick a review target' : loading ? 'loading…' : error !== undefined ? `error: ${error}` : `${filtered.length} candidates`
701
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/review · ${state} · ${t('panel.close')}`, viewport.contentColumns))
702
+ }
703
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
704
+ const offset = revealRow(0, cursor, phase === 'preset' ? 4 : filtered.length, bodyRows)
705
+ const header = phase === 'preset'
706
+ ? t('review.picker.title')
707
+ : phase === 'branches'
708
+ ? 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 })
709
+ : phase === 'commits'
710
+ ? 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 })
711
+ : t('review.picker.customHint')
712
+ const accent = panelAccent('review-picker', getPalette().dim, getPalette().brandBright)
713
+ return createElement(
714
+ Box,
715
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
716
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
717
+ ...(phase === 'preset'
718
+ ? [
719
+ t('review.picker.uncommitted'),
720
+ t('review.picker.branch'),
721
+ t('review.picker.commit'),
722
+ t('review.picker.custom'),
723
+ ].map((label, index) => {
724
+ const absolute = offset + index
725
+ const selected = absolute === cursor
726
+ return createElement(
727
+ Text,
728
+ { key: `preset-${index}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
729
+ truncateColumns(`${selected ? '› ' : ' '}${label}`, viewport.contentColumns),
730
+ )
731
+ })
732
+ : phase === 'custom'
733
+ ? [createElement(Text, { key: 'custom-input', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${query === '' ? t('review.picker.customHint') : singleLineText(query)}`, viewport.contentColumns))]
734
+ : filtered.length === 0
735
+ ? [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))]
736
+ : filtered.slice(offset, offset + bodyRows).map((row, index) => {
737
+ const absolute = offset + index
738
+ const selected = absolute === cursor
739
+ const label = 'name' in row ? row.name : `${row.sha.slice(0, 7)} · ${formatRelativeTime(row.at, now)} · ${row.title}`
740
+ return createElement(
741
+ Text,
742
+ { key: `row-${absolute}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
743
+ truncateColumns(`${selected ? '› ' : ' '}${singleLineText(label)}`, viewport.contentColumns),
744
+ )
745
+ })),
746
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('review.picker.footer'), viewport.contentColumns)),
747
+ )
748
+ }
749
+
750
+ /** One cross-session full-text search hit mapped from the session-query engine. */
751
+ export interface SearchRow {
752
+ /** Session id (Enter resumes it through the switch machinery). */
753
+ readonly id: string
754
+ /** Display label: session title or the short id form. */
755
+ readonly label: string
756
+ /** Secondary facts line (workspace · preset markers). */
757
+ readonly detail: string
758
+ /** Bounded plain-text excerpt around the strongest match. */
759
+ readonly snippet: string
760
+ /** Match timestamp (relative labels derive from it). */
761
+ readonly updatedAt: number
762
+ /** Whether the hit is a delegated subagent conversation (not resumable). */
763
+ readonly subagent: boolean
764
+ /** Whether Enter may switch into it. */
765
+ readonly resumable: boolean
766
+ }
767
+
768
+ /**
769
+ * The /search panel: full-text search over every persisted session through
770
+ * the in-process session-query engine (the same corpus the model's
771
+ * session_search tool reads). Type a query, Enter searches, Enter again
772
+ * resumes the hit; the query line edits like every kernel panel.
773
+ */
774
+ export function SearchPanel({ load, select, initialQuery = '', close }: {
775
+ load: (query: string, signal?: AbortSignal) => Promise<readonly SearchRow[]>
776
+ select: (row: SearchRow) => void
777
+ initialQuery?: string
778
+ close: () => void
779
+ }): ReactElement {
780
+ const [query, setQuery] = useState(initialQuery)
781
+ const [rows, setRows] = useState<readonly SearchRow[]>([])
782
+ const [cursor, setCursor] = useState(0)
783
+ const [loading, setLoading] = useState(false)
784
+ const [error, setError] = useState<string>()
785
+ const [searched, setSearched] = useState('')
786
+ const searchRef = useRef<AbortController>()
787
+ const run = (next: string): void => {
788
+ const trimmed = next.trim()
789
+ if (trimmed === '') return
790
+ searchRef.current?.abort()
791
+ const controller = new AbortController()
792
+ searchRef.current = controller
793
+ setLoading(true)
794
+ setError(undefined)
795
+ void Promise.resolve().then(() => load(trimmed, controller.signal)).then(hits => {
796
+ if (controller.signal.aborted) return
797
+ setLoading(false)
798
+ setRows(hits)
799
+ setCursor(0)
800
+ setSearched(next)
801
+ }, reason => {
802
+ if (controller.signal.aborted) return
803
+ setLoading(false)
804
+ // Stale results must not stay interactive under an error header: a
805
+ // later Enter re-runs the query instead of resuming an old hit.
806
+ setRows([])
807
+ setCursor(0)
808
+ setError(reason instanceof Error ? reason.message : String(reason))
809
+ })
810
+ }
811
+ // An /search <query> invocation searches immediately with its argument.
812
+ useEffect(() => {
813
+ if (initialQuery.trim() !== '') run(initialQuery)
814
+ return () => searchRef.current?.abort()
815
+ // eslint-disable-next-line react-hooks/exhaustive-deps
816
+ }, [])
817
+ useInput((input, key) => {
818
+ if (key.escape || (input === 'q' && query === '')) return close()
819
+ if (key.ctrl && input === 'c') return close()
820
+ if (key.backspace || key.delete) {
821
+ setQuery(current => deleteLastGrapheme(current))
822
+ setCursor(0)
823
+ return
824
+ }
825
+ const next = editQuery(query, input, key)
826
+ if (next !== undefined) {
827
+ setQuery(next)
828
+ setCursor(0)
829
+ return
830
+ }
831
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
832
+ if (key.downArrow) return setCursor(value => Math.min(rows.length - 1, value + 1))
833
+ if (key.return) {
834
+ // A changed query searches; the SAME query re-runs when the previous
835
+ // pass failed or produced nothing (Enter is then the refresh key).
836
+ const stale = error !== undefined || rows.length === 0
837
+ if (query.trim() !== '' && (query.trim() !== searched.trim() || stale)) {
838
+ run(query)
839
+ return
840
+ }
841
+ // Non-resumable hits (subagent conversations) keep the panel open:
842
+ // Enter must not trade the visible results for a rejected switch.
843
+ const row = rows[cursor]
844
+ if (row !== undefined && row.resumable) select(row)
845
+ return
846
+ }
847
+ })
848
+ const stdout = useStdout().stdout
849
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
850
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when a search result set lands
851
+ const now = useMemo(() => Date.now(), [rows, searched])
852
+ if (viewport.maxHeight === 0 || viewport.compact) {
853
+ const state = loading ? 'searching…' : error !== undefined ? `error: ${error}` : rows.length === 0 ? 'no results yet' : `❯ ${rows[cursor]?.label ?? ''}`
854
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/search · ${state} · ${t('panel.close')}`, viewport.contentColumns))
855
+ }
856
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
857
+ const offset = revealRow(0, cursor, rows.length, bodyRows)
858
+ const visible = rows.slice(offset, offset + bodyRows)
859
+ const header = error !== undefined
860
+ ? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) })
861
+ : loading
862
+ ? t('search.compact.searching')
863
+ : searched === ''
864
+ ? t('search.title.type')
865
+ : rows.length === 1
866
+ ? t('search.title.hits', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
867
+ : t('search.title.hitsPlural', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
868
+ const accent = panelAccent('search', getPalette().dim, getPalette().brandBright)
869
+ return createElement(
870
+ Box,
871
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
872
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
873
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.hint', { state: query === '' ? t('search.hintEmpty') : singleLineText(query) }), viewport.contentColumns)),
874
+ ...(visible.length === 0
875
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(searched === '' ? ` ${t('search.empty.idle')}` : ` ${t('search.empty.noHits', { loading: loading ? '…' : '' })}`, viewport.contentColumns))]
876
+ : visible.flatMap((row, index) => {
877
+ const absolute = offset + index
878
+ const selected = absolute === cursor
879
+ return [
880
+ createElement(
881
+ Text,
882
+ {
883
+ key: `search-${absolute}`,
884
+ color: selected ? inkColor(getPalette().brandBright) : undefined,
885
+ dimColor: row.subagent,
886
+ wrap: 'truncate-end',
887
+ },
888
+ truncateColumns(`${selected ? '› ' : ' '}${row.subagent ? '↳ ' : ''}${singleLineText(row.label)} · ${formatRelativeTime(row.updatedAt, now)}${row.detail === '' ? '' : ` · ${row.detail}`}${row.resumable ? '' : ' · read-only'}`, viewport.contentColumns),
889
+ ),
890
+ createElement(
891
+ Text,
892
+ { key: `search-snippet-${absolute}`, dimColor: true, wrap: 'truncate-end' },
893
+ truncateColumns(` ⎿ ${singleLineText(row.snippet)}`, viewport.contentColumns),
894
+ ),
895
+ ]
896
+ })),
897
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.footer'), viewport.contentColumns)),
548
898
  )
549
899
  }
550
900
 
@@ -556,8 +906,8 @@ export function HistoryPanel({ entries, fill, close }: {
556
906
  */
557
907
  export function StatuslinePanel({ enabled, change, close }: {
558
908
  enabled: readonly StatusItemId[]
559
- change(items: readonly StatusItemId[]): void
560
- close(): void
909
+ change: (items: readonly StatusItemId[]) => void
910
+ close: () => void
561
911
  }): ReactElement {
562
912
  // Working state: the full catalog in display order (enabled entries in
563
913
  // their configured positions, disabled ones trailing canonically) plus
@@ -578,7 +928,7 @@ export function StatuslinePanel({ enabled, change, close }: {
578
928
  if (target < 0 || target >= order.length) return
579
929
  const next = [...order]
580
930
  const [item] = next.splice(cursor, 1)
581
- next.splice(target, 0, item!)
931
+ next.splice(target, 0, item)
582
932
  commit(next, on)
583
933
  setCursor(target)
584
934
  }
@@ -607,16 +957,17 @@ export function StatuslinePanel({ enabled, change, close }: {
607
957
  const stdout = useStdout().stdout
608
958
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
609
959
  if (viewport.maxHeight === 0 || viewport.compact) {
610
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
960
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.statusline.compact'), viewport.contentColumns))
611
961
  }
612
962
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
613
963
  const offset = revealRow(0, cursor, order.length, bodyRows)
614
964
  const visible = order.slice(offset, offset + bodyRows)
615
- const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
965
+ const meta = new Map(localizedStatusItems().map(item => [item.id, item]))
966
+ const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright)
616
967
  return createElement(
617
968
  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)),
969
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
970
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('statusline.title'), viewport.contentColumns)),
620
971
  ...visible.map((id, index) => {
621
972
  const absolute = offset + index
622
973
  const selected = absolute === cursor
@@ -632,7 +983,7 @@ export function StatuslinePanel({ enabled, change, close }: {
632
983
  truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
633
984
  )
634
985
  }),
635
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
986
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.footer.statusline'), viewport.contentColumns)),
636
987
  )
637
988
  }
638
989
 
@@ -655,11 +1006,11 @@ export function EffortPanel({ row, current, select, back, onExit }: {
655
1006
  /** Effective effort currently in force ('' when none), for the ● mark. */
656
1007
  current: string | undefined
657
1008
  /** Accept one advertised effort id, or '' for the provider default. */
658
- select(effortId: string): void
1009
+ select: (effortId: string) => void
659
1010
  /** Return to the model list without applying. */
660
- back(): void
1011
+ back: () => void
661
1012
  /** Leave the whole /model flow (Ctrl+C). */
662
- onExit(): void
1013
+ onExit: () => void
663
1014
  }): ReactElement {
664
1015
  const advertised = row.reasoning?.efforts ?? []
665
1016
  const empty = row.reasoning === undefined || advertised.length === 0
@@ -706,21 +1057,21 @@ export function EffortPanel({ row, current, select, back, onExit }: {
706
1057
  return
707
1058
  }
708
1059
  if (key.return && rows[cursor] !== undefined) {
709
- select(rows[cursor]!.id)
1060
+ select(rows[cursor].id)
710
1061
  }
711
1062
  })
712
1063
  return createElement(ListFrame, {
713
- title: `/model effort for ${row.providerName} · ${row.modelName}`,
1064
+ title: t('panel.effort.title', { provider: row.providerName, model: row.modelName }),
714
1065
  rows: empty
715
- ? [{ key: 'empty', disabled: true, text: 'this model advertises no reasoning effort levels — the provider default applies' }]
1066
+ ? [{ key: 'empty', disabled: true, text: t('panel.effort.empty') }]
716
1067
  : rows.map(effort => ({
717
1068
  key: effort.id,
718
- text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
1069
+ text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ` · ${t('panel.default')}` : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
719
1070
  })),
720
1071
  cursor,
721
1072
  loading: false,
722
1073
  query: '',
723
- footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
1074
+ footer: empty ? t('panel.footer.effortEmpty') : t('panel.footer.effort'),
724
1075
  })
725
1076
  }
726
1077
 
@@ -745,10 +1096,10 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
745
1096
  /** Live feed rows (child sessions observed this process). */
746
1097
  live: readonly SubagentRow[]
747
1098
  /** Load this session's persisted child sessions by lineage. */
748
- load(): Promise<readonly SessionRow[]>
1099
+ load: () => Promise<readonly SessionRow[]>
749
1100
  /** Read one child session's full transcript as markdown. */
750
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
751
- close(): void
1101
+ readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
1102
+ close: () => void
752
1103
  }): ReactElement {
753
1104
  const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
754
1105
  const [error, setError] = useState<string>()
@@ -757,7 +1108,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
757
1108
  const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
758
1109
  const transcriptLoad = useRef<AbortController>()
759
1110
  useEffect(() => () => transcriptLoad.current?.abort(), [])
760
- const refresh = (): void => {
1111
+ const refresh = useCallback((): void => {
761
1112
  setLoading(true)
762
1113
  setError(undefined)
763
1114
  Promise.resolve().then(load).then(value => {
@@ -767,8 +1118,8 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
767
1118
  setError(reason instanceof Error ? reason.message : String(reason))
768
1119
  setLoading(false)
769
1120
  })
770
- }
771
- useEffect(refresh, [])
1121
+ }, [load])
1122
+ useEffect(refresh, [refresh])
772
1123
  // Live feed rows first (they carry the running state), then persisted
773
1124
  // children only the directory knows — settled subagents from earlier turns.
774
1125
  const rows = useMemo<readonly AgentsEntry[]>(() => {
@@ -819,7 +1170,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
819
1170
  }, { isActive: transcript === undefined })
820
1171
  if (transcript !== undefined) {
821
1172
  return createElement(DocumentPanel, {
822
- title: `subagent · ${transcript.id.slice(-12)}`,
1173
+ title: t('panel.subagent.transcriptTitle', { id: transcript.id.slice(-12) }),
823
1174
  text: transcript.text,
824
1175
  error: transcript.error,
825
1176
  close: () => {
@@ -829,7 +1180,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
829
1180
  })
830
1181
  }
831
1182
  return createElement(ListFrame, {
832
- title: `/agents · ${live.length} live · ${rows.length} total`,
1183
+ title: t('panel.agents.title', { live: live.length, total: rows.length }),
833
1184
  rows: rows.map(row => ({
834
1185
  key: row.id,
835
1186
  text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
@@ -838,7 +1189,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
838
1189
  loading,
839
1190
  ...error === undefined ? {} : { error },
840
1191
  query: '',
841
- footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
1192
+ footer: t('panel.footer.agents'),
842
1193
  })
843
1194
  }
844
1195
 
@@ -857,19 +1208,19 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
857
1208
  export function SubagentPanel({ current, load, pick, inherit, close }: {
858
1209
  /** Display label of the override in force, '' when following the current model. */
859
1210
  current: string
860
- load(): Promise<ModelDirectory>
1211
+ load: () => Promise<ModelDirectory>
861
1212
  /** Apply one model (with an advertised effort, when picked) as the override. */
862
- pick(row: ModelRow, effortId?: string): void
1213
+ pick: (row: ModelRow, effortId?: string) => void
863
1214
  /** Drop the override: subagents follow the current model again. */
864
- inherit(): void
865
- close(): void
1215
+ inherit: () => void
1216
+ close: () => void
866
1217
  }): ReactElement {
867
1218
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
868
1219
  const [error, setError] = useState<string>()
869
1220
  const [loading, setLoading] = useState(true)
870
1221
  const [cursor, setCursor] = useState(0)
871
1222
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
872
- const refresh = (): void => {
1223
+ const refresh = useCallback((): void => {
873
1224
  setLoading(true)
874
1225
  setError(undefined)
875
1226
  Promise.resolve().then(load).then(value => {
@@ -879,8 +1230,8 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
879
1230
  setError(reason instanceof Error ? reason.message : String(reason))
880
1231
  setLoading(false)
881
1232
  })
882
- }
883
- useEffect(refresh, [])
1233
+ }, [load])
1234
+ useEffect(refresh, [refresh])
884
1235
  const rows = useMemo(() => directory?.rows ?? [], [directory])
885
1236
  // The list opens on the override's own row (index 0 is the inherit row).
886
1237
  useEffect(() => {
@@ -906,7 +1257,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
906
1257
  setEffortFor(row)
907
1258
  return
908
1259
  }
909
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
1260
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined
910
1261
  pick(row, effortId)
911
1262
  }
912
1263
  })
@@ -920,9 +1271,9 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
920
1271
  })
921
1272
  }
922
1273
  return createElement(ListFrame, {
923
- title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
1274
+ title: `${t('panel.subagent.title')}${current === '' ? '' : t('panel.subagent.override', { value: current })}`,
924
1275
  rows: [
925
- { key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
1276
+ { key: '__inherit__', text: `${current === '' ? '●' : '○'} ${t('panel.subagent.inherit')}` },
926
1277
  ...rows.map(row => ({
927
1278
  key: `${row.provider}/${row.model}`,
928
1279
  text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
@@ -932,7 +1283,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
932
1283
  loading,
933
1284
  ...error === undefined ? {} : { error },
934
1285
  query: '',
935
- footer: '↑↓ choose · enter apply · r refresh · esc close',
1286
+ footer: t('panel.footer.subagent'),
936
1287
  })
937
1288
  }
938
1289
 
@@ -981,7 +1332,7 @@ export function scheduleDisplayRows(rows: readonly ScheduleRow[], now: number):
981
1332
  }))
982
1333
  }
983
1334
 
984
- export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[]; close(): void }): ReactElement {
1335
+ export function SchedulePanel({ rows, close }: { rows: () => readonly ScheduleRow[]; close: () => void }): ReactElement {
985
1336
  const [, setTick] = useState(0)
986
1337
  useEffect(() => {
987
1338
  const id = setInterval(() => setTick(value => value + 1), 1_000)
@@ -994,23 +1345,87 @@ export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[];
994
1345
  if (key.escape || input === 'q') return close()
995
1346
  })
996
1347
  if (viewport.maxHeight === 0 || viewport.compact) {
997
- const summary = display.length === 0 ? 'no active reminders' : singleLineText(display[0]!.text)
1348
+ const summary = display.length === 0 ? t('panel.schedule.none') : singleLineText(display[0].text)
998
1349
  return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns))
999
1350
  }
1000
1351
  const budget = Math.max(1, viewport.bodyRows)
1001
1352
  const visible = display.slice(0, budget)
1002
1353
  const hidden = display.length - visible.length
1354
+ const accent = panelAccent('schedule', getPalette().dim, getPalette().brandBright)
1003
1355
  return createElement(
1004
1356
  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)),
1357
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
1358
+ 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
1359
  ...(display.length === 0
1008
- ? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no active reminders — the model creates them with schedule_create', viewport.contentColumns))]
1360
+ ? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('schedule.empty')}`, viewport.contentColumns))]
1009
1361
  : visible.map(row => createElement(Text, {
1010
1362
  key: row.key,
1011
1363
  color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
1012
1364
  wrap: 'truncate-end',
1013
1365
  }, 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)),
1366
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.schedule.footer', { more: hidden > 0 ? t('panel.schedule.more', { count: hidden }) : '' }), viewport.contentColumns)),
1367
+ )
1368
+ }
1369
+
1370
+ /**
1371
+ * The /usage panel: the session's provider-reported token totals, its context
1372
+ * pressure and estimated composition, and the exact per-turn accounting, in
1373
+ * one bounded scrollable surface. Read-only — Esc or q closes it.
1374
+ */
1375
+ export function UsagePanel({ load, close }: {
1376
+ /** Read the current session's usage blocks from the mounted projections. */
1377
+ load: () => Promise<UsageView>
1378
+ close: () => void
1379
+ }): ReactElement {
1380
+ const stdout = useStdout().stdout
1381
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1382
+ const [scroll, setScroll] = useState(0)
1383
+ const [view, setView] = useState<UsageView>()
1384
+ const [error, setError] = useState<string>()
1385
+ // The panel opens on the loading row and swaps in the numbers when the
1386
+ // loader settles: materializing the projection units folds the whole log,
1387
+ // and that must not run inside the keystroke that opened the panel.
1388
+ useEffect(() => {
1389
+ let live = true
1390
+ Promise.resolve().then(load).then(
1391
+ loaded => {
1392
+ if (live) setView(loaded)
1393
+ },
1394
+ reason => {
1395
+ if (live) setError(reason instanceof Error ? reason.message : String(reason))
1396
+ },
1397
+ )
1398
+ return () => {
1399
+ live = false
1400
+ }
1401
+ }, [load])
1402
+ const lines = useMemo(
1403
+ () => view === undefined ? [] : usageLines(view, viewport.contentColumns),
1404
+ [view, viewport.contentColumns],
1405
+ )
1406
+ useInput((input, key) => {
1407
+ if (key.escape || input === 'q') return close()
1408
+ if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
1409
+ if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
1410
+ if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
1411
+ if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
1412
+ if (input === 'g') return setScroll(0)
1413
+ if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
1414
+ })
1415
+ if (viewport.compact) {
1416
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.usage.compact'), viewport.contentColumns))
1417
+ }
1418
+ const body: readonly StyledLine[] = error !== undefined
1419
+ ? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
1420
+ : view === undefined
1421
+ ? textLines(t('panel.loading'), viewport.contentColumns, 'dim')
1422
+ : lines.slice(scroll, scroll + viewport.bodyRows)
1423
+ const accent = panelAccent('usage', getPalette().dim, getPalette().brandBright)
1424
+ return createElement(
1425
+ Box,
1426
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
1427
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('panel.usage.title'), viewport.contentColumns)),
1428
+ createElement(DocumentRows, { lines: body }),
1429
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.usage.footer'), viewport.contentColumns)),
1015
1430
  )
1016
1431
  }