dsh-code 1.0.7 → 1.3.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 +5002 -1061
- package/lib/session-query.mjs +3 -2
- package/lib/startup.mjs +4 -4
- package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
- package/lib/types/app.d.ts +120 -63
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/authorization-panel.d.ts +3 -3
- package/lib/types/git-workflow.d.ts +91 -2
- package/lib/types/history.d.ts +10 -0
- package/lib/types/i18n.d.ts +39 -0
- package/lib/types/index.d.ts +74 -2
- package/lib/types/input-split.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +89 -32
- package/lib/types/language-panel.d.ts +12 -0
- package/lib/types/locales/en.d.ts +498 -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 +12 -9
- 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 +42 -1
- package/lib/types/session-switch.d.ts +8 -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 +27 -6
- package/lib/types/update.d.ts +10 -1
- package/lib/types/version.d.ts +6 -3
- package/package.json +26 -7
- package/src/app.ts +1426 -627
- package/src/approval.ts +166 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +24 -18
- package/src/editor-keys.ts +371 -371
- package/src/fork.ts +11 -7
- package/src/git-workflow.ts +229 -3
- package/src/history.ts +14 -0
- package/src/i18n.ts +68 -0
- package/src/index.ts +503 -128
- package/src/input-split.ts +27 -7
- package/src/kernel-panels.ts +528 -113
- package/src/keyboard.ts +5 -4
- package/src/language-panel.ts +53 -0
- package/src/locales/en.ts +538 -0
- package/src/locales/zh.ts +537 -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 +218 -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 +119 -62
- package/src/render/text.ts +15 -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 +90 -9
- package/src/session-query.ts +8 -4
- package/src/session-switch.ts +14 -0
- package/src/startup.ts +3 -3
- package/src/store.ts +19 -1
- 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 +148 -31
- package/src/update.ts +19 -3
- package/src/version.ts +63 -20
- package/src/whale-glyph.ts +23 -23
package/src/kernel-panels.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
|
|
2
2
|
|
|
3
|
-
import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
3
|
+
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
4
4
|
import { Box, Text, useInput, useStdout } from 'ink'
|
|
5
5
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
6
6
|
import type { SubagentRow } from './subagents.ts'
|
|
@@ -9,13 +9,17 @@ import type { PermissionRow } from './permissions.ts'
|
|
|
9
9
|
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
|
-
import { formatRelativeTime } from './session-directory.ts'
|
|
12
|
+
import { formatRelativeTime, matchSessionRow } 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
|
-
import { DEFAULT_STATUSLINE_ITEMS,
|
|
19
|
+
import { DEFAULT_STATUSLINE_ITEMS, localizedStatusItems, 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,22 +110,22 @@ 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('')
|
|
114
119
|
const [cursor, setCursor] = useState(0)
|
|
115
120
|
const [loading, setLoading] = useState(true)
|
|
116
121
|
const [error, setError] = useState<string>()
|
|
117
|
-
const refresh = (): void => {
|
|
122
|
+
const refresh = useCallback((): void => {
|
|
118
123
|
setLoading(true); setError(undefined)
|
|
119
124
|
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
|
|
120
125
|
setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
|
|
121
126
|
})
|
|
122
|
-
}
|
|
123
|
-
useEffect(refresh, [])
|
|
127
|
+
}, [load])
|
|
128
|
+
useEffect(refresh, [refresh])
|
|
124
129
|
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
|
|
125
130
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
|
|
126
131
|
useInput((input, key) => {
|
|
@@ -133,35 +138,35 @@ 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('')
|
|
155
160
|
const [cursor, setCursor] = useState(0)
|
|
156
161
|
const [loading, setLoading] = useState(true)
|
|
157
162
|
const [error, setError] = useState<string>()
|
|
158
|
-
const refresh = (): void => {
|
|
163
|
+
const refresh = useCallback((): void => {
|
|
159
164
|
setLoading(true); setError(undefined)
|
|
160
165
|
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
|
|
161
166
|
setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
|
|
162
167
|
})
|
|
163
|
-
}
|
|
164
|
-
useEffect(refresh, [])
|
|
168
|
+
}, [load])
|
|
169
|
+
useEffect(refresh, [refresh])
|
|
165
170
|
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
|
|
166
171
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
|
|
167
172
|
useInput((input, key) => {
|
|
@@ -171,23 +176,24 @@ 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)
|
|
189
194
|
const [expanded, setExpanded] = useState(false)
|
|
190
|
-
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the panel's explicit registry refresh trigger
|
|
196
|
+
const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, load, query])
|
|
191
197
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
192
198
|
useInput((input, key) => {
|
|
193
199
|
if (key.escape) return close()
|
|
@@ -201,12 +207,12 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
|
|
|
201
207
|
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
202
208
|
})
|
|
203
209
|
return createElement(ListFrame, {
|
|
204
|
-
title: '
|
|
210
|
+
title: t('panel.plugin.title'),
|
|
205
211
|
rows: rows.map((row, index) => ({
|
|
206
212
|
key: row.entryId,
|
|
207
213
|
disabled: !row.enabled,
|
|
208
214
|
text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
|
|
209
|
-
})), cursor, loading: false, query, footer: '
|
|
215
|
+
})), cursor, loading: false, query, footer: t('panel.footer.inspectDetails'),
|
|
210
216
|
})
|
|
211
217
|
}
|
|
212
218
|
|
|
@@ -252,7 +258,7 @@ const JOB_MARK: Record<JobRow['status'], string> = {
|
|
|
252
258
|
* interval dies with the panel). Cancel stays upstream-only; an absent
|
|
253
259
|
* registry renders as the plain empty state (a harmless missing service).
|
|
254
260
|
*/
|
|
255
|
-
export function JobsPanel({ load, close }: { load()
|
|
261
|
+
export function JobsPanel({ load, close }: { load: () => readonly JobRow[]; close: () => void }): ReactElement {
|
|
256
262
|
const [, setRefresh] = useState(0)
|
|
257
263
|
const [cursor, setCursor] = useState(0)
|
|
258
264
|
const [, setTick] = useState(0)
|
|
@@ -269,34 +275,42 @@ export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close():
|
|
|
269
275
|
})
|
|
270
276
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
271
277
|
return createElement(ListFrame, {
|
|
272
|
-
title:
|
|
278
|
+
title: t('panel.jobs.title', { count: rows.length }),
|
|
273
279
|
rows: rows.map(row => ({
|
|
274
280
|
key: row.id,
|
|
275
281
|
text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
|
|
276
282
|
})),
|
|
277
|
-
cursor, loading: false, query: '', searching: false, footer: '
|
|
283
|
+
cursor, loading: false, query: '', searching: false, footer: t('panel.footer.inspectRefresh'),
|
|
278
284
|
})
|
|
279
285
|
}
|
|
280
286
|
|
|
281
|
-
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
|
|
287
|
+
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, presetId, close }: {
|
|
282
288
|
currentCwd: string
|
|
283
|
-
load(options: SessionDirectoryOptions, signal?: AbortSignal)
|
|
284
|
-
readTranscript(id: string, signal?: AbortSignal)
|
|
285
|
-
select(row: SessionRow)
|
|
289
|
+
load: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
|
|
290
|
+
readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
|
|
291
|
+
select: (row: SessionRow) => void
|
|
286
292
|
/** Arm the composer-based delete confirm for one row (App owns the keys). */
|
|
287
|
-
requestDelete
|
|
293
|
+
requestDelete?: (row: SessionRow) => void
|
|
288
294
|
/** The row id awaiting y/n in the composer, when any (App-owned). */
|
|
289
295
|
deleteConfirmId?: string
|
|
290
296
|
/** Bump to reload the listing (e.g. after a deletion). */
|
|
291
297
|
reloadToken?: number
|
|
292
298
|
/** Opened via /delete: hint-first delete mode. */
|
|
293
299
|
deleteMode?: boolean
|
|
294
|
-
|
|
300
|
+
/** `/delete <id>` argument to resolve after the listing loads. */
|
|
301
|
+
presetId?: string
|
|
302
|
+
close: () => void
|
|
295
303
|
}): ReactElement {
|
|
296
304
|
// Codex resume-picker default: the CURRENT directory's root sessions; the
|
|
297
305
|
// cwd filter widens to all only on request (the old default leaked every
|
|
298
306
|
// directory's sessions into what read as a current-directory view).
|
|
299
|
-
const [options, setOptions] = useState<SessionDirectoryOptions>({
|
|
307
|
+
const [options, setOptions] = useState<SessionDirectoryOptions>({
|
|
308
|
+
sessions: presetId === undefined || presetId === '' ? 'roots' : 'all',
|
|
309
|
+
cwd: presetId === undefined || presetId === '' ? 'current' : 'all',
|
|
310
|
+
sort: 'newest',
|
|
311
|
+
currentCwd,
|
|
312
|
+
query: '',
|
|
313
|
+
})
|
|
300
314
|
const [focus, setFocus] = useState(0)
|
|
301
315
|
const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
|
|
302
316
|
const [rows, setRows] = useState<readonly SessionRow[]>([])
|
|
@@ -308,6 +322,7 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
308
322
|
/** Ctrl+F-gated search: typing filters only while searching (codex). */
|
|
309
323
|
const [searching, setSearching] = useState(false)
|
|
310
324
|
/** Reference clock pinned per row render, so relative times never drift mid-list. */
|
|
325
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when the listing or its options change
|
|
311
326
|
const now = useMemo(() => Date.now(), [rows, options])
|
|
312
327
|
const transcriptLoad = useRef<AbortController>()
|
|
313
328
|
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
@@ -320,8 +335,22 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
320
335
|
if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
|
|
321
336
|
})
|
|
322
337
|
return () => controller.abort()
|
|
323
|
-
}, [options, reloadToken])
|
|
338
|
+
}, [load, options, reloadToken])
|
|
324
339
|
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
340
|
+
const presetArmed = useRef(false)
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (presetArmed.current || presetId === undefined || presetId === '' || rows.length === 0 || requestDelete === undefined) return
|
|
343
|
+
try {
|
|
344
|
+
const row = matchSessionRow(rows, presetId)
|
|
345
|
+
presetArmed.current = true
|
|
346
|
+
const index = rows.findIndex(candidate => candidate.id === row.id)
|
|
347
|
+
if (index >= 0) setCursor(index)
|
|
348
|
+
requestDelete(row)
|
|
349
|
+
} catch (reason: unknown) {
|
|
350
|
+
presetArmed.current = true
|
|
351
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
352
|
+
}
|
|
353
|
+
}, [rows, presetId, requestDelete])
|
|
325
354
|
const cycle = (): void => {
|
|
326
355
|
if (focus === 3) {
|
|
327
356
|
setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
|
|
@@ -358,12 +387,11 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
358
387
|
if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
|
|
359
388
|
if (input === 'g') return setCursor(0)
|
|
360
389
|
if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
|
|
361
|
-
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
|
|
362
390
|
if (input === 'e' && rows[cursor] !== undefined) {
|
|
363
|
-
return setExpanded(value => value === rows[cursor]
|
|
391
|
+
return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id)
|
|
364
392
|
}
|
|
365
393
|
if (input === 't' && rows[cursor] !== undefined) {
|
|
366
|
-
const row = rows[cursor]
|
|
394
|
+
const row = rows[cursor]
|
|
367
395
|
transcriptLoad.current?.abort()
|
|
368
396
|
setTranscript({ id: row.id })
|
|
369
397
|
const controller = new AbortController()
|
|
@@ -374,11 +402,17 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
374
402
|
)
|
|
375
403
|
return
|
|
376
404
|
}
|
|
377
|
-
if (key.return && rows[cursor]
|
|
405
|
+
if (key.return && rows[cursor] !== undefined) {
|
|
406
|
+
if (deleteMode) {
|
|
407
|
+
if (requestDelete !== undefined && !rows[cursor].live) requestDelete(rows[cursor])
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
if (rows[cursor].resumable) select(rows[cursor])
|
|
411
|
+
}
|
|
378
412
|
}, { isActive: transcript === undefined })
|
|
379
413
|
if (transcript !== undefined) {
|
|
380
414
|
return createElement(DocumentPanel, {
|
|
381
|
-
title:
|
|
415
|
+
title: t('panel.document.title', { id: transcript.id }),
|
|
382
416
|
text: transcript.text,
|
|
383
417
|
error: transcript.error,
|
|
384
418
|
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
|
|
@@ -387,15 +421,17 @@ export function ResumePanel({ currentCwd, load, readTranscript, select, requestD
|
|
|
387
421
|
const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
|
|
388
422
|
const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
|
|
389
423
|
return createElement(ListFrame, {
|
|
390
|
-
title: deleteConfirmId
|
|
391
|
-
?
|
|
392
|
-
:
|
|
424
|
+
title: deleteConfirmId !== undefined
|
|
425
|
+
? t('panel.resume.deleteTitle', { target: pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id })
|
|
426
|
+
: deleteMode
|
|
427
|
+
? t('panel.delete.title', { search: searching ? ` — ${t('panel.searching')}` : '', toolbar })
|
|
428
|
+
: t('panel.resume.title', { mode: '', search: searching ? ` — ${t('panel.searching')}` : '', toolbar }),
|
|
393
429
|
rows: rows.map(row => ({
|
|
394
430
|
key: row.id,
|
|
395
|
-
disabled: !row.resumable,
|
|
431
|
+
disabled: deleteMode ? row.live : !row.resumable,
|
|
396
432
|
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
433
|
})), cursor, loading, error, query: options.query, searching,
|
|
398
|
-
footer:
|
|
434
|
+
footer: t(deleteMode ? 'panel.footer.delete' : 'panel.footer.resume'),
|
|
399
435
|
})
|
|
400
436
|
}
|
|
401
437
|
|
|
@@ -440,7 +476,7 @@ function DocumentPanel({ title, text, error, close }: {
|
|
|
440
476
|
title: string
|
|
441
477
|
text?: string
|
|
442
478
|
error?: string
|
|
443
|
-
close()
|
|
479
|
+
close: () => void
|
|
444
480
|
}): ReactElement {
|
|
445
481
|
const stdout = useStdout().stdout
|
|
446
482
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
@@ -455,18 +491,19 @@ function DocumentPanel({ title, text, error, close }: {
|
|
|
455
491
|
if (input === 'g') return setScroll(0)
|
|
456
492
|
if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
457
493
|
})
|
|
458
|
-
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('
|
|
494
|
+
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('document.compact'), viewport.contentColumns))
|
|
459
495
|
const body: readonly StyledLine[] = error !== undefined
|
|
460
496
|
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
461
497
|
: text === undefined
|
|
462
|
-
? textLines('loading
|
|
498
|
+
? textLines(t('document.loading'), viewport.contentColumns, 'dim')
|
|
463
499
|
: lines.slice(scroll, scroll + viewport.bodyRows)
|
|
500
|
+
const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright)
|
|
464
501
|
return createElement(
|
|
465
502
|
Box,
|
|
466
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
467
|
-
createElement(Text, { color: inkColor(
|
|
503
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
504
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
|
|
468
505
|
createElement(DocumentRows, { lines: body }),
|
|
469
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
506
|
+
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
507
|
)
|
|
471
508
|
}
|
|
472
509
|
|
|
@@ -480,8 +517,8 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
480
517
|
/** Newest-first recall entries (persistent + in-session, deduped). */
|
|
481
518
|
entries: readonly string[]
|
|
482
519
|
/** Accept one entry: its text plus its recall-space index (browsing resumes there). */
|
|
483
|
-
fill(text: string, index: number)
|
|
484
|
-
close()
|
|
520
|
+
fill: (text: string, index: number) => void
|
|
521
|
+
close: () => void
|
|
485
522
|
}): ReactElement {
|
|
486
523
|
const [query, setQuery] = useState('')
|
|
487
524
|
const [cursor, setCursor] = useState(0)
|
|
@@ -497,14 +534,18 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
497
534
|
}
|
|
498
535
|
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
499
536
|
if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (
|
|
537
|
+
// g/G stay vim-style jumps only on an empty query (the /mode contract):
|
|
538
|
+
// mid-filter they are query text, so filters like 'Fix' or 'grep' survive.
|
|
539
|
+
if (input === 'g' && query === '') return setCursor(0)
|
|
540
|
+
if (input === 'G' && query === '') return setCursor(matches.length - 1)
|
|
541
|
+
if (key.backspace || key.delete) {
|
|
503
542
|
setQuery(current => deleteLastGrapheme(current))
|
|
504
543
|
setCursor(0)
|
|
505
544
|
return
|
|
506
545
|
}
|
|
507
|
-
|
|
546
|
+
// Ink reports single uppercase letters and shifted symbols ('!', '@')
|
|
547
|
+
// with key.shift set; only ctrl/meta mark real command input.
|
|
548
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
508
549
|
const text = stripPasteMarkers(input)
|
|
509
550
|
if (text !== '') {
|
|
510
551
|
setQuery(current => (current + text).slice(0, 120))
|
|
@@ -516,21 +557,22 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
516
557
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
517
558
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
518
559
|
const picked = matches[cursor]
|
|
519
|
-
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(
|
|
560
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/history · ${picked === undefined ? t('history.compact.none') : singleLineText(picked)} · ${t('panel.close')}`, viewport.contentColumns))
|
|
520
561
|
}
|
|
521
562
|
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
522
563
|
const offset = revealRow(0, cursor, matches.length, bodyRows)
|
|
523
564
|
const visible = matches.slice(offset, offset + bodyRows)
|
|
524
565
|
const header = query === ''
|
|
525
|
-
?
|
|
526
|
-
:
|
|
566
|
+
? t('history.title.prompts', { count: entries.length })
|
|
567
|
+
: t('history.title.match', { matches: matches.length, count: entries.length, query: truncateColumns(singleLineText(query), Math.max(6, viewport.contentColumns - 34)) })
|
|
568
|
+
const accent = panelAccent('history', getPalette().dim, getPalette().brandBright)
|
|
527
569
|
return createElement(
|
|
528
570
|
Box,
|
|
529
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
530
|
-
createElement(Text, { color: inkColor(
|
|
531
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
571
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
572
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
573
|
+
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
574
|
...(visible.length === 0
|
|
533
|
-
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns('
|
|
575
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.empty'), viewport.contentColumns))]
|
|
534
576
|
: visible.map((entry, index) => {
|
|
535
577
|
const absolute = offset + index
|
|
536
578
|
const selected = absolute === cursor
|
|
@@ -544,7 +586,315 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
544
586
|
truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
|
|
545
587
|
)
|
|
546
588
|
})),
|
|
547
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('
|
|
589
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.footer'), viewport.contentColumns)),
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The /review candidate picker (Codex's preset popup): bare /review opens a
|
|
595
|
+
* four-way preset — review uncommitted changes, pick a base branch, pick a
|
|
596
|
+
* recent commit, or type a custom focus. Branch and commit phases are
|
|
597
|
+
* type-to-filter lists; every selection resolves to the same /review
|
|
598
|
+
* argument string the direct command accepts.
|
|
599
|
+
*/
|
|
600
|
+
export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }: {
|
|
601
|
+
loadBranches: (signal?: AbortSignal) => Promise<readonly ReviewBranch[]>
|
|
602
|
+
loadCommits: (signal?: AbortSignal) => Promise<readonly ReviewCommit[]>
|
|
603
|
+
/** Run the review for one picker selection. */
|
|
604
|
+
choose: (selection: ReviewSelection) => void
|
|
605
|
+
close: () => void
|
|
606
|
+
}): ReactElement {
|
|
607
|
+
const [phase, setPhase] = useState<'preset' | 'branches' | 'commits' | 'custom'>('preset')
|
|
608
|
+
const [cursor, setCursor] = useState(0)
|
|
609
|
+
const [query, setQuery] = useState('')
|
|
610
|
+
const [rows, setRows] = useState<readonly (ReviewBranch | ReviewCommit)[]>([])
|
|
611
|
+
const [loading, setLoading] = useState(false)
|
|
612
|
+
const [error, setError] = useState<string>()
|
|
613
|
+
const loadRef = useRef<AbortController>()
|
|
614
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when review rows change
|
|
615
|
+
const now = useMemo(() => Date.now(), [rows])
|
|
616
|
+
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
if (phase !== 'branches' && phase !== 'commits') return undefined
|
|
619
|
+
loadRef.current?.abort()
|
|
620
|
+
const controller = new AbortController()
|
|
621
|
+
loadRef.current = controller
|
|
622
|
+
setLoading(true)
|
|
623
|
+
setError(undefined)
|
|
624
|
+
const load = phase === 'branches'
|
|
625
|
+
? (signal?: AbortSignal) => loadBranches(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
|
|
626
|
+
: (signal?: AbortSignal) => loadCommits(signal) as Promise<readonly (ReviewBranch | ReviewCommit)[]>
|
|
627
|
+
void Promise.resolve().then(() => load(controller.signal)).then(list => {
|
|
628
|
+
if (controller.signal.aborted) return
|
|
629
|
+
setLoading(false)
|
|
630
|
+
setRows(list)
|
|
631
|
+
setCursor(0)
|
|
632
|
+
}, reason => {
|
|
633
|
+
if (controller.signal.aborted) return
|
|
634
|
+
setLoading(false)
|
|
635
|
+
setRows([])
|
|
636
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
637
|
+
})
|
|
638
|
+
return () => controller.abort()
|
|
639
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
640
|
+
}, [phase])
|
|
641
|
+
|
|
642
|
+
const filtered = useMemo(() => {
|
|
643
|
+
if (phase !== 'branches' && phase !== 'commits') return []
|
|
644
|
+
const needle = query.toLowerCase()
|
|
645
|
+
if (needle === '') return rows
|
|
646
|
+
return rows.filter(row => {
|
|
647
|
+
const hay = 'name' in row ? row.name : `${row.sha} ${row.title}`
|
|
648
|
+
return hay.toLowerCase().includes(needle)
|
|
649
|
+
})
|
|
650
|
+
}, [phase, rows, query])
|
|
651
|
+
|
|
652
|
+
const submit = (selection: ReviewSelection): void => {
|
|
653
|
+
choose(selection)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
useInput((input, key) => {
|
|
657
|
+
if (key.escape || (input === 'q' && query === '' && phase !== 'custom')) {
|
|
658
|
+
if (phase !== 'preset') {
|
|
659
|
+
setPhase('preset')
|
|
660
|
+
setQuery('')
|
|
661
|
+
setCursor(0)
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
return close()
|
|
665
|
+
}
|
|
666
|
+
if (key.ctrl && input === 'c') return close()
|
|
667
|
+
if (phase === 'custom' || ((phase === 'branches' || phase === 'commits') && query !== '')) {
|
|
668
|
+
const next = editQuery(query, input, key)
|
|
669
|
+
if (next !== undefined) {
|
|
670
|
+
setQuery(next)
|
|
671
|
+
setCursor(0)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// The row budget per phase: the preset list is fixed at four rows, the
|
|
676
|
+
// branch/commit lists clamp to their filtered length.
|
|
677
|
+
const rowCount = phase === 'preset' ? 4 : filtered.length
|
|
678
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
679
|
+
if (key.downArrow) return setCursor(value => Math.min(Math.max(0, rowCount - 1), value + 1))
|
|
680
|
+
if (key.return) {
|
|
681
|
+
if (phase === 'preset') {
|
|
682
|
+
if (cursor === 0) return submit({ kind: 'uncommitted' })
|
|
683
|
+
if (cursor === 1) return setPhase('branches')
|
|
684
|
+
if (cursor === 2) return setPhase('commits')
|
|
685
|
+
return setPhase('custom')
|
|
686
|
+
}
|
|
687
|
+
if (phase === 'branches' || phase === 'commits') {
|
|
688
|
+
const row = filtered[cursor]
|
|
689
|
+
if (row !== undefined) submit('name' in row ? { kind: 'base-branch', branch: row.name } : { kind: 'commit', sha: row.sha })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
if (query.trim() !== '') submit({ kind: 'custom', instructions: query.trim() })
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
const stdout = useStdout().stdout
|
|
698
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
699
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
700
|
+
const state = phase === 'preset' ? 'pick a review target' : loading ? 'loading…' : error !== undefined ? `error: ${error}` : `${filtered.length} candidates`
|
|
701
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/review · ${state} · ${t('panel.close')}`, viewport.contentColumns))
|
|
702
|
+
}
|
|
703
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
704
|
+
const offset = revealRow(0, cursor, phase === 'preset' ? 4 : filtered.length, bodyRows)
|
|
705
|
+
const header = phase === 'preset'
|
|
706
|
+
? t('review.picker.title')
|
|
707
|
+
: phase === 'branches'
|
|
708
|
+
? 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 })
|
|
709
|
+
: phase === 'commits'
|
|
710
|
+
? 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 })
|
|
711
|
+
: t('review.picker.customHint')
|
|
712
|
+
const accent = panelAccent('review-picker', getPalette().dim, getPalette().brandBright)
|
|
713
|
+
return createElement(
|
|
714
|
+
Box,
|
|
715
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
716
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
717
|
+
...(phase === 'preset'
|
|
718
|
+
? [
|
|
719
|
+
t('review.picker.uncommitted'),
|
|
720
|
+
t('review.picker.branch'),
|
|
721
|
+
t('review.picker.commit'),
|
|
722
|
+
t('review.picker.custom'),
|
|
723
|
+
].map((label, index) => {
|
|
724
|
+
const absolute = offset + index
|
|
725
|
+
const selected = absolute === cursor
|
|
726
|
+
return createElement(
|
|
727
|
+
Text,
|
|
728
|
+
{ key: `preset-${index}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
|
|
729
|
+
truncateColumns(`${selected ? '› ' : ' '}${label}`, viewport.contentColumns),
|
|
730
|
+
)
|
|
731
|
+
})
|
|
732
|
+
: phase === 'custom'
|
|
733
|
+
? [createElement(Text, { key: 'custom-input', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${query === '' ? t('review.picker.customHint') : singleLineText(query)}`, viewport.contentColumns))]
|
|
734
|
+
: filtered.length === 0
|
|
735
|
+
? [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))]
|
|
736
|
+
: filtered.slice(offset, offset + bodyRows).map((row, index) => {
|
|
737
|
+
const absolute = offset + index
|
|
738
|
+
const selected = absolute === cursor
|
|
739
|
+
const label = 'name' in row ? row.name : `${row.sha.slice(0, 7)} · ${formatRelativeTime(row.at, now)} · ${row.title}`
|
|
740
|
+
return createElement(
|
|
741
|
+
Text,
|
|
742
|
+
{ key: `row-${absolute}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' },
|
|
743
|
+
truncateColumns(`${selected ? '› ' : ' '}${singleLineText(label)}`, viewport.contentColumns),
|
|
744
|
+
)
|
|
745
|
+
})),
|
|
746
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('review.picker.footer'), viewport.contentColumns)),
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** One cross-session full-text search hit mapped from the session-query engine. */
|
|
751
|
+
export interface SearchRow {
|
|
752
|
+
/** Session id (Enter resumes it through the switch machinery). */
|
|
753
|
+
readonly id: string
|
|
754
|
+
/** Display label: session title or the short id form. */
|
|
755
|
+
readonly label: string
|
|
756
|
+
/** Secondary facts line (workspace · preset markers). */
|
|
757
|
+
readonly detail: string
|
|
758
|
+
/** Bounded plain-text excerpt around the strongest match. */
|
|
759
|
+
readonly snippet: string
|
|
760
|
+
/** Match timestamp (relative labels derive from it). */
|
|
761
|
+
readonly updatedAt: number
|
|
762
|
+
/** Whether the hit is a delegated subagent conversation (not resumable). */
|
|
763
|
+
readonly subagent: boolean
|
|
764
|
+
/** Whether Enter may switch into it. */
|
|
765
|
+
readonly resumable: boolean
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The /search panel: full-text search over every persisted session through
|
|
770
|
+
* the in-process session-query engine (the same corpus the model's
|
|
771
|
+
* session_search tool reads). Type a query, Enter searches, Enter again
|
|
772
|
+
* resumes the hit; the query line edits like every kernel panel.
|
|
773
|
+
*/
|
|
774
|
+
export function SearchPanel({ load, select, initialQuery = '', close }: {
|
|
775
|
+
load: (query: string, signal?: AbortSignal) => Promise<readonly SearchRow[]>
|
|
776
|
+
select: (row: SearchRow) => void
|
|
777
|
+
initialQuery?: string
|
|
778
|
+
close: () => void
|
|
779
|
+
}): ReactElement {
|
|
780
|
+
const [query, setQuery] = useState(initialQuery)
|
|
781
|
+
const [rows, setRows] = useState<readonly SearchRow[]>([])
|
|
782
|
+
const [cursor, setCursor] = useState(0)
|
|
783
|
+
const [loading, setLoading] = useState(false)
|
|
784
|
+
const [error, setError] = useState<string>()
|
|
785
|
+
const [searched, setSearched] = useState('')
|
|
786
|
+
const searchRef = useRef<AbortController>()
|
|
787
|
+
const run = (next: string): void => {
|
|
788
|
+
const trimmed = next.trim()
|
|
789
|
+
if (trimmed === '') return
|
|
790
|
+
searchRef.current?.abort()
|
|
791
|
+
const controller = new AbortController()
|
|
792
|
+
searchRef.current = controller
|
|
793
|
+
setLoading(true)
|
|
794
|
+
setError(undefined)
|
|
795
|
+
void Promise.resolve().then(() => load(trimmed, controller.signal)).then(hits => {
|
|
796
|
+
if (controller.signal.aborted) return
|
|
797
|
+
setLoading(false)
|
|
798
|
+
setRows(hits)
|
|
799
|
+
setCursor(0)
|
|
800
|
+
setSearched(next)
|
|
801
|
+
}, reason => {
|
|
802
|
+
if (controller.signal.aborted) return
|
|
803
|
+
setLoading(false)
|
|
804
|
+
// Stale results must not stay interactive under an error header: a
|
|
805
|
+
// later Enter re-runs the query instead of resuming an old hit.
|
|
806
|
+
setRows([])
|
|
807
|
+
setCursor(0)
|
|
808
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
// An /search <query> invocation searches immediately with its argument.
|
|
812
|
+
useEffect(() => {
|
|
813
|
+
if (initialQuery.trim() !== '') run(initialQuery)
|
|
814
|
+
return () => searchRef.current?.abort()
|
|
815
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
816
|
+
}, [])
|
|
817
|
+
useInput((input, key) => {
|
|
818
|
+
if (key.escape || (input === 'q' && query === '')) return close()
|
|
819
|
+
if (key.ctrl && input === 'c') return close()
|
|
820
|
+
if (key.backspace || key.delete) {
|
|
821
|
+
setQuery(current => deleteLastGrapheme(current))
|
|
822
|
+
setCursor(0)
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
const next = editQuery(query, input, key)
|
|
826
|
+
if (next !== undefined) {
|
|
827
|
+
setQuery(next)
|
|
828
|
+
setCursor(0)
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
832
|
+
if (key.downArrow) return setCursor(value => Math.min(rows.length - 1, value + 1))
|
|
833
|
+
if (key.return) {
|
|
834
|
+
// A changed query searches; the SAME query re-runs when the previous
|
|
835
|
+
// pass failed or produced nothing (Enter is then the refresh key).
|
|
836
|
+
const stale = error !== undefined || rows.length === 0
|
|
837
|
+
if (query.trim() !== '' && (query.trim() !== searched.trim() || stale)) {
|
|
838
|
+
run(query)
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
// Non-resumable hits (subagent conversations) keep the panel open:
|
|
842
|
+
// Enter must not trade the visible results for a rejected switch.
|
|
843
|
+
const row = rows[cursor]
|
|
844
|
+
if (row !== undefined && row.resumable) select(row)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
const stdout = useStdout().stdout
|
|
849
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
850
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-pin when a search result set lands
|
|
851
|
+
const now = useMemo(() => Date.now(), [rows, searched])
|
|
852
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
853
|
+
const state = loading ? 'searching…' : error !== undefined ? `error: ${error}` : rows.length === 0 ? 'no results yet' : `❯ ${rows[cursor]?.label ?? ''}`
|
|
854
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/search · ${state} · ${t('panel.close')}`, viewport.contentColumns))
|
|
855
|
+
}
|
|
856
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
857
|
+
const offset = revealRow(0, cursor, rows.length, bodyRows)
|
|
858
|
+
const visible = rows.slice(offset, offset + bodyRows)
|
|
859
|
+
const header = error !== undefined
|
|
860
|
+
? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) })
|
|
861
|
+
: loading
|
|
862
|
+
? t('search.compact.searching')
|
|
863
|
+
: searched === ''
|
|
864
|
+
? t('search.title.type')
|
|
865
|
+
: rows.length === 1
|
|
866
|
+
? t('search.title.hits', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
|
|
867
|
+
: t('search.title.hitsPlural', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
|
|
868
|
+
const accent = panelAccent('search', getPalette().dim, getPalette().brandBright)
|
|
869
|
+
return createElement(
|
|
870
|
+
Box,
|
|
871
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
872
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
873
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.hint', { state: query === '' ? t('search.hintEmpty') : singleLineText(query) }), viewport.contentColumns)),
|
|
874
|
+
...(visible.length === 0
|
|
875
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(searched === '' ? ` ${t('search.empty.idle')}` : ` ${t('search.empty.noHits', { loading: loading ? '…' : '' })}`, viewport.contentColumns))]
|
|
876
|
+
: visible.flatMap((row, index) => {
|
|
877
|
+
const absolute = offset + index
|
|
878
|
+
const selected = absolute === cursor
|
|
879
|
+
return [
|
|
880
|
+
createElement(
|
|
881
|
+
Text,
|
|
882
|
+
{
|
|
883
|
+
key: `search-${absolute}`,
|
|
884
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
885
|
+
dimColor: row.subagent,
|
|
886
|
+
wrap: 'truncate-end',
|
|
887
|
+
},
|
|
888
|
+
truncateColumns(`${selected ? '› ' : ' '}${row.subagent ? '↳ ' : ''}${singleLineText(row.label)} · ${formatRelativeTime(row.updatedAt, now)}${row.detail === '' ? '' : ` · ${row.detail}`}${row.resumable ? '' : ' · read-only'}`, viewport.contentColumns),
|
|
889
|
+
),
|
|
890
|
+
createElement(
|
|
891
|
+
Text,
|
|
892
|
+
{ key: `search-snippet-${absolute}`, dimColor: true, wrap: 'truncate-end' },
|
|
893
|
+
truncateColumns(` ⎿ ${singleLineText(row.snippet)}`, viewport.contentColumns),
|
|
894
|
+
),
|
|
895
|
+
]
|
|
896
|
+
})),
|
|
897
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.footer'), viewport.contentColumns)),
|
|
548
898
|
)
|
|
549
899
|
}
|
|
550
900
|
|
|
@@ -556,8 +906,8 @@ export function HistoryPanel({ entries, fill, close }: {
|
|
|
556
906
|
*/
|
|
557
907
|
export function StatuslinePanel({ enabled, change, close }: {
|
|
558
908
|
enabled: readonly StatusItemId[]
|
|
559
|
-
change(items: readonly StatusItemId[])
|
|
560
|
-
close()
|
|
909
|
+
change: (items: readonly StatusItemId[]) => void
|
|
910
|
+
close: () => void
|
|
561
911
|
}): ReactElement {
|
|
562
912
|
// Working state: the full catalog in display order (enabled entries in
|
|
563
913
|
// their configured positions, disabled ones trailing canonically) plus
|
|
@@ -578,7 +928,7 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
578
928
|
if (target < 0 || target >= order.length) return
|
|
579
929
|
const next = [...order]
|
|
580
930
|
const [item] = next.splice(cursor, 1)
|
|
581
|
-
next.splice(target, 0, item
|
|
931
|
+
next.splice(target, 0, item)
|
|
582
932
|
commit(next, on)
|
|
583
933
|
setCursor(target)
|
|
584
934
|
}
|
|
@@ -607,16 +957,17 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
607
957
|
const stdout = useStdout().stdout
|
|
608
958
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
609
959
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
610
|
-
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('
|
|
960
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.statusline.compact'), viewport.contentColumns))
|
|
611
961
|
}
|
|
612
962
|
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
613
963
|
const offset = revealRow(0, cursor, order.length, bodyRows)
|
|
614
964
|
const visible = order.slice(offset, offset + bodyRows)
|
|
615
|
-
const meta = new Map(
|
|
965
|
+
const meta = new Map(localizedStatusItems().map(item => [item.id, item]))
|
|
966
|
+
const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright)
|
|
616
967
|
return createElement(
|
|
617
968
|
Box,
|
|
618
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
619
|
-
createElement(Text, { color: inkColor(
|
|
969
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
970
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('statusline.title'), viewport.contentColumns)),
|
|
620
971
|
...visible.map((id, index) => {
|
|
621
972
|
const absolute = offset + index
|
|
622
973
|
const selected = absolute === cursor
|
|
@@ -632,7 +983,7 @@ export function StatuslinePanel({ enabled, change, close }: {
|
|
|
632
983
|
truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
|
|
633
984
|
)
|
|
634
985
|
}),
|
|
635
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('
|
|
986
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.footer.statusline'), viewport.contentColumns)),
|
|
636
987
|
)
|
|
637
988
|
}
|
|
638
989
|
|
|
@@ -655,11 +1006,11 @@ export function EffortPanel({ row, current, select, back, onExit }: {
|
|
|
655
1006
|
/** Effective effort currently in force ('' when none), for the ● mark. */
|
|
656
1007
|
current: string | undefined
|
|
657
1008
|
/** Accept one advertised effort id, or '' for the provider default. */
|
|
658
|
-
select(effortId: string)
|
|
1009
|
+
select: (effortId: string) => void
|
|
659
1010
|
/** Return to the model list without applying. */
|
|
660
|
-
back()
|
|
1011
|
+
back: () => void
|
|
661
1012
|
/** Leave the whole /model flow (Ctrl+C). */
|
|
662
|
-
onExit()
|
|
1013
|
+
onExit: () => void
|
|
663
1014
|
}): ReactElement {
|
|
664
1015
|
const advertised = row.reasoning?.efforts ?? []
|
|
665
1016
|
const empty = row.reasoning === undefined || advertised.length === 0
|
|
@@ -706,21 +1057,21 @@ export function EffortPanel({ row, current, select, back, onExit }: {
|
|
|
706
1057
|
return
|
|
707
1058
|
}
|
|
708
1059
|
if (key.return && rows[cursor] !== undefined) {
|
|
709
|
-
select(rows[cursor]
|
|
1060
|
+
select(rows[cursor].id)
|
|
710
1061
|
}
|
|
711
1062
|
})
|
|
712
1063
|
return createElement(ListFrame, {
|
|
713
|
-
title:
|
|
1064
|
+
title: t('panel.effort.title', { provider: row.providerName, model: row.modelName }),
|
|
714
1065
|
rows: empty
|
|
715
|
-
? [{ key: 'empty', disabled: true, text: '
|
|
1066
|
+
? [{ key: 'empty', disabled: true, text: t('panel.effort.empty') }]
|
|
716
1067
|
: rows.map(effort => ({
|
|
717
1068
|
key: effort.id,
|
|
718
|
-
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ?
|
|
1069
|
+
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ` · ${t('panel.default')}` : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
|
|
719
1070
|
})),
|
|
720
1071
|
cursor,
|
|
721
1072
|
loading: false,
|
|
722
1073
|
query: '',
|
|
723
|
-
footer: empty ? '
|
|
1074
|
+
footer: empty ? t('panel.footer.effortEmpty') : t('panel.footer.effort'),
|
|
724
1075
|
})
|
|
725
1076
|
}
|
|
726
1077
|
|
|
@@ -745,10 +1096,10 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
745
1096
|
/** Live feed rows (child sessions observed this process). */
|
|
746
1097
|
live: readonly SubagentRow[]
|
|
747
1098
|
/** Load this session's persisted child sessions by lineage. */
|
|
748
|
-
load()
|
|
1099
|
+
load: () => Promise<readonly SessionRow[]>
|
|
749
1100
|
/** Read one child session's full transcript as markdown. */
|
|
750
|
-
readTranscript(id: string, signal?: AbortSignal)
|
|
751
|
-
close()
|
|
1101
|
+
readTranscript: (id: string, signal?: AbortSignal) => Promise<string>
|
|
1102
|
+
close: () => void
|
|
752
1103
|
}): ReactElement {
|
|
753
1104
|
const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
|
|
754
1105
|
const [error, setError] = useState<string>()
|
|
@@ -757,7 +1108,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
757
1108
|
const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
|
|
758
1109
|
const transcriptLoad = useRef<AbortController>()
|
|
759
1110
|
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
760
|
-
const refresh = (): void => {
|
|
1111
|
+
const refresh = useCallback((): void => {
|
|
761
1112
|
setLoading(true)
|
|
762
1113
|
setError(undefined)
|
|
763
1114
|
Promise.resolve().then(load).then(value => {
|
|
@@ -767,8 +1118,8 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
767
1118
|
setError(reason instanceof Error ? reason.message : String(reason))
|
|
768
1119
|
setLoading(false)
|
|
769
1120
|
})
|
|
770
|
-
}
|
|
771
|
-
useEffect(refresh, [])
|
|
1121
|
+
}, [load])
|
|
1122
|
+
useEffect(refresh, [refresh])
|
|
772
1123
|
// Live feed rows first (they carry the running state), then persisted
|
|
773
1124
|
// children only the directory knows — settled subagents from earlier turns.
|
|
774
1125
|
const rows = useMemo<readonly AgentsEntry[]>(() => {
|
|
@@ -819,7 +1170,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
819
1170
|
}, { isActive: transcript === undefined })
|
|
820
1171
|
if (transcript !== undefined) {
|
|
821
1172
|
return createElement(DocumentPanel, {
|
|
822
|
-
title:
|
|
1173
|
+
title: t('panel.subagent.transcriptTitle', { id: transcript.id.slice(-12) }),
|
|
823
1174
|
text: transcript.text,
|
|
824
1175
|
error: transcript.error,
|
|
825
1176
|
close: () => {
|
|
@@ -829,7 +1180,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
829
1180
|
})
|
|
830
1181
|
}
|
|
831
1182
|
return createElement(ListFrame, {
|
|
832
|
-
title:
|
|
1183
|
+
title: t('panel.agents.title', { live: live.length, total: rows.length }),
|
|
833
1184
|
rows: rows.map(row => ({
|
|
834
1185
|
key: row.id,
|
|
835
1186
|
text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
|
|
@@ -838,7 +1189,7 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
838
1189
|
loading,
|
|
839
1190
|
...error === undefined ? {} : { error },
|
|
840
1191
|
query: '',
|
|
841
|
-
footer:
|
|
1192
|
+
footer: t('panel.footer.agents'),
|
|
842
1193
|
})
|
|
843
1194
|
}
|
|
844
1195
|
|
|
@@ -857,19 +1208,19 @@ export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
|
857
1208
|
export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
858
1209
|
/** Display label of the override in force, '' when following the current model. */
|
|
859
1210
|
current: string
|
|
860
|
-
load()
|
|
1211
|
+
load: () => Promise<ModelDirectory>
|
|
861
1212
|
/** Apply one model (with an advertised effort, when picked) as the override. */
|
|
862
|
-
pick(row: ModelRow, effortId?: string)
|
|
1213
|
+
pick: (row: ModelRow, effortId?: string) => void
|
|
863
1214
|
/** Drop the override: subagents follow the current model again. */
|
|
864
|
-
inherit()
|
|
865
|
-
close()
|
|
1215
|
+
inherit: () => void
|
|
1216
|
+
close: () => void
|
|
866
1217
|
}): ReactElement {
|
|
867
1218
|
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
868
1219
|
const [error, setError] = useState<string>()
|
|
869
1220
|
const [loading, setLoading] = useState(true)
|
|
870
1221
|
const [cursor, setCursor] = useState(0)
|
|
871
1222
|
const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
|
|
872
|
-
const refresh = (): void => {
|
|
1223
|
+
const refresh = useCallback((): void => {
|
|
873
1224
|
setLoading(true)
|
|
874
1225
|
setError(undefined)
|
|
875
1226
|
Promise.resolve().then(load).then(value => {
|
|
@@ -879,8 +1230,8 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
879
1230
|
setError(reason instanceof Error ? reason.message : String(reason))
|
|
880
1231
|
setLoading(false)
|
|
881
1232
|
})
|
|
882
|
-
}
|
|
883
|
-
useEffect(refresh, [])
|
|
1233
|
+
}, [load])
|
|
1234
|
+
useEffect(refresh, [refresh])
|
|
884
1235
|
const rows = useMemo(() => directory?.rows ?? [], [directory])
|
|
885
1236
|
// The list opens on the override's own row (index 0 is the inherit row).
|
|
886
1237
|
useEffect(() => {
|
|
@@ -906,7 +1257,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
906
1257
|
setEffortFor(row)
|
|
907
1258
|
return
|
|
908
1259
|
}
|
|
909
|
-
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]
|
|
1260
|
+
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined
|
|
910
1261
|
pick(row, effortId)
|
|
911
1262
|
}
|
|
912
1263
|
})
|
|
@@ -920,9 +1271,9 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
920
1271
|
})
|
|
921
1272
|
}
|
|
922
1273
|
return createElement(ListFrame, {
|
|
923
|
-
title:
|
|
1274
|
+
title: `${t('panel.subagent.title')}${current === '' ? '' : t('panel.subagent.override', { value: current })}`,
|
|
924
1275
|
rows: [
|
|
925
|
-
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit
|
|
1276
|
+
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} ${t('panel.subagent.inherit')}` },
|
|
926
1277
|
...rows.map(row => ({
|
|
927
1278
|
key: `${row.provider}/${row.model}`,
|
|
928
1279
|
text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
|
|
@@ -932,7 +1283,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
|
932
1283
|
loading,
|
|
933
1284
|
...error === undefined ? {} : { error },
|
|
934
1285
|
query: '',
|
|
935
|
-
footer: '
|
|
1286
|
+
footer: t('panel.footer.subagent'),
|
|
936
1287
|
})
|
|
937
1288
|
}
|
|
938
1289
|
|
|
@@ -981,7 +1332,7 @@ export function scheduleDisplayRows(rows: readonly ScheduleRow[], now: number):
|
|
|
981
1332
|
}))
|
|
982
1333
|
}
|
|
983
1334
|
|
|
984
|
-
export function SchedulePanel({ rows, close }: { rows()
|
|
1335
|
+
export function SchedulePanel({ rows, close }: { rows: () => readonly ScheduleRow[]; close: () => void }): ReactElement {
|
|
985
1336
|
const [, setTick] = useState(0)
|
|
986
1337
|
useEffect(() => {
|
|
987
1338
|
const id = setInterval(() => setTick(value => value + 1), 1_000)
|
|
@@ -994,23 +1345,87 @@ export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[];
|
|
|
994
1345
|
if (key.escape || input === 'q') return close()
|
|
995
1346
|
})
|
|
996
1347
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
997
|
-
const summary = display.length === 0 ? '
|
|
1348
|
+
const summary = display.length === 0 ? t('panel.schedule.none') : singleLineText(display[0].text)
|
|
998
1349
|
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns))
|
|
999
1350
|
}
|
|
1000
1351
|
const budget = Math.max(1, viewport.bodyRows)
|
|
1001
1352
|
const visible = display.slice(0, budget)
|
|
1002
1353
|
const hidden = display.length - visible.length
|
|
1354
|
+
const accent = panelAccent('schedule', getPalette().dim, getPalette().brandBright)
|
|
1003
1355
|
return createElement(
|
|
1004
1356
|
Box,
|
|
1005
|
-
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(
|
|
1006
|
-
createElement(Text, { color: inkColor(
|
|
1357
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
1358
|
+
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
1359
|
...(display.length === 0
|
|
1008
|
-
? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
1360
|
+
? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('schedule.empty')}`, viewport.contentColumns))]
|
|
1009
1361
|
: visible.map(row => createElement(Text, {
|
|
1010
1362
|
key: row.key,
|
|
1011
1363
|
color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
|
|
1012
1364
|
wrap: 'truncate-end',
|
|
1013
1365
|
}, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns)))),
|
|
1014
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(
|
|
1366
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.schedule.footer', { more: hidden > 0 ? t('panel.schedule.more', { count: hidden }) : '' }), viewport.contentColumns)),
|
|
1367
|
+
)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* The /usage panel: the session's provider-reported token totals, its context
|
|
1372
|
+
* pressure and estimated composition, and the exact per-turn accounting, in
|
|
1373
|
+
* one bounded scrollable surface. Read-only — Esc or q closes it.
|
|
1374
|
+
*/
|
|
1375
|
+
export function UsagePanel({ load, close }: {
|
|
1376
|
+
/** Read the current session's usage blocks from the mounted projections. */
|
|
1377
|
+
load: () => Promise<UsageView>
|
|
1378
|
+
close: () => void
|
|
1379
|
+
}): ReactElement {
|
|
1380
|
+
const stdout = useStdout().stdout
|
|
1381
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1382
|
+
const [scroll, setScroll] = useState(0)
|
|
1383
|
+
const [view, setView] = useState<UsageView>()
|
|
1384
|
+
const [error, setError] = useState<string>()
|
|
1385
|
+
// The panel opens on the loading row and swaps in the numbers when the
|
|
1386
|
+
// loader settles: materializing the projection units folds the whole log,
|
|
1387
|
+
// and that must not run inside the keystroke that opened the panel.
|
|
1388
|
+
useEffect(() => {
|
|
1389
|
+
let live = true
|
|
1390
|
+
Promise.resolve().then(load).then(
|
|
1391
|
+
loaded => {
|
|
1392
|
+
if (live) setView(loaded)
|
|
1393
|
+
},
|
|
1394
|
+
reason => {
|
|
1395
|
+
if (live) setError(reason instanceof Error ? reason.message : String(reason))
|
|
1396
|
+
},
|
|
1397
|
+
)
|
|
1398
|
+
return () => {
|
|
1399
|
+
live = false
|
|
1400
|
+
}
|
|
1401
|
+
}, [load])
|
|
1402
|
+
const lines = useMemo(
|
|
1403
|
+
() => view === undefined ? [] : usageLines(view, viewport.contentColumns),
|
|
1404
|
+
[view, viewport.contentColumns],
|
|
1405
|
+
)
|
|
1406
|
+
useInput((input, key) => {
|
|
1407
|
+
if (key.escape || input === 'q') return close()
|
|
1408
|
+
if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
|
|
1409
|
+
if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
|
|
1410
|
+
if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
|
|
1411
|
+
if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
|
|
1412
|
+
if (input === 'g') return setScroll(0)
|
|
1413
|
+
if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
1414
|
+
})
|
|
1415
|
+
if (viewport.compact) {
|
|
1416
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.usage.compact'), viewport.contentColumns))
|
|
1417
|
+
}
|
|
1418
|
+
const body: readonly StyledLine[] = error !== undefined
|
|
1419
|
+
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
1420
|
+
: view === undefined
|
|
1421
|
+
? textLines(t('panel.loading'), viewport.contentColumns, 'dim')
|
|
1422
|
+
: lines.slice(scroll, scroll + viewport.bodyRows)
|
|
1423
|
+
const accent = panelAccent('usage', getPalette().dim, getPalette().brandBright)
|
|
1424
|
+
return createElement(
|
|
1425
|
+
Box,
|
|
1426
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
|
|
1427
|
+
createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('panel.usage.title'), viewport.contentColumns)),
|
|
1428
|
+
createElement(DocumentRows, { lines: body }),
|
|
1429
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.usage.footer'), viewport.contentColumns)),
|
|
1015
1430
|
)
|
|
1016
1431
|
}
|