@toddzheng024/dscode-bundle 0.7.11 → 0.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -3
  2. package/cordis.patch.yml +1 -1
  3. package/package.json +5 -4
  4. package/plugins/i18n/messages.mjs +6 -6
  5. package/plugins/session-metrics/view.mjs +16 -5
  6. package/plugins/tui-tools/index.mjs +14 -1
  7. package/vendor/tui/lib/app.mjs +6674 -0
  8. package/vendor/tui/lib/approval.mjs +122 -0
  9. package/vendor/tui/lib/attachments.mjs +218 -0
  10. package/vendor/tui/lib/authorization-panel.mjs +242 -0
  11. package/vendor/tui/lib/authorization.mjs +111 -0
  12. package/vendor/tui/lib/commands.mjs +123 -0
  13. package/vendor/tui/lib/dscode/chat.mjs +64 -0
  14. package/vendor/tui/lib/dscode/flags.mjs +32 -0
  15. package/vendor/tui/lib/dscode/model-search.mjs +68 -0
  16. package/vendor/tui/lib/dscode/paste.mjs +87 -0
  17. package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
  18. package/vendor/tui/lib/dscode/welcome.mjs +100 -0
  19. package/vendor/tui/lib/editor-keys.mjs +346 -0
  20. package/vendor/tui/lib/editor.mjs +50 -0
  21. package/vendor/tui/lib/fork.mjs +24 -0
  22. package/vendor/tui/lib/git-workflow.mjs +278 -0
  23. package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
  24. package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
  25. package/vendor/tui/lib/index.mjs +2198 -0
  26. package/vendor/tui/lib/input-split.mjs +170 -0
  27. package/vendor/tui/lib/internals.mjs +90 -0
  28. package/vendor/tui/lib/invariant.mjs +22 -0
  29. package/vendor/tui/lib/kernel-panels.mjs +1265 -0
  30. package/vendor/tui/lib/keyboard.mjs +283 -0
  31. package/vendor/tui/lib/language-panel.mjs +41 -0
  32. package/vendor/tui/lib/locales/en.mjs +495 -0
  33. package/vendor/tui/lib/locales/zh.mjs +495 -0
  34. package/vendor/tui/lib/mentions.mjs +150 -0
  35. package/vendor/tui/lib/model-capabilities.mjs +245 -0
  36. package/vendor/tui/lib/models.mjs +215 -0
  37. package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
  38. package/vendor/tui/lib/permissions.mjs +45 -0
  39. package/vendor/tui/lib/plugin-inventory.mjs +27 -0
  40. package/vendor/tui/lib/presets.mjs +46 -0
  41. package/vendor/tui/lib/provider-settings.mjs +600 -0
  42. package/vendor/tui/lib/questions.mjs +116 -0
  43. package/vendor/tui/lib/rainbow.mjs +181 -0
  44. package/vendor/tui/lib/render/animations.mjs +665 -0
  45. package/vendor/tui/lib/render/editor.mjs +437 -0
  46. package/vendor/tui/lib/render/export.mjs +119 -0
  47. package/vendor/tui/lib/render/fuzzy.mjs +79 -0
  48. package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
  49. package/vendor/tui/lib/render/inspector.mjs +93 -0
  50. package/vendor/tui/lib/render/lines.mjs +538 -0
  51. package/vendor/tui/lib/render/markdown.mjs +592 -0
  52. package/vendor/tui/lib/render/projection.mjs +1862 -0
  53. package/vendor/tui/lib/render/status.mjs +591 -0
  54. package/vendor/tui/lib/render/text.mjs +157 -0
  55. package/vendor/tui/lib/render/tool-detail.mjs +187 -0
  56. package/vendor/tui/lib/render/tool-preview.mjs +79 -0
  57. package/vendor/tui/lib/render/usage.mjs +336 -0
  58. package/vendor/tui/lib/render/width.mjs +191 -0
  59. package/vendor/tui/lib/session-directory.mjs +329 -0
  60. package/vendor/tui/lib/session-query.mjs +184 -0
  61. package/vendor/tui/lib/session-switch.mjs +51 -0
  62. package/vendor/tui/lib/settings-file.mjs +75 -0
  63. package/vendor/tui/lib/skills.mjs +119 -0
  64. package/vendor/tui/lib/startup.mjs +111 -0
  65. package/vendor/tui/lib/store.mjs +129 -0
  66. package/vendor/tui/lib/subagents.mjs +200 -0
  67. package/vendor/tui/lib/terminal-title.mjs +186 -0
  68. package/vendor/tui/lib/theme-panel.mjs +60 -0
  69. package/vendor/tui/lib/theme.mjs +379 -0
  70. package/vendor/tui/lib/update-panel.mjs +239 -0
  71. package/vendor/tui/lib/update.mjs +101 -0
  72. package/vendor/tui/lib/version.mjs +105 -0
  73. package/vendor/tui/lib/whale-glyph.mjs +20 -0
  74. package/vendor/tui/LICENSE +0 -21
  75. package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
  76. package/vendor/tui/dscode-email/cli.mjs +0 -32
  77. package/vendor/tui/dscode-email/contacts.mjs +0 -36
  78. package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
  79. package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
  80. package/vendor/tui/dscode-email/gmail.mjs +0 -194
  81. package/vendor/tui/dscode-email/imap.mjs +0 -136
  82. package/vendor/tui/dscode-email/inbox.d.mts +0 -25
  83. package/vendor/tui/dscode-email/inbox.mjs +0 -76
  84. package/vendor/tui/dscode-email/smtp.mjs +0 -69
  85. package/vendor/tui/dscode-email/store.mjs +0 -32
  86. package/vendor/tui/dscode-providers/catalog.mjs +0 -136
  87. package/vendor/tui/dscode-providers/effort.mjs +0 -35
  88. package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
  89. package/vendor/tui/index.mjs +0 -44483
  90. package/vendor/tui/invariant.mjs +0 -21
  91. package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
  92. package/vendor/tui/session-query.mjs +0 -150
  93. package/vendor/tui/startup.mjs +0 -109
  94. package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
  95. package/vendor/tui/types/app.d.ts +0 -348
  96. package/vendor/tui/types/approval.d.ts +0 -59
  97. package/vendor/tui/types/attachments.d.ts +0 -52
  98. package/vendor/tui/types/authorization-panel.d.ts +0 -22
  99. package/vendor/tui/types/authorization.d.ts +0 -36
  100. package/vendor/tui/types/commands.d.ts +0 -52
  101. package/vendor/tui/types/editor-keys.d.ts +0 -105
  102. package/vendor/tui/types/editor.d.ts +0 -6
  103. package/vendor/tui/types/fork.d.ts +0 -8
  104. package/vendor/tui/types/git-workflow.d.ts +0 -121
  105. package/vendor/tui/types/index.d.ts +0 -223
  106. package/vendor/tui/types/input-split.d.ts +0 -54
  107. package/vendor/tui/types/internals.d.ts +0 -26
  108. package/vendor/tui/types/invariant.d.ts +0 -15
  109. package/vendor/tui/types/kernel-panels.d.ts +0 -245
  110. package/vendor/tui/types/keyboard.d.ts +0 -80
  111. package/vendor/tui/types/language-panel.d.ts +0 -12
  112. package/vendor/tui/types/locales/en.d.ts +0 -450
  113. package/vendor/tui/types/locales/zh.d.ts +0 -9
  114. package/vendor/tui/types/mentions.d.ts +0 -85
  115. package/vendor/tui/types/model-capabilities.d.ts +0 -82
  116. package/vendor/tui/types/models.d.ts +0 -133
  117. package/vendor/tui/types/permissions.d.ts +0 -27
  118. package/vendor/tui/types/plugin-inventory.d.ts +0 -11
  119. package/vendor/tui/types/presets.d.ts +0 -22
  120. package/vendor/tui/types/provider-settings.d.ts +0 -239
  121. package/vendor/tui/types/questions.d.ts +0 -54
  122. package/vendor/tui/types/rainbow.d.ts +0 -69
  123. package/vendor/tui/types/render/animations.d.ts +0 -307
  124. package/vendor/tui/types/render/editor.d.ts +0 -163
  125. package/vendor/tui/types/render/export.d.ts +0 -9
  126. package/vendor/tui/types/render/fuzzy.d.ts +0 -21
  127. package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
  128. package/vendor/tui/types/render/inspector.d.ts +0 -62
  129. package/vendor/tui/types/render/lines.d.ts +0 -86
  130. package/vendor/tui/types/render/markdown.d.ts +0 -29
  131. package/vendor/tui/types/render/projection.d.ts +0 -587
  132. package/vendor/tui/types/render/status.d.ts +0 -196
  133. package/vendor/tui/types/render/text.d.ts +0 -64
  134. package/vendor/tui/types/render/tool-detail.d.ts +0 -94
  135. package/vendor/tui/types/render/tool-preview.d.ts +0 -28
  136. package/vendor/tui/types/render/usage.d.ts +0 -113
  137. package/vendor/tui/types/render/width.d.ts +0 -29
  138. package/vendor/tui/types/session-directory.d.ts +0 -190
  139. package/vendor/tui/types/session-query.d.ts +0 -92
  140. package/vendor/tui/types/session-switch.d.ts +0 -17
  141. package/vendor/tui/types/settings-file.d.ts +0 -41
  142. package/vendor/tui/types/skills.d.ts +0 -47
  143. package/vendor/tui/types/startup.d.ts +0 -65
  144. package/vendor/tui/types/store.d.ts +0 -58
  145. package/vendor/tui/types/subagents.d.ts +0 -70
  146. package/vendor/tui/types/terminal-title.d.ts +0 -66
  147. package/vendor/tui/types/theme-panel.d.ts +0 -24
  148. package/vendor/tui/types/theme.d.ts +0 -434
  149. package/vendor/tui/types/update-panel.d.ts +0 -49
  150. package/vendor/tui/types/update.d.ts +0 -75
  151. package/vendor/tui/types/version.d.ts +0 -19
  152. package/vendor/tui/types/whale-glyph.d.ts +0 -6
  153. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
  154. /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
+ }