dsh-code 1.2.0 → 1.4.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 (102) hide show
  1. package/README.en.md +5 -5
  2. package/README.md +5 -5
  3. package/lib/index.mjs +6679 -5232
  4. package/lib/startup.mjs +1 -1
  5. package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
  6. package/lib/types/app.d.ts +32 -45
  7. package/lib/types/attachments.d.ts +16 -7
  8. package/lib/types/completion.d.ts +29 -0
  9. package/lib/types/composer.d.ts +150 -0
  10. package/lib/types/git-workflow.d.ts +6 -0
  11. package/lib/types/index.d.ts +6 -188
  12. package/lib/types/locales/en.d.ts +65 -5
  13. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  14. package/lib/types/panels/completion-panel.d.ts +13 -0
  15. package/lib/types/panels/interaction-bars.d.ts +38 -0
  16. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
  17. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  18. package/lib/types/panels/model-panels.d.ts +86 -0
  19. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  20. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
  21. package/lib/types/provider-settings.d.ts +11 -0
  22. package/lib/types/render/inspector.d.ts +8 -0
  23. package/lib/types/render/status.d.ts +4 -1
  24. package/lib/types/render/text.d.ts +4 -0
  25. package/lib/types/runner/harness-gate.d.ts +83 -0
  26. package/lib/types/runner/input-history.d.ts +31 -0
  27. package/lib/types/runner/mode-cycle.d.ts +44 -0
  28. package/lib/types/runner/preferences.d.ts +40 -0
  29. package/lib/types/runner/quit.d.ts +27 -0
  30. package/lib/types/runner/search-rows.d.ts +38 -0
  31. package/lib/types/runner/session-io.d.ts +46 -0
  32. package/lib/types/runner/session-target.d.ts +42 -0
  33. package/lib/types/runner/startup-config.d.ts +33 -0
  34. package/lib/types/runner/submissions.d.ts +87 -0
  35. package/lib/types/session/attach.d.ts +39 -0
  36. package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
  37. package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
  38. package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
  39. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  40. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  41. package/lib/types/settings-file.d.ts +10 -0
  42. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  43. package/lib/types/ui/panel-gap.d.ts +6 -0
  44. package/lib/types/ui/query-editor.d.ts +10 -0
  45. package/lib/types/ui/styled-rows.d.ts +8 -0
  46. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  47. package/lib/types/ui/ui-contract.d.ts +12 -0
  48. package/lib/types/ui/use-frames.d.ts +6 -0
  49. package/lib/types/ui/use-stable-input.d.ts +7 -0
  50. package/lib/types/version.d.ts +2 -0
  51. package/package.json +7 -5
  52. package/src/app.ts +707 -4108
  53. package/src/attachments.ts +65 -19
  54. package/src/completion.ts +117 -0
  55. package/src/composer.ts +1956 -0
  56. package/src/git-workflow.ts +18 -0
  57. package/src/index.ts +164 -645
  58. package/src/input-split.ts +24 -4
  59. package/src/internals.ts +1 -1
  60. package/src/locales/en.ts +66 -5
  61. package/src/locales/zh.ts +66 -5
  62. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
  63. package/src/panels/completion-panel.ts +79 -0
  64. package/src/panels/interaction-bars.ts +567 -0
  65. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
  66. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  67. package/src/panels/model-panels.ts +1021 -0
  68. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  69. package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
  70. package/src/provider-settings.ts +38 -0
  71. package/src/rainbow.ts +13 -3
  72. package/src/render/inspector.ts +23 -0
  73. package/src/render/status.ts +80 -29
  74. package/src/render/text.ts +10 -1
  75. package/src/runner/harness-gate.ts +168 -0
  76. package/src/runner/input-history.ts +77 -0
  77. package/src/runner/mode-cycle.ts +49 -0
  78. package/src/runner/preferences.ts +67 -0
  79. package/src/runner/quit.ts +53 -0
  80. package/src/runner/search-rows.ts +55 -0
  81. package/src/runner/session-io.ts +206 -0
  82. package/src/runner/session-target.ts +81 -0
  83. package/src/runner/startup-config.ts +54 -0
  84. package/src/runner/submissions.ts +157 -0
  85. package/src/session/attach.ts +87 -0
  86. package/src/{fork.ts → session/fork.ts} +11 -7
  87. package/src/{history.ts → session/history.ts} +14 -0
  88. package/src/{session-directory.ts → session/session-directory.ts} +83 -4
  89. package/src/{session-switch.ts → session/session-switch.ts} +14 -0
  90. package/src/{store.ts → session/store.ts} +20 -2
  91. package/src/{subagents.ts → session/subagents.ts} +12 -1
  92. package/src/settings-file.ts +19 -1
  93. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  94. package/src/ui/panel-gap.ts +9 -0
  95. package/src/ui/query-editor.ts +16 -0
  96. package/src/ui/styled-rows.ts +124 -0
  97. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  98. package/src/ui/ui-contract.ts +10 -0
  99. package/src/ui/use-frames.ts +23 -0
  100. package/src/ui/use-stable-input.ts +17 -0
  101. package/src/version.ts +5 -0
  102. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
