dsh-code 0.6.1 → 0.8.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 (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
@@ -1,419 +1,518 @@
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 { PresetRow } from './presets.ts'
6
- import type { PluginRow } from './plugin-inventory.ts'
7
- import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
8
- import { panelViewport, revealRow } from './render/inspector.ts'
9
- import { textLines } from './render/lines.ts'
10
- import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
11
- import { displayText, singleLineText, truncateColumns } from './render/text.ts'
12
- import { TUI_RGB } from './theme.ts'
13
-
14
- function color(rgb: readonly [number, number, number]): string {
15
- return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`
16
- }
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
- readonly footer: string
26
- }
27
-
28
- function ListFrame(props: ListFrameProps): ReactElement {
29
- const stdout = useStdout().stdout
30
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
31
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
32
- if (viewport.compact) {
33
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${props.title} · esc close`, viewport.contentColumns))
34
- }
35
- const stateRows = props.loading
36
- ? [{ key: 'loading', text: ' loading…' }]
37
- : props.error !== undefined
38
- ? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
39
- : props.rows.length === 0
40
- ? [{ key: 'empty', text: ' no matching entries' }]
41
- : props.rows
42
- const bodyRows = Math.max(1, viewport.bodyRows - 1)
43
- const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
44
- const visible = stateRows.slice(offset, offset + bodyRows)
45
- return createElement(
46
- Box,
47
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
48
- createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(props.title, viewport.contentColumns)),
49
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`search: ${props.query === '' ? 'type to filter' : props.query}`, viewport.contentColumns)),
50
- ...visible.map((row, index) => {
51
- const absolute = offset + index
52
- const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
53
- return createElement(Text, {
54
- key: row.key,
55
- color: selected ? color(TUI_RGB.brandBright) : row.disabled ? color(TUI_RGB.dim) : undefined,
56
- dimColor: row.disabled,
57
- wrap: 'truncate-end',
58
- }, truncateColumns(`${selected ? '› ' : ' '}${row.text}`, viewport.contentColumns))
59
- }),
60
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(props.footer, viewport.contentColumns)),
61
- )
62
- }
63
-
64
- function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
65
- if (key.backspace || key.delete) return query.slice(0, -1)
66
- if (input.length === 1 && input >= ' ' && input !== '\x7f') return query + input
67
- return undefined
68
- }
69
-
70
- export function ModePanel({ current, load, select, close }: {
71
- current: string
72
- load(): Promise<readonly PresetRow[]>
73
- select(id: string): void
74
- close(): void
75
- }): ReactElement {
76
- const [rows, setRows] = useState<readonly PresetRow[]>([])
77
- const [query, setQuery] = useState('')
78
- const [cursor, setCursor] = useState(0)
79
- const [loading, setLoading] = useState(true)
80
- const [error, setError] = useState<string>()
81
- const refresh = (): void => {
82
- setLoading(true); setError(undefined)
83
- Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
84
- setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
85
- })
86
- }
87
- useEffect(refresh, [])
88
- const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
89
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
90
- useInput((input, key) => {
91
- if (key.escape || input === 'q') return close()
92
- if (input === 'r' && query === '') return refresh()
93
- if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
94
- if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
95
- if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
96
- const next = editQuery(query, input, key)
97
- if (next !== undefined) { setQuery(next); setCursor(0) }
98
- })
99
- return createElement(ListFrame, {
100
- title: `/mode · current ${current}`,
101
- 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}`}` })),
102
- cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
103
- })
104
- }
105
-
106
- export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
107
- const [epoch, setEpoch] = useState(0)
108
- const [query, setQuery] = useState(initialQuery)
109
- const [cursor, setCursor] = useState(0)
110
- const [expanded, setExpanded] = useState(false)
111
- const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
112
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
113
- useInput((input, key) => {
114
- if (key.escape || input === 'q') return close()
115
- if (input === 'r' && query === '') return setEpoch(value => value + 1)
116
- if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
117
- if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
118
- if (key.return) return setExpanded(value => !value)
119
- const next = editQuery(query, input, key)
120
- if (next !== undefined) { setQuery(next); setCursor(0) }
121
- })
122
- return createElement(ListFrame, {
123
- title: '/plugin · loader inspector',
124
- rows: rows.map((row, index) => ({
125
- key: row.entryId,
126
- disabled: !row.enabled,
127
- text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
128
- })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
129
- })
130
- }
131
-
132
- export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
133
- currentCwd: string
134
- load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
135
- readTranscript(id: string, signal?: AbortSignal): Promise<string>
136
- select(row: SessionRow): void
137
- close(): void
138
- }): ReactElement {
139
- const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'all', sort: 'newest', currentCwd, query: '' })
140
- const [focus, setFocus] = useState(0)
141
- const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
142
- const [rows, setRows] = useState<readonly SessionRow[]>([])
143
- const [cursor, setCursor] = useState(0)
144
- const [loading, setLoading] = useState(true)
145
- const [error, setError] = useState<string>()
146
- const [expanded, setExpanded] = useState<string>()
147
- const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
148
- const transcriptLoad = useRef<AbortController>()
149
- useEffect(() => () => transcriptLoad.current?.abort(), [])
150
- useEffect(() => {
151
- const controller = new AbortController()
152
- setLoading(true); setError(undefined)
153
- Promise.resolve().then(() => load(options, controller.signal)).then(value => {
154
- if (!controller.signal.aborted) { setRows(value); setLoading(false) }
155
- }, reason => {
156
- if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
157
- })
158
- return () => controller.abort()
159
- }, [options])
160
- useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
161
- const cycle = (): void => {
162
- if (focus === 3) {
163
- setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
164
- return
165
- }
166
- setOptions(value => {
167
- if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
168
- if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
169
- return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
170
- })
171
- }
172
- useInput((input, key) => {
173
- if (key.escape || input === 'q') return close()
174
- if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
175
- if (key.leftArrow) return cycle()
176
- if (key.rightArrow) return cycle()
177
- if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
178
- if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
179
- if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
180
- if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
181
- if (input === 'g') return setCursor(0)
182
- if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
183
- if (input === 'd') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
184
- if (input === 'e' && rows[cursor] !== undefined) {
185
- return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
186
- }
187
- if (input === 't' && rows[cursor] !== undefined) {
188
- const row = rows[cursor]!
189
- transcriptLoad.current?.abort()
190
- setTranscript({ id: row.id })
191
- const controller = new AbortController()
192
- transcriptLoad.current = controller
193
- Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
194
- text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
195
- reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
196
- )
197
- return
198
- }
199
- if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
200
- const next = editQuery(options.query, input, key)
201
- if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
202
- }, { isActive: transcript === undefined })
203
- if (transcript !== undefined) {
204
- return createElement(DocumentPanel, {
205
- title: `transcript · ${transcript.id}`,
206
- text: transcript.text,
207
- error: transcript.error,
208
- close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
209
- })
210
- }
211
- const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
212
- return createElement(ListFrame, {
213
- title: `/resume · ${toolbar}`,
214
- rows: rows.map(row => ({
215
- key: row.id,
216
- disabled: !row.resumable,
217
- text: `${row.subagent ? '' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
218
- })), cursor, loading, error, query: options.query,
219
- footer: 'type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume',
220
- })
221
- }
222
-
223
- function DocumentPanel({ title, text, error, close }: {
224
- title: string
225
- text?: string
226
- error?: string
227
- close(): void
228
- }): ReactElement {
229
- const stdout = useStdout().stdout
230
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
231
- const [scroll, setScroll] = useState(0)
232
- const lines = useMemo(() => text === undefined ? [] : textLines(text, viewport.contentColumns).map(line => line.segments.map(segment => segment.text).join('')), [text, viewport.contentColumns])
233
- useInput((input, key) => {
234
- if (key.escape || input === 'q' || input === 't') return close()
235
- if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
236
- if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
237
- if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
238
- if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
239
- if (input === 'g') return setScroll(0)
240
- if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
241
- })
242
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
243
- const body = error !== undefined
244
- ? [`error: ${singleLineText(error)}`]
245
- : text === undefined
246
- ? ['loading transcript…']
247
- : lines.slice(scroll, scroll + viewport.bodyRows)
248
- return createElement(
249
- Box,
250
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
251
- createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)),
252
- ...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
253
- 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)),
254
- )
255
- }
256
-
257
- /**
258
- * The /history recall panel (Codex composer-history search, bounded): one
259
- * query line over the newest-first recall space, filtered by substring, with
260
- * arrow selection and enter to fill the composer. Editing the query restarts
261
- * from the newest match; Esc closes without touching the draft.
262
- */
263
- export function HistoryPanel({ entries, fill, close }: {
264
- /** Newest-first recall entries (persistent + in-session, deduped). */
265
- entries: readonly string[]
266
- /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
267
- fill(text: string, index: number): void
268
- close(): void
269
- }): ReactElement {
270
- const [query, setQuery] = useState('')
271
- const [cursor, setCursor] = useState(0)
272
- const matches = query === ''
273
- ? entries
274
- : entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
275
- useInput((input, key) => {
276
- if (key.escape) return close()
277
- if (key.return) {
278
- const entry = matches[cursor]
279
- if (entry !== undefined) fill(entry, entries.indexOf(entry))
280
- return
281
- }
282
- if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
283
- if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
284
- if (input === 'g') return setCursor(0)
285
- if (input === 'G') return setCursor(matches.length - 1)
286
- if (key.backspace) {
287
- setQuery(current => current.slice(0, -1))
288
- setCursor(0)
289
- return
290
- }
291
- if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
292
- setQuery(current => (current + input).slice(0, 120))
293
- setCursor(0)
294
- }
295
- })
296
- const stdout = useStdout().stdout
297
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
298
- if (viewport.compact) {
299
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
300
- }
301
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
302
- const bodyRows = Math.max(1, viewport.bodyRows - 1)
303
- const offset = revealRow(0, cursor, matches.length, bodyRows)
304
- const visible = matches.slice(offset, offset + bodyRows)
305
- const header = query === ''
306
- ? `/history · ${entries.length} prompts · type to filter`
307
- : `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
308
- return createElement(
309
- Box,
310
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
311
- createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
312
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
313
- ...(visible.length === 0
314
- ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
315
- : visible.map((entry, index) => {
316
- const absolute = offset + index
317
- const selected = absolute === cursor
318
- return createElement(
319
- Text,
320
- {
321
- key: `history-${absolute}`,
322
- color: selected ? color(TUI_RGB.brandBright) : undefined,
323
- wrap: 'truncate-end',
324
- },
325
- truncateColumns((selected ? '' : ' ') + displayText(entry), viewport.contentColumns),
326
- )
327
- })),
328
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
329
- )
330
- }
331
-
332
- /**
333
- * The /statusline picker (the Codex setup-view contract): one bounded list
334
- * of every status item with its enabled mark, arrow reordering, and a
335
- * live preview the real status line under the composer updates as you
336
- * edit, so the panel itself carries no duplicate preview row.
337
- */
338
- export function StatuslinePanel({ enabled, change, close }: {
339
- enabled: readonly StatusItemId[]
340
- change(items: readonly StatusItemId[]): void
341
- close(): void
342
- }): ReactElement {
343
- // Working state: the full catalog in display order (enabled entries in
344
- // their configured positions, disabled ones trailing canonically) plus
345
- // the enabled set. Persisted shape is the enabled subsequence only.
346
- const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
347
- const seen = new Set(enabled)
348
- return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
349
- })
350
- const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
351
- const [cursor, setCursor] = useState(0)
352
- const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
353
- setOrder(nextOrder)
354
- setOn(nextOn)
355
- change(nextOrder.filter(id => nextOn.has(id)))
356
- }
357
- const move = (offset: number): void => {
358
- const target = cursor + offset
359
- if (target < 0 || target >= order.length) return
360
- const next = [...order]
361
- const [item] = next.splice(cursor, 1)
362
- next.splice(target, 0, item!)
363
- commit(next, on)
364
- setCursor(target)
365
- }
366
- useInput((input, key) => {
367
- if (key.escape || input === 'q' || key.return) return close()
368
- if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
369
- if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
370
- if (key.leftArrow) return move(-1)
371
- if (key.rightArrow) return move(1)
372
- if (input === 'g') return setCursor(0)
373
- if (input === 'G') return setCursor(order.length - 1)
374
- if (input === 'd') {
375
- commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
376
- setCursor(0)
377
- return
378
- }
379
- if (input === ' ') {
380
- const item = order[cursor]
381
- if (item === undefined) return
382
- const next = new Set(on)
383
- if (next.has(item)) next.delete(item)
384
- else next.add(item)
385
- commit(order, next)
386
- }
387
- })
388
- const stdout = useStdout().stdout
389
- const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
390
- if (viewport.compact) {
391
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
392
- }
393
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
394
- const bodyRows = Math.max(1, viewport.bodyRows - 1)
395
- const offset = revealRow(0, cursor, order.length, bodyRows)
396
- const visible = order.slice(offset, offset + bodyRows)
397
- const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
398
- return createElement(
399
- Box,
400
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: color(TUI_RGB.dim), flexDirection: 'column', paddingX: 1 },
401
- createElement(Text, { color: color(TUI_RGB.brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
402
- ...visible.map((id, index) => {
403
- const absolute = offset + index
404
- const selected = absolute === cursor
405
- const info = meta.get(id)
406
- return createElement(
407
- Text,
408
- {
409
- key: id,
410
- color: selected ? color(TUI_RGB.brandBright) : undefined,
411
- dimColor: !on.has(id) || undefined,
412
- wrap: 'truncate-end',
413
- },
414
- truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
415
- )
416
- }),
417
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
418
- )
419
- }
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 { ModelRow } from './models.ts'
6
+ import type { PermissionRow } from './permissions.ts'
7
+ import type { PresetRow } from './presets.ts'
8
+ import type { PluginRow } from './plugin-inventory.ts'
9
+ import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
10
+ import { panelViewport, revealRow } from './render/inspector.ts'
11
+ import { textLines } from './render/lines.ts'
12
+ import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
13
+ import { singleLineText, truncateColumns } from './render/text.ts'
14
+ import { getPalette, inkColor } from './theme.ts'
15
+
16
+ interface ListFrameProps {
17
+ readonly title: string
18
+ readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
19
+ readonly cursor: number
20
+ readonly loading: boolean
21
+ readonly error?: string
22
+ readonly query: string
23
+ readonly footer: string
24
+ }
25
+
26
+ function ListFrame(props: ListFrameProps): ReactElement {
27
+ const stdout = useStdout().stdout
28
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
29
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
30
+ if (viewport.compact) {
31
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns))
32
+ }
33
+ const stateRows = props.loading
34
+ ? [{ key: 'loading', text: ' loading…' }]
35
+ : props.error !== undefined
36
+ ? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
37
+ : props.rows.length === 0
38
+ ? [{ key: 'empty', text: ' no matching entries' }]
39
+ : props.rows
40
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
41
+ const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
42
+ const visible = stateRows.slice(offset, offset + bodyRows)
43
+ return createElement(
44
+ Box,
45
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
46
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
47
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(`search: ${props.query === '' ? 'type to filter' : props.query}`), viewport.contentColumns)),
48
+ ...visible.map((row, index) => {
49
+ const absolute = offset + index
50
+ const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
51
+ return createElement(Text, {
52
+ key: row.key,
53
+ color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : undefined,
54
+ dimColor: row.disabled,
55
+ wrap: 'truncate-end',
56
+ }, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(row.text)}`, viewport.contentColumns))
57
+ }),
58
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)),
59
+ )
60
+ }
61
+
62
+ function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
63
+ if (key.backspace || key.delete) return query.slice(0, -1)
64
+ if (input.length === 1 && input >= ' ' && input !== '\x7f') return query + input
65
+ return undefined
66
+ }
67
+
68
+ export function ModePanel({ current, load, select, close }: {
69
+ current: string
70
+ load(): Promise<readonly PresetRow[]>
71
+ select(id: string): void
72
+ close(): void
73
+ }): ReactElement {
74
+ const [rows, setRows] = useState<readonly PresetRow[]>([])
75
+ const [query, setQuery] = useState('')
76
+ const [cursor, setCursor] = useState(0)
77
+ const [loading, setLoading] = useState(true)
78
+ const [error, setError] = useState<string>()
79
+ const refresh = (): void => {
80
+ setLoading(true); setError(undefined)
81
+ Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
82
+ setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
83
+ })
84
+ }
85
+ useEffect(refresh, [])
86
+ const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
87
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
88
+ useInput((input, key) => {
89
+ if (key.escape || input === 'q') return close()
90
+ if (input === 'r' && query === '') return refresh()
91
+ if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
92
+ if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
93
+ if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
94
+ const next = editQuery(query, input, key)
95
+ if (next !== undefined) { setQuery(next); setCursor(0) }
96
+ })
97
+ return createElement(ListFrame, {
98
+ title: `/mode · current ${current}`,
99
+ 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}`}` })),
100
+ cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
101
+ })
102
+ }
103
+
104
+ export function PermissionPanel({ current, load, select, close }: {
105
+ current: string
106
+ load(): Promise<readonly PermissionRow[]>
107
+ select(id: string): void
108
+ close(): void
109
+ }): ReactElement {
110
+ const [rows, setRows] = useState<readonly PermissionRow[]>([])
111
+ const [query, setQuery] = useState('')
112
+ const [cursor, setCursor] = useState(0)
113
+ const [loading, setLoading] = useState(true)
114
+ const [error, setError] = useState<string>()
115
+ const refresh = (): void => {
116
+ setLoading(true); setError(undefined)
117
+ Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
118
+ setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
119
+ })
120
+ }
121
+ useEffect(refresh, [])
122
+ const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
123
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
124
+ useInput((input, key) => {
125
+ if (key.escape || input === 'q') return close()
126
+ if (input === 'r' && query === '') return refresh()
127
+ if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
128
+ if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
129
+ if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
130
+ const next = editQuery(query, input, key)
131
+ if (next !== undefined) { setQuery(next); setCursor(0) }
132
+ })
133
+ return createElement(ListFrame, {
134
+ title: `/permission · current ${current}`,
135
+ rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
136
+ cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
137
+ })
138
+ }
139
+
140
+ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
141
+ const [epoch, setEpoch] = useState(0)
142
+ const [query, setQuery] = useState(initialQuery)
143
+ const [cursor, setCursor] = useState(0)
144
+ const [expanded, setExpanded] = useState(false)
145
+ const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
146
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
147
+ useInput((input, key) => {
148
+ if (key.escape || input === 'q') return close()
149
+ if (input === 'r' && query === '') return setEpoch(value => value + 1)
150
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
151
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
152
+ if (key.return) return setExpanded(value => !value)
153
+ const next = editQuery(query, input, key)
154
+ if (next !== undefined) { setQuery(next); setCursor(0) }
155
+ })
156
+ return createElement(ListFrame, {
157
+ title: '/plugin · loader inspector',
158
+ rows: rows.map((row, index) => ({
159
+ key: row.entryId,
160
+ disabled: !row.enabled,
161
+ text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
162
+ })), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
163
+ })
164
+ }
165
+
166
+ export function ResumePanel({ currentCwd, load, readTranscript, select, close }: {
167
+ currentCwd: string
168
+ load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
169
+ readTranscript(id: string, signal?: AbortSignal): Promise<string>
170
+ select(row: SessionRow): void
171
+ close(): void
172
+ }): ReactElement {
173
+ const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'all', sort: 'newest', currentCwd, query: '' })
174
+ const [focus, setFocus] = useState(0)
175
+ const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
176
+ const [rows, setRows] = useState<readonly SessionRow[]>([])
177
+ const [cursor, setCursor] = useState(0)
178
+ const [loading, setLoading] = useState(true)
179
+ const [error, setError] = useState<string>()
180
+ const [expanded, setExpanded] = useState<string>()
181
+ const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
182
+ const transcriptLoad = useRef<AbortController>()
183
+ useEffect(() => () => transcriptLoad.current?.abort(), [])
184
+ useEffect(() => {
185
+ const controller = new AbortController()
186
+ setLoading(true); setError(undefined)
187
+ Promise.resolve().then(() => load(options, controller.signal)).then(value => {
188
+ if (!controller.signal.aborted) { setRows(value); setLoading(false) }
189
+ }, reason => {
190
+ if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
191
+ })
192
+ return () => controller.abort()
193
+ }, [options])
194
+ useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
195
+ const cycle = (): void => {
196
+ if (focus === 3) {
197
+ setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
198
+ return
199
+ }
200
+ setOptions(value => {
201
+ if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
202
+ if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
203
+ return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
204
+ })
205
+ }
206
+ useInput((input, key) => {
207
+ if (key.escape || input === 'q') return close()
208
+ if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
209
+ if (key.leftArrow) return cycle()
210
+ if (key.rightArrow) return cycle()
211
+ if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
212
+ if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
213
+ if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
214
+ if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
215
+ if (input === 'g') return setCursor(0)
216
+ if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
217
+ if (input === 'd') return setDensity(value => value === 'comfortable' ? 'dense' : 'comfortable')
218
+ if (input === 'e' && rows[cursor] !== undefined) {
219
+ return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
220
+ }
221
+ if (input === 't' && rows[cursor] !== undefined) {
222
+ const row = rows[cursor]!
223
+ transcriptLoad.current?.abort()
224
+ setTranscript({ id: row.id })
225
+ const controller = new AbortController()
226
+ transcriptLoad.current = controller
227
+ Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
228
+ text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
229
+ reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
230
+ )
231
+ return
232
+ }
233
+ if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
234
+ const next = editQuery(options.query, input, key)
235
+ if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
236
+ }, { isActive: transcript === undefined })
237
+ if (transcript !== undefined) {
238
+ return createElement(DocumentPanel, {
239
+ title: `transcript · ${transcript.id}`,
240
+ text: transcript.text,
241
+ error: transcript.error,
242
+ close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
243
+ })
244
+ }
245
+ const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
246
+ return createElement(ListFrame, {
247
+ title: `/resume · ${toolbar}`,
248
+ rows: rows.map(row => ({
249
+ key: row.id,
250
+ disabled: !row.resumable,
251
+ text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
252
+ })), cursor, loading, error, query: options.query,
253
+ footer: 'type search · tab/←→ filters · ↑↓/pg navigate · e details · t transcript · enter resume',
254
+ })
255
+ }
256
+
257
+ function DocumentPanel({ title, text, error, close }: {
258
+ title: string
259
+ text?: string
260
+ error?: string
261
+ close(): void
262
+ }): ReactElement {
263
+ const stdout = useStdout().stdout
264
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
265
+ const [scroll, setScroll] = useState(0)
266
+ const lines = useMemo(() => text === undefined ? [] : textLines(text, viewport.contentColumns).map(line => line.segments.map(segment => segment.text).join('')), [text, viewport.contentColumns])
267
+ useInput((input, key) => {
268
+ if (key.escape || input === 'q' || input === 't') return close()
269
+ if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
270
+ if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
271
+ if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
272
+ if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
273
+ if (input === 'g') return setScroll(0)
274
+ if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
275
+ })
276
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
277
+ const body = error !== undefined
278
+ ? [`error: ${singleLineText(error)}`]
279
+ : text === undefined
280
+ ? ['loading transcript…']
281
+ : lines.slice(scroll, scroll + viewport.bodyRows)
282
+ return createElement(
283
+ Box,
284
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
285
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
286
+ ...body.map((line, index) => createElement(Text, { key: `${scroll}-${index}`, wrap: 'truncate-end' }, truncateColumns(line, viewport.contentColumns))),
287
+ 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)),
288
+ )
289
+ }
290
+
291
+ /**
292
+ * The /history recall panel (Codex composer-history search, bounded): one
293
+ * query line over the newest-first recall space, filtered by substring, with
294
+ * arrow selection and enter to fill the composer. Editing the query restarts
295
+ * from the newest match; Esc closes without touching the draft.
296
+ */
297
+ export function HistoryPanel({ entries, fill, close }: {
298
+ /** Newest-first recall entries (persistent + in-session, deduped). */
299
+ entries: readonly string[]
300
+ /** Accept one entry: its text plus its recall-space index (browsing resumes there). */
301
+ fill(text: string, index: number): void
302
+ close(): void
303
+ }): ReactElement {
304
+ const [query, setQuery] = useState('')
305
+ const [cursor, setCursor] = useState(0)
306
+ const matches = query === ''
307
+ ? entries
308
+ : entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
309
+ useInput((input, key) => {
310
+ if (key.escape) return close()
311
+ if (key.return) {
312
+ const entry = matches[cursor]
313
+ if (entry !== undefined) fill(entry, entries.indexOf(entry))
314
+ return
315
+ }
316
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
317
+ if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
318
+ if (input === 'g') return setCursor(0)
319
+ if (input === 'G') return setCursor(matches.length - 1)
320
+ if (key.backspace) {
321
+ setQuery(current => current.slice(0, -1))
322
+ setCursor(0)
323
+ return
324
+ }
325
+ if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
326
+ setQuery(current => (current + input).slice(0, 120))
327
+ setCursor(0)
328
+ }
329
+ })
330
+ const stdout = useStdout().stdout
331
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
332
+ if (viewport.compact) {
333
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
334
+ }
335
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
336
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
337
+ const offset = revealRow(0, cursor, matches.length, bodyRows)
338
+ const visible = matches.slice(offset, offset + bodyRows)
339
+ const header = query === ''
340
+ ? `/history · ${entries.length} prompts · type to filter`
341
+ : `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
342
+ return createElement(
343
+ Box,
344
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
345
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
346
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
347
+ ...(visible.length === 0
348
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
349
+ : visible.map((entry, index) => {
350
+ const absolute = offset + index
351
+ const selected = absolute === cursor
352
+ return createElement(
353
+ Text,
354
+ {
355
+ key: `history-${absolute}`,
356
+ color: selected ? inkColor(getPalette().brandBright) : undefined,
357
+ wrap: 'truncate-end',
358
+ },
359
+ truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
360
+ )
361
+ })),
362
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
363
+ )
364
+ }
365
+
366
+ /**
367
+ * The /statusline picker (the Codex setup-view contract): one bounded list
368
+ * of every status item with its enabled mark, arrow reordering, and a
369
+ * live preview the real status line under the composer updates as you
370
+ * edit, so the panel itself carries no duplicate preview row.
371
+ */
372
+ export function StatuslinePanel({ enabled, change, close }: {
373
+ enabled: readonly StatusItemId[]
374
+ change(items: readonly StatusItemId[]): void
375
+ close(): void
376
+ }): ReactElement {
377
+ // Working state: the full catalog in display order (enabled entries in
378
+ // their configured positions, disabled ones trailing canonically) plus
379
+ // the enabled set. Persisted shape is the enabled subsequence only.
380
+ const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
381
+ const seen = new Set(enabled)
382
+ return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
383
+ })
384
+ const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
385
+ const [cursor, setCursor] = useState(0)
386
+ const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
387
+ setOrder(nextOrder)
388
+ setOn(nextOn)
389
+ change(nextOrder.filter(id => nextOn.has(id)))
390
+ }
391
+ const move = (offset: number): void => {
392
+ const target = cursor + offset
393
+ if (target < 0 || target >= order.length) return
394
+ const next = [...order]
395
+ const [item] = next.splice(cursor, 1)
396
+ next.splice(target, 0, item!)
397
+ commit(next, on)
398
+ setCursor(target)
399
+ }
400
+ useInput((input, key) => {
401
+ if (key.escape || input === 'q' || key.return) return close()
402
+ if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
403
+ if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
404
+ if (key.leftArrow) return move(-1)
405
+ if (key.rightArrow) return move(1)
406
+ if (input === 'g') return setCursor(0)
407
+ if (input === 'G') return setCursor(order.length - 1)
408
+ if (input === 'd') {
409
+ commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
410
+ setCursor(0)
411
+ return
412
+ }
413
+ if (input === ' ') {
414
+ const item = order[cursor]
415
+ if (item === undefined) return
416
+ const next = new Set(on)
417
+ if (next.has(item)) next.delete(item)
418
+ else next.add(item)
419
+ commit(order, next)
420
+ }
421
+ })
422
+ const stdout = useStdout().stdout
423
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
424
+ if (viewport.compact) {
425
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
426
+ }
427
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
428
+ const bodyRows = Math.max(1, viewport.bodyRows - 1)
429
+ const offset = revealRow(0, cursor, order.length, bodyRows)
430
+ const visible = order.slice(offset, offset + bodyRows)
431
+ const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
432
+ return createElement(
433
+ Box,
434
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
435
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
436
+ ...visible.map((id, index) => {
437
+ const absolute = offset + index
438
+ const selected = absolute === cursor
439
+ const info = meta.get(id)
440
+ return createElement(
441
+ Text,
442
+ {
443
+ key: id,
444
+ color: selected ? inkColor(getPalette().brandBright) : undefined,
445
+ dimColor: !on.has(id) || undefined,
446
+ wrap: 'truncate-end',
447
+ },
448
+ truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
449
+ )
450
+ }),
451
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
452
+ )
453
+ }
454
+
455
+ /**
456
+ * The `/model` reasoning-effort stage (the Codex model → reasoning popup
457
+ * contract): one bounded list over the selected model's adapter-advertised
458
+ * effort levels, with the effective effort and the model default marked.
459
+ * A model WITHOUT an adapter-declared default leads with a "Default"
460
+ * (provider-default) row — the web effort pane's first entry — so the user
461
+ * can clear a picked level back to provider behavior instead of being forced
462
+ * to choose an advertised one. Enter applies one level; Esc returns to the
463
+ * model list without applying.
464
+ */
465
+ export function EffortPanel({ row, current, select, back }: {
466
+ /** The model row whose advertised levels this stage lists. */
467
+ row: ModelRow
468
+ /** Effective effort currently in force ('' when none), for the ● mark. */
469
+ current: string | undefined
470
+ /** Accept one advertised effort id, or '' for the provider default. */
471
+ select(effortId: string): void
472
+ /** Return to the model list without applying. */
473
+ back(): void
474
+ }): ReactElement {
475
+ const [cursor, setCursor] = useState(0)
476
+ const efforts = row.reasoning?.efforts ?? []
477
+ // The provider-default row only exists when the adapter declares no default
478
+ // effort: with one, the default is an advertised level already in the list.
479
+ const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined
480
+ const rows = hasDefaultRow
481
+ ? [{ id: '', name: 'Default' }, ...efforts]
482
+ : efforts
483
+ // An absent or cleared effort is the Default row's current state.
484
+ const effective = current === undefined || current === '' ? '' : current
485
+ useEffect(() => {
486
+ if (rows.length === 0) {
487
+ if (cursor !== 0) setCursor(0)
488
+ return
489
+ }
490
+ if (cursor >= rows.length) setCursor(rows.length - 1)
491
+ }, [rows.length, cursor])
492
+ useInput((input, key) => {
493
+ if (key.escape || input === 'q') return back()
494
+ if (rows.length === 0) return
495
+ if (key.upArrow) {
496
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
497
+ return
498
+ }
499
+ if (key.downArrow) {
500
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
501
+ return
502
+ }
503
+ if (key.return && rows[cursor] !== undefined) {
504
+ select(rows[cursor]!.id)
505
+ }
506
+ })
507
+ return createElement(ListFrame, {
508
+ title: `/model — effort for ${row.providerName} · ${row.modelName}`,
509
+ rows: rows.map(effort => ({
510
+ key: effort.id,
511
+ text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
512
+ })),
513
+ cursor,
514
+ loading: false,
515
+ query: '',
516
+ footer: '↑↓ choose · enter apply · esc/q back',
517
+ })
518
+ }