@toddzheng024/dscode-bundle 0.7.12 → 0.7.14
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/THIRD_PARTY_NOTICES.md +3 -3
- package/cordis.patch.yml +6 -2
- package/package.json +7 -5
- package/plugins/code-review/index.mjs +5 -2
- package/plugins/compaction/threshold.mjs +12 -0
- package/plugins/credentials/index.mjs +13 -0
- package/plugins/grok/adapter.mjs +114 -0
- package/plugins/grok/auth.mjs +76 -0
- package/plugins/grok/billing.mjs +146 -0
- package/plugins/grok/index.mjs +118 -0
- package/plugins/grok/models.mjs +129 -0
- package/plugins/grok/status.mjs +28 -0
- package/plugins/grok/wire.mjs +268 -0
- package/plugins/i18n/messages.mjs +6 -6
- package/plugins/providers/catalog.mjs +2 -0
- package/plugins/session-metrics/view.mjs +34 -3
- package/vendor/compaction-basic/index.js +91 -1
- package/vendor/persistent/index.js +10 -9
- package/vendor/terminal/index.js +5 -0
- package/vendor/tui/lib/app.mjs +6711 -0
- package/vendor/tui/lib/approval.mjs +122 -0
- package/vendor/tui/lib/attachments.mjs +218 -0
- package/vendor/tui/lib/authorization-panel.mjs +242 -0
- package/vendor/tui/lib/authorization.mjs +111 -0
- package/vendor/tui/lib/commands.mjs +123 -0
- package/vendor/tui/lib/dscode/chat.mjs +84 -0
- package/vendor/tui/lib/dscode/flags.mjs +32 -0
- package/vendor/tui/lib/dscode/model-search.mjs +68 -0
- package/vendor/tui/lib/dscode/paste.mjs +87 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
- package/vendor/tui/lib/dscode/welcome.mjs +100 -0
- package/vendor/tui/lib/editor-keys.mjs +346 -0
- package/vendor/tui/lib/editor.mjs +50 -0
- package/vendor/tui/lib/fork.mjs +24 -0
- package/vendor/tui/lib/git-workflow.mjs +278 -0
- package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
- package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
- package/vendor/tui/lib/index.mjs +2200 -0
- package/vendor/tui/lib/input-split.mjs +170 -0
- package/vendor/tui/lib/internals.mjs +90 -0
- package/vendor/tui/lib/invariant.mjs +22 -0
- package/vendor/tui/lib/kernel-panels.mjs +1265 -0
- package/vendor/tui/lib/keyboard.mjs +283 -0
- package/vendor/tui/lib/language-panel.mjs +41 -0
- package/vendor/tui/lib/locales/en.mjs +495 -0
- package/vendor/tui/lib/locales/zh.mjs +495 -0
- package/vendor/tui/lib/mentions.mjs +150 -0
- package/vendor/tui/lib/model-capabilities.mjs +245 -0
- package/vendor/tui/lib/models.mjs +215 -0
- package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
- package/vendor/tui/lib/permissions.mjs +45 -0
- package/vendor/tui/lib/plugin-inventory.mjs +27 -0
- package/vendor/tui/lib/presets.mjs +46 -0
- package/vendor/tui/lib/provider-settings.mjs +600 -0
- package/vendor/tui/lib/questions.mjs +116 -0
- package/vendor/tui/lib/rainbow.mjs +181 -0
- package/vendor/tui/lib/render/animations.mjs +665 -0
- package/vendor/tui/lib/render/editor.mjs +437 -0
- package/vendor/tui/lib/render/export.mjs +119 -0
- package/vendor/tui/lib/render/fuzzy.mjs +79 -0
- package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
- package/vendor/tui/lib/render/inspector.mjs +93 -0
- package/vendor/tui/lib/render/lines.mjs +538 -0
- package/vendor/tui/lib/render/markdown.mjs +592 -0
- package/vendor/tui/lib/render/projection.mjs +1864 -0
- package/vendor/tui/lib/render/status.mjs +591 -0
- package/vendor/tui/lib/render/text.mjs +157 -0
- package/vendor/tui/lib/render/tool-detail.mjs +187 -0
- package/vendor/tui/lib/render/tool-preview.mjs +79 -0
- package/vendor/tui/lib/render/usage.mjs +336 -0
- package/vendor/tui/lib/render/width.mjs +191 -0
- package/vendor/tui/lib/session-directory.mjs +329 -0
- package/vendor/tui/lib/session-query.mjs +184 -0
- package/vendor/tui/lib/session-switch.mjs +51 -0
- package/vendor/tui/lib/settings-file.mjs +75 -0
- package/vendor/tui/lib/skills.mjs +119 -0
- package/vendor/tui/lib/startup.mjs +111 -0
- package/vendor/tui/lib/store.mjs +129 -0
- package/vendor/tui/lib/subagents.mjs +200 -0
- package/vendor/tui/lib/terminal-title.mjs +186 -0
- package/vendor/tui/lib/theme-panel.mjs +60 -0
- package/vendor/tui/lib/theme.mjs +379 -0
- package/vendor/tui/lib/update-panel.mjs +239 -0
- package/vendor/tui/lib/update.mjs +101 -0
- package/vendor/tui/lib/version.mjs +105 -0
- package/vendor/tui/lib/whale-glyph.mjs +20 -0
- package/vendor/tui/LICENSE +0 -21
- package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
- package/vendor/tui/dscode-email/cli.mjs +0 -32
- package/vendor/tui/dscode-email/contacts.mjs +0 -36
- package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
- package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
- package/vendor/tui/dscode-email/gmail.mjs +0 -194
- package/vendor/tui/dscode-email/imap.mjs +0 -136
- package/vendor/tui/dscode-email/inbox.d.mts +0 -25
- package/vendor/tui/dscode-email/inbox.mjs +0 -76
- package/vendor/tui/dscode-email/smtp.mjs +0 -69
- package/vendor/tui/dscode-email/store.mjs +0 -32
- package/vendor/tui/dscode-providers/catalog.mjs +0 -136
- package/vendor/tui/dscode-providers/effort.mjs +0 -35
- package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
- package/vendor/tui/index.mjs +0 -44441
- package/vendor/tui/invariant.mjs +0 -21
- package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
- package/vendor/tui/session-query.mjs +0 -150
- package/vendor/tui/startup.mjs +0 -109
- package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
- package/vendor/tui/types/app.d.ts +0 -348
- package/vendor/tui/types/approval.d.ts +0 -59
- package/vendor/tui/types/attachments.d.ts +0 -52
- package/vendor/tui/types/authorization-panel.d.ts +0 -22
- package/vendor/tui/types/authorization.d.ts +0 -36
- package/vendor/tui/types/commands.d.ts +0 -52
- package/vendor/tui/types/editor-keys.d.ts +0 -105
- package/vendor/tui/types/editor.d.ts +0 -6
- package/vendor/tui/types/fork.d.ts +0 -8
- package/vendor/tui/types/git-workflow.d.ts +0 -121
- package/vendor/tui/types/index.d.ts +0 -223
- package/vendor/tui/types/input-split.d.ts +0 -54
- package/vendor/tui/types/internals.d.ts +0 -26
- package/vendor/tui/types/invariant.d.ts +0 -15
- package/vendor/tui/types/kernel-panels.d.ts +0 -245
- package/vendor/tui/types/keyboard.d.ts +0 -80
- package/vendor/tui/types/language-panel.d.ts +0 -12
- package/vendor/tui/types/locales/en.d.ts +0 -450
- package/vendor/tui/types/locales/zh.d.ts +0 -9
- package/vendor/tui/types/mentions.d.ts +0 -85
- package/vendor/tui/types/model-capabilities.d.ts +0 -82
- package/vendor/tui/types/models.d.ts +0 -133
- package/vendor/tui/types/permissions.d.ts +0 -27
- package/vendor/tui/types/plugin-inventory.d.ts +0 -11
- package/vendor/tui/types/presets.d.ts +0 -22
- package/vendor/tui/types/provider-settings.d.ts +0 -239
- package/vendor/tui/types/questions.d.ts +0 -54
- package/vendor/tui/types/rainbow.d.ts +0 -69
- package/vendor/tui/types/render/animations.d.ts +0 -307
- package/vendor/tui/types/render/editor.d.ts +0 -163
- package/vendor/tui/types/render/export.d.ts +0 -9
- package/vendor/tui/types/render/fuzzy.d.ts +0 -21
- package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
- package/vendor/tui/types/render/inspector.d.ts +0 -62
- package/vendor/tui/types/render/lines.d.ts +0 -86
- package/vendor/tui/types/render/markdown.d.ts +0 -29
- package/vendor/tui/types/render/projection.d.ts +0 -587
- package/vendor/tui/types/render/status.d.ts +0 -196
- package/vendor/tui/types/render/text.d.ts +0 -64
- package/vendor/tui/types/render/tool-detail.d.ts +0 -94
- package/vendor/tui/types/render/tool-preview.d.ts +0 -28
- package/vendor/tui/types/render/usage.d.ts +0 -113
- package/vendor/tui/types/render/width.d.ts +0 -29
- package/vendor/tui/types/session-directory.d.ts +0 -190
- package/vendor/tui/types/session-query.d.ts +0 -92
- package/vendor/tui/types/session-switch.d.ts +0 -17
- package/vendor/tui/types/settings-file.d.ts +0 -41
- package/vendor/tui/types/skills.d.ts +0 -47
- package/vendor/tui/types/startup.d.ts +0 -65
- package/vendor/tui/types/store.d.ts +0 -58
- package/vendor/tui/types/subagents.d.ts +0 -70
- package/vendor/tui/types/terminal-title.d.ts +0 -66
- package/vendor/tui/types/theme-panel.d.ts +0 -24
- package/vendor/tui/types/theme.d.ts +0 -434
- package/vendor/tui/types/update-panel.d.ts +0 -49
- package/vendor/tui/types/update.d.ts +0 -75
- package/vendor/tui/types/version.d.ts +0 -19
- package/vendor/tui/types/whale-glyph.d.ts +0 -6
- /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
- /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/index.mjs +0 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
/** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
|
|
2
|
+
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { formatRelativeTime } from './session-directory.mjs';
|
|
5
|
+
import { panelViewport, revealRow } from './render/inspector.mjs';
|
|
6
|
+
import { markdownLines, textLines } from './render/lines.mjs';
|
|
7
|
+
import { usageLines } from './render/usage.mjs';
|
|
8
|
+
import { deleteLastGrapheme } from './render/editor.mjs';
|
|
9
|
+
import { stripPasteMarkers } from './keyboard.mjs';
|
|
10
|
+
import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS } from './render/status.mjs';
|
|
11
|
+
import { singleLineText, truncateColumns } from './render/text.mjs';
|
|
12
|
+
import { panelAccent } from './panel-accent.mjs';
|
|
13
|
+
import { t } from './i18n.mjs';
|
|
14
|
+
import { getPalette, inkColor } from './theme.mjs';
|
|
15
|
+
/** True for the Ctrl+F search-focus toggle. */
|
|
16
|
+
function isSearchToggle(input, key) {
|
|
17
|
+
return key.ctrl === true && input === 'f';
|
|
18
|
+
}
|
|
19
|
+
/** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
|
|
20
|
+
* toggle lives in the footer), direct-typing panels keep the plain prompt. */
|
|
21
|
+
function searchLine(searching, query) {
|
|
22
|
+
if (searching === true)
|
|
23
|
+
return query === '' ? t('panel.searchIdleStop') : `${t('panel.searchPrefix')}${query}`;
|
|
24
|
+
if (searching === false)
|
|
25
|
+
return query === '' ? '' : `${t('panel.searchPrefix')}${query}`;
|
|
26
|
+
return query === '' ? t('panel.searchIdle') : `${t('panel.searchPrefix')}${query}`;
|
|
27
|
+
}
|
|
28
|
+
function ListFrame(props) {
|
|
29
|
+
const stdout = useStdout().stdout;
|
|
30
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
31
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
32
|
+
// One visible row instead of a hidden panel: the current selection (or
|
|
33
|
+
// load state) is shown so Enter/arrows are never blind keys acting on
|
|
34
|
+
// invisible state. The selection leads and `esc close` follows it, so a
|
|
35
|
+
// narrow terminal truncates the panel title — never the actionable facts.
|
|
36
|
+
const body = props.loading
|
|
37
|
+
? `${props.title} · loading…`
|
|
38
|
+
: props.error !== undefined
|
|
39
|
+
? `${props.title} · load failed`
|
|
40
|
+
: props.rows.length === 0
|
|
41
|
+
? `${props.title} · no matching entries`
|
|
42
|
+
: `❯ ${singleLineText(props.rows[props.cursor]?.text ?? '')}`;
|
|
43
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${body} · esc close`), viewport.contentColumns));
|
|
44
|
+
}
|
|
45
|
+
const stateRows = props.loading
|
|
46
|
+
? [{ key: 'loading', text: ' loading…' }]
|
|
47
|
+
: props.error !== undefined
|
|
48
|
+
? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
|
|
49
|
+
: props.rows.length === 0
|
|
50
|
+
? [{ key: 'empty', text: ' no matching entries' }]
|
|
51
|
+
: props.rows;
|
|
52
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1);
|
|
53
|
+
const offset = revealRow(0, props.cursor, stateRows.length, bodyRows);
|
|
54
|
+
const visible = stateRows.slice(offset, offset + bodyRows);
|
|
55
|
+
const accent = panelAccent('kernel-list', getPalette().dim, getPalette().brandBright);
|
|
56
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)), ...visible.map((row, index) => {
|
|
57
|
+
const absolute = offset + index;
|
|
58
|
+
const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor;
|
|
59
|
+
return createElement(Text, {
|
|
60
|
+
key: row.key,
|
|
61
|
+
color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : undefined,
|
|
62
|
+
dimColor: row.disabled,
|
|
63
|
+
wrap: 'truncate-end',
|
|
64
|
+
}, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(row.text)}`, viewport.contentColumns));
|
|
65
|
+
}), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Apply one keystroke to a panel search query. IME commits arrive as one
|
|
69
|
+
* multi-character chunk, so the whole printable run is appended; paste
|
|
70
|
+
* markers are stripped and control-laden chunks are ignored.
|
|
71
|
+
*/
|
|
72
|
+
export function editQuery(query, input, key) {
|
|
73
|
+
if (key.backspace || key.delete)
|
|
74
|
+
return deleteLastGrapheme(query);
|
|
75
|
+
const text = stripPasteMarkers(input);
|
|
76
|
+
if (text !== '' && !/[\u0000-\u001f\u007f]/u.test(text))
|
|
77
|
+
return query + text;
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
export function ModePanel({ current, load, select, close }) {
|
|
81
|
+
const [rows, setRows] = useState([]);
|
|
82
|
+
const [query, setQuery] = useState('');
|
|
83
|
+
const [cursor, setCursor] = useState(0);
|
|
84
|
+
const [loading, setLoading] = useState(true);
|
|
85
|
+
const [error, setError] = useState();
|
|
86
|
+
const refresh = () => {
|
|
87
|
+
setLoading(true);
|
|
88
|
+
setError(undefined);
|
|
89
|
+
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false); }, reason => {
|
|
90
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
91
|
+
setLoading(false);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
useEffect(refresh, []);
|
|
95
|
+
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query]);
|
|
96
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length]);
|
|
97
|
+
useInput((input, key) => {
|
|
98
|
+
if (key.escape)
|
|
99
|
+
return close();
|
|
100
|
+
// q closes only while the query is empty; mid-filter it is query text.
|
|
101
|
+
if (input === 'q' && query === '')
|
|
102
|
+
return close();
|
|
103
|
+
if (input === 'r' && query === '')
|
|
104
|
+
return refresh();
|
|
105
|
+
if (key.upArrow)
|
|
106
|
+
return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length);
|
|
107
|
+
if (key.downArrow)
|
|
108
|
+
return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length);
|
|
109
|
+
// Empty/loading/filtered-out lists have no row at the cursor: a bare
|
|
110
|
+
// `?.broken === undefined` check passes on undefined and crashes the
|
|
111
|
+
// process on the `!.id` access (PermissionPanel guards this correctly).
|
|
112
|
+
if (key.return && visible[cursor] !== undefined && visible[cursor].broken === undefined)
|
|
113
|
+
return select(visible[cursor].id);
|
|
114
|
+
const next = editQuery(query, input, key);
|
|
115
|
+
if (next !== undefined) {
|
|
116
|
+
setQuery(next);
|
|
117
|
+
setCursor(0);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return createElement(ListFrame, {
|
|
121
|
+
title: t('panel.mode.title', { current }),
|
|
122
|
+
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}`}` })),
|
|
123
|
+
cursor, loading, error, query, footer: t('panel.footer.chooseSwitch'),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
export function PermissionPanel({ current, load, select, close }) {
|
|
127
|
+
const [rows, setRows] = useState([]);
|
|
128
|
+
const [query, setQuery] = useState('');
|
|
129
|
+
const [cursor, setCursor] = useState(0);
|
|
130
|
+
const [loading, setLoading] = useState(true);
|
|
131
|
+
const [error, setError] = useState();
|
|
132
|
+
const refresh = () => {
|
|
133
|
+
setLoading(true);
|
|
134
|
+
setError(undefined);
|
|
135
|
+
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false); }, reason => {
|
|
136
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
137
|
+
setLoading(false);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
useEffect(refresh, []);
|
|
141
|
+
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query]);
|
|
142
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length]);
|
|
143
|
+
useInput((input, key) => {
|
|
144
|
+
if (key.escape)
|
|
145
|
+
return close();
|
|
146
|
+
// q closes only while the query is empty; mid-filter it is query text.
|
|
147
|
+
if (input === 'q' && query === '')
|
|
148
|
+
return close();
|
|
149
|
+
if (input === 'r' && query === '')
|
|
150
|
+
return refresh();
|
|
151
|
+
if (key.upArrow)
|
|
152
|
+
return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length);
|
|
153
|
+
if (key.downArrow)
|
|
154
|
+
return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length);
|
|
155
|
+
if (key.return && visible[cursor] !== undefined)
|
|
156
|
+
return select(visible[cursor].id);
|
|
157
|
+
const next = editQuery(query, input, key);
|
|
158
|
+
if (next !== undefined) {
|
|
159
|
+
setQuery(next);
|
|
160
|
+
setCursor(0);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return createElement(ListFrame, {
|
|
164
|
+
title: t('panel.permission.title', { current }),
|
|
165
|
+
rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
|
|
166
|
+
cursor, loading, error, query, footer: t('panel.footer.chooseSelect'),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export function PluginPanel({ load, close, initialQuery = '' }) {
|
|
170
|
+
const [epoch, setEpoch] = useState(0);
|
|
171
|
+
const [query, setQuery] = useState(initialQuery);
|
|
172
|
+
const [cursor, setCursor] = useState(0);
|
|
173
|
+
const [expanded, setExpanded] = useState(false);
|
|
174
|
+
const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query]);
|
|
175
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
|
|
176
|
+
useInput((input, key) => {
|
|
177
|
+
if (key.escape)
|
|
178
|
+
return close();
|
|
179
|
+
// q closes only while the query is empty; mid-filter it is query text.
|
|
180
|
+
if (input === 'q' && query === '')
|
|
181
|
+
return close();
|
|
182
|
+
if (input === 'r' && query === '')
|
|
183
|
+
return setEpoch(value => value + 1);
|
|
184
|
+
if (key.upArrow)
|
|
185
|
+
return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length);
|
|
186
|
+
if (key.downArrow)
|
|
187
|
+
return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length);
|
|
188
|
+
if (key.return)
|
|
189
|
+
return setExpanded(value => !value);
|
|
190
|
+
const next = editQuery(query, input, key);
|
|
191
|
+
if (next !== undefined) {
|
|
192
|
+
setQuery(next);
|
|
193
|
+
setCursor(0);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return createElement(ListFrame, {
|
|
197
|
+
title: t('panel.plugin.title'),
|
|
198
|
+
rows: rows.map((row, index) => ({
|
|
199
|
+
key: row.entryId,
|
|
200
|
+
disabled: !row.enabled,
|
|
201
|
+
text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
|
|
202
|
+
})), cursor, loading: false, query, footer: t('panel.footer.inspectDetails'),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
|
|
206
|
+
export function runClock(ms) {
|
|
207
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
208
|
+
const minutes = Math.floor(total / 60);
|
|
209
|
+
const seconds = total % 60;
|
|
210
|
+
return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
|
|
211
|
+
}
|
|
212
|
+
/** Status glyph per job lifecycle state. */
|
|
213
|
+
const JOB_MARK = {
|
|
214
|
+
running: '●',
|
|
215
|
+
stopping: '⏸',
|
|
216
|
+
completed: '✓',
|
|
217
|
+
killed: '⊘',
|
|
218
|
+
failed: '✗',
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* The read-only background-job panel: caller-owned and unowned jobs from the
|
|
222
|
+
* host `jobs` registry in registration order, with a local second-hand while
|
|
223
|
+
* the panel is open (elapsed clocks advance and the snapshot re-reads; the
|
|
224
|
+
* interval dies with the panel). Cancel stays upstream-only; an absent
|
|
225
|
+
* registry renders as the plain empty state (a harmless missing service).
|
|
226
|
+
*/
|
|
227
|
+
export function JobsPanel({ load, close }) {
|
|
228
|
+
const [, setRefresh] = useState(0);
|
|
229
|
+
const [cursor, setCursor] = useState(0);
|
|
230
|
+
const [, setTick] = useState(0);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const id = setInterval(() => setTick(value => value + 1), 1_000);
|
|
233
|
+
return () => clearInterval(id);
|
|
234
|
+
}, []);
|
|
235
|
+
const rows = load();
|
|
236
|
+
useInput((input, key) => {
|
|
237
|
+
if (key.escape || input === 'q')
|
|
238
|
+
return close();
|
|
239
|
+
if (input === 'r')
|
|
240
|
+
return setRefresh(value => value + 1);
|
|
241
|
+
if (key.upArrow)
|
|
242
|
+
return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length);
|
|
243
|
+
if (key.downArrow)
|
|
244
|
+
return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length);
|
|
245
|
+
});
|
|
246
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
|
|
247
|
+
return createElement(ListFrame, {
|
|
248
|
+
title: t('panel.jobs.title', { count: rows.length }),
|
|
249
|
+
rows: rows.map(row => ({
|
|
250
|
+
key: row.id,
|
|
251
|
+
text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
|
|
252
|
+
})),
|
|
253
|
+
cursor, loading: false, query: '', searching: false, footer: t('panel.footer.inspectRefresh'),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }) {
|
|
257
|
+
// Codex resume-picker default: the CURRENT directory's root sessions; the
|
|
258
|
+
// cwd filter widens to all only on request (the old default leaked every
|
|
259
|
+
// directory's sessions into what read as a current-directory view).
|
|
260
|
+
const [options, setOptions] = useState({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' });
|
|
261
|
+
const [focus, setFocus] = useState(0);
|
|
262
|
+
const [density, setDensity] = useState('comfortable');
|
|
263
|
+
const [rows, setRows] = useState([]);
|
|
264
|
+
const [cursor, setCursor] = useState(0);
|
|
265
|
+
const [loading, setLoading] = useState(true);
|
|
266
|
+
const [error, setError] = useState();
|
|
267
|
+
const [expanded, setExpanded] = useState();
|
|
268
|
+
const [transcript, setTranscript] = useState();
|
|
269
|
+
/** Ctrl+F-gated search: typing filters only while searching (codex). */
|
|
270
|
+
const [searching, setSearching] = useState(false);
|
|
271
|
+
/** Reference clock pinned per row render, so relative times never drift mid-list. */
|
|
272
|
+
const now = useMemo(() => Date.now(), [rows, options]);
|
|
273
|
+
const transcriptLoad = useRef();
|
|
274
|
+
useEffect(() => () => transcriptLoad.current?.abort(), []);
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
setLoading(true);
|
|
278
|
+
setError(undefined);
|
|
279
|
+
Promise.resolve().then(() => load(options, controller.signal)).then(value => {
|
|
280
|
+
if (!controller.signal.aborted) {
|
|
281
|
+
setRows(value);
|
|
282
|
+
setLoading(false);
|
|
283
|
+
}
|
|
284
|
+
}, reason => {
|
|
285
|
+
if (!controller.signal.aborted) {
|
|
286
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
287
|
+
setLoading(false);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return () => controller.abort();
|
|
291
|
+
}, [options, reloadToken]);
|
|
292
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
|
|
293
|
+
const cycle = () => {
|
|
294
|
+
if (focus === 3) {
|
|
295
|
+
setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
setOptions(value => {
|
|
299
|
+
if (focus === 0)
|
|
300
|
+
return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' };
|
|
301
|
+
if (focus === 1)
|
|
302
|
+
return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' };
|
|
303
|
+
return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' };
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
useInput((input, key) => {
|
|
307
|
+
// While a deletion awaits y/n, the COMPOSER owns every key (App routes
|
|
308
|
+
// them); the panel yields so y/n cannot be handled twice.
|
|
309
|
+
if (deleteConfirmId !== undefined)
|
|
310
|
+
return;
|
|
311
|
+
if (key.escape) {
|
|
312
|
+
if (searching) {
|
|
313
|
+
setSearching(false);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
return close();
|
|
317
|
+
}
|
|
318
|
+
if (isSearchToggle(input, key)) {
|
|
319
|
+
setSearching(current => !current);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (searching) {
|
|
323
|
+
if (key.return) {
|
|
324
|
+
setSearching(false);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const next = editQuery(options.query, input, key);
|
|
328
|
+
if (next !== undefined) {
|
|
329
|
+
setOptions(value => ({ ...value, query: next }));
|
|
330
|
+
setCursor(0);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (input === 'q')
|
|
335
|
+
return close();
|
|
336
|
+
if (key.tab)
|
|
337
|
+
return setFocus(value => (value + (key.shift ? 3 : 1)) % 4);
|
|
338
|
+
if (key.leftArrow)
|
|
339
|
+
return cycle();
|
|
340
|
+
if (key.rightArrow)
|
|
341
|
+
return cycle();
|
|
342
|
+
if (key.upArrow)
|
|
343
|
+
return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1));
|
|
344
|
+
if (key.downArrow)
|
|
345
|
+
return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1));
|
|
346
|
+
if (key.pageUp)
|
|
347
|
+
return setCursor(value => Math.max(0, value - 8));
|
|
348
|
+
if (key.pageDown)
|
|
349
|
+
return setCursor(value => Math.min(rows.length - 1, value + 8));
|
|
350
|
+
if (input === 'g')
|
|
351
|
+
return setCursor(0);
|
|
352
|
+
if (input === 'G')
|
|
353
|
+
return setCursor(Math.max(0, rows.length - 1));
|
|
354
|
+
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined)
|
|
355
|
+
return requestDelete(rows[cursor]);
|
|
356
|
+
if (input === 'e' && rows[cursor] !== undefined) {
|
|
357
|
+
return setExpanded(value => value === rows[cursor].id ? undefined : rows[cursor].id);
|
|
358
|
+
}
|
|
359
|
+
if (input === 't' && rows[cursor] !== undefined) {
|
|
360
|
+
const row = rows[cursor];
|
|
361
|
+
transcriptLoad.current?.abort();
|
|
362
|
+
setTranscript({ id: row.id });
|
|
363
|
+
const controller = new AbortController();
|
|
364
|
+
transcriptLoad.current = controller;
|
|
365
|
+
Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(text => { if (!controller.signal.aborted)
|
|
366
|
+
setTranscript({ id: row.id, text }); }, reason => { if (!controller.signal.aborted)
|
|
367
|
+
setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }); });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (key.return && rows[cursor]?.resumable === true)
|
|
371
|
+
return select(rows[cursor]);
|
|
372
|
+
}, { isActive: transcript === undefined });
|
|
373
|
+
if (transcript !== undefined) {
|
|
374
|
+
return createElement(DocumentPanel, {
|
|
375
|
+
title: t('panel.document.title', { id: transcript.id }),
|
|
376
|
+
text: transcript.text,
|
|
377
|
+
error: transcript.error,
|
|
378
|
+
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined); },
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId);
|
|
382
|
+
const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`;
|
|
383
|
+
return createElement(ListFrame, {
|
|
384
|
+
title: deleteConfirmId === undefined
|
|
385
|
+
? `/resume${deleteMode ? ' — delete mode' : ''}${searching ? ' — searching' : ''} · ${toolbar}`
|
|
386
|
+
: `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
|
|
387
|
+
rows: rows.map(row => ({
|
|
388
|
+
key: row.id,
|
|
389
|
+
disabled: !row.resumable,
|
|
390
|
+
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}`}` : ''}`,
|
|
391
|
+
})), cursor, loading, error, query: options.query, searching,
|
|
392
|
+
footer: t('panel.footer.resume'),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function documentStyleProps(style) {
|
|
396
|
+
switch (style) {
|
|
397
|
+
case 'accent':
|
|
398
|
+
return { color: inkColor(getPalette().brandBright) };
|
|
399
|
+
case 'accentBold':
|
|
400
|
+
return { color: inkColor(getPalette().brandBright), bold: true };
|
|
401
|
+
case 'code':
|
|
402
|
+
return { color: inkColor(getPalette().code) };
|
|
403
|
+
case 'dim':
|
|
404
|
+
return { color: inkColor(getPalette().dim) };
|
|
405
|
+
case 'bold':
|
|
406
|
+
return { bold: true };
|
|
407
|
+
case 'italic':
|
|
408
|
+
return { italic: true };
|
|
409
|
+
case 'boldItalic':
|
|
410
|
+
return { bold: true, italic: true };
|
|
411
|
+
case 'strike':
|
|
412
|
+
return { color: inkColor(getPalette().dim), strikethrough: true };
|
|
413
|
+
default:
|
|
414
|
+
return {};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function DocumentRows({ lines }) {
|
|
418
|
+
return createElement(Box, { flexDirection: 'column' }, ...lines.map((line, index) => createElement(Text, { key: index, wrap: 'truncate-end' }, line.segments.length === 0
|
|
419
|
+
? ' '
|
|
420
|
+
: line.segments.map((segment, at) => createElement(Text, { key: at, ...documentStyleProps(segment.style) }, segment.text)))));
|
|
421
|
+
}
|
|
422
|
+
function DocumentPanel({ title, text, error, close }) {
|
|
423
|
+
const stdout = useStdout().stdout;
|
|
424
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
425
|
+
const [scroll, setScroll] = useState(0);
|
|
426
|
+
const lines = useMemo(() => text === undefined ? [] : markdownLines(text, viewport.contentColumns), [text, viewport.contentColumns]);
|
|
427
|
+
useInput((input, key) => {
|
|
428
|
+
if (key.escape || input === 'q' || input === 't')
|
|
429
|
+
return close();
|
|
430
|
+
if (key.upArrow)
|
|
431
|
+
return setScroll(value => Math.max(0, value - 1));
|
|
432
|
+
if (key.downArrow)
|
|
433
|
+
return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1));
|
|
434
|
+
if (key.pageUp)
|
|
435
|
+
return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)));
|
|
436
|
+
if (key.pageDown)
|
|
437
|
+
return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)));
|
|
438
|
+
if (input === 'g')
|
|
439
|
+
return setScroll(0);
|
|
440
|
+
if (input === 'G')
|
|
441
|
+
return setScroll(Math.max(0, lines.length - viewport.bodyRows));
|
|
442
|
+
});
|
|
443
|
+
if (viewport.compact)
|
|
444
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('document.compact'), viewport.contentColumns));
|
|
445
|
+
const body = error !== undefined
|
|
446
|
+
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
447
|
+
: text === undefined
|
|
448
|
+
? textLines(t('document.loading'), viewport.contentColumns, 'dim')
|
|
449
|
+
: lines.slice(scroll, scroll + viewport.bodyRows);
|
|
450
|
+
const accent = panelAccent('kernel-transcript', getPalette().dim, getPalette().brandBright);
|
|
451
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)), createElement(DocumentRows, { lines: body }), 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)));
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The /history recall panel (Codex composer-history search, bounded): one
|
|
455
|
+
* query line over the newest-first recall space, filtered by substring, with
|
|
456
|
+
* arrow selection and enter to fill the composer. Editing the query restarts
|
|
457
|
+
* from the newest match; Esc closes without touching the draft.
|
|
458
|
+
*/
|
|
459
|
+
export function HistoryPanel({ entries, fill, close }) {
|
|
460
|
+
const [query, setQuery] = useState('');
|
|
461
|
+
const [cursor, setCursor] = useState(0);
|
|
462
|
+
const matches = query === ''
|
|
463
|
+
? entries
|
|
464
|
+
: entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()));
|
|
465
|
+
useInput((input, key) => {
|
|
466
|
+
if (key.escape)
|
|
467
|
+
return close();
|
|
468
|
+
if (key.return) {
|
|
469
|
+
const entry = matches[cursor];
|
|
470
|
+
if (entry !== undefined)
|
|
471
|
+
fill(entry, entries.indexOf(entry));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (key.upArrow)
|
|
475
|
+
return setCursor(value => Math.max(0, value - 1));
|
|
476
|
+
if (key.downArrow)
|
|
477
|
+
return setCursor(value => Math.min(matches.length - 1, value + 1));
|
|
478
|
+
// g/G stay vim-style jumps only on an empty query (the /mode contract):
|
|
479
|
+
// mid-filter they are query text, so filters like 'Fix' or 'grep' survive.
|
|
480
|
+
if (input === 'g' && query === '')
|
|
481
|
+
return setCursor(0);
|
|
482
|
+
if (input === 'G' && query === '')
|
|
483
|
+
return setCursor(matches.length - 1);
|
|
484
|
+
if (key.backspace || key.delete) {
|
|
485
|
+
setQuery(current => deleteLastGrapheme(current));
|
|
486
|
+
setCursor(0);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Ink reports single uppercase letters and shifted symbols ('!', '@')
|
|
490
|
+
// with key.shift set; only ctrl/meta mark real command input.
|
|
491
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
492
|
+
const text = stripPasteMarkers(input);
|
|
493
|
+
if (text !== '') {
|
|
494
|
+
setQuery(current => (current + text).slice(0, 120));
|
|
495
|
+
setCursor(0);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
const stdout = useStdout().stdout;
|
|
500
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
501
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
502
|
+
const picked = matches[cursor];
|
|
503
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/history · ${picked === undefined ? t('history.compact.none') : singleLineText(picked)} · ${t('panel.close')}`, viewport.contentColumns));
|
|
504
|
+
}
|
|
505
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1);
|
|
506
|
+
const offset = revealRow(0, cursor, matches.length, bodyRows);
|
|
507
|
+
const visible = matches.slice(offset, offset + bodyRows);
|
|
508
|
+
const header = query === ''
|
|
509
|
+
? t('history.title.prompts', { count: entries.length })
|
|
510
|
+
: t('history.title.match', { matches: matches.length, count: entries.length, query: truncateColumns(singleLineText(query), Math.max(6, viewport.contentColumns - 34)) });
|
|
511
|
+
const accent = panelAccent('history', getPalette().dim, getPalette().brandBright);
|
|
512
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.filterHint', { state: query === '' ? t('history.filterEmpty') : t('history.filterQuery', { query: singleLineText(query) }) }), viewport.contentColumns)), ...(visible.length === 0
|
|
513
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.empty'), viewport.contentColumns))]
|
|
514
|
+
: visible.map((entry, index) => {
|
|
515
|
+
const absolute = offset + index;
|
|
516
|
+
const selected = absolute === cursor;
|
|
517
|
+
return createElement(Text, {
|
|
518
|
+
key: `history-${absolute}`,
|
|
519
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
520
|
+
wrap: 'truncate-end',
|
|
521
|
+
}, truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns));
|
|
522
|
+
})), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('history.footer'), viewport.contentColumns)));
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The /review candidate picker (Codex's preset popup): bare /review opens a
|
|
526
|
+
* four-way preset — review uncommitted changes, pick a base branch, pick a
|
|
527
|
+
* recent commit, or type a custom focus. Branch and commit phases are
|
|
528
|
+
* type-to-filter lists; every selection resolves to the same /review
|
|
529
|
+
* argument string the direct command accepts.
|
|
530
|
+
*/
|
|
531
|
+
export function ReviewPickerPanel({ loadBranches, loadCommits, choose, close }) {
|
|
532
|
+
const [phase, setPhase] = useState('preset');
|
|
533
|
+
const [cursor, setCursor] = useState(0);
|
|
534
|
+
const [query, setQuery] = useState('');
|
|
535
|
+
const [rows, setRows] = useState([]);
|
|
536
|
+
const [loading, setLoading] = useState(false);
|
|
537
|
+
const [error, setError] = useState();
|
|
538
|
+
const loadRef = useRef();
|
|
539
|
+
const now = useMemo(() => Date.now(), [rows]);
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
if (phase !== 'branches' && phase !== 'commits')
|
|
542
|
+
return undefined;
|
|
543
|
+
loadRef.current?.abort();
|
|
544
|
+
const controller = new AbortController();
|
|
545
|
+
loadRef.current = controller;
|
|
546
|
+
setLoading(true);
|
|
547
|
+
setError(undefined);
|
|
548
|
+
const load = phase === 'branches'
|
|
549
|
+
? (signal) => loadBranches(signal)
|
|
550
|
+
: (signal) => loadCommits(signal);
|
|
551
|
+
void Promise.resolve().then(() => load(controller.signal)).then(list => {
|
|
552
|
+
if (controller.signal.aborted)
|
|
553
|
+
return;
|
|
554
|
+
setLoading(false);
|
|
555
|
+
setRows(list);
|
|
556
|
+
setCursor(0);
|
|
557
|
+
}, reason => {
|
|
558
|
+
if (controller.signal.aborted)
|
|
559
|
+
return;
|
|
560
|
+
setLoading(false);
|
|
561
|
+
setRows([]);
|
|
562
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
563
|
+
});
|
|
564
|
+
return () => controller.abort();
|
|
565
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
566
|
+
}, [phase]);
|
|
567
|
+
const filtered = useMemo(() => {
|
|
568
|
+
if (phase !== 'branches' && phase !== 'commits')
|
|
569
|
+
return [];
|
|
570
|
+
const needle = query.toLowerCase();
|
|
571
|
+
if (needle === '')
|
|
572
|
+
return rows;
|
|
573
|
+
return rows.filter(row => {
|
|
574
|
+
const hay = 'name' in row ? row.name : `${row.sha} ${row.title}`;
|
|
575
|
+
return hay.toLowerCase().includes(needle);
|
|
576
|
+
});
|
|
577
|
+
}, [phase, rows, query]);
|
|
578
|
+
const submit = (selection) => {
|
|
579
|
+
choose(selection);
|
|
580
|
+
};
|
|
581
|
+
useInput((input, key) => {
|
|
582
|
+
if (key.escape || (input === 'q' && query === '' && phase !== 'custom')) {
|
|
583
|
+
if (phase !== 'preset') {
|
|
584
|
+
setPhase('preset');
|
|
585
|
+
setQuery('');
|
|
586
|
+
setCursor(0);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
return close();
|
|
590
|
+
}
|
|
591
|
+
if (key.ctrl && input === 'c')
|
|
592
|
+
return close();
|
|
593
|
+
if (phase === 'custom' || ((phase === 'branches' || phase === 'commits') && query !== '')) {
|
|
594
|
+
const next = editQuery(query, input, key);
|
|
595
|
+
if (next !== undefined) {
|
|
596
|
+
setQuery(next);
|
|
597
|
+
setCursor(0);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// The row budget per phase: the preset list is fixed at four rows, the
|
|
602
|
+
// branch/commit lists clamp to their filtered length.
|
|
603
|
+
const rowCount = phase === 'preset' ? 4 : filtered.length;
|
|
604
|
+
if (key.upArrow)
|
|
605
|
+
return setCursor(value => Math.max(0, value - 1));
|
|
606
|
+
if (key.downArrow)
|
|
607
|
+
return setCursor(value => Math.min(Math.max(0, rowCount - 1), value + 1));
|
|
608
|
+
if (key.return) {
|
|
609
|
+
if (phase === 'preset') {
|
|
610
|
+
if (cursor === 0)
|
|
611
|
+
return submit({ kind: 'uncommitted' });
|
|
612
|
+
if (cursor === 1)
|
|
613
|
+
return setPhase('branches');
|
|
614
|
+
if (cursor === 2)
|
|
615
|
+
return setPhase('commits');
|
|
616
|
+
return setPhase('custom');
|
|
617
|
+
}
|
|
618
|
+
if (phase === 'branches' || phase === 'commits') {
|
|
619
|
+
const row = filtered[cursor];
|
|
620
|
+
if (row !== undefined)
|
|
621
|
+
submit('name' in row ? { kind: 'base-branch', branch: row.name } : { kind: 'commit', sha: row.sha });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (query.trim() !== '')
|
|
625
|
+
submit({ kind: 'custom', instructions: query.trim() });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
const stdout = useStdout().stdout;
|
|
630
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
631
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
632
|
+
const state = phase === 'preset' ? 'pick a review target' : loading ? 'loading…' : error !== undefined ? `error: ${error}` : `${filtered.length} candidates`;
|
|
633
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/review · ${state} · ${t('panel.close')}`, viewport.contentColumns));
|
|
634
|
+
}
|
|
635
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1);
|
|
636
|
+
const offset = revealRow(0, cursor, phase === 'preset' ? 4 : filtered.length, bodyRows);
|
|
637
|
+
const header = phase === 'preset'
|
|
638
|
+
? t('review.picker.title')
|
|
639
|
+
: phase === 'branches'
|
|
640
|
+
? 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 })
|
|
641
|
+
: phase === 'commits'
|
|
642
|
+
? 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 })
|
|
643
|
+
: t('review.picker.customHint');
|
|
644
|
+
const accent = panelAccent('review-picker', getPalette().dim, getPalette().brandBright);
|
|
645
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)), ...(phase === 'preset'
|
|
646
|
+
? [
|
|
647
|
+
t('review.picker.uncommitted'),
|
|
648
|
+
t('review.picker.branch'),
|
|
649
|
+
t('review.picker.commit'),
|
|
650
|
+
t('review.picker.custom'),
|
|
651
|
+
].map((label, index) => {
|
|
652
|
+
const absolute = offset + index;
|
|
653
|
+
const selected = absolute === cursor;
|
|
654
|
+
return createElement(Text, { key: `preset-${index}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' }, truncateColumns(`${selected ? '› ' : ' '}${label}`, viewport.contentColumns));
|
|
655
|
+
})
|
|
656
|
+
: phase === 'custom'
|
|
657
|
+
? [createElement(Text, { key: 'custom-input', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${query === '' ? t('review.picker.customHint') : singleLineText(query)}`, viewport.contentColumns))]
|
|
658
|
+
: filtered.length === 0
|
|
659
|
+
? [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))]
|
|
660
|
+
: filtered.slice(offset, offset + bodyRows).map((row, index) => {
|
|
661
|
+
const absolute = offset + index;
|
|
662
|
+
const selected = absolute === cursor;
|
|
663
|
+
const label = 'name' in row ? row.name : `${row.sha.slice(0, 7)} · ${formatRelativeTime(row.at, now)} · ${row.title}`;
|
|
664
|
+
return createElement(Text, { key: `row-${absolute}`, color: selected ? inkColor(getPalette().brandBright) : undefined, wrap: 'truncate-end' }, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(label)}`, viewport.contentColumns));
|
|
665
|
+
})), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('review.picker.footer'), viewport.contentColumns)));
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The /search panel: full-text search over every persisted session through
|
|
669
|
+
* the in-process session-query engine (the same corpus the model's
|
|
670
|
+
* session_search tool reads). Type a query, Enter searches, Enter again
|
|
671
|
+
* resumes the hit; the query line edits like every kernel panel.
|
|
672
|
+
*/
|
|
673
|
+
export function SearchPanel({ load, select, initialQuery = '', close }) {
|
|
674
|
+
const [query, setQuery] = useState(initialQuery);
|
|
675
|
+
const [rows, setRows] = useState([]);
|
|
676
|
+
const [cursor, setCursor] = useState(0);
|
|
677
|
+
const [loading, setLoading] = useState(false);
|
|
678
|
+
const [error, setError] = useState();
|
|
679
|
+
const [searched, setSearched] = useState('');
|
|
680
|
+
const searchRef = useRef();
|
|
681
|
+
const run = (next) => {
|
|
682
|
+
const trimmed = next.trim();
|
|
683
|
+
if (trimmed === '')
|
|
684
|
+
return;
|
|
685
|
+
searchRef.current?.abort();
|
|
686
|
+
const controller = new AbortController();
|
|
687
|
+
searchRef.current = controller;
|
|
688
|
+
setLoading(true);
|
|
689
|
+
setError(undefined);
|
|
690
|
+
void Promise.resolve().then(() => load(trimmed, controller.signal)).then(hits => {
|
|
691
|
+
if (controller.signal.aborted)
|
|
692
|
+
return;
|
|
693
|
+
setLoading(false);
|
|
694
|
+
setRows(hits);
|
|
695
|
+
setCursor(0);
|
|
696
|
+
setSearched(next);
|
|
697
|
+
}, reason => {
|
|
698
|
+
if (controller.signal.aborted)
|
|
699
|
+
return;
|
|
700
|
+
setLoading(false);
|
|
701
|
+
// Stale results must not stay interactive under an error header: a
|
|
702
|
+
// later Enter re-runs the query instead of resuming an old hit.
|
|
703
|
+
setRows([]);
|
|
704
|
+
setCursor(0);
|
|
705
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
// An /search <query> invocation searches immediately with its argument.
|
|
709
|
+
useEffect(() => {
|
|
710
|
+
if (initialQuery.trim() !== '')
|
|
711
|
+
run(initialQuery);
|
|
712
|
+
return () => searchRef.current?.abort();
|
|
713
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
714
|
+
}, []);
|
|
715
|
+
useInput((input, key) => {
|
|
716
|
+
if (key.escape || (input === 'q' && query === ''))
|
|
717
|
+
return close();
|
|
718
|
+
if (key.ctrl && input === 'c')
|
|
719
|
+
return close();
|
|
720
|
+
if (key.backspace || key.delete) {
|
|
721
|
+
setQuery(current => deleteLastGrapheme(current));
|
|
722
|
+
setCursor(0);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const next = editQuery(query, input, key);
|
|
726
|
+
if (next !== undefined) {
|
|
727
|
+
setQuery(next);
|
|
728
|
+
setCursor(0);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (key.upArrow)
|
|
732
|
+
return setCursor(value => Math.max(0, value - 1));
|
|
733
|
+
if (key.downArrow)
|
|
734
|
+
return setCursor(value => Math.min(rows.length - 1, value + 1));
|
|
735
|
+
if (key.return) {
|
|
736
|
+
// A changed query searches; the SAME query re-runs when the previous
|
|
737
|
+
// pass failed or produced nothing (Enter is then the refresh key).
|
|
738
|
+
const stale = error !== undefined || rows.length === 0;
|
|
739
|
+
if (query.trim() !== '' && (query.trim() !== searched.trim() || stale)) {
|
|
740
|
+
run(query);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
// Non-resumable hits (subagent conversations) keep the panel open:
|
|
744
|
+
// Enter must not trade the visible results for a rejected switch.
|
|
745
|
+
const row = rows[cursor];
|
|
746
|
+
if (row !== undefined && row.resumable)
|
|
747
|
+
select(row);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
const stdout = useStdout().stdout;
|
|
752
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
753
|
+
const now = useMemo(() => Date.now(), [rows, searched]);
|
|
754
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
755
|
+
const state = loading ? 'searching…' : error !== undefined ? `error: ${error}` : rows.length === 0 ? 'no results yet' : `❯ ${rows[cursor]?.label ?? ''}`;
|
|
756
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/search · ${state} · ${t('panel.close')}`, viewport.contentColumns));
|
|
757
|
+
}
|
|
758
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1);
|
|
759
|
+
const offset = revealRow(0, cursor, rows.length, bodyRows);
|
|
760
|
+
const visible = rows.slice(offset, offset + bodyRows);
|
|
761
|
+
const header = error !== undefined
|
|
762
|
+
? t('search.compact.error', { message: truncateColumns(singleLineText(error), Math.max(6, viewport.contentColumns - 14)) })
|
|
763
|
+
: loading
|
|
764
|
+
? t('search.compact.searching')
|
|
765
|
+
: searched === ''
|
|
766
|
+
? t('search.title.type')
|
|
767
|
+
: rows.length === 1
|
|
768
|
+
? t('search.title.hits', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) })
|
|
769
|
+
: t('search.title.hitsPlural', { count: rows.length, query: truncateColumns(singleLineText(searched), Math.max(6, viewport.contentColumns - 30)) });
|
|
770
|
+
const accent = panelAccent('search', getPalette().dim, getPalette().brandBright);
|
|
771
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.hint', { state: query === '' ? t('search.hintEmpty') : singleLineText(query) }), viewport.contentColumns)), ...(visible.length === 0
|
|
772
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(searched === '' ? ` ${t('search.empty.idle')}` : ` ${t('search.empty.noHits', { loading: loading ? '…' : '' })}`, viewport.contentColumns))]
|
|
773
|
+
: visible.flatMap((row, index) => {
|
|
774
|
+
const absolute = offset + index;
|
|
775
|
+
const selected = absolute === cursor;
|
|
776
|
+
return [
|
|
777
|
+
createElement(Text, {
|
|
778
|
+
key: `search-${absolute}`,
|
|
779
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
780
|
+
dimColor: row.subagent,
|
|
781
|
+
wrap: 'truncate-end',
|
|
782
|
+
}, truncateColumns(`${selected ? '› ' : ' '}${row.subagent ? '↳ ' : ''}${singleLineText(row.label)} · ${formatRelativeTime(row.updatedAt, now)}${row.detail === '' ? '' : ` · ${row.detail}`}${row.resumable ? '' : ' · read-only'}`, viewport.contentColumns)),
|
|
783
|
+
createElement(Text, { key: `search-snippet-${absolute}`, dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ⎿ ${singleLineText(row.snippet)}`, viewport.contentColumns)),
|
|
784
|
+
];
|
|
785
|
+
})), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('search.footer'), viewport.contentColumns)));
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The /statusline picker (the Codex setup-view contract): one bounded list
|
|
789
|
+
* of every status item with its enabled mark, arrow reordering, and a
|
|
790
|
+
* live preview — the real status line under the composer updates as you
|
|
791
|
+
* edit, so the panel itself carries no duplicate preview row.
|
|
792
|
+
*/
|
|
793
|
+
export function StatuslinePanel({ enabled, change, close }) {
|
|
794
|
+
// Working state: the full catalog in display order (enabled entries in
|
|
795
|
+
// their configured positions, disabled ones trailing canonically) plus
|
|
796
|
+
// the enabled set. Persisted shape is the enabled subsequence only.
|
|
797
|
+
const [order, setOrder] = useState(() => {
|
|
798
|
+
const seen = new Set(enabled);
|
|
799
|
+
return [...enabled, ...STATUS_ITEMS.map(item => item.id).filter(id => !seen.has(id))];
|
|
800
|
+
});
|
|
801
|
+
const [on, setOn] = useState(() => new Set(enabled));
|
|
802
|
+
const [cursor, setCursor] = useState(0);
|
|
803
|
+
const commit = (nextOrder, nextOn) => {
|
|
804
|
+
setOrder(nextOrder);
|
|
805
|
+
setOn(nextOn);
|
|
806
|
+
change(nextOrder.filter(id => nextOn.has(id)));
|
|
807
|
+
};
|
|
808
|
+
const move = (offset) => {
|
|
809
|
+
const target = cursor + offset;
|
|
810
|
+
if (target < 0 || target >= order.length)
|
|
811
|
+
return;
|
|
812
|
+
const next = [...order];
|
|
813
|
+
const [item] = next.splice(cursor, 1);
|
|
814
|
+
next.splice(target, 0, item);
|
|
815
|
+
commit(next, on);
|
|
816
|
+
setCursor(target);
|
|
817
|
+
};
|
|
818
|
+
useInput((input, key) => {
|
|
819
|
+
if (key.escape || input === 'q' || key.return)
|
|
820
|
+
return close();
|
|
821
|
+
if (key.upArrow)
|
|
822
|
+
return setCursor(value => Math.max(0, value - 1));
|
|
823
|
+
if (key.downArrow)
|
|
824
|
+
return setCursor(value => Math.min(order.length - 1, value + 1));
|
|
825
|
+
if (key.leftArrow)
|
|
826
|
+
return move(-1);
|
|
827
|
+
if (key.rightArrow)
|
|
828
|
+
return move(1);
|
|
829
|
+
if (input === 'g')
|
|
830
|
+
return setCursor(0);
|
|
831
|
+
if (input === 'G')
|
|
832
|
+
return setCursor(order.length - 1);
|
|
833
|
+
if (input === 'd') {
|
|
834
|
+
commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS));
|
|
835
|
+
setCursor(0);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (input === ' ') {
|
|
839
|
+
const item = order[cursor];
|
|
840
|
+
if (item === undefined)
|
|
841
|
+
return;
|
|
842
|
+
const next = new Set(on);
|
|
843
|
+
if (next.has(item))
|
|
844
|
+
next.delete(item);
|
|
845
|
+
else
|
|
846
|
+
next.add(item);
|
|
847
|
+
commit(order, next);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
const stdout = useStdout().stdout;
|
|
851
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
852
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
853
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.statusline.compact'), viewport.contentColumns));
|
|
854
|
+
}
|
|
855
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1);
|
|
856
|
+
const offset = revealRow(0, cursor, order.length, bodyRows);
|
|
857
|
+
const visible = order.slice(offset, offset + bodyRows);
|
|
858
|
+
const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]));
|
|
859
|
+
const accent = panelAccent('statusline', getPalette().dim, getPalette().brandBright);
|
|
860
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('statusline.title'), viewport.contentColumns)), ...visible.map((id, index) => {
|
|
861
|
+
const absolute = offset + index;
|
|
862
|
+
const selected = absolute === cursor;
|
|
863
|
+
const info = meta.get(id);
|
|
864
|
+
return createElement(Text, {
|
|
865
|
+
key: id,
|
|
866
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
867
|
+
dimColor: !on.has(id) || undefined,
|
|
868
|
+
wrap: 'truncate-end',
|
|
869
|
+
}, truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns));
|
|
870
|
+
}), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.footer.statusline'), viewport.contentColumns)));
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* The `/model` reasoning-effort stage (the Codex model → reasoning popup
|
|
874
|
+
* contract): one bounded list over the selected model's adapter-advertised
|
|
875
|
+
* effort levels — in the adapter's own display order, ids verbatim (the
|
|
876
|
+
* kernel treats them as opaque and rejects anything else) — with the
|
|
877
|
+
* effective effort and the model default marked. A model WITHOUT an
|
|
878
|
+
* adapter-declared default leads with a "Default" (provider-default) row —
|
|
879
|
+
* the web effort pane's first entry — so the user can clear a picked level
|
|
880
|
+
* back to provider behavior. A model advertising no levels opens the same
|
|
881
|
+
* stage with an explicit empty state (the web pane's "no levels" copy)
|
|
882
|
+
* instead of a bare failure notice. Enter applies one level; Esc returns to
|
|
883
|
+
* the model list without applying.
|
|
884
|
+
*/
|
|
885
|
+
export function EffortPanel({ row, current, select, back, onExit }) {
|
|
886
|
+
const advertised = row.reasoning?.efforts ?? [];
|
|
887
|
+
const empty = row.reasoning === undefined || advertised.length === 0;
|
|
888
|
+
// The provider-default row only exists when the adapter declares no default
|
|
889
|
+
// effort: with one, the default is an advertised level already in the list.
|
|
890
|
+
const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined;
|
|
891
|
+
const rows = empty
|
|
892
|
+
? [{ id: '', name: '' }]
|
|
893
|
+
: hasDefaultRow
|
|
894
|
+
? [{ id: '', name: 'Default' }, ...advertised]
|
|
895
|
+
: advertised;
|
|
896
|
+
// An absent or cleared effort is the Default row's current state.
|
|
897
|
+
const effective = current === undefined || current === '' ? '' : current;
|
|
898
|
+
// The list opens ON the effective level (or the model's default row), so a
|
|
899
|
+
// quick re-pick never restarts the cursor from the top.
|
|
900
|
+
const wanted = effective === '' ? row.reasoning?.defaultEffort ?? '' : effective;
|
|
901
|
+
const initialCursor = Math.max(0, rows.findIndex(effort => effort.id === wanted));
|
|
902
|
+
const [cursor, setCursor] = useState(initialCursor);
|
|
903
|
+
useEffect(() => {
|
|
904
|
+
if (rows.length === 0) {
|
|
905
|
+
if (cursor !== 0)
|
|
906
|
+
setCursor(0);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (cursor >= rows.length)
|
|
910
|
+
setCursor(rows.length - 1);
|
|
911
|
+
}, [rows.length, cursor]);
|
|
912
|
+
useInput((input, key) => {
|
|
913
|
+
if (key.escape || input === 'q')
|
|
914
|
+
return back();
|
|
915
|
+
if (key.ctrl && input === 'c')
|
|
916
|
+
return onExit();
|
|
917
|
+
if (empty)
|
|
918
|
+
return;
|
|
919
|
+
if (input === 'g') {
|
|
920
|
+
setCursor(0);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (input === 'G') {
|
|
924
|
+
setCursor(rows.length - 1);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (key.upArrow) {
|
|
928
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (key.downArrow) {
|
|
932
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (key.return && rows[cursor] !== undefined) {
|
|
936
|
+
select(rows[cursor].id);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
return createElement(ListFrame, {
|
|
940
|
+
title: t('panel.effort.title', { provider: row.providerName, model: row.modelName }),
|
|
941
|
+
rows: empty
|
|
942
|
+
? [{ key: 'empty', disabled: true, text: t('panel.effort.empty') }]
|
|
943
|
+
: rows.map(effort => ({
|
|
944
|
+
key: effort.id,
|
|
945
|
+
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ` · ${t('panel.default')}` : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
|
|
946
|
+
})),
|
|
947
|
+
cursor,
|
|
948
|
+
loading: false,
|
|
949
|
+
query: '',
|
|
950
|
+
footer: empty ? t('panel.footer.effortEmpty') : t('panel.footer.effort'),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* The /agents panel (the Codex agent-picker contract, read-only): this
|
|
955
|
+
* conversation's subagent conversations — live rows from the activity feed
|
|
956
|
+
* first, persisted children the feed has not seen this process after — with
|
|
957
|
+
* Enter/t opening the child's full transcript in the shared read-only
|
|
958
|
+
* document view (the same projection the exporter uses).
|
|
959
|
+
*/
|
|
960
|
+
export function AgentsPanel({ live, load, readTranscript, close }) {
|
|
961
|
+
const [dirRows, setDirRows] = useState(undefined);
|
|
962
|
+
const [error, setError] = useState();
|
|
963
|
+
const [loading, setLoading] = useState(true);
|
|
964
|
+
const [cursor, setCursor] = useState(0);
|
|
965
|
+
const [transcript, setTranscript] = useState();
|
|
966
|
+
const transcriptLoad = useRef();
|
|
967
|
+
useEffect(() => () => transcriptLoad.current?.abort(), []);
|
|
968
|
+
const refresh = () => {
|
|
969
|
+
setLoading(true);
|
|
970
|
+
setError(undefined);
|
|
971
|
+
Promise.resolve().then(load).then(value => {
|
|
972
|
+
setDirRows(value);
|
|
973
|
+
setLoading(false);
|
|
974
|
+
}, reason => {
|
|
975
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
976
|
+
setLoading(false);
|
|
977
|
+
});
|
|
978
|
+
};
|
|
979
|
+
useEffect(refresh, []);
|
|
980
|
+
// Live feed rows first (they carry the running state), then persisted
|
|
981
|
+
// children only the directory knows — settled subagents from earlier turns.
|
|
982
|
+
const rows = useMemo(() => {
|
|
983
|
+
const seen = new Set(live.map(row => row.id));
|
|
984
|
+
const feedRows = live.map(row => ({
|
|
985
|
+
id: row.id,
|
|
986
|
+
label: row.label,
|
|
987
|
+
activity: row.activity,
|
|
988
|
+
running: row.state === 'running',
|
|
989
|
+
done: row.state === 'done',
|
|
990
|
+
live: true,
|
|
991
|
+
}));
|
|
992
|
+
const persisted = (dirRows ?? [])
|
|
993
|
+
.filter(row => !seen.has(row.id))
|
|
994
|
+
.map(row => ({
|
|
995
|
+
id: row.id,
|
|
996
|
+
label: row.title ?? row.id.slice(-12),
|
|
997
|
+
activity: row.workspace,
|
|
998
|
+
running: false,
|
|
999
|
+
done: !row.live,
|
|
1000
|
+
live: row.live,
|
|
1001
|
+
}));
|
|
1002
|
+
return [...feedRows, ...persisted];
|
|
1003
|
+
}, [live, dirRows]);
|
|
1004
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length]);
|
|
1005
|
+
const openTranscript = () => {
|
|
1006
|
+
const row = rows[cursor];
|
|
1007
|
+
if (row === undefined)
|
|
1008
|
+
return;
|
|
1009
|
+
transcriptLoad.current?.abort();
|
|
1010
|
+
setTranscript({ id: row.id });
|
|
1011
|
+
const controller = new AbortController();
|
|
1012
|
+
transcriptLoad.current = controller;
|
|
1013
|
+
Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(text => {
|
|
1014
|
+
if (!controller.signal.aborted)
|
|
1015
|
+
setTranscript({ id: row.id, text });
|
|
1016
|
+
}, reason => {
|
|
1017
|
+
if (!controller.signal.aborted)
|
|
1018
|
+
setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) });
|
|
1019
|
+
});
|
|
1020
|
+
};
|
|
1021
|
+
useInput((input, key) => {
|
|
1022
|
+
if (key.escape || input === 'q')
|
|
1023
|
+
return close();
|
|
1024
|
+
if (input === 'r')
|
|
1025
|
+
return refresh();
|
|
1026
|
+
if (key.upArrow)
|
|
1027
|
+
return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length);
|
|
1028
|
+
if (key.downArrow)
|
|
1029
|
+
return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length);
|
|
1030
|
+
if ((key.return || input === 't') && rows[cursor] !== undefined)
|
|
1031
|
+
return openTranscript();
|
|
1032
|
+
}, { isActive: transcript === undefined });
|
|
1033
|
+
if (transcript !== undefined) {
|
|
1034
|
+
return createElement(DocumentPanel, {
|
|
1035
|
+
title: t('panel.subagent.transcriptTitle', { id: transcript.id.slice(-12) }),
|
|
1036
|
+
text: transcript.text,
|
|
1037
|
+
error: transcript.error,
|
|
1038
|
+
close: () => {
|
|
1039
|
+
transcriptLoad.current?.abort();
|
|
1040
|
+
setTranscript(undefined);
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return createElement(ListFrame, {
|
|
1045
|
+
title: t('panel.agents.title', { live: live.length, total: rows.length }),
|
|
1046
|
+
rows: rows.map(row => ({
|
|
1047
|
+
key: row.id,
|
|
1048
|
+
text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
|
|
1049
|
+
})),
|
|
1050
|
+
cursor,
|
|
1051
|
+
loading,
|
|
1052
|
+
...error === undefined ? {} : { error },
|
|
1053
|
+
query: '',
|
|
1054
|
+
footer: t('panel.footer.agents'),
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The /subagent model panel: which model configuration delegated subagents
|
|
1059
|
+
* run on. The kernel seeds child agents from the parent's CREATE-TIME
|
|
1060
|
+
* AgentOptions, so a mid-session /model switch would otherwise leave them on
|
|
1061
|
+
* the launch-time route; the TUI mirrors the selection onto subagent-origin
|
|
1062
|
+
* requests (or an explicit override picked here) via an agent/request
|
|
1063
|
+
* listener. The leading "inherit" row restores follow-the-current-model
|
|
1064
|
+
* behavior; picking a model with several advertised efforts opens the same
|
|
1065
|
+
* effort stage /model uses. Effort overrides are not offered separately —
|
|
1066
|
+
* the kernel's AgentOptions has no effort channel for children, so the level
|
|
1067
|
+
* rides the selected model exactly as /model applies it.
|
|
1068
|
+
*/
|
|
1069
|
+
export function SubagentPanel({ current, load, pick, inherit, close }) {
|
|
1070
|
+
const [directory, setDirectory] = useState(undefined);
|
|
1071
|
+
const [error, setError] = useState();
|
|
1072
|
+
const [loading, setLoading] = useState(true);
|
|
1073
|
+
const [cursor, setCursor] = useState(0);
|
|
1074
|
+
const [effortFor, setEffortFor] = useState(undefined);
|
|
1075
|
+
const refresh = () => {
|
|
1076
|
+
setLoading(true);
|
|
1077
|
+
setError(undefined);
|
|
1078
|
+
Promise.resolve().then(load).then(value => {
|
|
1079
|
+
setDirectory(value);
|
|
1080
|
+
setLoading(false);
|
|
1081
|
+
}, reason => {
|
|
1082
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
1083
|
+
setLoading(false);
|
|
1084
|
+
});
|
|
1085
|
+
};
|
|
1086
|
+
useEffect(refresh, []);
|
|
1087
|
+
const rows = useMemo(() => directory?.rows ?? [], [directory]);
|
|
1088
|
+
// The list opens on the override's own row (index 0 is the inherit row).
|
|
1089
|
+
useEffect(() => {
|
|
1090
|
+
if (current === '' || rows.length === 0)
|
|
1091
|
+
return;
|
|
1092
|
+
const index = rows.findIndex(row => current.startsWith(`${row.provider}/${row.model}`));
|
|
1093
|
+
if (index >= 0)
|
|
1094
|
+
setCursor(index + 1);
|
|
1095
|
+
}, [rows, current]);
|
|
1096
|
+
useEffect(() => setCursor(value => Math.min(value, rows.length)), [rows.length]);
|
|
1097
|
+
// Hooks stay unconditional: the effort stage below swaps the rendered
|
|
1098
|
+
// subtree but must never skip the input hook (an early return here would
|
|
1099
|
+
// change the hook count when the stage opens and closes).
|
|
1100
|
+
useInput((input, key) => {
|
|
1101
|
+
if (effortFor !== undefined)
|
|
1102
|
+
return;
|
|
1103
|
+
if (key.escape || input === 'q')
|
|
1104
|
+
return close();
|
|
1105
|
+
if (input === 'r' && !loading)
|
|
1106
|
+
return refresh();
|
|
1107
|
+
if (key.upArrow)
|
|
1108
|
+
return setCursor(value => (value + rows.length) % (rows.length + 1));
|
|
1109
|
+
if (key.downArrow)
|
|
1110
|
+
return setCursor(value => (value + 1) % (rows.length + 1));
|
|
1111
|
+
if (key.return) {
|
|
1112
|
+
if (cursor === 0)
|
|
1113
|
+
return inherit();
|
|
1114
|
+
const row = rows[cursor - 1];
|
|
1115
|
+
if (row === undefined)
|
|
1116
|
+
return;
|
|
1117
|
+
if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
|
|
1118
|
+
setEffortFor(row);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : undefined;
|
|
1122
|
+
pick(row, effortId);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
if (effortFor !== undefined) {
|
|
1126
|
+
return createElement(EffortPanel, {
|
|
1127
|
+
row: effortFor,
|
|
1128
|
+
current: current === '' ? undefined : current.split('@')[1],
|
|
1129
|
+
select: effortId => pick(effortFor, effortId),
|
|
1130
|
+
back: () => setEffortFor(undefined),
|
|
1131
|
+
onExit: close,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
return createElement(ListFrame, {
|
|
1135
|
+
title: `${t('panel.subagent.title')}${current === '' ? '' : t('panel.subagent.override', { value: current })}`,
|
|
1136
|
+
rows: [
|
|
1137
|
+
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} ${t('panel.subagent.inherit')}` },
|
|
1138
|
+
...rows.map(row => ({
|
|
1139
|
+
key: `${row.provider}/${row.model}`,
|
|
1140
|
+
text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
|
|
1141
|
+
})),
|
|
1142
|
+
],
|
|
1143
|
+
cursor,
|
|
1144
|
+
loading,
|
|
1145
|
+
...error === undefined ? {} : { error },
|
|
1146
|
+
query: '',
|
|
1147
|
+
footer: t('panel.footer.subagent'),
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
/** Human frequency label: one-shot kinds read as Once, every rows carry the interval. */
|
|
1151
|
+
export function scheduleFrequency(row) {
|
|
1152
|
+
if (row.kind !== 'every')
|
|
1153
|
+
return 'Once';
|
|
1154
|
+
const seconds = row.everySeconds ?? 0;
|
|
1155
|
+
if (seconds >= 3600 && seconds % 3600 === 0)
|
|
1156
|
+
return `Every ${seconds / 3600}h`;
|
|
1157
|
+
if (seconds >= 60 && seconds % 60 === 0)
|
|
1158
|
+
return `Every ${seconds / 60}m`;
|
|
1159
|
+
return `Every ${seconds}s`;
|
|
1160
|
+
}
|
|
1161
|
+
/** Relative label for the next target: in N unit, or N unit overdue. */
|
|
1162
|
+
export function scheduleRelative(targetAt, now) {
|
|
1163
|
+
const delta = Math.max(0, Math.abs(targetAt - now));
|
|
1164
|
+
const minutes = Math.floor(delta / 60_000);
|
|
1165
|
+
const unit = minutes === 0
|
|
1166
|
+
? '<1m'
|
|
1167
|
+
: minutes >= 60
|
|
1168
|
+
? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
|
|
1169
|
+
: `${minutes}m`;
|
|
1170
|
+
return targetAt <= now ? `${unit} overdue` : `in ${unit}`;
|
|
1171
|
+
}
|
|
1172
|
+
/** Ordered display rows: overdue first (error tone), then ascending target. */
|
|
1173
|
+
export function scheduleDisplayRows(rows, now) {
|
|
1174
|
+
return [...rows]
|
|
1175
|
+
.sort((left, right) => (Number(left.targetAt > now) - Number(right.targetAt > now)) || (left.targetAt - right.targetAt))
|
|
1176
|
+
.map(row => ({
|
|
1177
|
+
key: row.id,
|
|
1178
|
+
text: `${row.prompt} · ${scheduleFrequency(row)} · ${new Date(row.targetAt).toLocaleString()} (${scheduleRelative(row.targetAt, now)})`,
|
|
1179
|
+
tone: row.targetAt <= now ? 'error' : undefined,
|
|
1180
|
+
}));
|
|
1181
|
+
}
|
|
1182
|
+
export function SchedulePanel({ rows, close }) {
|
|
1183
|
+
const [, setTick] = useState(0);
|
|
1184
|
+
useEffect(() => {
|
|
1185
|
+
const id = setInterval(() => setTick(value => value + 1), 1_000);
|
|
1186
|
+
return () => clearInterval(id);
|
|
1187
|
+
}, []);
|
|
1188
|
+
const display = scheduleDisplayRows(rows(), Date.now());
|
|
1189
|
+
const stdout = useStdout().stdout;
|
|
1190
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
1191
|
+
useInput((input, key) => {
|
|
1192
|
+
if (key.escape || input === 'q')
|
|
1193
|
+
return close();
|
|
1194
|
+
});
|
|
1195
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
1196
|
+
const summary = display.length === 0 ? t('panel.schedule.none') : singleLineText(display[0].text);
|
|
1197
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns));
|
|
1198
|
+
}
|
|
1199
|
+
const budget = Math.max(1, viewport.bodyRows);
|
|
1200
|
+
const visible = display.slice(0, budget);
|
|
1201
|
+
const hidden = display.length - visible.length;
|
|
1202
|
+
const accent = panelAccent('schedule', getPalette().dim, getPalette().brandBright);
|
|
1203
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, 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)), ...(display.length === 0
|
|
1204
|
+
? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('schedule.empty')}`, viewport.contentColumns))]
|
|
1205
|
+
: visible.map(row => createElement(Text, {
|
|
1206
|
+
key: row.key,
|
|
1207
|
+
color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
|
|
1208
|
+
wrap: 'truncate-end',
|
|
1209
|
+
}, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns)))), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.schedule.footer', { more: hidden > 0 ? t('panel.schedule.more', { count: hidden }) : '' }), viewport.contentColumns)));
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* The /usage panel: the session's provider-reported token totals, its context
|
|
1213
|
+
* pressure and estimated composition, and the exact per-turn accounting, in
|
|
1214
|
+
* one bounded scrollable surface. Read-only — Esc or q closes it.
|
|
1215
|
+
*/
|
|
1216
|
+
export function UsagePanel({ load, close }) {
|
|
1217
|
+
const stdout = useStdout().stdout;
|
|
1218
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
1219
|
+
const [scroll, setScroll] = useState(0);
|
|
1220
|
+
const [view, setView] = useState();
|
|
1221
|
+
const [error, setError] = useState();
|
|
1222
|
+
// The panel opens on the loading row and swaps in the numbers when the
|
|
1223
|
+
// loader settles: materializing the projection units folds the whole log,
|
|
1224
|
+
// and that must not run inside the keystroke that opened the panel.
|
|
1225
|
+
useEffect(() => {
|
|
1226
|
+
let live = true;
|
|
1227
|
+
Promise.resolve().then(load).then(loaded => {
|
|
1228
|
+
if (live)
|
|
1229
|
+
setView(loaded);
|
|
1230
|
+
}, reason => {
|
|
1231
|
+
if (live)
|
|
1232
|
+
setError(reason instanceof Error ? reason.message : String(reason));
|
|
1233
|
+
});
|
|
1234
|
+
return () => {
|
|
1235
|
+
live = false;
|
|
1236
|
+
};
|
|
1237
|
+
}, [load]);
|
|
1238
|
+
const lines = useMemo(() => view === undefined ? [] : usageLines(view, viewport.contentColumns), [view, viewport.contentColumns]);
|
|
1239
|
+
useInput((input, key) => {
|
|
1240
|
+
if (key.escape || input === 'q')
|
|
1241
|
+
return close();
|
|
1242
|
+
if (key.upArrow)
|
|
1243
|
+
return setScroll(value => Math.max(0, value - 1));
|
|
1244
|
+
if (key.downArrow)
|
|
1245
|
+
return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1));
|
|
1246
|
+
if (key.pageUp)
|
|
1247
|
+
return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)));
|
|
1248
|
+
if (key.pageDown)
|
|
1249
|
+
return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)));
|
|
1250
|
+
if (input === 'g')
|
|
1251
|
+
return setScroll(0);
|
|
1252
|
+
if (input === 'G')
|
|
1253
|
+
return setScroll(Math.max(0, lines.length - viewport.bodyRows));
|
|
1254
|
+
});
|
|
1255
|
+
if (viewport.compact) {
|
|
1256
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.usage.compact'), viewport.contentColumns));
|
|
1257
|
+
}
|
|
1258
|
+
const body = error !== undefined
|
|
1259
|
+
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
1260
|
+
: view === undefined
|
|
1261
|
+
? textLines(t('panel.loading'), viewport.contentColumns, 'dim')
|
|
1262
|
+
: lines.slice(scroll, scroll + viewport.bodyRows);
|
|
1263
|
+
const accent = panelAccent('usage', getPalette().dim, getPalette().brandBright);
|
|
1264
|
+
return createElement(Box, { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 }, createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(t('panel.usage.title'), viewport.contentColumns)), createElement(DocumentRows, { lines: body }), createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(t('panel.usage.footer'), viewport.contentColumns)));
|
|
1265
|
+
}
|