dsh-code 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +130 -4
  30. package/lib/types/render/status.d.ts +9 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
@@ -4,17 +4,22 @@ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement
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'
7
+ import type { ScheduleRow } from './render/projection.ts'
7
8
  import type { PermissionRow } from './permissions.ts'
8
9
  import type { PresetRow } from './presets.ts'
9
10
  import type { PluginRow } from './plugin-inventory.ts'
10
11
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
11
12
  import { formatRelativeTime } from './session-directory.ts'
13
+ import type { ReviewBranch, ReviewCommit, ReviewSelection } from './git-workflow.ts'
12
14
  import { panelViewport, revealRow } from './render/inspector.ts'
13
15
  import { markdownLines, textLines, type LineStyle, type StyledLine } from './render/lines.ts'
16
+ import { usageLines, type UsageView } from './render/usage.ts'
14
17
  import { deleteLastGrapheme } from './render/editor.ts'
15
18
  import { stripPasteMarkers } from './keyboard.ts'
16
19
  import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
17
20
  import { singleLineText, truncateColumns } from './render/text.ts'
21
+ import { panelAccent } from './panel-accent.ts'
22
+ import { t } from './i18n.ts'
18
23
  import { getPalette, inkColor } from './theme.ts'
19
24
 
20
25
  interface ListFrameProps {
@@ -39,9 +44,9 @@ function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
39
44
  /** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
40
45
  * toggle lives in the footer), direct-typing panels keep the plain prompt. */
41
46
  function searchLine(searching: boolean | undefined, query: string): string {
42
- if (searching === true) return `search: ${query === '' ? 'type to filter · esc stops' : query}`
43
- if (searching === false) return query === '' ? '' : `search: ${query}`
44
- 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}`
45
50
  }
46
51
 
47
52
  function ListFrame(props: ListFrameProps): ReactElement {
@@ -71,10 +76,11 @@ function ListFrame(props: ListFrameProps): ReactElement {
71
76
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
72
77
  const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
73
78
  const visible = stateRows.slice(offset, offset + bodyRows)
79
+ const accent = panelAccent('kernel-list', getPalette().dim, getPalette().brandBright)
74
80
  return createElement(
75
81
  Box,
76
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
77
- 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)),
78
84
  createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
79
85
  ...visible.map((row, index) => {
80
86
  const absolute = offset + index
@@ -104,9 +110,9 @@ export function editQuery(query: string, input: string, key: { backspace?: boole
104
110
 
105
111
  export function ModePanel({ current, load, select, close }: {
106
112
  current: string
107
- load(): Promise<readonly PresetRow[]>
108
- select(id: string): void
109
- close(): void
113
+ load: () => Promise<readonly PresetRow[]>
114
+ select: (id: string) => void
115
+ close: () => void
110
116
  }): ReactElement {
111
117
  const [rows, setRows] = useState<readonly PresetRow[]>([])
112
118
  const [query, setQuery] = useState('')
@@ -123,29 +129,31 @@ export function ModePanel({ current, load, select, close }: {
123
129
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
124
130
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
125
131
  useInput((input, key) => {
126
- if (key.escape || input === 'q') return close()
132
+ if (key.escape) return close()
133
+ // q closes only while the query is empty; mid-filter it is query text.
134
+ if (input === 'q' && query === '') return close()
127
135
  if (input === 'r' && query === '') return refresh()
128
136
  if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
129
137
  if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
130
138
  // Empty/loading/filtered-out lists have no row at the cursor: a bare
131
139
  // `?.broken === undefined` check passes on undefined and crashes the
132
140
  // process on the `!.id` access (PermissionPanel guards this correctly).
133
- 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)
134
142
  const next = editQuery(query, input, key)
135
143
  if (next !== undefined) { setQuery(next); setCursor(0) }
136
144
  })
137
145
  return createElement(ListFrame, {
138
- title: `/mode · current ${current}`,
146
+ title: t('panel.mode.title', { current }),
139
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}`}` })),
140
- cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
148
+ cursor, loading, error, query, footer: t('panel.footer.chooseSwitch'),
141
149
  })
142
150
  }
143
151
 
144
152
  export function PermissionPanel({ current, load, select, close }: {
145
153
  current: string
146
- load(): Promise<readonly PermissionRow[]>
147
- select(id: string): void
148
- close(): void
154
+ load: () => Promise<readonly PermissionRow[]>
155
+ select: (id: string) => void
156
+ close: () => void
149
157
  }): ReactElement {
150
158
  const [rows, setRows] = useState<readonly PermissionRow[]>([])
151
159
  const [query, setQuery] = useState('')
@@ -162,22 +170,24 @@ export function PermissionPanel({ current, load, select, close }: {
162
170
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
163
171
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
164
172
  useInput((input, key) => {
165
- if (key.escape || input === 'q') return close()
173
+ if (key.escape) return close()
174
+ // q closes only while the query is empty; mid-filter it is query text.
175
+ if (input === 'q' && query === '') return close()
166
176
  if (input === 'r' && query === '') return refresh()
167
177
  if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
168
178
  if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
169
- if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
179
+ if (key.return && visible[cursor] !== undefined) return select(visible[cursor].id)
170
180
  const next = editQuery(query, input, key)
171
181
  if (next !== undefined) { setQuery(next); setCursor(0) }
172
182
  })
173
183
  return createElement(ListFrame, {
174
- title: `/permission · current ${current}`,
184
+ title: t('panel.permission.title', { current }),
175
185
  rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
176
- cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
186
+ cursor, loading, error, query, footer: t('panel.footer.chooseSelect'),
177
187
  })
178
188
  }
179
189
 
180
- 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 {
181
191
  const [epoch, setEpoch] = useState(0)
182
192
  const [query, setQuery] = useState(initialQuery)
183
193
  const [cursor, setCursor] = useState(0)
@@ -185,7 +195,9 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
185
195
  const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
186
196
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
187
197
  useInput((input, key) => {
188
- if (key.escape || input === 'q') return close()
198
+ if (key.escape) return close()
199
+ // q closes only while the query is empty; mid-filter it is query text.
200
+ if (input === 'q' && query === '') return close()
189
201
  if (input === 'r' && query === '') return setEpoch(value => value + 1)
190
202
  if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
191
203
  if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
@@ -194,12 +206,12 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
194
206
  if (next !== undefined) { setQuery(next); setCursor(0) }
195
207
  })
196
208
  return createElement(ListFrame, {
197
- title: '/plugin · loader inspector',
209
+ title: t('panel.plugin.title'),
198
210
  rows: rows.map((row, index) => ({
199
211
  key: row.entryId,
200
212
  disabled: !row.enabled,
201
213
  text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
202
- })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
214
+ })), cursor, loading: false, query, footer: t('panel.footer.inspectDetails'),
203
215
  })
204
216
  }
205
217
 
@@ -245,7 +257,7 @@ const JOB_MARK: Record<JobRow['status'], string> = {
245
257
  * interval dies with the panel). Cancel stays upstream-only; an absent
246
258
  * registry renders as the plain empty state (a harmless missing service).
247
259
  */
248
- export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close(): void }): ReactElement {
260
+ export function JobsPanel({ load, close }: { load: () => readonly JobRow[]; close: () => void }): ReactElement {
249
261
  const [, setRefresh] = useState(0)
250
262
  const [cursor, setCursor] = useState(0)
251
263
  const [, setTick] = useState(0)
@@ -262,29 +274,29 @@ export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close():
262
274
  })
263
275
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
264
276
  return createElement(ListFrame, {
265
- title: `/jobs · background tasks · ${rows.length}`,
277
+ title: t('panel.jobs.title', { count: rows.length }),
266
278
  rows: rows.map(row => ({
267
279
  key: row.id,
268
280
  text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
269
281
  })),
270
- cursor, loading: false, query: '', searching: false, footer: '↑↓ inspect · r refresh · esc close',
282
+ cursor, loading: false, query: '', searching: false, footer: t('panel.footer.inspectRefresh'),
271
283
  })
272
284
  }
273
285
 
274
286
  export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
275
287
  currentCwd: string
276
- load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
277
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
278
- 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
279
291
  /** Arm the composer-based delete confirm for one row (App owns the keys). */
280
- requestDelete?(row: SessionRow): void
292
+ requestDelete?: (row: SessionRow) => void
281
293
  /** The row id awaiting y/n in the composer, when any (App-owned). */
282
294
  deleteConfirmId?: string
283
295
  /** Bump to reload the listing (e.g. after a deletion). */
284
296
  reloadToken?: number
285
297
  /** Opened via /delete: hint-first delete mode. */
286
298
  deleteMode?: boolean
287
- close(): void
299
+ close: () => void
288
300
  }): ReactElement {
289
301
  // Codex resume-picker default: the CURRENT directory's root sessions; the
290
302
  // cwd filter widens to all only on request (the old default leaked every
@@ -351,12 +363,12 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
351
363
  if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
352
364
  if (input === 'g') return setCursor(0)
353
365
  if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
354
- 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])
355
367
  if (input === 'e' && rows[cursor] !== undefined) {
356
- return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
368
+ return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id)
357
369
  }
358
370
  if (input === 't' && rows[cursor] !== undefined) {
359
- const row = rows[cursor]!
371
+ const row = rows[cursor]
360
372
  transcriptLoad.current?.abort()
361
373
  setTranscript({ id: row.id })
362
374
  const controller = new AbortController()
@@ -367,11 +379,11 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
367
379
  )
368
380
  return
369
381
  }
370
- if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
382
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor])
371
383
  }, { isActive: transcript === undefined })
372
384
  if (transcript !== undefined) {
373
385
  return createElement(DocumentPanel, {
374
- title: `transcript · ${transcript.id}`,
386
+ title: t('panel.document.title', { id: transcript.id }),
375
387
  text: transcript.text,
376
388
  error: transcript.error,
377
389
  close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
@@ -388,7 +400,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
388
400
  disabled: !row.resumable,
389
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}`}` : ''}`,
390
402
  })), cursor, loading, error, query: options.query, searching,
