dsh-code 0.9.1 → 1.0.1

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 (67) hide show
  1. package/README.en.md +278 -249
  2. package/README.md +131 -102
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +36 -1
  5. package/lib/index.mjs +3055 -819
  6. package/lib/startup.mjs +21 -11
  7. package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
  8. package/lib/types/app.d.ts +84 -16
  9. package/lib/types/attachments.d.ts +20 -0
  10. package/lib/types/authorization-panel.d.ts +22 -0
  11. package/lib/types/authorization.d.ts +36 -0
  12. package/lib/types/editor.d.ts +6 -0
  13. package/lib/types/fork.d.ts +8 -0
  14. package/lib/types/git-workflow.d.ts +23 -0
  15. package/lib/types/index.d.ts +6 -0
  16. package/lib/types/kernel-panels.d.ts +39 -0
  17. package/lib/types/keyboard.d.ts +41 -0
  18. package/lib/types/mentions.d.ts +30 -38
  19. package/lib/types/models.d.ts +3 -1
  20. package/lib/types/permissions.d.ts +4 -14
  21. package/lib/types/presets.d.ts +5 -20
  22. package/lib/types/provider-settings.d.ts +16 -0
  23. package/lib/types/render/animations.d.ts +10 -39
  24. package/lib/types/render/editor.d.ts +137 -0
  25. package/lib/types/render/export.d.ts +1 -1
  26. package/lib/types/render/lines.d.ts +6 -2
  27. package/lib/types/render/markdown.d.ts +3 -1
  28. package/lib/types/render/projection.d.ts +29 -3
  29. package/lib/types/render/status.d.ts +6 -13
  30. package/lib/types/session-directory.d.ts +1 -3
  31. package/lib/types/startup.d.ts +14 -11
  32. package/lib/types/store.d.ts +11 -9
  33. package/lib/types/subagents.d.ts +3 -3
  34. package/lib/types/theme.d.ts +14 -1
  35. package/lib/types/version.d.ts +15 -2
  36. package/package.json +159 -141
  37. package/src/app.ts +1490 -663
  38. package/src/attachments.ts +128 -0
  39. package/src/authorization-panel.ts +285 -0
  40. package/src/authorization.ts +147 -0
  41. package/src/editor.ts +51 -0
  42. package/src/fork.ts +31 -0
  43. package/src/git-workflow.ts +87 -0
  44. package/src/index.ts +1523 -1374
  45. package/src/internals.ts +14 -1
  46. package/src/kernel-panels.ts +914 -798
  47. package/src/keyboard.ts +126 -0
  48. package/src/mentions.ts +78 -117
  49. package/src/models.ts +20 -14
  50. package/src/permissions.ts +5 -13
  51. package/src/presets.ts +6 -22
  52. package/src/provider-settings.ts +95 -1
  53. package/src/render/animations.ts +420 -450
  54. package/src/render/editor.ts +398 -0
  55. package/src/render/export.ts +79 -79
  56. package/src/render/lines.ts +342 -236
  57. package/src/render/markdown.ts +99 -26
  58. package/src/render/projection.ts +106 -19
  59. package/src/render/status.ts +713 -650
  60. package/src/render/text.ts +150 -150
  61. package/src/render/tool-detail.ts +3 -1
  62. package/src/session-directory.ts +4 -4
  63. package/src/startup.ts +136 -119
  64. package/src/store.ts +23 -11
  65. package/src/subagents.ts +13 -5
  66. package/src/theme.ts +214 -206
  67. package/src/version.ts +58 -1