@@ -1,26 +1,30 @@
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
- import type { ModelDirectory, ModelRow } from './models.ts'
6
- import type { SubagentRow } from './subagents.ts'
7
- import type { ScheduleRow } from './render/projection.ts'
8
- import type { PermissionRow } from './permissions.ts'
9
- import type { PresetRow } from './presets.ts'
10
- import type { PluginRow } from './plugin-inventory.ts'
11
- import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
12
- import { formatRelativeTime } from './session-directory.ts'
13
- import type { ReviewBranch, ReviewCommit, ReviewSelection } from './git-workflow.ts'
14
- import { panelViewport, revealRow } from './render/inspector.ts'
15
- import { markdownLines, textLines, type LineStyle, type StyledLine } from './render/lines.ts'
16
- import { usageLines, type UsageView } from './render/usage.ts'
17
- import { deleteLastGrapheme } from './render/editor.ts'
18
- import { stripPasteMarkers } from './keyboard.ts'
19
- import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
20
- import { singleLineText, truncateColumns } from './render/text.ts'
21
- import { panelAccent } from './panel-accent.ts'
22
- import { t } from './i18n.ts'
23
- import { getPalette, inkColor } from './theme.ts'
5
+ import type { ModelDirectory, ModelRow } from '../models.ts'
6
+ import type { SubagentRow } from '../session/subagents.ts'
7
+ import type { SearchRow } from '../runner/search-rows.ts'
8
+ export type { SearchRow } from '../runner/search-rows.ts'
9
+ import type { ScheduleRow } from '../render/projection.ts'
10
+ import type { PermissionRow } from '../permissions.ts'
11
+ import type { PresetRow } from '../presets.ts'
12
+ import type { PluginRow } from '../plugin-inventory.ts'
13
+ import type { SessionDirectoryOptions, SessionRow } from '../session/session-directory.ts'
14
+ import { formatRelativeTime, matchSessionRow } from '../session/session-directory.ts'
15
+ import type { ReviewBranch, ReviewCommit, ReviewSelection } from '../git-workflow.ts'
16
+ import { expandedDocumentViewport, panelViewport, revealRow } from '../render/inspector.ts'
17
+ import { markdownLines, textLines, type LineStyle, type StyledLine } from '../render/lines.ts'
18
+ import { usageLines, type UsageView } from '../render/usage.ts'
19
+ import { deleteLastGrapheme } from '../render/editor.ts'
20
+ import { stripPasteMarkers } from '../keyboard.ts'
21
+ import { DEFAULT_STATUSLINE_ITEMS, localizedStatusItems, type StatusItemId } from '../render/status.ts'
22
+ import { singleLineText, truncateColumns } from '../render/text.ts'
23
+ import { panelAccent } from '../ui/panel-accent.ts'
24
+ import { editQuery } from '../ui/query-editor.ts'
25
+ export { editQuery } from '../ui/query-editor.ts'
26
+ import { t } from '../i18n.ts'
27
+ import { getPalette, inkColor } from '../theme.ts'
24
28
 
