dsh-code 1.0.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +70 -24
- package/README.md +71 -25
- package/bin/deepseek.mjs +202 -39
- package/cordis.patch.yml +13 -4
- package/lib/index.mjs +4063 -844
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
- package/lib/types/app.d.ts +100 -63
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +73 -1
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +86 -31
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +450 -0
- package/lib/types/locales/zh.d.ts +9 -0
- package/lib/types/mentions.d.ts +7 -3
- package/lib/types/models.d.ts +14 -0
- package/lib/types/panel-accent.d.ts +28 -0
- package/lib/types/rainbow.d.ts +69 -0
- package/lib/types/render/animations.d.ts +42 -0
- package/lib/types/render/inspector.d.ts +26 -0
- package/lib/types/render/lines.d.ts +21 -1
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +95 -4
- package/lib/types/render/status.d.ts +8 -8
- package/lib/types/render/text.d.ts +6 -0
- package/lib/types/render/usage.d.ts +113 -0
- package/lib/types/session-directory.d.ts +17 -0
- package/lib/types/startup.d.ts +1 -1
- package/lib/types/terminal-title.d.ts +8 -0
- package/lib/types/theme-panel.d.ts +2 -2
- package/lib/types/theme.d.ts +271 -52
- package/lib/types/update-panel.d.ts +5 -5
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +4 -3
- package/package.json +24 -7
- package/src/app.ts +1155 -478
- package/src/approval.ts +166 -166
- package/src/authorization-panel.ts +19 -16
- package/src/editor-keys.ts +371 -371
- package/src/git-workflow.ts +229 -3
- package/src/i18n.ts +68 -0
- package/src/index.ts +412 -76
- package/src/input-split.ts +3 -3
- package/src/kernel-panels.ts +471 -89
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +489 -0
- package/src/locales/zh.ts +488 -0
- package/src/mentions.ts +8 -4
- package/src/models.ts +264 -212
- package/src/panel-accent.ts +41 -0
- package/src/presets.ts +1 -1
- package/src/provider-settings.ts +1 -1
- package/src/rainbow.ts +208 -0
- package/src/render/animations.ts +104 -6
- package/src/render/editor.ts +20 -20
- package/src/render/export.ts +116 -95
- package/src/render/inspector.ts +42 -0
- package/src/render/lines.ts +628 -415
- package/src/render/markdown.ts +15 -3
- package/src/render/projection.ts +429 -19
- package/src/render/status.ts +41 -35
- package/src/render/text.ts +14 -0
- package/src/render/tool-preview.ts +77 -77
- package/src/render/usage.ts +430 -0
- package/src/render/width.ts +2 -2
- package/src/session-directory.ts +8 -6
- package/src/session-query.ts +8 -4
- package/src/startup.ts +3 -3
- package/src/subagents.ts +229 -229
- package/src/terminal-title.ts +22 -5
- package/src/theme-panel.ts +17 -21
- package/src/theme.ts +281 -33
- package/src/update-panel.ts +37 -27
- package/src/update.ts +19 -3
- package/src/version.ts +58 -20
- package/src/whale-glyph.ts +23 -23
package/src/kernel-panels.ts
CHANGED
|
@@ -10,12 +10,16 @@ import type { PresetRow } from './presets.ts'
|
|
|
10
10
|
import type { PluginRow } from './plugin-inventory.ts'
|
|
11
11
|
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
12
12
|
import { formatRelativeTime } from './session-directory.ts'
|
|
13
|
+
import type { ReviewBranch, ReviewCommit, ReviewSelection } from './git-workflow.ts'
|
|
13
14
|
import { panelViewport, revealRow } from './render/inspector.ts'
|
|
14
15
|
import { markdownLines, textLines, type LineStyle, type StyledLine } from './render/lines.ts'
|
|
16
|
+
import { usageLines, type UsageView } from './render/usage.ts'
|
|
15
17
|
import { deleteLastGrapheme } from './render/editor.ts'
|
|
16
18
|
import { stripPasteMarkers } from './keyboard.ts'
|
|
17
19
|
import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
|
|
18
20
|
import { singleLineText, truncateColumns } from './render/text.ts'
|
|
21
|
+
import { panelAccent } from './panel-accent.ts'
|
|
22
|
+
import { t } from './i18n.ts'
|
|
19
23
|
import { getPalette, inkColor } from './theme.ts'
|
|
20
24
|
|
|
21
25
|
interface ListFrameProps {
|
|
@@ -40,9 +44,9 @@ function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
|
|
|
40
44
|
/** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
|
|
41
45
|
* toggle lives in the footer), direct-typing panels keep the plain prompt. */
|
|
42
46
|
function searchLine(searching: boolean | undefined, query: string): string {
|
|
43
|
-
if (searching === true) return
|
|
44
|
-
if (searching === false) return query === '' ? '' :
|
|
45
|
-
return
|
|
47
|
+
if (searching === true) return query === '' ? t('panel.searchIdleStop') : `${t('panel.searchPrefix')}${query}`
|
|
48
|
+
if (searching === false) return query === '' ? '' : `${t('panel.searchPrefix')}${query}`
|
|
49
|
+
return query === '' ? t('panel.searchIdle') : `${t('panel.searchPrefix')}${query}`
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
function ListFrame(props: ListFrameProps): ReactElement {
|
|
@@ -72,10 +76,11 @@ function ListFrame(props: ListFrameProps): ReactElement {
|
|
|
72
76
|
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
73
77
|
const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
|
|
74
78
|
const visible = stateRows.slice(offset, offset + bodyRows)
|
|
79
|
+
const accent = panelAccent('kernel-list', getPalette().dim, getPalette().brandBright)
|
|
75
80
|
return createElement(
|
|
76
81
|
Box,
|
|
77
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
78
|
-
createElement(Text, { color: inkColor(
|
|
82
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
83
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
|
|
79
84
|
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
|
|
80
85
|
...visible.map((row, index) => {
|
|
81
86
|
const absolute = offset + index
|
|
@@ -105,9 +110,9 @@ export function editQuery(query: string, input: string, key: { backspace?: boole
|
|
|
105
110
|
|
|
106
111
|
export function ModePanel({ current, load, select, close }: {
|
|
107
112
|
current: string
|
|
108
|
-
load()
|
|
109
|
-
select(id: string)
|
|
110
|
-
close()
|
|
113
|
+
load: () => Promise<readonly PresetRow[]>
|
|
114
|
+
select: (id: string) => void
|
|
115
|
+
close: () => void
|
|
111
116
|
}): ReactElement {
|
|
112
117
|
const [rows, setRows] = useState<readonly PresetRow[]>([])
|
|
113
118
|
const [query, setQuery] = useState('')
|
|
@@ -133,22 +138,22 @@ export function ModePanel({ current, load, select, close }: {
|
|
|
133
138
|
// Empty/loading/filtered-out lists have no row at the cursor: a bare
|
|
134
139
|
// `?.broken === undefined` check passes on undefined and crashes the
|
|
135
140
|
// process on the `!.id` access (PermissionPanel guards this correctly).
|
|
136
|
-
if (key.return && visible[cursor] !== undefined && visible[cursor]
|
|
141
|
+
if (key.return && visible[cursor] !== undefined && visible[cursor].broken === undefined) return select(visible[cursor].id)
|
|
137
142
|
const next = editQuery(query, input, key)
|
|
138
143
|
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
139
144
|
})
|
|
140
145
|
return createElement(ListFrame, {
|
|
141
|
-
title:
|
|
146
|
+
title: t('panel.mode.title', { current }),
|
|
142
147
|
rows: visible.map(row => ({ key: row.id, disabled: row.broken !== undefined, text: `${row.id === current ? '●' : '○'} ${row.name ?? row.id} · ${row.description ?? row.trust}${row.broken === undefined ? '' : ` · broken: ${row.broken}`}` })),
|
|
143
|
-
cursor, loading, error, query, footer: '
|
|
148
|
+
cursor, loading, error, query, footer: t('panel.footer.chooseSwitch'),
|
|
144
149
|
})
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
export function PermissionPanel({ current, load, select, close }: {
|
|
148
153
|
current: string
|
|
149
|
-
load()
|
|
150
|
-
select(id: string)
|
|
151
|
-
close()
|
|
154
|
+
load: () => Promise<readonly PermissionRow[]>
|
|
155
|
+
select: (id: string) => void
|
|
156
|
+
close: () => void
|
|
152
157
|
}): ReactElement {
|
|
153
158
|
const [rows, setRows] = useState<readonly PermissionRow[]>([])
|
|
154
159
|
const [query, setQuery] = useState('')
|
|
@@ -171,18 +176,18 @@ export function PermissionPanel({ current, load, select, close }: {
|
|
|
171
176
|
if (input === 'r' && query === '') return refresh()
|
|
172
177
|
if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
|
|
173
178
|
if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
|
|
174
|
-
if (key.return && visible[cursor] !== undefined) return select(visible[cursor]
|
|
179
|
+
if (key.return && visible[cursor] !== undefined) return select(visible[cursor].id)
|
|
175
180
|
const next = editQuery(query, input, key)
|
|
176
181
|
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
177
182
|
})
|
|
178
183
|
return createElement(ListFrame, {
|
|
179
|
-
title:
|
|
184
|
+
title: t('panel.permission.title', { current }),
|
|
180
185
|
rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
|
|
181
|
-
cursor, loading, error, query, footer: '
|
|
186
|
+
cursor, loading, error, query, footer: t('panel.footer.chooseSelect'),
|
|
182
187
|
})
|
|
183
188
|
}
|
|
184
189
|
|
|
185
|
-
export function PluginPanel({ load, close, initialQuery = '' }: { load()
|
|
190
|
+
export function PluginPanel({ load, close, initialQuery = '' }: { load: () => readonly PluginRow[]; close: () => void; initialQuery?: string }): ReactElement {
|
|
186
191
|
const [epoch, setEpoch] = useState(0)
|
|
187
192
|
const [query, setQuery] = useState(initialQuery)
|
|
188
193
|
const [cursor, setCursor] = useState(0)
|
|
@@ -201,12 +206,12 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
|
|
|
201
206
|
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
202
207
|
})
|
|
203
208
|
return createElement(ListFrame, {
|
|
204
|
-
title: '
|
|
209
|
+
title: t('panel.plugin.title'),
|
|
205
210
|
rows: rows.map((row, index) => ({
|
|
206
211
|
key: row.entryId,
|
|
207
212
|
disabled: !row.enabled,
|
|
208
213
|
text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
|
|
209
|
-
})), cursor, loading: false, query, footer: '
|
|
214
|
+
})), cursor, loading: false, query, footer: t('panel.footer.inspectDetails'),
|
|
210
215
|
})
|
|
211
216
|
}
|
|
212
217
|
|
|
@@ -252,7 +257,7 @@ const JOB_MARK: Record<JobRow['status'], string> = {
|
|
|
252
257
|
* interval dies with the panel). Cancel stays upstream-only; an absent
|
|
253
258
|
* registry renders as the plain empty state (a harmless missing service).
|
|
254
259
|
*/
|
|
255
|
-
export function JobsPanel({ load, close }: { load()
|
|
260
|
+
export function JobsPanel({ load, close }: { load: () => readonly JobRow[]; close: () => void }): ReactElement {
|
|
256
261
|
const [, setRefresh] = useState(0)
|
|
257
262
|
const [cursor, setCursor] = useState(0)
|
|
258
263
|
const [, setTick] = useState(0)
|
|
@@ -269,29 +274,29 @@ export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close():
|
|
|
269
274
|
})
|
|
270
275
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
271
276
|
return createElement(ListFrame, {
|
|
272
|
-
title:
|
|
277
|
+
title: t('panel.jobs.title', { count: rows.length }),
|
|
273
278
|
rows: rows.map(row => ({
|
|
274
279
|
key: row.id,
|
|
275
280
|
text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
|
|
276
281
|
})),
|
|
277
|
-
cursor, loading: false, query: '', searching: false, footer: '
|
|
282
|
+
cursor, loading: false, query: '', searching: false, footer: t('panel.footer.inspectRefresh'),
|
|
278
283
|
})
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
|
|
282
287
|
currentCwd: string
|
|
283
|
-
load(options: SessionDirectoryOptions, signal?: AbortSignal)
|
|
284
|
-
readTranscript(id: string, signal?: AbortSignal)
|
|
285
|
-
select(row: SessionRow)
|
|
288
|
+
load: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
|
|
289
|
+
readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
|
|
290
|
+
select: (row: SessionRow) => void
|
|
286
291
|
/** Arm the composer-based delete confirm for one row (App owns the keys). */
|
|
287
|
-
requestDelete
|
|
292
|
+
requestDelete?: (row: SessionRow) => void
|
|
288
293
|
/** The row id awaiting y/n in the composer, when any (App-owned). */
|
|
289
294
|
deleteConfirmId?: string
|
|
290
295
|
/** Bump to reload the listing (e.g. after a deletion). */
|
|
291
296
|
reloadToken?: number
|
|
292
297
|
/** Opened via /delete: hint-first delete mode. */
|
|
293
298
|
deleteMode?: boolean
|
|
294
|
-
close()
|
|
299
|
+
close: () => void
|
|
295
300
|
}): ReactElement {
|
|
296
301
|
// Codex resume-picker default: the CURRENT directory's root sessions; the
|
|
297
302
|
// cwd filter widens to all only on request (the old default leaked every
|
|
@@ -358,12 +363,12 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
358
363
|
if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
|
|
359
364
|
if (input === 'g') return setCursor(0)
|
|
360
365
|
if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
|
|
361
|
-
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]
|
|
366
|
+
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor])
|
|
362
367
|
if (input === 'e' && rows[cursor] !== undefined) {
|
|
363
|
-
return setExpanded(value => value === rows[cursor]
|
|
368
|
+
return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id)
|
|
364
369
|
}
|
|
365
370
|
if (input === 't' && rows[cursor] !== undefined) {
|
|
366
|
-
const row = rows[cursor]
|
|
371
|
+
const row = rows[cursor]
|
|
367
372
|
transcriptLoad.current?.abort()
|
|
368
373
|
setTranscript({ id: row.id })
|
|
369
374
|
const controller = new AbortController()
|
|
@@ -374,11 +379,11 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
374
379
|
)
|
|
375
380
|
return
|
|
376
381
|
}
|
|
377
|
-
if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]
|
|
382
|
+
if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor])
|
|
378
383
|
}, { isActive: transcript === undefined })
|
|
379
384
|
if (transcript !== undefined) {
|
|
380
385
|
return createElement(DocumentPanel, {
|
|
381
|
-
title:
|
|
386
|
+
title: t('panel.document.title', { id: transcript.id }),
|
|
382
387
|
text: transcript.text,
|
|
383
388
|
error: transcript.error,
|
|
384
389
|
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
|
|
@@ -395,7 +400,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
395
400
|
disabled: !row.resumable,
|
|
396
401
|
text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${formatRelativeTime(row.updatedAt ?? row.createdAt, now)} · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
|
|
397
402
|
})), cursor, loading, error, query: options.query, searching,
|
|
398
|
-
footer: '
|
|
403
|
+
footer: t('panel.footer.resume'),
|
|
399
404
|
})
|
|
400
405
|
}
|
|
401
406
|
|
|
@@ -440,7 +445,7 @@ function DocumentPanel({ title, text, error, close }: {
|
|
|
440
445
|
title: string
|
|
441
446
|
text?: string
|
|
442
447
|
error?: string
|
|
443
|
-
close()
|
|
448
|
+
close: () => void
|
|
444
449
|
}): ReactElement {
|
|
445
450
|
const stdout = useStdout().stdout
|
|
446
451
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
@@ -455,18 +460,19 @@ function DocumentPanel({ title, text, error, close }: {
|
|
|
455
460
|
if (input === 'g') return setScroll(0)
|
|
456
461
|
if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
457
462
|
})
|
|
458
|
-
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('
|
|
463
|
+
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('document.compact'), viewport.contentColumns))
|
|
459
464
|
const body: readonly StyledLine[] = error !== undefined
|
|
460
465
|
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
461
466
|
: text === undefined
|
|
462
|
-
? textLines('loading
|
|
467
|
+
? textLines(t('document.loading'), viewport.contentColumns, 'dim')
|
|
463
468
|
: lines.slice(scroll, scroll + viewport.bodyRows)
|
|
469
|
+
const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright)
|
|
464
470
|
return createElement(
|
|
465
471
|
Box,
|
|
466
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
467
|
-
createElement(Text, { color: inkColor(
|
|
472
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
473
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
|
|
468
474
|
createElement(DocumentRows, { lines: body }),
|
|
469
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
475
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('document.footer', { from: lines.length === 0 ? 0 : scroll + 1, to: Math.min(lines.length, scroll + viewport.bodyRows), total: lines.length }), viewport.contentColumns)),
|
|
470
476
|
)
|
|
471
477
|
}
|
|
472
478
|
|
|
@@ -480,8 +486,8 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
480
486
|
/** Newest-first recall entries (persistent + in-session, deduped). */
|
|
481
487
|
entries: readonly string[]
|
|
482
488
|
/** Accept one entry: its text plus its recall-space index (browsing resumes there). */
|
|
483
|
-
fill(text: string, index: number)
|
|
484
|
-
close()
|
|
489
|
+
fill: (text: string, index: number) => void
|
|
490
|
+
close: () => void
|
|
485
491
|
}): ReactElement {
|
|
486
492
|
const [query, setQuery] = useState('')
|
|
487
493
|
const [cursor, setCursor] = useState(0)
|
|
@@ -497,14 +503,18 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
497
503
|
}
|
|
498
504
|
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
499
505
|
if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (
|
|
506
|
+
// g/G stay vim-style jumps only on an empty query (the /mode contract):
|
|
507
|
+
// mid-filter they are query text, so filters like 'Fix' or 'grep' survive.
|
|
508
|
+
if (input === 'g' && query === '') return setCursor(0)
|
|
509
|
+
if (input === 'G' && query === '') return setCursor(matches.length - 1)
|
|
510
|
+
if (key.backspace || key.delete) {
|
|
503
511
|
setQuery(current => deleteLastGrapheme(current))
|
|
504
512
|
setCursor(0)
|
|
505
513
|
return
|
|
506
514
|
}
|
|
507
|
-
|
|
515
|
+
// Ink reports single uppercase letters and shifted symbols ('!', '@')
|
|
516
|
+
// with key.shift set; only ctrl/meta mark real command input.
|
|
517
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
508
518
|
const text = stripPasteMarkers(input)
|
|
509
519
|
if (text !== '') {
|
|
510
520
|
setQuery(current => (current + text).slice(0, 120))
|
|
@@ -516,21 +526,22 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
516
526
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
517
527
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
518
528
|
const picked = matches[cursor]
|
|
519
|
-
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(
|
|
529
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/history · ${picked === undefined ? t('history.compact.none') : singleLineText(picked)} · ${t('panel.close')}`, viewport.contentColumns))
|
|
520
530
|
}
|
|
521
531
|
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
522
532
|
const offset = revealRow(0, cursor, matches.length, bodyRows)
|
|
523
533
|
const visible = matches.slice(offset, offset + bodyRows)
|
|
524
534
|
const header = query === ''
|
|
525
|
-
?
|
|
526
|
-
:
|
|
535
|
+
? t('history.title.prompts', { count: entries.length })
|
|
536
|
+
: t('history.title.match', { matches: matches.length, count: entries.length, query: truncateColumns(singleLineText(query), Math.max(6, viewport.contentColumns - 34)) })
|
|
537
|
+
const accent = panelAccent('history', getPalette().dim, getPalette().brandBright)
|
|
527
538
|
return createElement(
|
|
528
539
|
Box,
|
|
529
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
530
|
-
createElement(Text, { color: inkColor(
|
|
531
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
540
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
541
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
542
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.filterHint', { state: query === '' ? t('history.filterEmpty') : t('history.filterQuery', { query: singleLineText(query) }) }), viewport.contentColumns)),
|
|
532
543
|
...(visible.length === 0
|
|
533
|
-
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns('
|
|
544
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.empty'), viewport.contentColumns))]
|
|
534
545
|
: visible.map((entry, index) => {
|
|
535
546
|
const absolute = offset + index
|
|
536
547
|
const selected = absolute === cursor
|
|
@@ -544,7 +555,313 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
544
555
|
truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
|
|
545
556
|
)
|
|
546
557
|
})),
|
|
547
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('
|
|
558
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.footer'), viewport.contentColumns)),
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The /review candidate picker (Codex's preset popup): bare /review opens a
|
|
564
|
+
* four-way preset — review uncommitted changes, pick a base branch, pick a
|
|
565
|
+
* recent commit, or type a custom focus. Branch and commit phases are
|
|
566
|
+
* type-to-filter lists; every selection resolves to the same /review
|
|
567
|
+
* argument string the direct command accepts.
|
|
568
|
+
*/
|
|
569
|
+
export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }: {
|
|
570
|
+
loadBranches: (signal?: AbortSignal) => Promise<readonly ReviewBranch[]>
|
|
571
|
+
loadCommits: (signal?: AbortSignal) => Promise<readonly ReviewCommit[]>
|
|
572
|
+
/** Run the review for one picker selection. */
|
|
573
|
+
choose: (selection: ReviewSelection) => void
|
|
574
|
+
close: () => void
|
|
575
|
+
}): ReactElement {
|
|
576
|
+
const [phase, setPhase] = useState<'preset' | 'branches' | 'commits' | 'custom'>('preset')
|
|
577
|
+
const [cursor, setCursor] = useState(0)
|
|
578
|
+
const [query, setQuery] = useState('')
|
|
579
|
+
const [rows, setRows] = useState<readonly (ReviewBranch | ReviewCommit)[]>([])
|
|
580
|
+
const [loading, setLoading] = useState(false)
|
|
581
|
+
const [error, setError] = useState<string>()
|
|
582
|
+
const loadRef = useRef<AbortController>()
|
|
583
|
+
const now = useMemo(() => Date.now(), [rows])
|
|
584
|
+
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
if (phase !== 'branches' && phase !== 'commits') return undefined
|
|
587
|
+
loadRef.current?.abort()
|
|
588
|
+
const controller = new AbortController()
|
|
589
|
+
loadRef.current = controller
|
|
590
|
+
setLoading(true)
|
|
591
|
+
setError(undefined)
|
|
592
|
+
const load = phase === 'branches'
|
|
593
|
+
? (signal?: AbortSignal) => loadBranches(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
|
|
594
|
+
: (signal?: AbortSignal) => loadCommits(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
|
|
595
|
+
void Promise.resolve().then(() => load(controller.signal)).then(list => {
|
|
596
|
+
if (controller.signal.aborted) return
|
|
597
|
+
setLoading(false)
|
|
598
|
+
setRows(list)
|
|
599
|
+
setCursor(0)
|
|
600
|
+
}, reason => {
|
|
601
|
+
if (controller.signal.aborted) return
|
|
602
|
+
setLoading(false)
|
|
603
|
+
setRows([])
|
|
604
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
605
|
+
})
|
|
606
|
+
return () => controller.abort()
|
|
607
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
608
|
+
}, [phase])
|
|
609
|
+
|
|
610
|
+
const filtered = useMemo(() => {
|
|
611
|
+
if (phase !== 'branches' && phase !== 'commits') return []
|
|
612
|
+
const needle = query.toLowerCase()
|
|
613
|
+
if (needle === '') return rows
|
|
614
|
+
return rows.filter(row => {
|
|
615
|
+
const hay = 'name' in row ? row.name : `${row.sha} ${row.title}`
|
|
616
|
+
return hay.toLowerCase().includes(needle)
|
|
617
|
+
})
|
|
618
|
+
}, [phase, rows, query])
|
|
619
|
+
|
|
620
|
+
const submit = (selection: ReviewSelection): void => {
|
|
621
|
+
choose(selection)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
useInput((input, key) => {
|
|
625
|
+
if (key.escape || (input === 'q' && query === '' && phase !== 'custom')) {
|
|
626
|
+
if (phase !== 'preset') {
|
|
627
|
+
setPhase('preset')
|
|
628
|
+
setQuery('')
|
|
629
|
+
setCursor(0)
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
return close()
|
|
633
|
+
}
|
|
634
|
+
if (key.ctrl && input === 'c') return close()
|
|
635
|
+
if (phase === 'custom' || ((phase === 'branches' || phase === 'commits') && query !== '')) {
|
|
636
|
+
const next = editQuery(query, input, key)
|
|
637
|
+
if (next !== undefined) {
|
|
638
|
+
setQuery(next)
|
|
639
|
+
setCursor(0)
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// The row budget per phase: the preset list is fixed at four rows, the
|
|
644
|
+
// branch/commit lists clamp to their filtered length.
|
|
645
|
+
const rowCount = phase === 'preset' ? 4 : filtered.length
|
|
646
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
647
|
+
if (key.downArrow) return setCursor(value => Math.min(Math.max(0, rowCount - 1), value + 1))
|
|
648
|
+
if (key.return) {
|
|
649
|
+
if (phase === 'preset') {
|
|
650
|
+
if (cursor === 0) return submit({ kind: 'uncommitted' })
|
|
651
|
+
if (cursor === 1) return setPhase('branches')
|
|
652
|
+
if (cursor === 2) return setPhase('commits')
|
|
653
|
+
return setPhase('custom')
|
|
654
|
+
}
|
|
655
|
+
if (phase === 'branches' || phase === 'commits') {
|
|
656
|
+
const row = filtered[cursor]
|
|
657
|
+
if (row !== undefined) submit('name' in row ? { kind: 'base-branch', branch: row.name } : { kind: 'commit', sha: row.sha })
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
if (query.trim() !== '') submit({ kind: 'custom', instructions: query.trim() })
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
const stdout = useStdout().stdout
|
|
666
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
667
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
668
|
+
const state = phase === 'preset' ? 'pick a review target' : loading ? 'loading…' : error !== undefined ? `error: ${error}` : `${filtered.length} candidates`
|
|
669
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/review · ${state} · ${t('panel.close')}`, viewport.contentColumns))
|
|
670
|
+
}
|
|
671
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
672
|
+
const offset = revealRow(0, cursor, phase === 'preset' ? 4 : filtered.length, bodyRows)
|
|
673
|
+
const header = phase === 'preset'
|
|
674
|
+
? t('review.picker.title')
|
|
675
|
+
: phase === 'branches'
|
|
676
|
+
? loading ? t('review.picker.loadingBranches') : error !== undefined ? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) }) : t('review.picker.branches', { filtered: filtered.length, total: rows.length })
|
|
677
|
+
: phase === 'commits'
|
|
678
|
+
? loading ? t('review.picker.loadingCommits') : error !== undefined ? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) }) : t('review.picker.commits', { filtered: filtered.length, total: rows.length })
|
|
679
|
+
: t('review.picker.customHint')
|
|
680
|
+
const accent = panelAccent('review-picker', getPalette().dim, getPalette().brandBright)
|
|
681
|
+
return createElement(
|
|
682
|
+
Box,
|
|
683
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
684
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
685
|
+
...(phase === 'preset'
|
|
686
|
+
? [
|
|
687
|
+
t('review.picker.uncommitted'),
|
|
688
|
+
t('review.picker.branch'),
|
|
689
|
+
t('review.picker.commit'),
|
|
690
|
+
t('review.picker.custom'),
|
|
691
|
+
].map((label, index) => {
|
|
692
|
+
const absolute = offset + index
|
|
693
|
+
const selected = absolute === cursor
|
|
694
|
+
return createElement(
|
|
695
|
+
Text,
|
|
696
|
+
{ key: `preset-${index}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
|
|
697
|
+
truncateColumns(`${selected ? '› ' : ' '}${label}`, viewport.contentColumns),
|
|
698
|
+
)
|
|
699
|
+
})
|
|
700
|
+
: phase === 'custom'
|
|
701
|
+
? [createElement(Text, { key: 'custom-input', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${query === '' ? t('review.picker.customHint') : singleLineText(query)}`, viewport.contentColumns))]
|
|
702
|
+
: filtered.length === 0
|
|
703
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${loading ? t('review.picker.loading') : error !== undefined ? t('review.picker.loadFailed') : query === '' ? t('review.picker.empty') : t('review.picker.noMatch')}`, viewport.contentColumns))]
|
|
704
|
+
: filtered.slice(offset, offset + bodyRows).map((row, index) => {
|
|
705
|
+
const absolute = offset + index
|
|
706
|
+
const selected = absolute === cursor
|
|
707
|
+
const label = 'name' in row ? row.name : `${row.sha.slice(0, 7)} · ${formatRelativeTime(row.at, now)} · ${row.title}`
|
|
708
|
+
return createElement(
|
|
709
|
+
Text,
|
|
710
|
+
{ key: `row-${absolute}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
|
|
711
|
+
truncateColumns(`${selected ? '› ' : ' '}${singleLineText(label)}`, viewport.contentColumns),
|
|
712
|
+
)
|
|
713
|
+
})),
|
|
714
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('review.picker.footer'), viewport.contentColumns)),
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** One cross-session full-text search hit mapped from the session-query engine. */
|
|
719
|
+
export interface SearchRow {
|
|
720
|
+
/** Session id (Enter resumes it through the switch machinery). */
|
|
721
|
+
readonly id: string
|
|
722
|
+
/** Display label: session title or the short id form. */
|
|
723
|
+
readonly label: string
|
|
724
|
+
/** Secondary facts line (workspace · preset markers). */
|
|
725
|
+
readonly detail: string
|
|
726
|
+
/** Bounded plain-text excerpt around the strongest match. */
|
|
727
|
+
readonly snippet: string
|
|
728
|
+
/** Match timestamp (relative labels derive from it). */
|
|
729
|
+
readonly updatedAt: number
|
|
730
|
+
/** Whether the hit is a delegated subagent conversation (not resumable). */
|
|
731
|
+
readonly subagent: boolean
|
|
732
|
+
/** Whether Enter may switch into it. */
|
|
733
|
+
readonly resumable: boolean
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The /search panel: full-text search over every persisted session through
|
|
738
|
+
* the in-process session-query engine (the same corpus the model's
|
|
739
|
+
* session_search tool reads). Type a query, Enter searches, Enter again
|
|
740
|
+
* resumes the hit; the query line edits like every kernel panel.
|
|
741
|
+
*/
|
|
742
|
+
export function SearchPanel({ load, select, initialQuery = '', close }: {
|
|
743
|
+
load: (query: string, signal?: AbortSignal) => Promise<readonly SearchRow[]>
|
|
744
|
+
select: (row: SearchRow) => void
|
|
745
|
+
initialQuery?: string
|
|
746
|
+
close: () => void
|
|
747
|
+
}): ReactElement {
|
|
748
|
+
const [query, setQuery] = useState(initialQuery)
|
|
749
|
+
const [rows, setRows] = useState<readonly SearchRow[]>([])
|
|
750
|
+
const [cursor, setCursor] = useState(0)
|
|
751
|
+
const [loading, setLoading] = useState(false)
|
|
752
|
+
const [error, setError] = useState<string>()
|
|
753
|
+
const [searched, setSearched] = useState('')
|
|
754
|
+
const searchRef = useRef<AbortController>()
|
|
755
|
+
const run = (next: string): void => {
|
|
756
|
+
const trimmed = next.trim()
|
|
757
|
+
if (trimmed === '') return
|
|
758
|
+
searchRef.current?.abort()
|
|
759
|
+
const controller = new AbortController()
|
|
760
|
+
searchRef.current = controller
|
|
761
|
+
setLoading(true)
|
|
762
|
+
setError(undefined)
|
|
763
|
+
void Promise.resolve().then(() => load(trimmed, controller.signal)).then(hits => {
|
|
764
|
+
if (controller.signal.aborted) return
|
|
765
|
+
setLoading(false)
|
|
766
|
+
setRows(hits)
|
|
767
|
+
setCursor(0)
|
|
768
|
+
setSearched(next)
|
|
769
|
+
}, reason => {
|
|
770
|
+
if (controller.signal.aborted) return
|
|
771
|
+
setLoading(false)
|
|
772
|
+
// Stale results must not stay interactive under an error header: a
|
|
773
|
+
// later Enter re-runs the query instead of resuming an old hit.
|
|
774
|
+
setRows([])
|
|
775
|
+
setCursor(0)
|
|
776
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
// An /search <query> invocation searches immediately with its argument.
|
|
780
|
+
useEffect(() => {
|
|
781
|
+
if (initialQuery.trim() !== '') run(initialQuery)
|
|
782
|
+
return () => searchRef.current?.abort()
|
|
783
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
784
|
+
}, [])
|
|
785
|
+
useInput((input, key) => {
|
|
786
|
+
if (key.escape || (input === 'q' && query === '')) return close()
|
|
787
|
+
if (key.ctrl && input === 'c') return close()
|
|
788
|
+
if (key.backspace || key.delete) {
|
|
789
|
+
setQuery(current => deleteLastGrapheme(current))
|
|
790
|
+
setCursor(0)
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
const next = editQuery(query, input, key)
|
|
794
|
+
if (next !== undefined) {
|
|
795
|
+
setQuery(next)
|
|
796
|
+
setCursor(0)
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
800
|
+
if (key.downArrow) return setCursor(value => Math.min(rows.length - 1, value + 1))
|
|
801
|
+
if (key.return) {
|
|
802
|
+
// A changed query searches; the SAME query re-runs when the previous
|
|
803
|
+
// pass failed or produced nothing (Enter is then the refresh key).
|
|
804
|
+
const stale = error !== undefined || rows.length === 0
|
|
805
|
+
if (query.trim() !== '' && (query.trim() !== searched.trim() || stale)) {
|
|
806
|
+
run(query)
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
// Non-resumable hits (subagent conversations) keep the panel open:
|
|
810
|
+
// Enter must not trade the visible results for a rejected switch.
|
|
811
|
+
const row = rows[cursor]
|
|
812
|
+
if (row !== undefined && row.resumable) select(row)
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
const stdout = useStdout().stdout
|
|
817
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
818
|
+
const now = useMemo(() => Date.now(), [rows, searched])
|
|
819
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
820
|
+
const state = loading ? 'searching…' : error !== undefined ? `error: ${error}` : rows.length === 0 ? 'no results yet' : `❯ ${rows[cursor]?.label ?? ''}`
|
|
821
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/search · ${state} · ${t('panel.close')}`, viewport.contentColumns))
|
|
822
|
+
}
|
|
823
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
824
|
+
const offset = revealRow(0, cursor, rows.length, bodyRows)
|
|
825
|
+
const visible = rows.slice(offset, offset + bodyRows)
|
|
826
|
+
const header = error !== undefined
|
|
827
|
+
? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) })
|
|
828
|
+
: loading
|
|
829
|
+
? t('search.compact.searching')
|
|
830
|
+
: searched === ''
|
|
831
|
+
? t('search.title.type')
|
|
832
|
+
: rows.length === 1
|
|
833
|
+
? t('search.title.hits', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
|
|
834
|
+
: t('search.title.hitsPlural', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
|
|
835
|
+
const accent = panelAccent('search', getPalette().dim, getPalette().brandBright)
|
|
836
|
+
return createElement(
|
|
837
|
+
Box,
|
|
838
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
839
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
840
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.hint', { state: query === '' ? t('search.hintEmpty') : singleLineText(query) }), viewport.contentColumns)),
|
|
841
|
+
...(visible.length === 0
|
|
842
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(searched === '' ? ` ${t('search.empty.idle')}` : ` ${t('search.empty.noHits', { loading: loading ? '…' : '' })}`, viewport.contentColumns))]
|
|
843
|
+
: visible.flatMap((row, index) => {
|
|
844
|
+
const absolute = offset + index
|
|
845
|
+
const selected = absolute === cursor
|
|
846
|
+
return [
|
|
847
|
+
createElement(
|
|
848
|
+
Text,
|
|
849
|
+
{
|
|
850
|
+
key: `search-${absolute}`,
|
|
851
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
852
|
+
dimColor: row.subagent,
|
|
853
|
+
wrap: 'truncate-end',
|
|
854
|
+
},
|
|
855
|
+
truncateColumns(`${selected ? '› ' : ' '}${row.subagent ? '↳ ' : ''}${singleLineText(row.label)} · ${formatRelativeTime(row.updatedAt, now)}${row.detail === '' ? '' : ` · ${row.detail}`}${row.resumable ? '' : ' · read-only'}`, viewport.contentColumns),
|
|
856
|
+
),
|
|
857
|
+
createElement(
|
|
858
|
+
Text,
|
|
859
|
+
{ key: `search-snippet-${absolute}`, dimColor: true, wrap: 'truncate-end' },
|
|
860
|
+
truncateColumns(` ⎿ ${singleLineText(row.snippet)}`, viewport.contentColumns),
|
|
861
|
+
),
|
|
862
|
+
]
|
|
863
|
+
})),
|
|
864
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.footer'), viewport.contentColumns)),
|
|
548
865
|
)
|
|
549
866
|
}
|
|
550
867
|
|
|
@@ -556,8 +873,8 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
556
873
|
*/
|
|
557
874
|
export function StatuslinePanel({ enabled, change, close }: {
|
|
558
875
|
enabled: readonly StatusItemId[]
|
|
559
|
-
change(items: readonly StatusItemId[])
|
|
560
|
-
close()
|
|
876
|
+
change: (items: readonly StatusItemId[]) => void
|
|
877
|
+
close: () => void
|
|
561
878
|
}): ReactElement {
|
|
562
879
|
// Working state: the full catalog in display order (enabled entries in
|
|
563
880
|
// their configured positions, disabled ones trailing canonically) plus
|
|
@@ -578,7 +895,7 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
578
895
|
if (target < 0 || target >= order.length) return
|
|
579
896
|
const next = [...order]
|
|
580
897
|
const [item] = next.splice(cursor, 1)
|
|
581
|
-
next.splice(target, 0, item
|
|
898
|
+
next.splice(target, 0, item)
|
|
582
899
|
commit(next, on)
|
|
583
900
|
setCursor(target)
|
|
584
901
|
}
|
|
@@ -607,16 +924,17 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
607
924
|
const stdout = useStdout().stdout
|
|
608
925
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
609
926
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
610
|
-
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('
|
|
927
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.statusline.compact'), viewport.contentColumns))
|
|
611
928
|
}
|
|
612
929
|
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
613
930
|
const offset = revealRow(0, cursor, order.length, bodyRows)
|
|
614
931
|
const visible = order.slice(offset, offset + bodyRows)
|
|
615
932
|
const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
|
|
933
|
+
const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright)
|
|
616
934
|
return createElement(
|
|
617
935
|
Box,
|
|
618
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
619
|
-
createElement(Text, { color: inkColor(
|
|
936
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
937
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('statusline.title'), viewport.contentColumns)),
|
|
620
938
|
...visible.map((id, index) => {
|
|
621
939
|
const absolute = offset + index
|
|
622
940
|
const selected = absolute === cursor
|
|
@@ -632,7 +950,7 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
632
950
|
truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
|
|
633
951
|
)
|
|
634
952
|
}),
|
|
635
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('
|
|
953
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.footer.statusline'), viewport.contentColumns)),
|
|
636
954
|
)
|
|
637
955
|
}
|
|
638
956
|
|
|
@@ -655,11 +973,11 @@ export function EffortPanel({ row, current, select, back, onExit }: {
|
|
|
655
973
|
/** Effective effort currently in force ('' when none), for the ● mark. */
|
|
656
974
|
current: string | undefined
|
|
657
975
|
/** Accept one advertised effort id, or '' for the provider default. */
|
|
658
|
-
select(effortId: string)
|
|
976
|
+
select: (effortId: string) => void
|
|
659
977
|
/** Return to the model list without applying. */
|
|
660
|
-
back()
|
|
978
|
+
back: () => void
|
|
661
979
|
/** Leave the whole /model flow (Ctrl+C). */
|
|
662
|
-
onExit()
|
|
980
|
+
onExit: () => void
|
|
663
981
|
}): ReactElement {
|
|
664
982
|
const advertised = row.reasoning?.efforts ?? []
|
|
665
983
|
const empty = row.reasoning === undefined || advertised.length === 0
|
|
@@ -706,21 +1024,21 @@ export function EffortPanel({ row, current, select, back, onExit }: {
|
|
|
706
1024
|
return
|
|
707
1025
|
}
|
|
708
1026
|
if (key.return && rows[cursor] !== undefined) {
|
|
709
|
-
select(rows[cursor]
|
|
1027
|
+
select(rows[cursor].id)
|
|
710
1028
|
}
|
|
711
1029
|
})
|
|
712
1030
|
return createElement(ListFrame, {
|
|
713
|
-
title:
|
|
1031
|
+
title: t('panel.effort.title', { provider: row.providerName, model: row.modelName }),
|
|
714
1032
|
rows: empty
|
|
715
|
-
? [{ key: 'empty', disabled: true, text: '
|
|
1033
|
+
? [{ key: 'empty', disabled: true, text: t('panel.effort.empty') }]
|
|
716
1034
|
: rows.map(effort => ({
|
|
717
1035
|
key: effort.id,
|
|
718
|
-
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ?
|
|
1036
|
+
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ` · ${t('panel.default')}` : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
|
|
719
1037
|
})),
|
|
720
1038
|
cursor,
|
|
721
1039
|
loading: false,
|
|
722
1040
|
query: '',
|
|
723
|
-
footer: empty ? '
|
|
1041
|
+
footer: empty ? t('panel.footer.effortEmpty') : t('panel.footer.effort'),
|
|
724
1042
|
})
|
|
725
1043
|
}
|
|
726
1044
|
|
|
@@ -745,10 +1063,10 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
745
1063
|
/** Live feed rows (child sessions observed this process). */
|
|
746
1064
|
live: readonly SubagentRow[]
|
|
747
1065
|
/** Load this session's persisted child sessions by lineage. */
|
|
748
|
-
load()
|
|
1066
|
+
load: () => Promise<readonly SessionRow[]>
|
|
749
1067
|
/** Read one child session's full transcript as markdown. */
|
|
750
|
-
readTranscript(id: string, signal?: AbortSignal)
|
|
751
|
-
close()
|
|
1068
|
+
readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
|
|
1069
|
+
close: () => void
|
|
752
1070
|
}): ReactElement {
|
|
753
1071
|
const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
|
|
754
1072
|
const [error, setError] = useState<string>()
|
|
@@ -819,7 +1137,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
819
1137
|
}, { isActive: transcript === undefined })
|
|
820
1138
|
if (transcript !== undefined) {
|
|
821
1139
|
return createElement(DocumentPanel, {
|
|
822
|
-
title:
|
|
1140
|
+
title: t('panel.subagent.transcriptTitle', { id: transcript.id.slice(-12) }),
|
|
823
1141
|
text: transcript.text,
|
|
824
1142
|
error: transcript.error,
|
|
825
1143
|
close: () => {
|
|
@@ -829,7 +1147,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
829
1147
|
})
|
|
830
1148
|
}
|
|
831
1149
|
return createElement(ListFrame, {
|
|
832
|
-
title:
|
|
1150
|
+
title: t('panel.agents.title', { live: live.length, total: rows.length }),
|
|
833
1151
|
rows: rows.map(row => ({
|
|
834
1152
|
key: row.id,
|
|
835
1153
|
text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
|
|
@@ -838,7 +1156,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
838
1156
|
loading,
|
|
839
1157
|
...error === undefined ? {} : { error },
|
|
840
1158
|
query: '',
|
|
841
|
-
footer:
|
|
1159
|
+
footer: t('panel.footer.agents'),
|
|
842
1160
|
})
|
|
843
1161
|
}
|
|
844
1162
|
|
|
@@ -857,12 +1175,12 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
857
1175
|
export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
858
1176
|
/** Display label of the override in force, '' when following the current model. */
|
|
859
1177
|
current: string
|
|
860
|
-
load()
|
|
1178
|
+
load: () => Promise<ModelDirectory>
|
|
861
1179
|
/** Apply one model (with an advertised effort, when picked) as the override. */
|
|
862
|
-
pick(row: ModelRow, effortId?: string)
|
|
1180
|
+
pick: (row: ModelRow, effortId?: string) => void
|
|
863
1181
|
/** Drop the override: subagents follow the current model again. */
|
|
864
|
-
inherit()
|
|
865
|
-
close()
|
|
1182
|
+
inherit: () => void
|
|
1183
|
+
close: () => void
|
|
866
1184
|
}): ReactElement {
|
|
867
1185
|
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
868
1186
|
const [error, setError] = useState<string>()
|
|
@@ -906,7 +1224,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
906
1224
|
setEffortFor(row)
|
|
907
1225
|
return
|
|
908
1226
|
}
|
|
909
|
-
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]
|
|
1227
|
+
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined
|
|
910
1228
|
pick(row, effortId)
|
|
911
1229
|
}
|
|
912
1230
|
})
|
|
@@ -920,9 +1238,9 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
920
1238
|
})
|
|
921
1239
|
}
|
|
922
1240
|
return createElement(ListFrame, {
|
|
923
|
-
title:
|
|
1241
|
+
title: `${t('panel.subagent.title')}${current === '' ? '' : t('panel.subagent.override', { value: current })}`,
|
|
924
1242
|
rows: [
|
|
925
|
-
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit
|
|
1243
|
+
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} ${t('panel.subagent.inherit')}` },
|
|
926
1244
|
...rows.map(row => ({
|
|
927
1245
|
key: `${row.provider}/${row.model}`,
|
|
928
1246
|
text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
|
|
@@ -932,7 +1250,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
932
1250
|
loading,
|
|
933
1251
|
...error === undefined ? {} : { error },
|
|
934
1252
|
query: '',
|
|
935
|
-
footer: '
|
|
1253
|
+
footer: t('panel.footer.subagent'),
|
|
936
1254
|
})
|
|
937
1255
|
}
|
|
938
1256
|
|
|
@@ -981,7 +1299,7 @@ export function scheduleDisplayRows(rows: readonly ScheduleRow[], now: number):
|
|
|
981
1299
|
}))
|
|
982
1300
|
}
|
|
983
1301
|
|
|
984
|
-
export function SchedulePanel({ rows, close }: { rows()
|
|
1302
|
+
export function SchedulePanel({ rows, close }: { rows: () => readonly ScheduleRow[]; close: () => void }): ReactElement {
|
|
985
1303
|
const [, setTick] = useState(0)
|
|
986
1304
|
useEffect(() => {
|
|
987
1305
|
const id = setInterval(() => setTick(value => value + 1), 1_000)
|
|
@@ -994,23 +1312,87 @@ export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[];
|
|
|
994
1312
|
if (key.escape || input === 'q') return close()
|
|
995
1313
|
})
|
|
996
1314
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
997
|
-
const summary = display.length === 0 ? '
|
|
1315
|
+
const summary = display.length === 0 ? t('panel.schedule.none') : singleLineText(display[0].text)
|
|
998
1316
|
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns))
|
|
999
1317
|
}
|
|
1000
1318
|
const budget = Math.max(1, viewport.bodyRows)
|
|
1001
1319
|
const visible = display.slice(0, budget)
|
|
1002
1320
|
const hidden = display.length - visible.length
|
|
1321
|
+
const accent = panelAccent('schedule', getPalette().dim, getPalette().brandBright)
|
|
1003
1322
|
return createElement(
|
|
1004
1323
|
Box,
|
|
1005
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
1006
|
-
createElement(Text, { color: inkColor(
|
|
1324
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
1325
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(display.length === 1 ? t('schedule.title', { count: display.length }) : t('schedule.titlePlural', { count: display.length }), viewport.contentColumns)),
|
|
1007
1326
|
...(display.length === 0
|
|
1008
|
-
? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
1327
|
+
? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('schedule.empty')}`, viewport.contentColumns))]
|
|
1009
1328
|
: visible.map(row => createElement(Text, {
|
|
1010
1329
|
key: row.key,
|
|
1011
1330
|
color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
|
|
1012
1331
|
wrap: 'truncate-end',
|
|
1013
1332
|
}, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns)))),
|
|
1014
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
1333
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.schedule.footer', { more: hidden > 0 ? t('panel.schedule.more', { count: hidden }) : '' }), viewport.contentColumns)),
|
|
1334
|
+
)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* The /usage panel: the session's provider-reported token totals, its context
|
|
1339
|
+
* pressure and estimated composition, and the exact per-turn accounting, in
|
|
1340
|
+
* one bounded scrollable surface. Read-only — Esc or q closes it.
|
|
1341
|
+
*/
|
|
1342
|
+
export function UsagePanel({ load, close }: {
|
|
1343
|
+
/** Read the current session's usage blocks from the mounted projections. */
|
|
1344
|
+
load: () => Promise<UsageView>
|
|
1345
|
+
close: () => void
|
|
1346
|
+
}): ReactElement {
|
|
1347
|
+
const stdout = useStdout().stdout
|
|
1348
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1349
|
+
const [scroll, setScroll] = useState(0)
|
|
1350
|
+
const [view, setView] = useState<UsageView>()
|
|
1351
|
+
const [error, setError] = useState<string>()
|
|
1352
|
+
// The panel opens on the loading row and swaps in the numbers when the
|
|
1353
|
+
// loader settles: materializing the projection units folds the whole log,
|
|
1354
|
+
// and that must not run inside the keystroke that opened the panel.
|
|
1355
|
+
useEffect(() => {
|
|
1356
|
+
let live = true
|
|
1357
|
+
Promise.resolve().then(load).then(
|
|
1358
|
+
loaded => {
|
|
1359
|
+
if (live) setView(loaded)
|
|
1360
|
+
},
|
|
1361
|
+
reason => {
|
|
1362
|
+
if (live) setError(reason instanceof Error ? reason.message : String(reason))
|
|
1363
|
+
},
|
|
1364
|
+
)
|
|
1365
|
+
return () => {
|
|
1366
|
+
live = false
|
|
1367
|
+
}
|
|
1368
|
+
}, [load])
|
|
1369
|
+
const lines = useMemo(
|
|
1370
|
+
() => view === undefined ? [] : usageLines(view, viewport.contentColumns),
|
|
1371
|
+
[view, viewport.contentColumns],
|
|
1372
|
+
)
|
|
1373
|
+
useInput((input, key) => {
|
|
1374
|
+
if (key.escape || input === 'q') return close()
|
|
1375
|
+
if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
|
|
1376
|
+
if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
|
|
1377
|
+
if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
|
|
1378
|
+
if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
|
|
1379
|
+
if (input === 'g') return setScroll(0)
|
|
1380
|
+
if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
1381
|
+
})
|
|
1382
|
+
if (viewport.compact) {
|
|
1383
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.usage.compact'), viewport.contentColumns))
|
|
1384
|
+
}
|
|
1385
|
+
const body: readonly StyledLine[] = error !== undefined
|
|
1386
|
+
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
1387
|
+
: view === undefined
|
|
1388
|
+
? textLines(t('panel.loading'), viewport.contentColumns, 'dim')
|
|
1389
|
+
: lines.slice(scroll, scroll + viewport.bodyRows)
|
|
1390
|
+
const accent = panelAccent('usage', getPalette().dim, getPalette().brandBright)
|
|
1391
|
+
return createElement(
|
|
1392
|
+
Box,
|
|
1393
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
1394
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('panel.usage.title'), viewport.contentColumns)),
|
|
1395
|
+
createElement(DocumentRows, { lines: body }),
|
|
1396
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.usage.footer'), viewport.contentColumns)),
|
|
1015
1397
|
)
|
|
1016
1398
|
}
|