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