25
29
  interface ListFrameProps {
26
30
  readonly title: string
@@ -96,18 +100,6 @@ function ListFrame(props: ListFrameProps): ReactElement {
96
100
  )
97
101
  }
98
102
 
99
- /**
100
- * Apply one keystroke to a panel search query. IME commits arrive as one
101
- * multi-character chunk, so the whole printable run is appended; paste
102
- * markers are stripped and control-laden chunks are ignored.
103
- */
104
- export function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
105
- if (key.backspace || key.delete) return deleteLastGrapheme(query)
106
- const text = stripPasteMarkers(input)
107
- if (text !== '' && !/[\u0000-\u001f\u007f]/u.test(text)) return query + text
108
- return undefined
109
- }
110
-
111
103
  export function ModePanel({ current, load, select, close }: {
112
104
  current: string
113
105
  load: () => Promise<readonly PresetRow[]>
@@ -119,13 +111,13 @@ export function ModePanel({ current, load, select, close }: {
119
111
  const [cursor, setCursor] = useState(0)
120
112
  const [loading, setLoading] = useState(true)
121
113
  const [error, setError] = useState<string>()
122
- const refresh = (): void => {
114
+ const refresh = useCallback((): void => {
123
115
  setLoading(true); setError(undefined)
124
116
  Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
125
117
  setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
126
118
  })
127
- }
128
- useEffect(refresh, [])
119
+ }, [load])
120
+ useEffect(refresh, [refresh])
129
121
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
130
122
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
131
123
  useInput((input, key) => {
@@ -160,13 +152,13 @@ export function PermissionPanel({ current, load, select, close }: {
160
152
  const [cursor, setCursor] = useState(0)
161
153
  const [loading, setLoading] = useState(true)
162
154
  const [error, setError] = useState<string>()
163
- const refresh = (): void => {
155
+ const refresh = useCallback((): void => {
164
156
  setLoading(true); setError(undefined)
165
157
  Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
166
158
  setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
167
159
  })
168
- }
169
- useEffect(refresh, [])
160
+ }, [load])
161
+ useEffect(refresh, [refresh])
170
162
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
171
163
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
172
164
  useInput((input, key) => {
@@ -192,7 +184,8 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load: () => re
192
184
  const [query, setQuery] = useState(initialQuery)
193
185
  const [cursor, setCursor] = useState(0)
194
186
  const [expanded, setExpanded] = useState(false)
195
- const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the panel's explicit registry refresh trigger
188
+ const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, load, query])
196
189
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
197
190
  useInput((input, key) => {
198
191
  if (key.escape) return close()
@@ -283,7 +276,7 @@ export function JobsPanel({ load, close }: { load: () => readonly JobRow[]; clos
283
276
  })
284
277
  }
285
278
 
286
- export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
279
+ export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, presetId, close }: {
287
280
  currentCwd: string
288
281
  load: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
289
282
  readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
@@ -296,12 +289,20 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
296
289
  reloadToken?: number
297
290
  /** Opened via /delete: hint-first delete mode. */
298
291
  deleteMode?: boolean
292
+ /** `/delete <id>` argument to resolve after the listing loads. */
293
+ presetId?: string
299
294
  close: () => void
300
295
  }): ReactElement {
301
296
  // Codex resume-picker default: the CURRENT directory's root sessions; the
302
297
  // cwd filter widens to all only on request (the old default leaked every
303
298
  // directory's sessions into what read as a current-directory view).
304
- const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' })
299
+ const [options, setOptions] = useState<SessionDirectoryOptions>({
300
+ sessions: presetId === undefined || presetId === '' ? 'roots' : 'all',
301
+ cwd: presetId === undefined || presetId === '' ? 'current' : 'all',
302
+ sort: 'newest',
303
+ currentCwd,
304
+ query: '',
305
+ })
305
306
  const [focus, setFocus] = useState(0)
306
307
  const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
307
308
  const [rows, setRows] = useState<readonly SessionRow[]>([])
@@ -313,6 +314,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
313
314
  /** Ctrl+F-gated search: typing filters only while searching (codex). */
314
315
  const [searching, setSearching] = useState(false)
315
316
  /** Reference clock pinned per row render, so relative times never drift mid-list. */
317
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when the listing or its options change
316
318
  const now = useMemo(() => Date.now(), [rows, options])
317
319
  const transcriptLoad = useRef<AbortController>()
318
320
  useEffect(() => () => transcriptLoad.current?.abort(), [])
@@ -325,8 +327,22 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
325
327
  if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
326
328
  })
327
329
  return () => controller.abort()
328
- }, [options, reloadToken])
330
+ }, [load, options, reloadToken])
329
331
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
332
+ const presetArmed = useRef(false)
333
+ useEffect(() => {
334
+ if (presetArmed.current || presetId === undefined || presetId === '' || rows.length === 0 || requestDelete === undefined) return
335
+ try {
336
+ const row = matchSessionRow(rows, presetId)
337
+ presetArmed.current = true
338
+ const index = rows.findIndex(candidate => candidate.id === row.id)
339
+ if (index >= 0) setCursor(index)
340
+ requestDelete(row)
341
+ } catch (reason: unknown) {
342
+ presetArmed.current = true
343
+ setError(reason instanceof Error ? reason.message : String(reason))
344
+ }
345
+ }, [rows, presetId, requestDelete])
330
346
  const cycle = (): void => {
331
347
  if (focus === 3) {
332
348
  setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
@@ -363,7 +379,6 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
363
379
  if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
364
380
  if (input === 'g') return setCursor(0)
365
381
  if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
366
- if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor])
367
382
  if (input === 'e' && rows[cursor] !== undefined) {
368
383
  return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id)