@@ -1,798 +1,914 @@
1
- /** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
2
-
3
- import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
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 { PermissionRow } from './permissions.ts'
8
- import type { PresetRow } from './presets.ts'
9
- import type { PluginRow } from './plugin-inventory.ts'
10
- import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
11
- import { formatRelativeTime } from './session-directory.ts'
12
- import { panelViewport, revealRow } from './render/inspector.ts'
13
- import { textLines } from './render/lines.ts'
14
- import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
15
- import { singleLineText, truncateColumns } from './render/text.ts'
16
- import { getPalette, inkColor } from './theme.ts'
17
-
18
- interface ListFrameProps {
19
- readonly title: string
20
- readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
21
- readonly cursor: number
22
- readonly loading: boolean
23
- readonly error?: string
24
- readonly query: string
25
- /** Ctrl+F-gated search focus for this panel: typing edits the query only
26
- * while true. `undefined` keeps the plain "type to filter" prompt (the
27
- * panel filters by typing directly). */
28
- readonly searching?: boolean
29
- readonly footer: string
30
- }
31
-
32
- /** True for the Ctrl+F search-focus toggle. */
33
- function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
34
- return key.ctrl === true && input === 'f'
35
- }
36
-
37
- /** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
38
- * toggle lives in the footer), direct-typing panels keep the plain prompt. */
39
- function searchLine(searching: boolean | undefined, query: string): string {
40
- if (searching === true) return `search: ${query === '' ? 'type to filter · esc stops' : query}`
41
- if (searching === false) return query === '' ? '' : `search: ${query}`
42
- return `search: ${query === '' ? 'type to filter' : query}`
43
- }
44
-
45
- function ListFrame(props: ListFrameProps): ReactElement {
46
- const stdout = useStdout().stdout
47
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
48
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
49
- if (viewport.compact) {
50
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns))
51
- }
52
- const stateRows = props.loading
53
- ? [{ key: 'loading', text: ' loading…' }]
54
- : props.error !== undefined
55
- ? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
56
- : props.rows.length === 0
57
- ? [{ key: 'empty', text: ' no matching entries' }]
58
- : props.rows
59
- const bodyRows = Math.max(1, viewport.bodyRows - 1)
60
- const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
61
- const visible = stateRows.slice(offset, offset + bodyRows)
62
- return createElement(
63
- Box,
64
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
65
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
66
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
67
- ...visible.map((row, index) => {
68
- const absolute = offset + index
69
- const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
70
- return createElement(Text, {
71
- key: row.key,
72
- color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : undefined,
73
- dimColor: row.disabled,
74
- wrap: 'truncate-end',
75
- }, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(row.text)}`, viewport.contentColumns))
76
- }),
77
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)),
78
- )
79
- }
80
-
81
- function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
82
- if (key.backspace || key.delete) return query.slice(0, -1)
83
- if (input.length === 1 && input >= ' ' && input !== '\x7f') return query + input
84
- return undefined
85
- }
86
-
87
- export function ModePanel({ current, load, select, close }: {
88
- current: string
89
- load(): Promise<readonly PresetRow[]>
90
- select(id: string): void
91
- close(): void
92
- }): ReactElement {
93
- const [rows, setRows] = useState<readonly PresetRow[]>([])
94
- const [query, setQuery] = useState('')
95
- const [cursor, setCursor] = useState(0)
96
- const [loading, setLoading] = useState(true)
97
- const [error, setError] = useState<string>()
98
- const refresh = (): void => {
99
- setLoading(true); setError(undefined)
100
- Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
101
- setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
102
- })
103
- }
104
- useEffect(refresh, [])
105
- const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
106
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
107
- useInput((input, key) => {
108
- if (key.escape || input === 'q') return close()
109
- if (input === 'r' && query === '') return refresh()
110
- if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
111
- if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
112
- if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
113
- const next = editQuery(query, input, key)
114
- if (next !== undefined) { setQuery(next); setCursor(0) }
115
- })
116
- return createElement(ListFrame, {
117
- title: `/mode · current ${current}`,
118
- 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}`}` })),
119
- cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
120
- })
121
- }
122
-
123
- export function PermissionPanel({ current, load, select, close }: {
124
- current: string
125
- load(): Promise<readonly PermissionRow[]>
126
- select(id: string): void
127
- close(): void
128
- }): ReactElement {
129
- const [rows, setRows] = useState<readonly PermissionRow[]>([])
130
- const [query, setQuery] = useState('')
131
- const [cursor, setCursor] = useState(0)
132
- const [loading, setLoading] = useState(true)
133
- const [error, setError] = useState<string>()
134
- const refresh = (): void => {
135
- setLoading(true); setError(undefined)
136
- Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
137
- setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
138
- })
139
- }
140
- useEffect(refresh, [])
141
- const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
142
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
143
- useInput((input, key) => {
144
- if (key.escape || input === 'q') return close()
145
- if (input === 'r' && query === '') return refresh()
146
- if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
147
- if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
148
- if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
149
- const next = editQuery(query, input, key)
150
- if (next !== undefined) { setQuery(next); setCursor(0) }
151
- })
152
- return createElement(ListFrame, {
153
- title: `/permission · current ${current}`,
154
- rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
155
- cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
156
- })
157
- }
158
-
159
- export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
160
- const [epoch, setEpoch] = useState(0)
161
- const [query, setQuery] = useState(initialQuery)
162
- const [cursor, setCursor] = useState(0)
163
- const [expanded, setExpanded] = useState(false)
164
- const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
165
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
166
- useInput((input, key) => {
167
- if (key.escape || input === 'q') return close()
168
- if (input === 'r' && query === '') return setEpoch(value => value + 1)
169
- if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
170
- if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
171
- if (key.return) return setExpanded(value => !value)
172
- const next = editQuery(query, input, key)
173
- if (next !== undefined) { setQuery(next); setCursor(0) }
174
- })
175
- return createElement(ListFrame, {
176
- title: '/plugin · loader inspector',
177
- rows: rows.map((row, index) => ({
178
- key: row.entryId,
179
- disabled: !row.enabled,
180
- text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
181
- })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
182
- })
183
- }
184
-
185
- export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
186
- currentCwd: string
187
- load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
188
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
189
- select(row: SessionRow): void
190
- /** Arm the composer-based delete confirm for one row (App owns the keys). */
191
- requestDelete?(row: SessionRow): void
192
- /** The row id awaiting y/n in the composer, when any (App-owned). */
193
- deleteConfirmId?: string
194
- /** Bump to reload the listing (e.g. after a deletion). */
195
- reloadToken?: number
196
- /** Opened via /delete: hint-first delete mode. */
197
- deleteMode?: boolean
198
- close(): void
199
- }): ReactElement {
200
- // Codex resume-picker default: the CURRENT directory's root sessions; the
201
- // cwd filter widens to all only on request (the old default leaked every
202
- // directory's sessions into what read as a current-directory view).
203
- const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' })
204
- const [focus, setFocus] = useState(0)
205
- const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
206
- const [rows, setRows] = useState<readonly SessionRow[]>([])
207
- const [cursor, setCursor] = useState(0)
208
- const [loading, setLoading] = useState(true)
209
- const [error, setError] = useState<string>()
210
- const [expanded, setExpanded] = useState<string>()
211
- const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
212
- /** Ctrl+F-gated search: typing filters only while searching (codex). */
213
- const [searching, setSearching] = useState(false)
214
- /** Reference clock pinned per row render, so relative times never drift mid-list. */
215
- const now = useMemo(() => Date.now(), [rows, options])
216
- const transcriptLoad = useRef<AbortController>()
217
- useEffect(() => () => transcriptLoad.current?.abort(), [])
218
- useEffect(() => {
219
- const controller = new AbortController()
220
- setLoading(true); setError(undefined)
221
- Promise.resolve().then(() => load(options, controller.signal)).then(value => {
222
- if (!controller.signal.aborted) { setRows(value); setLoading(false) }
223
- }, reason => {
224
- if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
225
- })
226
- return () => controller.abort()
227
- }, [options, reloadToken])
228
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
229
- const cycle = (): void => {
230
- if (focus === 3) {
231
- setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
232
- return
233
- }
234
- setOptions(value => {
235
- if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
236
- if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
237
- return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
238
- })
239
- }
240
- useInput((input, key) => {
241
- // While a deletion awaits y/n, the COMPOSER owns every key (App routes
242
- // them); the panel yields so y/n cannot be handled twice.
243
- if (deleteConfirmId !== undefined) return
244
- if (key.escape) {
245
- if (searching) { setSearching(false); return }
246
- return close()
247
- }
248
- if (isSearchToggle(input, key)) { setSearching(current => !current); return }
249
- if (searching) {
250
- if (key.return) { setSearching(false); return }
251
- const next = editQuery(options.query, input, key)
252
- if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
253
- return
254
- }
255
- if (input === 'q') return close()
256
- if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
257
- if (key.leftArrow) return cycle()
258
- if (key.rightArrow) return cycle()
259
- if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
260
- if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
261
- if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
262
- if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
263
- if (input === 'g') return setCursor(0)
264
- if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
265
- if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
266
- if (input === 'e' && rows[cursor] !== undefined) {
267
- return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
268
- }
269
- if (input === 't' && rows[cursor] !== undefined) {
270
- const row = rows[cursor]!
271
- transcriptLoad.current?.abort()
272
- setTranscript({ id: row.id })
273
- const controller = new AbortController()
274
- transcriptLoad.current = controller
275
- Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
276
- text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
277
- reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
278
- )
279
- return
280
- }
281
- if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
282
- }, { isActive: transcript === undefined })
283
- if (transcript !== undefined) {
284
- return createElement(DocumentPanel, {
285
- title: `transcript · ${transcript.id}`,
286
- text: transcript.text,
287
- error: transcript.error,
288
- close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
289
- })
290
- }
291
- const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
292
- const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
293
- return createElement(ListFrame, {
294
- title: deleteConfirmId === undefined
295
- ? `/resume${deleteMode ? ' — delete mode' : ''}${searching ? ' — searching' : ''} · ${toolbar}`
296
- : `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
297
- rows: rows.map(row => ({
298
- key: row.id,
299
- disabled: !row.resumable,
300
- 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}`}` : ''}`,
301
- })), cursor, loading, error, query: options.query, searching,
302
- footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
303
- })
304
- }
305
-
306
- function DocumentPanel({ title, text, error, close }: {
307
- title: string
308
- text?: string
309
- error?: string
310
- close(): void
311
- }): ReactElement {
312
- const stdout = useStdout().stdout
313
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
314
- const [scroll, setScroll] = useState(0)
315
- const lines = useMemo(() => text === undefined ? [] : textLines(text, viewport.contentColumns).map(line => line.segments.map(segment => segment.text).join('')), [text, viewport.contentColumns])
316
- useInput((input, key) => {
317
- if (key.escape || input === 'q' || input === 't') return close()
318
- if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
319
- if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
320
- if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
321
- if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
322
- if (input === 'g') return setScroll(0)
323
- if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
324
- })
325
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
326
- const body = error !== undefined
327
- ? [`error: ${singleLineText(error)}`]
328
- : text === undefined
329
- ? ['loading transcript…']
330
- : lines.slice(scroll, scroll + viewport.bodyRows)
331
- return createElement(
332
- Box,
333
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
334
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
335
- ...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
336
- 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)),
337
- )
338
- }
339
-
340
- /**
341
- * The /history recall panel (Codex composer-history search, bounded): one
342
- * query line over the newest-first recall space, filtered by substring, with
343
- * arrow selection and enter to fill the composer. Editing the query restarts
344
- * from the newest match; Esc closes without touching the draft.
345
- */
346
- export function HistoryPanel({ entries, fill, close }: {
347
- /** Newest-first recall entries (persistent + in-session, deduped). */
348
- entries: readonly string[]
349
- /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
350
- fill(text: string, index: number): void
351
- close(): void
352
- }): ReactElement {
353
- const [query, setQuery] = useState('')
354
- const [cursor, setCursor] = useState(0)
355
- const matches = query === ''
356
- ? entries
357
- : entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
358
- useInput((input, key) => {
359
- if (key.escape) return close()
360
- if (key.return) {
361
- const entry = matches[cursor]
362
- if (entry !== undefined) fill(entry, entries.indexOf(entry))
363
- return
364
- }
365
- if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
366
- if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
367
- if (input === 'g') return setCursor(0)
368
- if (input === 'G') return setCursor(matches.length - 1)
369
- if (key.backspace) {
370
- setQuery(current => current.slice(0, -1))
371
- setCursor(0)
372
- return
373
- }
374
- if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
375
- setQuery(current => (current + input).slice(0, 120))
376
- setCursor(0)
377
- }
378
- })
379
- const stdout = useStdout().stdout
380
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
381
- if (viewport.compact) {
382
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
383
- }
384
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
385
- const bodyRows = Math.max(1, viewport.bodyRows - 1)
386
- const offset = revealRow(0, cursor, matches.length, bodyRows)
387
- const visible = matches.slice(offset, offset + bodyRows)
388
- const header = query === ''
389
- ? `/history · ${entries.length} prompts · type to filter`
390
- : `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
391
- return createElement(
392
- Box,
393
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
394
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
395
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
396
- ...(visible.length === 0
397
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
398
- : visible.map((entry, index) => {
399
- const absolute = offset + index
400
- const selected = absolute === cursor
401
- return createElement(
402
- Text,
403
- {
404
- key: `history-${absolute}`,
405
- color: selected ? inkColor(getPalette().brandBright) : undefined,
406
- wrap: 'truncate-end',
407
- },
408
- truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
409
- )
410
- })),
411
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
412
- )
413
- }
414
-
415
- /**
416
- * The /statusline picker (the Codex setup-view contract): one bounded list
417
- * of every status item with its enabled mark, arrow reordering, and a
418
- * live preview — the real status line under the composer updates as you
419
- * edit, so the panel itself carries no duplicate preview row.
420
- */
421
- export function StatuslinePanel({ enabled, change, close }: {
422
- enabled: readonly StatusItemId[]
423
- change(items: readonly StatusItemId[]): void
424
- close(): void
425
- }): ReactElement {
426
- // Working state: the full catalog in display order (enabled entries in
427
- // their configured positions, disabled ones trailing canonically) plus
428
- // the enabled set. Persisted shape is the enabled subsequence only.
429
- const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
430
- const seen = new Set(enabled)
431
- return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
432
- })
433
- const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
434
- const [cursor, setCursor] = useState(0)
435
- const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
436
- setOrder(nextOrder)
437
- setOn(nextOn)
438
- change(nextOrder.filter(id => nextOn.has(id)))
439
- }
440
- const move = (offset: number): void => {
441
- const target = cursor + offset
442
- if (target < 0 || target >= order.length) return
443
- const next = [...order]
444
- const [item] = next.splice(cursor, 1)
445
- next.splice(target, 0, item!)
446
- commit(next, on)
447
- setCursor(target)
448
- }
449
- useInput((input, key) => {
450
- if (key.escape || input === 'q' || key.return) return close()
451
- if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
452
- if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
453
- if (key.leftArrow) return move(-1)
454
- if (key.rightArrow) return move(1)
455
- if (input === 'g') return setCursor(0)
456
- if (input === 'G') return setCursor(order.length - 1)
457
- if (input === 'd') {
458
- commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
459
- setCursor(0)
460
- return
461
- }
462
- if (input === ' ') {
463
- const item = order[cursor]
464
- if (item === undefined) return
465
- const next = new Set(on)
466
- if (next.has(item)) next.delete(item)
467
- else next.add(item)
468
- commit(order, next)
469
- }
470
- })
471
- const stdout = useStdout().stdout
472
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
473
- if (viewport.compact) {
474
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
475
- }
476
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
477
- const bodyRows = Math.max(1, viewport.bodyRows - 1)
478
- const offset = revealRow(0, cursor, order.length, bodyRows)
479
- const visible = order.slice(offset, offset + bodyRows)
480
- const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
481
- return createElement(
482
- Box,
483
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
484
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
485
- ...visible.map((id, index) => {
486
- const absolute = offset + index
487
- const selected = absolute === cursor
488
- const info = meta.get(id)
489
- return createElement(
490
- Text,
491
- {
492
- key: id,
493
- color: selected ? inkColor(getPalette().brandBright) : undefined,
494
- dimColor: !on.has(id) || undefined,
495
- wrap: 'truncate-end',
496
- },
497
- truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
498
- )
499
- }),
500
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
501
- )
502
- }
503
-
504
- /**
505
- * The `/model` reasoning-effort stage (the Codex model reasoning popup
506
- * contract): one bounded list over the selected model's adapter-advertised
507
- * effort levels — in the adapter's own display order, ids verbatim (the
508
- * kernel treats them as opaque and rejects anything else) — with the
509
- * effective effort and the model default marked. A model WITHOUT an
510
- * adapter-declared default leads with a "Default" (provider-default) row —
511
- * the web effort pane's first entry so the user can clear a picked level
512
- * back to provider behavior. A model advertising no levels opens the same
513
- * stage with an explicit empty state (the web pane's "no levels" copy)
514
- * instead of a bare failure notice. Enter applies one level; Esc returns to
515
- * the model list without applying.
516
- */
517
- export function EffortPanel({ row, current, select, back }: {
518
- /** The model row whose advertised levels this stage lists. */
519
- row: ModelRow
520
- /** Effective effort currently in force ('' when none), for the ● mark. */
521
- current: string | undefined
522
- /** Accept one advertised effort id, or '' for the provider default. */
523
- select(effortId: string): void
524
- /** Return to the model list without applying. */
525
- back(): void
526
- }): ReactElement {
527
- const advertised = row.reasoning?.efforts ?? []
528
- const empty = row.reasoning === undefined || advertised.length === 0
529
- // The provider-default row only exists when the adapter declares no default
530
- // effort: with one, the default is an advertised level already in the list.
531
- const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined
532
- const rows = empty
533
- ? [{ id: '', name: '' }]
534
- : hasDefaultRow
535
- ? [{ id: '', name: 'Default' }, ...advertised]
536
- : advertised
537
- // An absent or cleared effort is the Default row's current state.
538
- const effective = current === undefined || current === '' ? '' : current
539
- // The list opens ON the effective level (or the model's default row), so a
540
- // quick re-pick never restarts the cursor from the top.
541
- const wanted = effective === '' ? row.reasoning?.defaultEffort ?? '' : effective
542
- const initialCursor = Math.max(0, rows.findIndex(effort => effort.id === wanted))
543
- const [cursor, setCursor] = useState(initialCursor)
544
- useEffect(() => {
545
- if (rows.length === 0) {
546
- if (cursor !== 0) setCursor(0)
547
- return
548
- }
549
- if (cursor >= rows.length) setCursor(rows.length - 1)
550
- }, [rows.length, cursor])
551
- useInput((input, key) => {
552
- if (key.escape || input === 'q') return back()
553
- if (empty) return
554
- if (input === 'g') {
555
- setCursor(0)
556
- return
557
- }
558
- if (input === 'G') {
559
- setCursor(rows.length - 1)
560
- return
561
- }
562
- if (key.upArrow) {
563
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
564
- return
565
- }
566
- if (key.downArrow) {
567
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
568
- return
569
- }
570
- if (key.return && rows[cursor] !== undefined) {
571
- select(rows[cursor]!.id)
572
- }
573
- })
574
- return createElement(ListFrame, {
575
- title: `/model — effort for ${row.providerName} · ${row.modelName}`,
576
- rows: empty
577
- ? [{ key: 'empty', disabled: true, text: 'this model advertises no reasoning effort levels — the provider default applies' }]
578
- : rows.map(effort => ({
579
- key: effort.id,
580
- text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
581
- })),
582
- cursor,
583
- loading: false,
584
- query: '',
585
- footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
586
- })
587
- }
588
-
589
- /** One merged /agents row: live feed state or a persisted child session. */
590
- interface AgentsEntry {
591
- readonly id: string
592
- readonly label: string
593
- readonly activity: string
594
- readonly running: boolean
595
- readonly done: boolean
596
- readonly live: boolean
597
- }
598
-
599
- /**
600
- * The /agents panel (the Codex agent-picker contract, read-only): this
601
- * conversation's subagent conversations — live rows from the activity feed
602
- * first, persisted children the feed has not seen this process after — with
603
- * Enter/t opening the child's full transcript in the shared read-only
604
- * document view (the same projection the exporter uses).
605
- */
606
- export function AgentsPanel({ live, load, readTranscript, close }: {
607
- /** Live feed rows (child sessions observed this process). */
608
- live: readonly SubagentRow[]
609
- /** Load this session's persisted child sessions by lineage. */
610
- load(): Promise<readonly SessionRow[]>
611
- /** Read one child session's full transcript as markdown. */
612
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
613
- close(): void
614
- }): ReactElement {
615
- const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
616
- const [error, setError] = useState<string>()
617
- const [loading, setLoading] = useState(true)
618
- const [cursor, setCursor] = useState(0)
619
- const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
620
- const transcriptLoad = useRef<AbortController>()
621
- useEffect(() => () => transcriptLoad.current?.abort(), [])
622
- const refresh = (): void => {
623
- setLoading(true)
624
- setError(undefined)
625
- Promise.resolve().then(load).then(value => {
626
- setDirRows(value)
627
- setLoading(false)
628
- }, reason => {
629
- setError(reason instanceof Error ? reason.message : String(reason))
630
- setLoading(false)
631
- })
632
- }
633
- useEffect(refresh, [])
634
- // Live feed rows first (they carry the running state), then persisted
635
- // children only the directory knows — settled subagents from earlier turns.
636
- const rows = useMemo<readonly AgentsEntry[]>(() => {
637
- const seen = new Set(live.map(row => row.id))
638
- const feedRows: AgentsEntry[] = live.map(row => ({
639
- id: row.id,
640
- label: row.label,
641
- activity: row.activity,
642
- running: row.state === 'running',
643
- done: row.state === 'done',
644
- live: true,
645
- }))
646
- const persisted: AgentsEntry[] = (dirRows ?? [])
647
- .filter(row => !seen.has(row.id))
648
- .map(row => ({
649
- id: row.id,
650
- label: row.title ?? row.id.slice(-12),
651
- activity: row.workspace,
652
- running: false,
653
- done: !row.live,
654
- live: row.live,
655
- }))
656
- return [...feedRows, ...persisted]
657
- }, [live, dirRows])
658
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
659
- const openTranscript = (): void => {
660
- const row = rows[cursor]
661
- if (row === undefined) return
662
- transcriptLoad.current?.abort()
663
- setTranscript({ id: row.id })
664
- const controller = new AbortController()
665
- transcriptLoad.current = controller
666
- Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
667
- text => {
668
- if (!controller.signal.aborted) setTranscript({ id: row.id, text })
669
- },
670
- reason => {
671
- if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) })
672
- },
673
- )
674
- }
675
- useInput((input, key) => {
676
- if (key.escape || input === 'q') return close()
677
- if (input === 'r') return refresh()
678
- if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
679
- if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
680
- if ((key.return || input === 't') && rows[cursor] !== undefined) return openTranscript()
681
- }, { isActive: transcript === undefined })
682
- if (transcript !== undefined) {
683
- return createElement(DocumentPanel, {
684
- title: `subagent · ${transcript.id.slice(-12)}`,
685
- text: transcript.text,
686
- error: transcript.error,
687
- close: () => {
688
- transcriptLoad.current?.abort()
689
- setTranscript(undefined)
690
- },
691
- })
692
- }
693
- return createElement(ListFrame, {
694
- title: `/agents · ${live.length} live · ${rows.length} total`,
695
- rows: rows.map(row => ({
696
- key: row.id,
697
- text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
698
- })),
699
- cursor,
700
- loading,
701
- ...error === undefined ? {} : { error },
702
- query: '',
703
- footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
704
- })
705
- }
706
-
707
- /**
708
- * The /subagent model panel: which model configuration delegated subagents
709
- * run on. The kernel seeds child agents from the parent's CREATE-TIME
710
- * AgentOptions, so a mid-session /model switch would otherwise leave them on
711
- * the launch-time route; the TUI mirrors the selection onto subagent-origin
712
- * requests (or an explicit override picked here) via an agent/request
713
- * listener. The leading "inherit" row restores follow-the-current-model
714
- * behavior; picking a model with several advertised efforts opens the same
715
- * effort stage /model uses. Effort overrides are not offered separately —
716
- * the kernel's AgentOptions has no effort channel for children, so the level
717
- * rides the selected model exactly as /model applies it.
718
- */
719
- export function SubagentPanel({ current, load, pick, inherit, close }: {
720
- /** Display label of the override in force, '' when following the current model. */
721
- current: string
722
- load(): Promise<ModelDirectory>
723
- /** Apply one model (with an advertised effort, when picked) as the override. */
724
- pick(row: ModelRow, effortId?: string): void
725
- /** Drop the override: subagents follow the current model again. */
726
- inherit(): void
727
- close(): void
728
- }): ReactElement {
729
- const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
730
- const [error, setError] = useState<string>()
731
- const [loading, setLoading] = useState(true)
732
- const [cursor, setCursor] = useState(0)
733
- const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
734
- const refresh = (): void => {
735
- setLoading(true)
736
- setError(undefined)
737
- Promise.resolve().then(load).then(value => {
738
- setDirectory(value)
739
- setLoading(false)
740
- }, reason => {
741
- setError(reason instanceof Error ? reason.message : String(reason))
742
- setLoading(false)
743
- })
744
- }
745
- useEffect(refresh, [])
746
- const rows = useMemo(() => directory?.rows ?? [], [directory])
747
- // The list opens on the override's own row (index 0 is the inherit row).
748
- useEffect(() => {
749
- if (current === '' || rows.length === 0) return
750
- const index = rows.findIndex(row => current.startsWith(`${row.provider}/${row.model}`))
751
- if (index >= 0) setCursor(index + 1)
752
- }, [rows, current])
753
- useEffect(() => setCursor(value => Math.min(value, rows.length)), [rows.length])
754
- // Hooks stay unconditional: the effort stage below swaps the rendered
755
- // subtree but must never skip the input hook (an early return here would
756
- // change the hook count when the stage opens and closes).
757
- useInput((input, key) => {
758
- if (effortFor !== undefined) return
759
- if (key.escape || input === 'q') return close()
760
- if (input === 'r' && !loading) return refresh()
761
- if (key.upArrow) return setCursor(value => (value + rows.length) % (rows.length + 1))
762
- if (key.downArrow) return setCursor(value => (value + 1) % (rows.length + 1))
763
- if (key.return) {
764
- if (cursor === 0) return inherit()
765
- const row = rows[cursor - 1]
766
- if (row === undefined) return
767
- if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
768
- setEffortFor(row)
769
- return
770
- }
771
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
772
- pick(row, effortId)
773
- }
774
- })
775
- if (effortFor !== undefined) {
776
- return createElement(EffortPanel, {
777
- row: effortFor,
778
- current: current === '' ? undefined : current.split('@')[1],
779
- select: effortId => pick(effortFor, effortId),
780
- back: () => setEffortFor(undefined),
781
- })
782
- }
783
- return createElement(ListFrame, {
784
- title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
785
- rows: [
786
- { key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
787
- ...rows.map(row => ({
788
- key: `${row.provider}/${row.model}`,
789
- text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
790
- })),
791
- ],
792
- cursor,
793
- loading,
794
- ...error === undefined ? {} : { error },
795
- query: '',
796
- footer: '↑↓ choose · enter apply · r refresh · esc close',
797
- })
798
- }
1
+ /** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
2
+
3
+ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
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 { PermissionRow } from './permissions.ts'
8
+ import type { PresetRow } from './presets.ts'
9
+ import type { PluginRow } from './plugin-inventory.ts'
10
+ import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
11
+ import { formatRelativeTime } from './session-directory.ts'
12
+ import { panelViewport, revealRow } from './render/inspector.ts'
13
+ import { markdownLines, textLines, type LineStyle, type StyledLine } from './render/lines.ts'
14
+ import { deleteLastGrapheme } from './render/editor.ts'
15
+ import { stripPasteMarkers } from './keyboard.ts'
16
+ import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
17
+ import { singleLineText, truncateColumns } from './render/text.ts'
18
+ import { getPalette, inkColor } from './theme.ts'
19
+
20
+ interface ListFrameProps {
21
+ readonly title: string
22
+ readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
23
+ readonly cursor: number
24
+ readonly loading: boolean
25
+ readonly error?: string
26
+ readonly query: string
27
+ /** Ctrl+F-gated search focus for this panel: typing edits the query only
28
+ * while true. `undefined` keeps the plain "type to filter" prompt (the
29
+ * panel filters by typing directly). */
30
+ readonly searching?: boolean
31
+ readonly footer: string
32
+ }
33
+
34
+ /** True for the Ctrl+F search-focus toggle. */
35
+ function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
36
+ return key.ctrl === true && input === 'f'
37
+ }
38
+
39
+ /** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
40
+ * toggle lives in the footer), direct-typing panels keep the plain prompt. */
41
+ 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}`
45
+ }
46
+
47
+ function ListFrame(props: ListFrameProps): ReactElement {
48
+ const stdout = useStdout().stdout
49
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
50
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
51
+ if (viewport.compact) {
52
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns))
53
+ }
54
+ const stateRows = props.loading
55
+ ? [{ key: 'loading', text: ' loading…' }]
56
+ : props.error !== undefined
57
+ ? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
58
+ : props.rows.length === 0
59
+ ? [{ key: 'empty', text: ' no matching entries' }]
60
+ : props.rows
61
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
62
+ const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
63
+ const visible = stateRows.slice(offset, offset + bodyRows)
64
+ return createElement(
65
+ Box,
66
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
67
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
68
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
69
+ ...visible.map((row, index) => {
70
+ const absolute = offset + index
71
+ const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
72
+ return createElement(Text, {
73
+ key: row.key,
74
+ color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : undefined,
75
+ dimColor: row.disabled,
76
+ wrap: 'truncate-end',
77
+ }, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(row.text)}`, viewport.contentColumns))
78
+ }),
79
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)),
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Apply one keystroke to a panel search query. IME commits arrive as one
85
+ * multi-character chunk, so the whole printable run is appended; paste
86
+ * markers are stripped and control-laden chunks are ignored.
87
+ */
88
+ export function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
89
+ if (key.backspace || key.delete) return deleteLastGrapheme(query)
90
+ const text = stripPasteMarkers(input)
91
+ if (text !== '' && !/[\u0000-\u001f\u007f]/u.test(text)) return query + text
92
+ return undefined
93
+ }
94
+
95
+ export function ModePanel({ current, load, select, close }: {
96
+ current: string
97
+ load(): Promise<readonly PresetRow[]>
98
+ select(id: string): void
99
+ close(): void
100
+ }): ReactElement {
101
+ const [rows, setRows] = useState<readonly PresetRow[]>([])
102
+ const [query, setQuery] = useState('')
103
+ const [cursor, setCursor] = useState(0)
104
+ const [loading, setLoading] = useState(true)
105
+ const [error, setError] = useState<string>()
106
+ const refresh = (): void => {
107
+ setLoading(true); setError(undefined)
108
+ Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
109
+ setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
110
+ })
111
+ }
112
+ useEffect(refresh, [])
113
+ const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
114
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
115
+ useInput((input, key) => {
116
+ if (key.escape || input === 'q') return close()
117
+ if (input === 'r' && query === '') return refresh()
118
+ if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
119
+ if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
120
+ if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
121
+ const next = editQuery(query, input, key)
122
+ if (next !== undefined) { setQuery(next); setCursor(0) }
123
+ })
124
+ return createElement(ListFrame, {
125
+ title: `/mode · current ${current}`,
126
+ 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}`}` })),
127
+ cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
128
+ })
129
+ }
130
+
131
+ export function PermissionPanel({ current, load, select, close }: {
132
+ current: string
133
+ load(): Promise<readonly PermissionRow[]>
134
+ select(id: string): void
135
+ close(): void
136
+ }): ReactElement {
137
+ const [rows, setRows] = useState<readonly PermissionRow[]>([])
138
+ const [query, setQuery] = useState('')
139
+ const [cursor, setCursor] = useState(0)
140
+ const [loading, setLoading] = useState(true)
141
+ const [error, setError] = useState<string>()
142
+ const refresh = (): void => {
143
+ setLoading(true); setError(undefined)
144
+ Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
145
+ setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
146
+ })
147
+ }
148
+ useEffect(refresh, [])
149
+ const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
150
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
151
+ useInput((input, key) => {
152
+ if (key.escape || input === 'q') return close()
153
+ if (input === 'r' && query === '') return refresh()
154
+ if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
155
+ if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
156
+ if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
157
+ const next = editQuery(query, input, key)
158
+ if (next !== undefined) { setQuery(next); setCursor(0) }
159
+ })
160
+ return createElement(ListFrame, {
161
+ title: `/permission · current ${current}`,
162
+ rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
163
+ cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
164
+ })
165
+ }
166
+
167
+ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
168
+ const [epoch, setEpoch] = useState(0)
169
+ const [query, setQuery] = useState(initialQuery)
170
+ const [cursor, setCursor] = useState(0)
171
+ const [expanded, setExpanded] = useState(false)
172
+ const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
173
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
174
+ useInput((input, key) => {
175
+ if (key.escape || input === 'q') return close()
176
+ if (input === 'r' && query === '') return setEpoch(value => value + 1)
177
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
178
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
179
+ if (key.return) return setExpanded(value => !value)
180
+ const next = editQuery(query, input, key)
181
+ if (next !== undefined) { setQuery(next); setCursor(0) }
182
+ })
183
+ return createElement(ListFrame, {
184
+ title: '/plugin · loader inspector',
185
+ rows: rows.map((row, index) => ({
186
+ key: row.entryId,
187
+ disabled: !row.enabled,
188
+ text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
189
+ })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
190
+ })
191
+ }
192
+
193
+ /** One background job snapshot for the /jobs panel (the registry's read-only view). */
194
+ export interface JobRow {
195
+ /** The registry-issued id (`<kind>-N`). */
196
+ readonly id: string
197
+ /** Producer kind (bash, subagent, …). */
198
+ readonly kind: string
199
+ /** One-line model-facing label (the command; the delegation description). */
200
+ readonly label: string
201
+ /** Lifecycle state. */
202
+ readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
203
+ /** Kind-specific status detail once the producer supplied one. */
204
+ readonly detail?: string
205
+ /** Epoch ms when the job was registered. */
206
+ readonly startedAt: number
207
+ /** Epoch ms when the job settled; absent while running/stopping. */
208
+ readonly finishedAt?: number
209
+ }
210
+
211
+ /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
212
+ export function runClock(ms: number): string {
213
+ const total = Math.max(0, Math.floor(ms / 1000))
214
+ const minutes = Math.floor(total / 60)
215
+ const seconds = total % 60
216
+ return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
217
+ }
218
+
219
+ /** Status glyph per job lifecycle state. */
220
+ const JOB_MARK: Record<JobRow['status'], string> = {
221
+ running: '●',
222
+ stopping: '⏸',
223
+ completed: '✓',
224
+ killed: '⊘',
225
+ failed: '✗',
226
+ }
227
+
228
+ /**
229
+ * The read-only background-job panel: caller-owned and unowned jobs from the
230
+ * host `jobs` registry in registration order, with a local second-hand while
231
+ * the panel is open (elapsed clocks advance and the snapshot re-reads; the
232
+ * interval dies with the panel). Cancel stays upstream-only; an absent
233
+ * registry renders as the plain empty state (a harmless missing service).
234
+ */
235
+ export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close(): void }): ReactElement {
236
+ const [, setRefresh] = useState(0)
237
+ const [cursor, setCursor] = useState(0)
238
+ const [, setTick] = useState(0)
239
+ useEffect(() => {
240
+ const id = setInterval(() => setTick(value => value + 1), 1_000)
241
+ return () => clearInterval(id)
242
+ }, [])
243
+ const rows = load()
244
+ useInput((input, key) => {
245
+ if (key.escape || input === 'q') return close()
246
+ if (input === 'r') return setRefresh(value => value + 1)
247
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
248
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
249
+ })
250
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
251
+ return createElement(ListFrame, {
252
+ title: `/jobs · background tasks · ${rows.length}`,
253
+ rows: rows.map(row => ({
254
+ key: row.id,
255
+ text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
256
+ })),
257
+ cursor, loading: false, query: '', searching: false, footer: '↑↓ inspect · r refresh · esc close',
258
+ })
259
+ }
260
+
261
+ export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
262
+ currentCwd: string
263
+ load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
264
+ readTranscript(id: string, signal?: AbortSignal): Promise<string>
265
+ select(row: SessionRow): void
266
+ /** Arm the composer-based delete confirm for one row (App owns the keys). */
267
+ requestDelete?(row: SessionRow): void
268
+ /** The row id awaiting y/n in the composer, when any (App-owned). */
269
+ deleteConfirmId?: string
270
+ /** Bump to reload the listing (e.g. after a deletion). */
271
+ reloadToken?: number
272
+ /** Opened via /delete: hint-first delete mode. */
273
+ deleteMode?: boolean
274
+ close(): void
275
+ }): ReactElement {
276
+ // Codex resume-picker default: the CURRENT directory's root sessions; the
277
+ // cwd filter widens to all only on request (the old default leaked every
278
+ // directory's sessions into what read as a current-directory view).
279
+ const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' })
280
+ const [focus, setFocus] = useState(0)
281
+ const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
282
+ const [rows, setRows] = useState<readonly SessionRow[]>([])
283
+ const [cursor, setCursor] = useState(0)
284
+ const [loading, setLoading] = useState(true)
285
+ const [error, setError] = useState<string>()
286
+ const [expanded, setExpanded] = useState<string>()
287
+ const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
288
+ /** Ctrl+F-gated search: typing filters only while searching (codex). */
289
+ const [searching, setSearching] = useState(false)
290
+ /** Reference clock pinned per row render, so relative times never drift mid-list. */
291
+ const now = useMemo(() => Date.now(), [rows, options])
292
+ const transcriptLoad = useRef<AbortController>()
293
+ useEffect(() => () => transcriptLoad.current?.abort(), [])
294
+ useEffect(() => {
295
+ const controller = new AbortController()
296
+ setLoading(true); setError(undefined)
297
+ Promise.resolve().then(() => load(options, controller.signal)).then(value => {
298
+ if (!controller.signal.aborted) { setRows(value); setLoading(false) }
299
+ }, reason => {
300
+ if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
301
+ })
302
+ return () => controller.abort()
303
+ }, [options, reloadToken])
304
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
305
+ const cycle = (): void => {
306
+ if (focus === 3) {
307
+ setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
308
+ return
309
+ }
310
+ setOptions(value => {
311
+ if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
312
+ if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
313
+ return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
314
+ })
315
+ }
316
+ useInput((input, key) => {
317
+ // While a deletion awaits y/n, the COMPOSER owns every key (App routes
318
+ // them); the panel yields so y/n cannot be handled twice.
319
+ if (deleteConfirmId !== undefined) return
320
+ if (key.escape) {
321
+ if (searching) { setSearching(false); return }
322
+ return close()
323
+ }
324
+ if (isSearchToggle(input, key)) { setSearching(current => !current); return }
325
+ if (searching) {
326
+ if (key.return) { setSearching(false); return }
327
+ const next = editQuery(options.query, input, key)
328
+ if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
329
+ return
330
+ }
331
+ if (input === 'q') return close()
332
+ if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
333
+ if (key.leftArrow) return cycle()
334
+ if (key.rightArrow) return cycle()
335
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
336
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
337
+ if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
338
+ if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
339
+ if (input === 'g') return setCursor(0)
340
+ if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
341
+ if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
342
+ if (input === 'e' && rows[cursor] !== undefined) {
343
+ return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
344
+ }
345
+ if (input === 't' && rows[cursor] !== undefined) {
346
+ const row = rows[cursor]!
347
+ transcriptLoad.current?.abort()
348
+ setTranscript({ id: row.id })
349
+ const controller = new AbortController()
350
+ transcriptLoad.current = controller
351
+ Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
352
+ text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
353
+ reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
354
+ )
355
+ return
356
+ }
357
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
358
+ }, { isActive: transcript === undefined })
359
+ if (transcript !== undefined) {
360
+ return createElement(DocumentPanel, {
361
+ title: `transcript · ${transcript.id}`,
362
+ text: transcript.text,
363
+ error: transcript.error,
364
+ close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
365
+ })
366
+ }
367
+ const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
368
+ const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
369
+ return createElement(ListFrame, {
370
+ title: deleteConfirmId === undefined
371
+ ? `/resume${deleteMode ? ' — delete mode' : ''}${searching ? ' — searching' : ''} · ${toolbar}`
372
+ : `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
373
+ rows: rows.map(row => ({
374
+ key: row.id,
375
+ disabled: !row.resumable,
376
+ 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}`}` : ''}`,
377
+ })), cursor, loading, error, query: options.query, searching,
378
+ footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
379
+ })
380
+ }
381
+
382
+ function documentStyleProps(style: LineStyle): { color?: string; bold?: boolean; italic?: boolean; strikethrough?: boolean } {
383
+ switch (style) {
384
+ case 'accent':
385
+ return { color: inkColor(getPalette().brandBright) }
386
+ case 'accentBold':
387
+ return { color: inkColor(getPalette().brandBright), bold: true }
388
+ case 'code':
389
+ return { color: inkColor(getPalette().code) }
390
+ case 'dim':
391
+ return { color: inkColor(getPalette().dim) }
392
+ case 'bold':
393
+ return { bold: true }
394
+ case 'italic':
395
+ return { italic: true }
396
+ case 'boldItalic':
397
+ return { bold: true, italic: true }
398
+ case 'strike':
399
+ return { color: inkColor(getPalette().dim), strikethrough: true }
400
+ default:
401
+ return {}
402
+ }
403
+ }
404
+
405
+ function DocumentRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
406
+ return createElement(
407
+ Box,
408
+ { flexDirection: 'column' },
409
+ ...lines.map((line, index) => createElement(
410
+ Text,
411
+ { key: index, wrap: 'truncate-end' },
412
+ line.segments.length === 0
413
+ ? ' '
414
+ : line.segments.map((segment, at) => createElement(Text, { key: at, ...documentStyleProps(segment.style) }, segment.text)),
415
+ )),
416
+ )
417
+ }
418
+
419
+ function DocumentPanel({ title, text, error, close }: {
420
+ title: string
421
+ text?: string
422
+ error?: string
423
+ close(): void
424
+ }): ReactElement {
425
+ const stdout = useStdout().stdout
426
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
427
+ const [scroll, setScroll] = useState(0)
428
+ const lines = useMemo(() => text === undefined ? [] : markdownLines(text, viewport.contentColumns), [text, viewport.contentColumns])
429
+ useInput((input, key) => {
430
+ if (key.escape || input === 'q' || input === 't') return close()
431
+ if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
432
+ if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
433
+ if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
434
+ if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
435
+ if (input === 'g') return setScroll(0)
436
+ if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
437
+ })
438
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
439
+ const body: readonly StyledLine[] = error !== undefined
440
+ ? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
441
+ : text === undefined
442
+ ? textLines('loading transcript…', viewport.contentColumns, 'dim')
443
+ : lines.slice(scroll, scroll + viewport.bodyRows)
444
+ return createElement(
445
+ Box,
446
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
447
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
448
+ createElement(DocumentRows, { lines: body }),
449
+ 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)),
450
+ )
451
+ }
452
+
453
+ /**
454
+ * The /history recall panel (Codex composer-history search, bounded): one
455
+ * query line over the newest-first recall space, filtered by substring, with
456
+ * arrow selection and enter to fill the composer. Editing the query restarts
457
+ * from the newest match; Esc closes without touching the draft.
458
+ */
459
+ export function HistoryPanel({ entries, fill, close }: {
460
+ /** Newest-first recall entries (persistent + in-session, deduped). */
461
+ entries: readonly string[]
462
+ /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
463
+ fill(text: string, index: number): void
464
+ close(): void
465
+ }): ReactElement {
466
+ const [query, setQuery] = useState('')
467
+ const [cursor, setCursor] = useState(0)
468
+ const matches = query === ''
469
+ ? entries
470
+ : entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
471
+ useInput((input, key) => {
472
+ if (key.escape) return close()
473
+ if (key.return) {
474
+ const entry = matches[cursor]
475
+ if (entry !== undefined) fill(entry, entries.indexOf(entry))
476
+ return
477
+ }
478
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
479
+ if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
480
+ if (input === 'g') return setCursor(0)
481
+ if (input === 'G') return setCursor(matches.length - 1)
482
+ if (key.backspace) {
483
+ setQuery(current => deleteLastGrapheme(current))
484
+ setCursor(0)
485
+ return
486
+ }
487
+ if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
488
+ const text = stripPasteMarkers(input)
489
+ if (text !== '') {
490
+ setQuery(current => (current + text).slice(0, 120))
491
+ setCursor(0)
492
+ }
493
+ }
494
+ })
495
+ const stdout = useStdout().stdout
496
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
497
+ if (viewport.compact) {
498
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
499
+ }
500
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
501
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
502
+ const offset = revealRow(0, cursor, matches.length, bodyRows)
503
+ const visible = matches.slice(offset, offset + bodyRows)
504
+ const header = query === ''
505
+ ? `/history · ${entries.length} prompts · type to filter`
506
+ : `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
507
+ return createElement(
508
+ Box,
509
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
510
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
511
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
512
+ ...(visible.length === 0
513
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
514
+ : visible.map((entry, index) => {
515
+ const absolute = offset + index
516
+ const selected = absolute === cursor
517
+ return createElement(
518
+ Text,
519
+ {
520
+ key: `history-${absolute}`,
521
+ color: selected ? inkColor(getPalette().brandBright) : undefined,
522
+ wrap: 'truncate-end',
523
+ },
524
+ truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
525
+ )
526
+ })),
527
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
528
+ )
529
+ }
530
+
531
+ /**
532
+ * The /statusline picker (the Codex setup-view contract): one bounded list
533
+ * of every status item with its enabled mark, arrow reordering, and a
534
+ * live preview — the real status line under the composer updates as you
535
+ * edit, so the panel itself carries no duplicate preview row.
536
+ */
537
+ export function StatuslinePanel({ enabled, change, close }: {
538
+ enabled: readonly StatusItemId[]
539
+ change(items: readonly StatusItemId[]): void
540
+ close(): void
541
+ }): ReactElement {
542
+ // Working state: the full catalog in display order (enabled entries in
543
+ // their configured positions, disabled ones trailing canonically) plus
544
+ // the enabled set. Persisted shape is the enabled subsequence only.
545
+ const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
546
+ const seen = new Set(enabled)
547
+ return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
548
+ })
549
+ const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
550
+ const [cursor, setCursor] = useState(0)
551
+ const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
552
+ setOrder(nextOrder)
553
+ setOn(nextOn)
554
+ change(nextOrder.filter(id => nextOn.has(id)))
555
+ }
556
+ const move = (offset: number): void => {
557
+ const target = cursor + offset
558
+ if (target < 0 || target >= order.length) return
559
+ const next = [...order]
560
+ const [item] = next.splice(cursor, 1)
561
+ next.splice(target, 0, item!)
562
+ commit(next, on)
563
+ setCursor(target)
564
+ }
565
+ useInput((input, key) => {
566
+ if (key.escape || input === 'q' || key.return) return close()
567
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
568
+ if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
569
+ if (key.leftArrow) return move(-1)
570
+ if (key.rightArrow) return move(1)
571
+ if (input === 'g') return setCursor(0)
572
+ if (input === 'G') return setCursor(order.length - 1)
573
+ if (input === 'd') {
574
+ commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
575
+ setCursor(0)
576
+ return
577
+ }
578
+ if (input === ' ') {
579
+ const item = order[cursor]
580
+ if (item === undefined) return
581
+ const next = new Set(on)
582
+ if (next.has(item)) next.delete(item)
583
+ else next.add(item)
584
+ commit(order, next)
585
+ }
586
+ })
587
+ const stdout = useStdout().stdout
588
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
589
+ if (viewport.compact) {
590
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
591
+ }
592
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
593
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
594
+ const offset = revealRow(0, cursor, order.length, bodyRows)
595
+ const visible = order.slice(offset, offset + bodyRows)
596
+ const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
597
+ return createElement(
598
+ Box,
599
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
600
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
601
+ ...visible.map((id, index) => {
602
+ const absolute = offset + index
603
+ const selected = absolute === cursor
604
+ const info = meta.get(id)
605
+ return createElement(
606
+ Text,
607
+ {
608
+ key: id,
609
+ color: selected ? inkColor(getPalette().brandBright) : undefined,
610
+ dimColor: !on.has(id) || undefined,
611
+ wrap: 'truncate-end',
612
+ },
613
+ truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
614
+ )
615
+ }),
616
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
617
+ )
618
+ }
619
+
620
+ /**
621
+ * The `/model` reasoning-effort stage (the Codex model → reasoning popup
622
+ * contract): one bounded list over the selected model's adapter-advertised
623
+ * effort levels — in the adapter's own display order, ids verbatim (the
624
+ * kernel treats them as opaque and rejects anything else) — with the
625
+ * effective effort and the model default marked. A model WITHOUT an
626
+ * adapter-declared default leads with a "Default" (provider-default) row —
627
+ * the web effort pane's first entry — so the user can clear a picked level
628
+ * back to provider behavior. A model advertising no levels opens the same
629
+ * stage with an explicit empty state (the web pane's "no levels" copy)
630
+ * instead of a bare failure notice. Enter applies one level; Esc returns to
631
+ * the model list without applying.
632
+ */
633
+ export function EffortPanel({ row, current, select, back }: {
634
+ /** The model row whose advertised levels this stage lists. */
635
+ row: ModelRow
636
+ /** Effective effort currently in force ('' when none), for the ● mark. */
637
+ current: string | undefined
638
+ /** Accept one advertised effort id, or '' for the provider default. */
639
+ select(effortId: string): void
640
+ /** Return to the model list without applying. */
641
+ back(): void
642
+ }): ReactElement {
643
+ const advertised = row.reasoning?.efforts ?? []
644
+ const empty = row.reasoning === undefined || advertised.length === 0
645
+ // The provider-default row only exists when the adapter declares no default
646
+ // effort: with one, the default is an advertised level already in the list.
647
+ const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined
648
+ const rows = empty
649
+ ? [{ id: '', name: '' }]
650
+ : hasDefaultRow
651
+ ? [{ id: '', name: 'Default' }, ...advertised]
652
+ : advertised
653
+ // An absent or cleared effort is the Default row's current state.
654
+ const effective = current === undefined || current === '' ? '' : current
655
+ // The list opens ON the effective level (or the model's default row), so a
656
+ // quick re-pick never restarts the cursor from the top.
657
+ const wanted = effective === '' ? row.reasoning?.defaultEffort ?? '' : effective
658
+ const initialCursor = Math.max(0, rows.findIndex(effort => effort.id === wanted))
659
+ const [cursor, setCursor] = useState(initialCursor)
660
+ useEffect(() => {
661
+ if (rows.length === 0) {
662
+ if (cursor !== 0) setCursor(0)
663
+ return
664
+ }
665
+ if (cursor >= rows.length) setCursor(rows.length - 1)
666
+ }, [rows.length, cursor])
667
+ useInput((input, key) => {
668
+ if (key.escape || input === 'q') return back()
669
+ if (empty) return
670
+ if (input === 'g') {
671
+ setCursor(0)
672
+ return
673
+ }
674
+ if (input === 'G') {
675
+ setCursor(rows.length - 1)
676
+ return
677
+ }
678
+ if (key.upArrow) {
679
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
680
+ return
681
+ }
682
+ if (key.downArrow) {
683
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
684
+ return
685
+ }
686
+ if (key.return && rows[cursor] !== undefined) {
687
+ select(rows[cursor]!.id)
688
+ }
689
+ })
690
+ return createElement(ListFrame, {
691
+ title: `/model — effort for ${row.providerName} · ${row.modelName}`,
692
+ rows: empty
693
+ ? [{ key: 'empty', disabled: true, text: 'this model advertises no reasoning effort levels — the provider default applies' }]
694
+ : rows.map(effort => ({
695
+ key: effort.id,
696
+ text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
697
+ })),
698
+ cursor,
699
+ loading: false,
700
+ query: '',
701
+ footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
702
+ })
703
+ }
704
+
705
+ /** One merged /agents row: live feed state or a persisted child session. */
706
+ interface AgentsEntry {
707
+ readonly id: string
708
+ readonly label: string
709
+ readonly activity: string
710
+ readonly running: boolean
711
+ readonly done: boolean
712
+ readonly live: boolean
713
+ }
714
+
715
+ /**
716
+ * The /agents panel (the Codex agent-picker contract, read-only): this
717
+ * conversation's subagent conversations live rows from the activity feed
718
+ * first, persisted children the feed has not seen this process after — with
719
+ * Enter/t opening the child's full transcript in the shared read-only
720
+ * document view (the same projection the exporter uses).
721
+ */
722
+ export function AgentsPanel({ live, load, readTranscript, close }: {
723
+ /** Live feed rows (child sessions observed this process). */
724
+ live: readonly SubagentRow[]
725
+ /** Load this session's persisted child sessions by lineage. */
726
+ load(): Promise<readonly SessionRow[]>
727
+ /** Read one child session's full transcript as markdown. */
728
+ readTranscript(id: string, signal?: AbortSignal): Promise<string>
729
+ close(): void
730
+ }): ReactElement {
731
+ const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
732
+ const [error, setError] = useState<string>()
733
+ const [loading, setLoading] = useState(true)
734
+ const [cursor, setCursor] = useState(0)
735
+ const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
736
+ const transcriptLoad = useRef<AbortController>()
737
+ useEffect(() => () => transcriptLoad.current?.abort(), [])
738
+ const refresh = (): void => {
739
+ setLoading(true)
740
+ setError(undefined)
741
+ Promise.resolve().then(load).then(value => {
742
+ setDirRows(value)
743
+ setLoading(false)
744
+ }, reason => {
745
+ setError(reason instanceof Error ? reason.message : String(reason))
746
+ setLoading(false)
747
+ })
748
+ }
749
+ useEffect(refresh, [])
750
+ // Live feed rows first (they carry the running state), then persisted
751
+ // children only the directory knows — settled subagents from earlier turns.
752
+ const rows = useMemo<readonly AgentsEntry[]>(() => {
753
+ const seen = new Set(live.map(row => row.id))
754
+ const feedRows: AgentsEntry[] = live.map(row => ({
755
+ id: row.id,
756
+ label: row.label,
757
+ activity: row.activity,
758
+ running: row.state === 'running',
759
+ done: row.state === 'done',
760
+ live: true,
761
+ }))
762
+ const persisted: AgentsEntry[] = (dirRows ?? [])
763
+ .filter(row => !seen.has(row.id))
764
+ .map(row => ({
765
+ id: row.id,
766
+ label: row.title ?? row.id.slice(-12),
767
+ activity: row.workspace,
768
+ running: false,
769
+ done: !row.live,
770
+ live: row.live,
771
+ }))
772
+ return [...feedRows, ...persisted]
773
+ }, [live, dirRows])
774
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
775
+ const openTranscript = (): void => {
776
+ const row = rows[cursor]
777
+ if (row === undefined) return
778
+ transcriptLoad.current?.abort()
779
+ setTranscript({ id: row.id })
780
+ const controller = new AbortController()
781
+ transcriptLoad.current = controller
782
+ Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
783
+ text => {
784
+ if (!controller.signal.aborted) setTranscript({ id: row.id, text })
785
+ },
786
+ reason => {
787
+ if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) })
788
+ },
789
+ )
790
+ }
791
+ useInput((input, key) => {
792
+ if (key.escape || input === 'q') return close()
793
+ if (input === 'r') return refresh()
794
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
795
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
796
+ if ((key.return || input === 't') && rows[cursor] !== undefined) return openTranscript()
797
+ }, { isActive: transcript === undefined })
798
+ if (transcript !== undefined) {
799
+ return createElement(DocumentPanel, {
800
+ title: `subagent · ${transcript.id.slice(-12)}`,
801
+ text: transcript.text,
802
+ error: transcript.error,
803
+ close: () => {
804
+ transcriptLoad.current?.abort()
805
+ setTranscript(undefined)
806
+ },
807
+ })
808
+ }
809
+ return createElement(ListFrame, {
810
+ title: `/agents · ${live.length} live · ${rows.length} total`,
811
+ rows: rows.map(row => ({
812
+ key: row.id,
813
+ text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
814
+ })),
815
+ cursor,
816
+ loading,
817
+ ...error === undefined ? {} : { error },
818
+ query: '',
819
+ footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
820
+ })
821
+ }
822
+
823
+ /**
824
+ * The /subagent model panel: which model configuration delegated subagents
825
+ * run on. The kernel seeds child agents from the parent's CREATE-TIME
826
+ * AgentOptions, so a mid-session /model switch would otherwise leave them on
827
+ * the launch-time route; the TUI mirrors the selection onto subagent-origin
828
+ * requests (or an explicit override picked here) via an agent/request
829
+ * listener. The leading "inherit" row restores follow-the-current-model
830
+ * behavior; picking a model with several advertised efforts opens the same
831
+ * effort stage /model uses. Effort overrides are not offered separately —
832
+ * the kernel's AgentOptions has no effort channel for children, so the level
833
+ * rides the selected model exactly as /model applies it.
834
+ */
835
+ export function SubagentPanel({ current, load, pick, inherit, close }: {
836
+ /** Display label of the override in force, '' when following the current model. */
837
+ current: string
838
+ load(): Promise<ModelDirectory>
839
+ /** Apply one model (with an advertised effort, when picked) as the override. */
840
+ pick(row: ModelRow, effortId?: string): void
841
+ /** Drop the override: subagents follow the current model again. */
842
+ inherit(): void
843
+ close(): void
844
+ }): ReactElement {
845
+ const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
846
+ const [error, setError] = useState<string>()
847
+ const [loading, setLoading] = useState(true)
848
+ const [cursor, setCursor] = useState(0)
849
+ const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
850
+ const refresh = (): void => {
851
+ setLoading(true)
852
+ setError(undefined)
853
+ Promise.resolve().then(load).then(value => {
854
+ setDirectory(value)
855
+ setLoading(false)
856
+ }, reason => {
857
+ setError(reason instanceof Error ? reason.message : String(reason))
858
+ setLoading(false)
859
+ })
860
+ }
861
+ useEffect(refresh, [])
862
+ const rows = useMemo(() => directory?.rows ?? [], [directory])
863
+ // The list opens on the override's own row (index 0 is the inherit row).
864
+ useEffect(() => {
865
+ if (current === '' || rows.length === 0) return
866
+ const index = rows.findIndex(row => current.startsWith(`${row.provider}/${row.model}`))
867
+ if (index >= 0) setCursor(index + 1)
868
+ }, [rows, current])
869
+ useEffect(() => setCursor(value => Math.min(value, rows.length)), [rows.length])
870
+ // Hooks stay unconditional: the effort stage below swaps the rendered
871
+ // subtree but must never skip the input hook (an early return here would
872
+ // change the hook count when the stage opens and closes).
873
+ useInput((input, key) => {
874
+ if (effortFor !== undefined) return
875
+ if (key.escape || input === 'q') return close()
876
+ if (input === 'r' && !loading) return refresh()
877
+ if (key.upArrow) return setCursor(value => (value + rows.length) % (rows.length + 1))
878
+ if (key.downArrow) return setCursor(value => (value + 1) % (rows.length + 1))
879
+ if (key.return) {
880
+ if (cursor === 0) return inherit()
881
+ const row = rows[cursor - 1]
882
+ if (row === undefined) return
883
+ if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
884
+ setEffortFor(row)
885
+ return
886
+ }
887
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
888
+ pick(row, effortId)
889
+ }
890
+ })
891
+ if (effortFor !== undefined) {
892
+ return createElement(EffortPanel, {
893
+ row: effortFor,
894
+ current: current === '' ? undefined : current.split('@')[1],
895
+ select: effortId => pick(effortFor, effortId),
896
+ back: () => setEffortFor(undefined),
897
+ })
898
+ }
899
+ return createElement(ListFrame, {
900
+ title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
901
+ rows: [
902
+ { key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
903
+ ...rows.map(row => ({
904
+ key: `${row.provider}/${row.model}`,
905
+ text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
906
+ })),
907
+ ],
908
+ cursor,
909
+ loading,
910
+ ...error === undefined ? {} : { error },
911
+ query: '',
912
+ footer: '↑↓ choose · enter apply · r refresh · esc close',
913
+ })
914
+ }