391
- footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
403
+ footer: t('panel.footer.resume'),
392
404
  })
393
405
  }
394
406
 
@@ -433,7 +445,7 @@ function DocumentPanel({ title, text, error, close }: {
433
445
  title: string
434
446
  text?: string
435
447
  error?: string
436
- close(): void
448
+ close: () => void
437
449
  }): ReactElement {
438
450
  const stdout = useStdout().stdout
439
451
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -448,18 +460,19 @@ function DocumentPanel({ title, text, error, close }: {
448
460
  if (input === 'g') return setScroll(0)
449
461
  if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
450
462
  })
451
- 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))
452
464
  const body: readonly StyledLine[] = error !== undefined
453
465
  ? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
454
466
  : text === undefined
455
- ? textLines('loading transcript…', viewport.contentColumns, 'dim')
467
+ ? textLines(t('document.loading'), viewport.contentColumns, 'dim')
456
468
  : lines.slice(scroll, scroll + viewport.bodyRows)
469
+ const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright)
457
470
  return createElement(
458
471
  Box,
459
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
460
- 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)),
461
474
  createElement(DocumentRows, { lines: body }),
462
- 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)),
463
476
  )
464
477
  }
465
478
 
@@ -473,8 +486,8 @@ export function HistoryPanel({ entries, fill, close }: {
473
486
  /** Newest-first recall entries (persistent + in-session, deduped). */
474
487
  entries: readonly string[]
475
488
  /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
476
- fill(text: string, index: number): void
477
- close(): void
489
+ fill: (text: string, index: number) => void
490
+ close: () => void
478
491
  }): ReactElement {
479
492
  const [query, setQuery] = useState('')
480
493
  const [cursor, setCursor] = useState(0)
@@ -490,14 +503,18 @@ export function HistoryPanel({ entries, fill, close }: {
490
503
  }
491
504
  if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
492
505
  if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
493
- if (input === 'g') return setCursor(0)
494
- if (input === 'G') return setCursor(matches.length - 1)
495
- 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) {
496
511
  setQuery(current => deleteLastGrapheme(current))
497
512
  setCursor(0)
498
513
  return
499
514
  }
500
- 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) {
501
518
  const text = stripPasteMarkers(input)
502
519
  if (text !== '') {
503
520
  setQuery(current => (current + text).slice(0, 120))
@@ -509,21 +526,22 @@ export function HistoryPanel({ entries, fill, close }: {
509
526
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
510
527
  if (viewport.maxHeight === 0 || viewport.compact) {
511
528
  const picked = matches[cursor]
512
- 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))
513
530
  }
514
531
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
515
532
  const offset = revealRow(0, cursor, matches.length, bodyRows)
516
533
  const visible = matches.slice(offset, offset + bodyRows)
517
534
  const header = query === ''
518
- ? `/history · ${entries.length} prompts · type to filter`
519
- : `/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)
520
538
  return createElement(
521
539
  Box,
522
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
523
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
524
- 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)),
525
543
  ...(visible.length === 0
526
- ? [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))]
527
545
  : visible.map((entry, index) => {
528
546
  const absolute = offset + index
529
547
  const selected = absolute === cursor
@@ -537,7 +555,313 @@ export function HistoryPanel({ entries, fill, close }: {
537
555
  truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
538
556
  )
539
557
  })),
540
- 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)),
541
865
  )
542
866
  }
543
867
 
@@ -549,8 +873,8 @@ export function HistoryPanel({ entries, fill, close }: {
549
873
  */
550
874
  export function StatuslinePanel({ enabled, change, close }: {
551
875
  enabled: readonly StatusItemId[]
552
- change(items: readonly StatusItemId[]): void
553
- close(): void
876
+ change: (items: readonly StatusItemId[]) => void
877
+ close: () => void
554
878
  }): ReactElement {
555
879
  // Working state: the full catalog in display order (enabled entries in
556
880
  // their configured positions, disabled ones trailing canonically) plus
@@ -571,7 +895,7 @@ export function StatuslinePanel({ enabled, change, close }: {
571
895
  if (target < 0 || target >= order.length) return
572
896
  const next = [...order]
573
897
  const [item] = next.splice(cursor, 1)
574
- next.splice(target, 0, item!)
898
+ next.splice(target, 0, item)
575
899
  commit(next, on)
576
900
  setCursor(target)
577
901
  }
@@ -600,16 +924,17 @@ export function StatuslinePanel({ enabled, change, close }: {
600
924
  const stdout = useStdout().stdout
601
925
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
602
926
  if (viewport.maxHeight === 0 || viewport.compact) {
603
- 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))
604
928
  }
605
929
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
606
930
  const offset = revealRow(0, cursor, order.length, bodyRows)
607
931
  const visible = order.slice(offset, offset + bodyRows)
608
932
  const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
933
+ const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright)
609
934
  return createElement(
610
935
  Box,
611
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
612
- 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)),
613
938
  ...visible.map((id, index) => {
614
939
  const absolute = offset + index
615
940
  const selected = absolute === cursor
@@ -625,7 +950,7 @@ export function StatuslinePanel({ enabled, change, close }: {
625
950
  truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
626
951
  )
627
952
  }),
628
- 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)),
629
954
  )
630
955
  }
631
956
 
@@ -648,11 +973,11 @@ export function EffortPanel({ row, current, select, back, onExit }: {
648
973
  /** Effective effort currently in force ('' when none), for the ● mark. */
649
974
  current: string | undefined
650
975
  /** Accept one advertised effort id, or '' for the provider default. */
651
- select(effortId: string): void
976
+ select: (effortId: string) => void
652
977
  /** Return to the model list without applying. */
653
- back(): void
978
+ back: () => void
654
979
  /** Leave the whole /model flow (Ctrl+C). */
655
- onExit(): void
980
+ onExit: () => void
656
981
  }): ReactElement {
657
982
  const advertised = row.reasoning?.efforts ?? []
658
983
  const empty = row.reasoning === undefined || advertised.length === 0
@@ -699,21 +1024,21 @@ export function EffortPanel({ row, current, select, back, onExit }: {
699
1024
  return
700
1025
  }
701
1026
  if (key.return && rows[cursor] !== undefined) {
702
- select(rows[cursor]!.id)
1027
+ select(rows[cursor].id)
703
1028
  }
704
1029
  })
705
1030
  return createElement(ListFrame, {
706
- title: `/model effort for ${row.providerName} · ${row.modelName}`,
1031
+ title: t('panel.effort.title', { provider: row.providerName, model: row.modelName }),
707
1032
  rows: empty
708
- ? [{ 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') }]
709
1034
  : rows.map(effort => ({
710
1035
  key: effort.id,
711
- 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}`}`,
712
1037
  })),
713
1038
  cursor,
714
1039
  loading: false,
715
1040
  query: '',
716
- footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
1041
+ footer: empty ? t('panel.footer.effortEmpty') : t('panel.footer.effort'),
717
1042
  })
718
1043
  }
719
1044
 
@@ -738,10 +1063,10 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
738
1063
  /** Live feed rows (child sessions observed this process). */
739
1064
  live: readonly SubagentRow[]
740
1065
  /** Load this session's persisted child sessions by lineage. */
741
- load(): Promise<readonly SessionRow[]>
1066
+ load: () => Promise<readonly SessionRow[]>
742
1067
  /** Read one child session's full transcript as markdown. */
743
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
744
- close(): void
1068
+ readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
1069
+ close: () => void
745
1070
  }): ReactElement {
746
1071
  const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
747
1072
  const [error, setError] = useState<string>()
@@ -812,7 +1137,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
812
1137
  }, { isActive: transcript === undefined })
813
1138
  if (transcript !== undefined) {
814
1139
  return createElement(DocumentPanel, {
815
- title: `subagent · ${transcript.id.slice(-12)}`,
1140
+ title: t('panel.subagent.transcriptTitle', { id: transcript.id.slice(-12) }),
816
1141
  text: transcript.text,
817
1142
  error: transcript.error,
818
1143
  close: () => {
@@ -822,7 +1147,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
822
1147
  })
823
1148
  }
824
1149
  return createElement(ListFrame, {
825
- title: `/agents · ${live.length} live · ${rows.length} total`,
1150
+ title: t('panel.agents.title', { live: live.length, total: rows.length }),
826
1151
  rows: rows.map(row => ({
827
1152
  key: row.id,
828
1153
  text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
@@ -831,7 +1156,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
831
1156
  loading,
832
1157
  ...error === undefined ? {} : { error },
833
1158
  query: '',
834
- footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
1159
+ footer: t('panel.footer.agents'),
835
1160
  })
836
1161
  }
837
1162
 
@@ -850,12 +1175,12 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
850
1175
  export function SubagentPanel({ current, load, pick, inherit, close }: {
851
1176
  /** Display label of the override in force, '' when following the current model. */
852
1177
  current: string
853
- load(): Promise<ModelDirectory>
1178
+ load: () => Promise<ModelDirectory>
854
1179
  /** Apply one model (with an advertised effort, when picked) as the override. */
855
- pick(row: ModelRow, effortId?: string): void
1180
+ pick: (row: ModelRow, effortId?: string) => void
856
1181
  /** Drop the override: subagents follow the current model again. */
857
- inherit(): void
858
- close(): void
1182
+ inherit: () => void
1183
+ close: () => void
859
1184
  }): ReactElement {
860
1185
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
861
1186
  const [error, setError] = useState<string>()
@@ -899,7 +1224,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
899
1224
  setEffortFor(row)
900
1225
  return
901
1226
  }
902
- 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
903
1228
  pick(row, effortId)
904
1229
  }
905
1230
  })
@@ -913,9 +1238,9 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
913
1238
  })
914
1239
  }
915
1240
  return createElement(ListFrame, {
916
- title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
1241
+ title: `${t('panel.subagent.title')}${current === '' ? '' : t('panel.subagent.override', { value: current })}`,
917
1242
  rows: [
918
- { key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
1243
+ { key: '__inherit__', text: `${current === '' ? '●' : '○'} ${t('panel.subagent.inherit')}` },
919
1244
  ...rows.map(row => ({
920
1245
  key: `${row.provider}/${row.model}`,
921
1246
  text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
@@ -925,6 +1250,149 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
925
1250
  loading,
926
1251
  ...error === undefined ? {} : { error },
927
1252
  query: '',
928
- footer: '↑↓ choose · enter apply · r refresh · esc close',
1253
+ footer: t('panel.footer.subagent'),
1254
+ })
1255
+ }
1256
+
1257
+ /**
1258
+ * The /schedule panel: the read-only catalog of active reminders folded from
1259
+ * durable schedule/change events (the web ui-schedule contract: overdue
1260
+ * first, then ascending target; the model creates and cancels through its
1261
+ * schedule_* tools, the panel only shows state). A local second-hand keeps
1262
+ * the relative labels live while the panel is open.
1263
+ */
1264
+ export interface ScheduleDisplayRow {
1265
+ readonly key: string
1266
+ readonly text: string
1267
+ readonly tone?: 'error'
1268
+ }
1269
+
1270
+ /** Human frequency label: one-shot kinds read as Once, every rows carry the interval. */
1271
+ export function scheduleFrequency(row: ScheduleRow): string {
1272
+ if (row.kind !== 'every') return 'Once'
1273
+ const seconds = row.everySeconds ?? 0
1274
+ if (seconds >= 3600 && seconds % 3600 === 0) return `Every ${seconds / 3600}h`
1275
+ if (seconds >= 60 && seconds % 60 === 0) return `Every ${seconds / 60}m`
1276
+ return `Every ${seconds}s`
1277
+ }
1278
+
1279
+ /** Relative label for the next target: in N unit, or N unit overdue. */
1280
+ export function scheduleRelative(targetAt: number, now: number): string {
1281
+ const delta = Math.max(0, Math.abs(targetAt - now))
1282
+ const minutes = Math.floor(delta / 60_000)
1283
+ const unit = minutes === 0
1284
+ ? '<1m'
1285
+ : minutes >= 60
1286
+ ? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
1287
+ : `${minutes}m`
1288
+ return targetAt <= now ? `${unit} overdue` : `in ${unit}`
1289
+ }
1290
+
1291
+ /** Ordered display rows: overdue first (error tone), then ascending target. */
1292
+ export function scheduleDisplayRows(rows: readonly ScheduleRow[], now: number): readonly ScheduleDisplayRow[] {
1293
+ return [...rows]
1294
+ .sort((left, right) => (Number(left.targetAt > now) - Number(right.targetAt > now)) || (left.targetAt - right.targetAt))
1295
+ .map(row => ({
1296
+ key: row.id,
1297
+ text: `${row.prompt} · ${scheduleFrequency(row)} · ${new Date(row.targetAt).toLocaleString()} (${scheduleRelative(row.targetAt, now)})`,
1298
+ tone: row.targetAt <= now ? 'error' as const : undefined,
1299
+ }))
1300
+ }
1301
+
1302
+ export function SchedulePanel({ rows, close }: { rows: () => readonly ScheduleRow[]; close: () => void }): ReactElement {
1303
+ const [, setTick] = useState(0)
1304
+ useEffect(() => {
1305
+ const id = setInterval(() => setTick(value => value + 1), 1_000)
1306
+ return () => clearInterval(id)
1307
+ }, [])
1308
+ const display = scheduleDisplayRows(rows(), Date.now())
1309
+ const stdout = useStdout().stdout
1310
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1311
+ useInput((input, key) => {
1312
+ if (key.escape || input === 'q') return close()
929
1313
  })
1314
+ if (viewport.maxHeight === 0 || viewport.compact) {
1315
+ const summary = display.length === 0 ? t('panel.schedule.none') : singleLineText(display[0].text)
1316
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns))
1317
+ }
1318
+ const budget = Math.max(1, viewport.bodyRows)
1319
+ const visible = display.slice(0, budget)
1320
+ const hidden = display.length - visible.length
1321
+ const accent = panelAccent('schedule', getPalette().dim, getPalette().brandBright)
1322
+ return createElement(
1323
+ Box,
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)),
1326
+ ...(display.length === 0
1327
+ ? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('schedule.empty')}`, viewport.contentColumns))]
1328
+ : visible.map(row => createElement(Text, {
1329
+ key: row.key,
1330
+ color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
1331
+ wrap: 'truncate-end',
1332
+ }, truncateColumns(` ${singleLineText(row.text)}`, 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)),
1397
+ )
930
1398
  }