369
384
  }
@@ -379,7 +394,13 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
379
394
  )
380
395
  return
381
396
  }
382
- if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor])
397
+ if (key.return && rows[cursor] !== undefined) {
398
+ if (deleteMode) {
399
+ if (requestDelete !== undefined && !rows[cursor].live) requestDelete(rows[cursor])
400
+ return
401
+ }
402
+ if (rows[cursor].resumable) select(rows[cursor])
403
+ }
383
404
  }, { isActive: transcript === undefined })
384
405
  if (transcript !== undefined) {
385
406
  return createElement(DocumentPanel, {
@@ -392,15 +413,17 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
392
413
  const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
393
414
  const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
394
415
  return createElement(ListFrame, {
395
- title: deleteConfirmId === undefined
396
- ? `/resume${deleteMode ? ' delete mode' : ''}${searching ? ' searching' : ''} · ${toolbar}`
397
- : `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
416
+ title: deleteConfirmId !== undefined
417
+ ? t('panel.resume.deleteTitle', { target: pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id })
418
+ : deleteMode
419
+ ? t('panel.delete.title', { search: searching ? ` — ${t('panel.searching')}` : '', toolbar })
420
+ : t('panel.resume.title', { mode: '', search: searching ? ` — ${t('panel.searching')}` : '', toolbar }),
398
421
  rows: rows.map(row => ({
399
422
  key: row.id,
400
- disabled: !row.resumable,
423
+ disabled: deleteMode ? row.live : !row.resumable,
401
424
  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}`}` : ''}`,
402
425
  })), cursor, loading, error, query: options.query, searching,
403
- footer: t('panel.footer.resume'),
426
+ footer: t(deleteMode ? 'panel.footer.delete' : 'panel.footer.resume'),
404
427
  })
405
428
  }
406
429
 
@@ -441,24 +464,50 @@ function DocumentRows({ lines }: { lines: readonly StyledLine[] }): ReactElement
441
464
  )
442
465
  }
443
466
 
444
- function DocumentPanel({ title, text, error, close }: {
467
+ function DocumentPanel({ title, text, error, close, onRefresh, onExpand }: {
445
468
  title: string
446
469
  text?: string
447
470
  error?: string
448
471
  close: () => void
472
+ /** Re-read the document; offered as `r` (running subagents keep moving). */
473
+ onRefresh?: () => void
474
+ /** Replace this preview with a fuller surface (subagent attachment). */
475
+ onExpand?: () => void
449
476
  }): ReactElement {
450
477
  const stdout = useStdout().stdout
451
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
478
+ // Expanded mode: the document replaces the transcript area (near the whole
479
+ // terminal minus the bottom live region), entered from the preview with
480
+ // Enter/f. Esc leaves for the caller's list directly — the preview is a
481
+ // peek step, not a level to back through.
482
+ const [expanded, setExpanded] = useState(false)
483
+ const viewport = expanded
484
+ ? expandedDocumentViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
485
+ : panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
452
486
  const [scroll, setScroll] = useState(0)
453
487
  const lines = useMemo(() => text === undefined ? [] : markdownLines(text, viewport.contentColumns), [text, viewport.contentColumns])
488
+ const maxScroll = Math.max(0, lines.length - viewport.bodyRows)
454
489
  useInput((input, key) => {
455
490
  if (key.escape || input === 'q' || input === 't') return close()
491
+ // Ctrl+D keeps its step-out meaning: empty-draft exit in the composer,
492
+ // leave-the-document here.
493
+ if (expanded && key.ctrl && input === 'd') return close()
494
+ if (!expanded && (key.return || input === 'f')) {
495
+ if (onExpand !== undefined) {
496
+ close()
497
+ onExpand()
498
+ return
499
+ }
500
+ setExpanded(true)
501
+ setScroll(value => Math.min(value, Math.max(0, lines.length - expandedDocumentViewport(stdout?.columns ?? 80, stdout?.rows ?? 30).bodyRows)))
502
+ return
503
+ }
504
+ if (expanded && input === 'r' && onRefresh !== undefined) return onRefresh()
456
505
  if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
457
- if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
506
+ if (key.downArrow) return setScroll(value => Math.min(maxScroll, value + 1))
458
507
  if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
459
- if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
508
+ if (key.pageDown) return setScroll(value => Math.min(maxScroll, value + Math.max(1, viewport.bodyRows - 1)))
460
509
  if (input === 'g') return setScroll(0)
461
- if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
510
+ if (input === 'G') return setScroll(maxScroll)
462
511
  })
463
512
  if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('document.compact'), viewport.contentColumns))
464
513
  const body: readonly StyledLine[] = error !== undefined
@@ -469,10 +518,12 @@ function DocumentPanel({ title, text, error, close }: {
469
518
  const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright)
470
519
  return createElement(
471
520
  Box,
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)),
521
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: expanded ? inkColor(accent.title) : inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
522
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title) + (expanded ? ` · ${t('document.expanded')}` : ''), viewport.contentColumns)),
474
523
  createElement(DocumentRows, { lines: body }),
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)),
524
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(expanded
525
+ ? t('document.footerExpanded', { from: lines.length === 0 ? 0 : scroll + 1, to: Math.min(lines.length, scroll + viewport.bodyRows), total: lines.length, refresh: onRefresh === undefined ? '' : ` · ${t('document.refreshHint')}` })
526
+ : t('document.footer', { from: lines.length === 0 ? 0 : scroll + 1, to: Math.min(lines.length, scroll + viewport.bodyRows), total: lines.length }), viewport.contentColumns)),
476
527
  )
477
528
  }
478
529
 
@@ -580,6 +631,7 @@ export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }:
580
631
  const [loading, setLoading] = useState(false)
581
632
  const [error, setError] = useState<string>()
582
633
  const loadRef = useRef<AbortController>()
634
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when review rows change
583
635
  const now = useMemo(() => Date.now(), [rows])
584
636
 
585
637
  useEffect(() => {
@@ -715,24 +767,6 @@ export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }:
715
767
  )
716
768
  }
717
769
 
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
770
  /**
737
771
  * The /search panel: full-text search over every persisted session through
738
772
  * the in-process session-query engine (the same corpus the model's
@@ -815,6 +849,7 @@ export function SearchPanel({ load, select, initialQuery = '', close }: {
815
849
  })
816
850
  const stdout = useStdout().stdout
817
851
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
852
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when a search result set lands
818
853
  const now = useMemo(() => Date.now(), [rows, searched])
819
854
  if (viewport.maxHeight === 0 || viewport.compact) {
820
855
  const state = loading ? 'searching…' : error !== undefined ? `error: ${error}` : rows.length === 0 ? 'no results yet' : `❯ ${rows[cursor]?.label ?? ''}`
@@ -929,7 +964,7 @@ export function StatuslinePanel({ enabled, change, close }: {
929
964
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
930
965
  const offset = revealRow(0, cursor, order.length, bodyRows)
931
966
  const visible = order.slice(offset, offset + bodyRows)
932
- const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
967
+ const meta = new Map(localizedStatusItems().map(item => [item.id, item]))
933
968
  const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright)
934
969
  return createElement(
935
970
  Box,
@@ -1059,13 +1094,15 @@ interface AgentsEntry {
1059
1094
  * Enter/t opening the child's full transcript in the shared read-only
1060
1095
  * document view (the same projection the exporter uses).
1061
1096
  */
1062
- export function AgentsPanel({ live, load, readTranscript, close }: {
1097
+ export function AgentsPanel({ live, load, readTranscript, attach, close }: {
1063
1098
  /** Live feed rows (child sessions observed this process). */
1064
1099
  live: readonly SubagentRow[]
1065
1100
  /** Load this session's persisted child sessions by lineage. */
1066
1101
  load: () => Promise<readonly SessionRow[]>
1067
1102
  /** Read one child session's full transcript as markdown. */
1068
1103
  readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
1104
+ /** Attach to one child as the whole view (the runner's live buses). */
1105
+ attach: ((id: string, label: string) => void) | undefined
1069
1106
  close: () => void
1070
1107
  }): ReactElement {
1071
1108
  const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
@@ -1075,7 +1112,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
1075
1112
  const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
1076
1113
  const transcriptLoad = useRef<AbortController>()
1077
1114
  useEffect(() => () => transcriptLoad.current?.abort(), [])
1078
- const refresh = (): void => {
1115
+ const refresh = useCallback((): void => {
1079
1116
  setLoading(true)
1080
1117
  setError(undefined)
1081
1118
  Promise.resolve().then(load).then(value => {
@@ -1085,8 +1122,8 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
1085
1122
  setError(reason instanceof Error ? reason.message : String(reason))
1086
1123
  setLoading(false)
1087
1124
  })
1088
- }
1089
- useEffect(refresh, [])
1125
+ }, [load])
1126
+ useEffect(refresh, [refresh])
1090
1127
  // Live feed rows first (they carry the running state), then persisted
1091
1128
  // children only the directory knows — settled subagents from earlier turns.
1092
1129
  const rows = useMemo<readonly AgentsEntry[]>(() => {
@@ -1112,22 +1149,26 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
1112
1149
  return [...feedRows, ...persisted]
1113
1150
  }, [live, dirRows])
1114
1151
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
1115
- const openTranscript = (): void => {
1116
- const row = rows[cursor]
1117
- if (row === undefined) return
1152
+ /** (Re-)read one child's transcript; the id is captured, not re-derived. */
1153
+ const readTranscriptOf = (id: string): void => {
1118
1154
  transcriptLoad.current?.abort()
1119
- setTranscript({ id: row.id })
1155
+ setTranscript({ id })
1120
1156
  const controller = new AbortController()
1121
1157
  transcriptLoad.current = controller
1122
- Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
1158
+ Promise.resolve().then(() => readTranscript(id, controller.signal)).then(
1123
1159
  text => {
1124
- if (!controller.signal.aborted) setTranscript({ id: row.id, text })
1160
+ if (!controller.signal.aborted) setTranscript({ id, text })
1125
1161
  },
1126
1162
  reason => {
1127
- if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) })
1163
+ if (!controller.signal.aborted) setTranscript({ id, error: reason instanceof Error ? reason.message : String(reason) })
1128
1164
  },
1129
1165
  )
1130
1166
  }
1167
+ const openTranscript = (): void => {
1168
+ const row = rows[cursor]
1169
+ if (row === undefined) return
1170
+ readTranscriptOf(row.id)
1171
+ }
1131
1172
  useInput((input, key) => {
1132
1173
  if (key.escape || input === 'q') return close()
1133
1174
  if (input === 'r') return refresh()
@@ -1144,6 +1185,17 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
1144
1185
  transcriptLoad.current?.abort()
1145
1186
  setTranscript(undefined)
1146
1187
  },
1188
+ // Running subagents keep moving: `r` re-reads the same child.
1189
+ onRefresh: () => readTranscriptOf(transcript.id),
1190
+ // Enter/f in the preview attaches the child as the whole view when the
1191
+ // runner wired the live buses; without them the bordered expansion
1192
+ // keeps serving the same document.
1193
+ ...(attach === undefined ? {} : {
1194
+ onExpand: () => {
1195
+ const label = rows.find(row => row.id === transcript.id)?.label ?? transcript.id.slice(-12)
1196
+ attach(transcript.id, label)
1197
+ },
1198
+ }),
1147
1199
  })
1148
1200
  }
1149
1201
  return createElement(ListFrame, {
@@ -1187,7 +1239,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
1187
1239
  const [loading, setLoading] = useState(true)
1188
1240
  const [cursor, setCursor] = useState(0)
1189
1241
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
1190
- const refresh = (): void => {
1242
+ const refresh = useCallback((): void => {
1191
1243
  setLoading(true)
1192
1244
  setError(undefined)
1193
1245
  Promise.resolve().then(load).then(value => {
@@ -1197,8 +1249,8 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
1197
1249
  setError(reason instanceof Error ? reason.message : String(reason))
1198
1250
  setLoading(false)
1199
1251
  })
1200
- }
1201
- useEffect(refresh, [])
1252
+ }, [load])
1253
+ useEffect(refresh, [refresh])
1202
1254
  const rows = useMemo(() => directory?.rows ?? [], [directory])
1203
1255
  // The list opens on the override's own row (index 0 is the inherit row).
1204
1256
  useEffect(() => {
@@ -6,10 +6,10 @@
6
6
 
7
7
  import { createElement, useState, type ReactElement } from 'react'
8
8
  import { Box, Text, useInput, useStdout } from 'ink'
9
- import { panelViewport } from './render/inspector.ts'
10
- import { truncateColumns } from './render/text.ts'
11
- import { LANGUAGES, t, type LanguageName } from './i18n.ts'
12
- import { getPalette, inkColor } from './theme.ts'
9
+ import { panelViewport } from '../render/inspector.ts'
10
+ import { truncateColumns } from '../render/text.ts'
11
+ import { LANGUAGES, t, type LanguageName } from '../i18n.ts'
12
+ import { getPalette, inkColor } from '../theme.ts'
13
13
 
14
14
  export function LanguagePanel({ current, select, close }: {
15
15
  current: LanguageName