ccompactor 0.1.14 → 0.1.16

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/dist/tui/App.js CHANGED
@@ -1,44 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
- * The interactive browser.
3
+ * `ccompactor --tui`
4
4
  *
5
- * Three screens, because there are three questions: *which session* (browse),
6
- * *is this the one* (quick look), and *what do I do with it* (actions). The
7
- * first version had one screen and answered none of them it listed sessions,
8
- * and pressing `h` did nothing at all unless you had already extracted, which
9
- * is the wrong order for anyone who does not yet know what they want.
5
+ * A frame with a top bar, a sidebar, a content pane and a bottom bar, and
6
+ * modals over the top of it. The screens that need a decision are modals rather
7
+ * than pages, so the thing you were looking at stays where it was.
10
8
  *
11
- * The rules it follows:
12
- *
13
- * - **The session id is never truncated.** It is the one column a reader may
14
- * need to copy, and a truncated id is a useless one.
15
- * - **Nothing is silent.** Every operation that can take longer than a blink
16
- * shows the stage it is in, because on a 260 MB transcript a frozen screen and
17
- * a working one look identical.
18
- * - **The actions are named, not remembered.** Choosing a session shows what can
19
- * be done to it, with the destination written out.
9
+ * Nothing here is reachable only by a key. Every cell of the top bar, the
10
+ * sidebar and the bottom bar is clickable, and the bottom bar prints the key on
11
+ * the button so the shortcut is learned by using it.
20
12
  */
21
13
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
22
14
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
15
  import { listSessions, fuzzyScore } from '../discover/index.js';
24
16
  import { extractSession } from '../extract.js';
25
17
  import { plan, installed } from '../handoff/index.js';
26
- import { THEME, hazard, sizeLabel } from './theme.js';
27
- import { enableMouse, readMouse } from './mouse.js';
28
- import { barCells, inSpan, layoutCells } from './bar.js';
29
18
  import { loadDetails } from './details.js';
30
- /**
31
- * Terminal rows, 1-based, that a click is measured against.
32
- *
33
- * Every screen puts the stripe, the title chips and the action bar on rows 1-3,
34
- * so a click means the same thing on all of them and the arithmetic stays in one
35
- * place. The row below the bar is a column header on the browse screen and a
36
- * question on the menu screens; either way the first thing you can click is 5.
37
- */
38
- const ROW_CHIPS = 2;
39
- const ROW_BAR = 3;
40
- const LIST_TOP = 5;
41
- const MENU_TOP = 5;
19
+ import { THEME, sizeLabel } from './theme.js';
20
+ import { enableMouse, readMouse } from './mouse.js';
21
+ import { MENU, ROW_TOP, SIDEBAR_TOP, SIDEBAR_WIDTH, bottomBar, inCell, layoutCells, menuIndex, tabCells, } from './frame.js';
42
22
  /**
43
23
  * How large a transcript may be before the table stops reading it in the
44
24
  * background. Above this the row shows a size and a date, and the rest arrives
@@ -49,12 +29,11 @@ export function App({ outDir, anyProject, onDone }) {
49
29
  const { exit } = useApp();
50
30
  const { stdout } = useStdout();
51
31
  // Not `?? 100`: a pty with no window size reports 0, and 0 is not nullish, so
52
- // every layout decision saw a zero-width terminal and collapsed to a single
53
- // button. A width too small to draw anything is a width we do not know.
32
+ // every layout decision saw a zero-width terminal and collapsed to nothing.
54
33
  const rawColumns = stdout?.columns;
55
- const columns = rawColumns !== undefined && rawColumns >= 20 ? rawColumns : 100;
34
+ const columns = rawColumns !== undefined && rawColumns >= 40 ? rawColumns : 100;
56
35
  const rawRows = stdout?.rows;
57
- const rows = rawRows !== undefined && rawRows >= 8 ? rawRows : 30;
36
+ const rows = rawRows !== undefined && rawRows >= 12 ? rawRows : 30;
58
37
  const [sessions, setSessions] = useState([]);
59
38
  const [loading, setLoading] = useState('scanning agent stores …');
60
39
  const [query, setQuery] = useState('');
@@ -62,11 +41,10 @@ export function App({ outDir, anyProject, onDone }) {
62
41
  const [agentsOff, setAgentsOff] = useState(new Set());
63
42
  const [cursor, setCursor] = useState(0);
64
43
  const [offset, setOffset] = useState(0);
65
- const [screen, setScreen] = useState('browse');
66
- // One cache for every session whose metadata has been read, and one for the
67
- // row being looked at. The window is prefilled in the background so the
68
- // MSGS/TURNS/CWD columns fill in as the reader browses rather than sitting
69
- // empty until they land on a row.
44
+ const [page, setPage] = useState('sessions');
45
+ const [menuCursor, setMenuCursor] = useState(0);
46
+ const [focus, setFocus] = useState('content');
47
+ const [modal, setModal] = useState();
70
48
  const [cache, setCache] = useState(new Map());
71
49
  const [previewScroll, setPreviewScroll] = useState(0);
72
50
  const [actionIndex, setActionIndex] = useState(0);
@@ -77,6 +55,8 @@ export function App({ outDir, anyProject, onDone }) {
77
55
  const [results, setResults] = useState([]);
78
56
  const [lastArtifact, setLastArtifact] = useState();
79
57
  const [error, setError] = useState();
58
+ const [report, setReport] = useState();
59
+ const [artifacts, setArtifacts] = useState([]);
80
60
  const busy = useRef(false);
81
61
  // ---- loading ------------------------------------------------------------
82
62
  useEffect(() => {
@@ -104,9 +84,11 @@ export function App({ outDir, anyProject, onDone }) {
104
84
  }
105
85
  return out;
106
86
  }, [sessions, query, agentsOff]);
107
- const visibleRows = Math.max(3, rows - LIST_TOP - 3);
87
+ // Rows the content pane may draw on, minus what the pane itself spends.
88
+ const bodyHeight = Math.max(4, rows - 2);
89
+ const contentHeight = Math.max(2, bodyHeight - 3);
90
+ const visibleRows = Math.max(2, contentHeight - 2);
108
91
  const selected = filtered[cursor];
109
- // Keep the cursor on screen without a scroll library.
110
92
  useEffect(() => {
111
93
  setOffset((current) => {
112
94
  if (cursor < current)
@@ -116,21 +98,9 @@ export function App({ outDir, anyProject, onDone }) {
116
98
  return current;
117
99
  });
118
100
  }, [cursor, visibleRows]);
119
- // ---- details, loaded for whatever is under the cursor ----------------
120
- //
121
- // Debounced, because holding an arrow key should not queue twenty parses of a
122
- // 260 MB file.
123
- // ---- prefill the window -------------------------------------------------
124
- //
125
- // Bounded on purpose: only what is on screen, three at a time, and never a
126
- // file already in the cache. Filling all 720 would mean parsing hundreds of
127
- // megabytes for rows nobody has looked at.
101
+ // ---- details, loaded for whatever is under the cursor --------------------
128
102
  useEffect(() => {
129
103
  const window = filtered.slice(offset, offset + visibleRows);
130
- // Big transcripts are left alone here and read only when the reader lands on
131
- // one. Parsing a 268 MB session to fill in a column of file sizes is not a
132
- // trade worth making, and doing thirty of them at once is what killed the
133
- // process.
134
104
  const wanted = window.filter((ref) => !cache.has(`${ref.agent}:${ref.id}`) && (ref.bytes ?? 0) < PREFILL_MAX_BYTES);
135
105
  if (wanted.length === 0)
136
106
  return;
@@ -150,8 +120,7 @@ export function App({ outDir, anyProject, onDone }) {
150
120
  });
151
121
  }
152
122
  catch {
153
- // A session that cannot be read is a row with no metadata, not a
154
- // failed run. The real error is still available through `extract`.
123
+ // A session that cannot be read is a row with no metadata.
155
124
  }
156
125
  }
157
126
  })();
@@ -159,7 +128,6 @@ export function App({ outDir, anyProject, onDone }) {
159
128
  cancelled = true;
160
129
  };
161
130
  }, [filtered, offset, visibleRows, cache]);
162
- // Whatever is under the cursor is always read, however large, one at a time.
163
131
  useEffect(() => {
164
132
  if (!selected)
165
133
  return;
@@ -188,20 +156,10 @@ export function App({ outDir, anyProject, onDone }) {
188
156
  }, [selected, cache]);
189
157
  // ---- actions ------------------------------------------------------------
190
158
  const actions = useMemo(() => {
191
- const hasLlm = Boolean(process.env['CCOMPAT_API_KEY'] ?? process.env['CCOMPACTOR_API_KEY'] ?? process.env['ANTHROPIC_API_KEY'] ?? process.env['OPENAI_API_KEY']);
159
+ const hasLlm = Boolean(process.env['CCOMPACTOR_API_KEY'] ??
160
+ process.env['ANTHROPIC_API_KEY'] ??
161
+ process.env['OPENAI_API_KEY']);
192
162
  return [
193
- // These two read as synonyms — "hand off" and "launch" both suggest the
194
- // agent starts. They differ in who starts it, so the labels say that.
195
- {
196
- id: 'handoff',
197
- label: 'Hand off — print the command for the next agent',
198
- detail: 'fork this session into a new one and show the command; you run it yourself',
199
- },
200
- {
201
- id: 'handoff-run',
202
- label: 'Hand off — and start the next agent here',
203
- detail: 'the same command, run for you in this terminal',
204
- },
205
163
  {
206
164
  id: 'extract',
207
165
  label: 'Extract handoff — deterministic, no model',
@@ -210,7 +168,16 @@ export function App({ outDir, anyProject, onDone }) {
210
168
  {
211
169
  id: 'extract-llm',
212
170
  label: 'Extract handoff — with a model-written summary',
213
- detail: hasLlm ? `adds L1 via the configured backend → ${outDir}/handoff.md` : 'no API key found; set CCOMPACTOR_API_KEY and CCOMPACTOR_BASE_URL',
171
+ detail: hasLlm
172
+ ? `adds L1 via the configured backend → ${outDir}/handoff.md`
173
+ : 'no API key found; set CCOMPACTOR_API_KEY and CCOMPACTOR_BASE_URL',
174
+ },
175
+ {
176
+ id: 'narrate',
177
+ label: 'Narrate — write L1 from the artifact',
178
+ detail: hasLlm
179
+ ? `reads ${outDir}/handoff.md, not the transcript; ~5k tokens`
180
+ : 'needs a model; and an artifact to narrate',
214
181
  },
215
182
  {
216
183
  id: 'transcript',
@@ -222,6 +189,16 @@ export function App({ outDir, anyProject, onDone }) {
222
189
  label: 'Readable transcript — including tool calls',
223
190
  detail: `adds every tool call and its output → ${outDir}/transcript.md`,
224
191
  },
192
+ {
193
+ id: 'handoff',
194
+ label: 'Hand off — print the command for the next agent',
195
+ detail: 'fork this session into a new one and show the command; you run it yourself',
196
+ },
197
+ {
198
+ id: 'handoff-run',
199
+ label: 'Hand off — and start the next agent here',
200
+ detail: 'the same command, run for you in this terminal',
201
+ },
225
202
  {
226
203
  id: 'verify',
227
204
  label: 'Verify the artifact already in this directory',
@@ -236,19 +213,37 @@ export function App({ outDir, anyProject, onDone }) {
236
213
  setError(undefined);
237
214
  setResults([]);
238
215
  setProgress([]);
239
- setScreen('running');
216
+ setModal({ kind: 'running' });
240
217
  const onProgress = (stage, message) => {
241
218
  setProgress((current) => [...current.slice(-8), `${stage}: ${message}`]);
242
219
  };
243
220
  try {
244
221
  if (actionId === 'verify') {
245
222
  const { verify } = await import('../artifact/verify.js');
246
- const report = await verify(outDir);
223
+ const r = await verify(outDir);
224
+ setResults([
225
+ `${r.constraintsQuoted} quote(s) checked, ${r.missingQuotes.length} missing`,
226
+ `${r.filesClaimed} file(s) claimed, ${r.filesChecked} looked for, ${r.missingFiles.length} gone`,
227
+ ]);
228
+ setModal({ kind: 'result' });
229
+ return;
230
+ }
231
+ if (actionId === 'narrate') {
232
+ const { narrate, writeNarrative, requireArtifact } = await import('../compact/narrate.js');
233
+ const { resolveAuto, build } = await import('../llm/index.js');
234
+ await requireArtifact(outDir);
235
+ const backend = build(resolveAuto());
236
+ if (!backend)
237
+ throw new Error('narrate needs a model; set CCOMPACTOR_API_KEY and CCOMPACTOR_BASE_URL');
238
+ onProgress('narrate', `reading ${outDir}/handoff.md`);
239
+ const result = await narrate(outDir, backend);
240
+ await writeNarrative(outDir, result.narrative);
241
+ setLastArtifact(`${outDir}/handoff.md`);
247
242
  setResults([
248
- `${report.constraintsQuoted} quote(s) checked, ${report.missingQuotes.length} missing`,
249
- `${report.filesClaimed} file(s) claimed, ${report.missingFiles.length} gone`,
243
+ `${result.artifactTokens} input token(s)`,
244
+ `written into ${outDir}/handoff.md`,
250
245
  ]);
251
- setScreen('done');
246
+ setModal({ kind: 'result' });
252
247
  return;
253
248
  }
254
249
  const format = actionId.startsWith('transcript') ? 'transcript' : 'handoff';
@@ -263,15 +258,12 @@ export function App({ outDir, anyProject, onDone }) {
263
258
  }, onProgress);
264
259
  const artifact = result.written[0] ?? `${outDir}/handoff.md`;
265
260
  setLastArtifact(artifact);
266
- setResults([
267
- `${result.rendered.tokens} tokens in ${result.elapsedMs} ms`,
268
- ...result.written,
269
- ]);
261
+ setResults([`${result.rendered.tokens} tokens in ${result.elapsedMs} ms`, ...result.written]);
270
262
  if (actionId === 'handoff' || actionId === 'handoff-run') {
271
263
  const chosen = target ?? targets[0];
272
264
  if (!chosen) {
273
265
  setError('no launchable agent found on PATH');
274
- setScreen('done');
266
+ setModal({ kind: 'result' });
275
267
  return;
276
268
  }
277
269
  const launch = plan(chosen, artifact, process.cwd());
@@ -284,32 +276,23 @@ export function App({ outDir, anyProject, onDone }) {
284
276
  return;
285
277
  }
286
278
  }
287
- setScreen('done');
279
+ setModal({ kind: 'result' });
288
280
  }
289
281
  catch (caught) {
290
282
  setError(caught.message);
291
- setScreen('done');
283
+ setModal({ kind: 'result' });
292
284
  }
293
285
  finally {
294
286
  busy.current = false;
295
287
  }
296
288
  }, [selected, outDir, targets, exit, onDone]);
297
- const openActions = useCallback(() => {
298
- setActionIndex(0);
299
- setScreen('actions');
300
- }, []);
301
- const openPreview = useCallback(() => {
302
- setPreviewScroll(0);
303
- setScreen('preview');
304
- }, []);
305
- // ---- the top bar --------------------------------------------------------
306
289
  const runSelectedAction = useCallback(() => {
307
290
  const action = actions[actionIndex];
308
291
  if (!action)
309
292
  return;
310
293
  if (action.id === 'handoff' || action.id === 'handoff-run') {
311
294
  setTargetIndex(0);
312
- setScreen('target');
295
+ setModal({ kind: 'target' });
313
296
  return;
314
297
  }
315
298
  void run(action.id);
@@ -320,170 +303,263 @@ export function App({ outDir, anyProject, onDone }) {
320
303
  if (action && target)
321
304
  void run(action.id, target);
322
305
  }, [actions, actionIndex, targetIndex, targets, run]);
323
- /**
324
- * The buttons for this screen. The key handler calls these same functions, so
325
- * a click and a keystroke cannot drift apart — there is one implementation per
326
- * action and two ways to reach it.
327
- */
328
- const barButtons = (() => {
329
- if (screen === 'browse') {
330
- return [
331
- {
332
- id: 'search',
333
- key: '/',
334
- label: 'search',
335
- run: () => {
336
- setSearching(true);
337
- setQuery('');
338
- },
339
- },
340
- {
341
- id: 'all',
342
- key: '0',
343
- label: 'all agents',
344
- run: () => {
345
- setAgentsOff(new Set());
346
- setCursor(0);
347
- },
348
- },
349
- { id: 'open', key: '↵', label: 'quick look', run: openPreview },
350
- { id: 'actions', key: 'a', label: 'actions', run: openActions },
351
- {
352
- id: 'quit',
353
- key: 'q',
354
- label: 'quit',
355
- run: () => {
356
- exit();
357
- onDone(0);
358
- },
359
- },
360
- ];
306
+ const openActions = useCallback(() => {
307
+ setActionIndex(0);
308
+ setModal({ kind: 'actions' });
309
+ }, []);
310
+ const openPreview = useCallback(() => {
311
+ setPreviewScroll(0);
312
+ setModal({ kind: 'preview' });
313
+ }, []);
314
+ // ---- pages --------------------------------------------------------------
315
+ const loadArtifacts = useCallback(async () => {
316
+ const { readdir, stat } = await import('node:fs/promises');
317
+ const { join } = await import('node:path');
318
+ try {
319
+ const names = await readdir(outDir);
320
+ const entries = await Promise.all(names.map(async (name) => ({
321
+ name,
322
+ size: (await stat(join(outDir, name))).size,
323
+ })));
324
+ setArtifacts(entries.sort((a, b) => a.name.localeCompare(b.name)));
361
325
  }
362
- if (screen === 'preview') {
363
- return [
364
- { id: 'actions', key: 'a', label: 'actions', run: openActions },
365
- { id: 'back', key: 'esc', label: 'back to the list', run: () => setScreen('browse') },
366
- ];
326
+ catch {
327
+ setArtifacts([]);
367
328
  }
368
- if (screen === 'actions') {
369
- return [
370
- { id: 'run', key: '↵', label: 'run this', run: runSelectedAction },
371
- { id: 'back', key: 'esc', label: 'back to the list', run: () => setScreen('browse') },
372
- ];
329
+ }, [outDir]);
330
+ const showReport = useCallback(async (title, build) => {
331
+ setModal({ kind: 'report', title, lines: ['working '] });
332
+ try {
333
+ setModal({ kind: 'report', title, lines: await build() });
373
334
  }
374
- if (screen === 'target') {
375
- return [
376
- { id: 'fork', key: '↵', label: 'continue in this agent', run: runSelectedTarget },
377
- { id: 'back', key: 'esc', label: 'back', run: () => setScreen('actions') },
378
- ];
335
+ catch (caught) {
336
+ setModal({ kind: 'report', title, lines: [`error: ${caught.message}`] });
337
+ }
338
+ }, []);
339
+ const doctorLines = useCallback(async () => {
340
+ const { adapters } = await import('../adapters/index.js');
341
+ const { resolveAuto, selectionLabel } = await import('../llm/index.js');
342
+ const { installations } = await import('../install.js');
343
+ const out = [];
344
+ for (const adapter of adapters()) {
345
+ const available = adapter.available();
346
+ const count = available ? (await adapter.list({ anyProject: true })).length : 0;
347
+ out.push(`${available ? '✓' : '·'} ${adapter.kind.padEnd(10)} ${adapter.store()} ${available ? `${count} session(s)` : 'not found'}`);
379
348
  }
380
- if (screen === 'done') {
381
- return [{ id: 'back', key: '↵', label: 'back to the list', run: () => setScreen('browse') }];
382
- }
383
- return [];
384
- })();
385
- const barSpans = barCells(barButtons, columns);
386
- // The title and the counts are cells too, so the chips that follow are drawn
387
- // and hit-tested at the same columns.
388
- const chipCells = [
389
- { id: 'title', text: ' ccompactor ' },
390
- { id: 'count', text: ` ${filtered.length} of ${sessions.length} ` },
391
- { id: 'agent:all', text: ' all ' },
392
- ...agents.map(([agent, count]) => ({ id: `agent:${agent}`, text: ` ${agent} ${count} ` })),
349
+ out.push('');
350
+ out.push('backends');
351
+ const auto = resolveAuto();
352
+ const key = (name) => (process.env[name] ? 'set' : 'not set');
353
+ out.push(` none always available`);
354
+ out.push(` auto resolves to ${selectionLabel(auto)}`);
355
+ out.push(` api:anthropic ANTHROPIC_API_KEY ${key('ANTHROPIC_API_KEY')}`);
356
+ out.push(` api:openai OPENAI_API_KEY ${key('OPENAI_API_KEY')}`);
357
+ out.push(` api:compat/<m> CCOMPACTOR_BASE_URL ${key('CCOMPACTOR_BASE_URL')}, CCOMPACTOR_API_KEY ${key('CCOMPACTOR_API_KEY')}`);
358
+ out.push('');
359
+ out.push('installs on PATH');
360
+ for (const entry of installations()) {
361
+ out.push(` ${entry.version ?? 'did not answer'} ${entry.path}${entry.current ? ' <- this one' : ''}`);
362
+ }
363
+ return out;
364
+ }, []);
365
+ // ---- the frame ----------------------------------------------------------
366
+ // The top bar is three blocks: the brand and the agent tabs on the left, the
367
+ // destination on the right, and whatever space is between them. The left block
368
+ // is laid out through `layoutCells` so the tab columns are real, and the pad is
369
+ // then whatever is left — computed from the laid-out width rather than from the
370
+ // sum of the strings, which is off by one per join and pushed the right-hand
371
+ // block off the end of the row.
372
+ const rightText = `out: ${outDir}${anyProject ? '' : ' · this project'}`;
373
+ const leftCells = [
374
+ { id: 'brand', text: ' ccompactor ' },
375
+ { id: 'gap', text: ' ' },
376
+ ...tabCells(agents, undefined, columns).map((cell) => ({ id: cell.id, text: cell.text })),
393
377
  ];
394
- const chipSpans = layoutCells(chipCells, columns);
395
- /**
396
- * Act on a click at `x` on terminal row `row`.
397
- *
398
- * The row has to select which row's spans are searched. Looking through all of
399
- * them and taking the first whose columns match sent a click on the `claude`
400
- * chip at column 38 to the `[↵ quick look]` button, which happens to cover the
401
- * same columns one row down — so filtering by agent opened the preview instead.
402
- */
403
- function clickCell(row, x) {
404
- if (row === ROW_BAR) {
405
- const bar = barSpans.find((span) => inSpan(span, x));
406
- if (bar)
407
- barButtons.find((b) => b.id === bar.id)?.run();
378
+ const topSpans = layoutCells(leftCells, columns);
379
+ const leftWidth = topSpans.length === 0 ? 0 : topSpans[topSpans.length - 1].end + 1;
380
+ // Three blocks means two joins, and the pad has to leave room for both. One
381
+ // short and the row is a column too wide, which Ink wraps onto a second line
382
+ // and the frame scrolls.
383
+ let right = rightText;
384
+ let pad = columns - leftWidth - right.length - 2;
385
+ if (pad < 1) {
386
+ // The destination is the first thing to go: it is context, not a control.
387
+ right = '';
388
+ pad = columns - leftWidth - 1;
389
+ }
390
+ if (pad < 0)
391
+ pad = 0;
392
+ /** Bottom-bar buttons for the current page. */
393
+ /** Bottom-bar buttons for the current page. */
394
+ const navButtons = [
395
+ {
396
+ id: 'pane',
397
+ key: '←→',
398
+ label: 'pane',
399
+ run: () => setFocus((f) => (f === 'menu' ? 'content' : 'menu')),
400
+ },
401
+ { id: 'up', key: '↑↓', label: 'move', run: () => move(1) },
402
+ { id: 'select', key: '⏎', label: 'select', run: () => activate() },
403
+ { id: 'filter', key: '/', label: 'filter', run: () => beginSearch() },
404
+ { id: 'back', key: 'esc', label: 'back', run: () => back() },
405
+ { id: 'help', key: '?', label: 'help', run: () => setModal({ kind: 'help' }) },
406
+ ];
407
+ const actButtons = page === 'sessions'
408
+ ? [
409
+ { id: 'look', key: 'v', label: 'look', run: openPreview },
410
+ { id: 'actions', key: 'a', label: 'actions', run: openActions },
411
+ ]
412
+ : page === 'artifacts'
413
+ ? [
414
+ { id: 'verify', key: 'V', label: 'verify', run: () => void run('verify') },
415
+ { id: 'reload', key: 'r', label: 'reload', run: () => void loadArtifacts() },
416
+ ]
417
+ : page === 'skill'
418
+ ? [
419
+ { id: 'install', key: 'i', label: 'install', run: () => void doSkill('install') },
420
+ { id: 'uninstall', key: 'u', label: 'uninstall', run: () => void doSkill('uninstall') },
421
+ { id: 'path', key: 'p', label: 'path', run: () => void doSkill('path') },
422
+ ]
423
+ : page === 'update'
424
+ ? [
425
+ { id: 'check', key: 'c', label: 'check', run: () => void doUpdate(true) },
426
+ { id: 'apply', key: 'U', label: 'update', run: () => void doUpdate(false) },
427
+ ]
428
+ : [];
429
+ const bar = bottomBar(navButtons.map((b) => ({ id: b.id, key: b.key, label: b.label })), actButtons.map((b) => ({ id: b.id, key: b.key, label: b.label })), columns, rows);
430
+ const barButtons = [...navButtons, ...actButtons];
431
+ const menuSpans = layoutCells(MENU.map((item) => ({ id: `menu:${item.id}`, text: ` ${item.label} ` })), SIDEBAR_WIDTH - 2);
432
+ function move(delta) {
433
+ if (modal) {
434
+ if (modal.kind === 'actions') {
435
+ setActionIndex((i) => Math.min(actions.length - 1, Math.max(0, i + delta)));
436
+ }
437
+ else if (modal.kind === 'target') {
438
+ setTargetIndex((i) => Math.min(targets.length - 1, Math.max(0, i + delta)));
439
+ }
408
440
  return;
409
441
  }
410
- const chip = chipSpans.find((span) => inSpan(span, x));
411
- if (!chip || !chip.id.startsWith('agent:'))
412
- return;
413
- const agent = chip.id.slice('agent:'.length);
414
- if (agent === 'all') {
415
- setAgentsOff(new Set());
416
- setCursor(0);
442
+ if (focus === 'menu') {
443
+ setMenuCursor((i) => Math.min(MENU.length - 1, Math.max(0, i + delta)));
417
444
  return;
418
445
  }
419
- setAgentFilter(agent);
446
+ if (page === 'sessions') {
447
+ setCursor((c) => Math.min(filtered.length - 1, Math.max(0, c + delta)));
448
+ }
420
449
  }
421
- function handleMouse(event) {
422
- if (event.kind === 'click') {
423
- // The bar and the chips first: they sit on every screen, and a click that
424
- // lands on one must not also be read as a list row.
425
- if (event.y === ROW_BAR || event.y === ROW_CHIPS) {
426
- clickCell(event.y, event.x);
427
- return;
428
- }
450
+ function openPage(next) {
451
+ if (next === 'exit') {
452
+ exit();
453
+ onDone(0);
454
+ return;
429
455
  }
430
- if (screen === 'browse') {
431
- if (event.kind === 'wheel-up') {
432
- setCursor((c) => Math.max(0, c - 3));
433
- return;
434
- }
435
- if (event.kind === 'wheel-down') {
436
- setCursor((c) => Math.min(filtered.length - 1, c + 3));
437
- return;
438
- }
439
- if (event.kind === 'click') {
440
- const row = event.y - LIST_TOP + offset;
441
- if (row >= 0 && row < filtered.length) {
442
- setCursor(row);
443
- openPreview();
444
- }
456
+ setPage(next);
457
+ setFocus('content');
458
+ setMenuCursor(menuIndex(next));
459
+ if (next === 'artifacts')
460
+ void loadArtifacts();
461
+ if (next === 'doctor')
462
+ void showReport('Doctor', doctorLines);
463
+ }
464
+ function activate() {
465
+ if (modal) {
466
+ if (modal.kind === 'actions')
467
+ return runSelectedAction();
468
+ if (modal.kind === 'target')
469
+ return runSelectedTarget();
470
+ if (modal.kind === 'report' || modal.kind === 'result' || modal.kind === 'help') {
471
+ return setModal(undefined);
445
472
  }
446
473
  return;
447
474
  }
448
- if (screen === 'preview') {
449
- if (event.kind === 'wheel-up')
450
- setPreviewScroll((s) => Math.max(0, s - 3));
451
- if (event.kind === 'wheel-down')
452
- setPreviewScroll((s) => s + 3);
475
+ if (focus === 'menu')
476
+ return openPage(MENU[menuCursor]?.id ?? 'sessions');
477
+ if (page === 'sessions')
478
+ return openPreview();
479
+ if (page === 'artifacts')
480
+ void run('verify');
481
+ }
482
+ function beginSearch() {
483
+ if (page !== 'sessions')
453
484
  return;
454
- }
455
- if (screen === 'actions' || screen === 'target') {
456
- const both = screen === 'actions' ? actions.length : targets.length;
457
- if (event.kind === 'wheel-up') {
458
- const step = (i) => Math.max(0, i - 1);
459
- if (screen === 'actions')
460
- setActionIndex(step);
461
- else
462
- setTargetIndex(step);
463
- return;
464
- }
465
- if (event.kind === 'wheel-down') {
466
- const step = (i) => Math.min(both - 1, i + 1);
467
- if (screen === 'actions')
468
- setActionIndex(step);
469
- else
470
- setTargetIndex(step);
485
+ setSearching(true);
486
+ setQuery('');
487
+ }
488
+ function back() {
489
+ if (modal)
490
+ return setModal(undefined);
491
+ if (searching)
492
+ return setSearching(false);
493
+ if (focus === 'content')
494
+ return setFocus('menu');
495
+ setFocus('content');
496
+ }
497
+ async function doSkill(action) {
498
+ await showReport(`Skill · ${action}`, async () => {
499
+ const { install, uninstall, skillTargets, payloadDir } = await import('../skill/index.js');
500
+ if (action === 'install')
501
+ return await install();
502
+ if (action === 'uninstall')
503
+ return await uninstall();
504
+ return [
505
+ `payload: ${payloadDir()}`,
506
+ ...skillTargets().map((t) => `target: ${t.dir}`),
507
+ ];
508
+ });
509
+ }
510
+ async function doUpdate(check) {
511
+ await showReport(`Update${check ? ' · check' : ''}`, async () => {
512
+ const { update } = await import('../update/index.js');
513
+ const result = await update({ check });
514
+ return [
515
+ `${result.current} → ${result.latest} (${result.kind})`,
516
+ result.location,
517
+ '',
518
+ ...result.message.split('\n'),
519
+ ];
520
+ });
521
+ }
522
+ function setAgentFilter(agent) {
523
+ const known = agents.map(([name]) => name);
524
+ setAgentsOff(new Set(known.filter((name) => name !== agent)));
525
+ setCursor(0);
526
+ }
527
+ // ---- mouse --------------------------------------------------------------
528
+ function handleMouse(event) {
529
+ // A modal owns the pointer while it is open. This has to come first: written
530
+ // the other way round, the early return narrowed `modal` to `undefined` and
531
+ // the wheel could never reach the quick look it was scrolling.
532
+ if (modal) {
533
+ if (event.kind === 'wheel-up' || event.kind === 'wheel-down') {
534
+ const delta = event.kind === 'wheel-up' ? -3 : 3;
535
+ if (modal.kind === 'preview') {
536
+ setPreviewScroll((s) => Math.max(0, s + delta));
537
+ }
538
+ else if (modal.kind === 'actions') {
539
+ setActionIndex((i) => Math.min(actions.length - 1, Math.max(0, i + (delta > 0 ? 1 : -1))));
540
+ }
541
+ else if (modal.kind === 'target') {
542
+ setTargetIndex((i) => Math.min(targets.length - 1, Math.max(0, i + (delta > 0 ? 1 : -1))));
543
+ }
471
544
  return;
472
545
  }
473
- if (event.kind === 'click') {
474
- const row = event.y - MENU_TOP;
475
- if (row < 0 || row >= both)
546
+ if (event.kind === 'click' && (modal.kind === 'actions' || modal.kind === 'target')) {
547
+ const itemTop = modalItemTop(modalContent().lines.length);
548
+ // Action dialogs put a detail line under every label, so the clickable
549
+ // stride is two rows, not one.
550
+ const raw = event.y - itemTop;
551
+ const row = modal.kind === 'actions' ? Math.floor(raw / 2) : raw;
552
+ const count = modal.kind === 'actions' ? actions.length : targets.length;
553
+ if (row < 0 || row >= count)
476
554
  return;
477
- // A click on a menu item runs it, which is what clicking a menu item
478
- // does everywhere else.
479
- if (screen === 'actions') {
555
+ if (modal.kind === 'actions') {
480
556
  setActionIndex(row);
481
557
  const action = actions[row];
482
558
  if (!action)
483
559
  return;
484
560
  if (action.id === 'handoff' || action.id === 'handoff-run') {
485
561
  setTargetIndex(0);
486
- setScreen('target');
562
+ setModal({ kind: 'target' });
487
563
  return;
488
564
  }
489
565
  void run(action.id);
@@ -496,11 +572,54 @@ export function App({ outDir, anyProject, onDone }) {
496
572
  void run(action.id, target);
497
573
  }
498
574
  }
575
+ return;
576
+ }
577
+ if (event.kind === 'click') {
578
+ const barCell = bar.cells.find((c) => inCell(c, event.x));
579
+ if (event.y === bar.row && barCell) {
580
+ const button = barButtons.find((b) => b.id === barCell.id.replace(/^(nav|act):/, ''));
581
+ if (button)
582
+ button.run();
583
+ return;
584
+ }
585
+ if (event.y === ROW_TOP) {
586
+ const cell = topSpans.find((c) => inCell(c, event.x));
587
+ if (cell?.id === 'agent:all') {
588
+ setAgentsOff(new Set());
589
+ setCursor(0);
590
+ return;
591
+ }
592
+ if (cell?.id.startsWith('agent:')) {
593
+ setAgentFilter(cell.id.slice('agent:'.length));
594
+ }
595
+ return;
596
+ }
597
+ // The sidebar.
598
+ if (event.x <= SIDEBAR_WIDTH) {
599
+ const row = event.y - SIDEBAR_TOP;
600
+ const item = MENU[row];
601
+ if (item) {
602
+ setMenuCursor(row);
603
+ openPage(item.id);
604
+ }
605
+ return;
606
+ }
607
+ if (page === 'sessions') {
608
+ const row = event.y - SIDEBAR_TOP + 1 + offset;
609
+ if (row >= 0 && row < filtered.length) {
610
+ setCursor(row);
611
+ setFocus('content');
612
+ openPreview();
613
+ }
614
+ }
615
+ return;
616
+ }
617
+ if (event.kind === 'wheel-up' || event.kind === 'wheel-down') {
618
+ const delta = event.kind === 'wheel-up' ? -3 : 3;
619
+ if (page === 'sessions')
620
+ setCursor((c) => Math.min(filtered.length - 1, Math.max(0, c + delta)));
499
621
  }
500
622
  }
501
- // The listener is installed once and reads the handler through a ref. Written
502
- // the other way it has to list every value the handler closes over, and a
503
- // missed dependency silently freezes a click on a stale screen.
504
623
  const mouseRef = useRef(handleMouse);
505
624
  mouseRef.current = handleMouse;
506
625
  useEffect(() => {
@@ -520,8 +639,7 @@ export function App({ outDir, anyProject, onDone }) {
520
639
  useInput((input, key) => {
521
640
  // Mouse sequences arrive on the same stream and Ink's key parser hands the
522
641
  // unrecognised remainder through as typed text, so a click while the search
523
- // box is open spelled `[<0;3;3M` into the query. `mouse.ts` consumes them
524
- // for its own use; this stops the leftovers reaching a text field.
642
+ // box is open spelled `[<0;3;3M` into the query.
525
643
  if (/\u001b?\[<\d+;\d+;\d+[Mm]/.test(input))
526
644
  return;
527
645
  if (key.ctrl && input === 'c') {
@@ -529,51 +647,8 @@ export function App({ outDir, anyProject, onDone }) {
529
647
  onDone(0);
530
648
  return;
531
649
  }
532
- if (screen === 'running')
533
- return;
534
- if (screen === 'done') {
535
- if (key.return || key.escape || input === 'q') {
536
- setScreen('browse');
537
- }
538
- return;
539
- }
540
- if (screen === 'actions') {
541
- if (key.upArrow)
542
- setActionIndex((i) => Math.max(0, i - 1));
543
- if (key.downArrow)
544
- setActionIndex((i) => Math.min(actions.length - 1, i + 1));
545
- if (key.escape)
546
- setScreen('browse');
547
- if (key.return)
548
- runSelectedAction();
650
+ if (modal?.kind === 'running')
549
651
  return;
550
- }
551
- if (screen === 'target') {
552
- if (key.upArrow)
553
- setTargetIndex((i) => Math.max(0, i - 1));
554
- if (key.downArrow)
555
- setTargetIndex((i) => Math.min(targets.length - 1, i + 1));
556
- if (key.escape)
557
- setScreen('actions');
558
- if (key.return)
559
- runSelectedTarget();
560
- return;
561
- }
562
- if (screen === 'preview') {
563
- if (key.escape || input === 'q') {
564
- setScreen('browse');
565
- return;
566
- }
567
- const lines = selected ? (cache.get(`${selected.agent}:${selected.id}`)?.preview.length ?? 0) : 0;
568
- if (key.upArrow)
569
- setPreviewScroll((s) => Math.max(0, s - 1));
570
- if (key.downArrow)
571
- setPreviewScroll((s) => Math.min(Math.max(0, lines - 1), s + 1));
572
- if (input === 'a' || input === 'h' || key.return)
573
- openActions();
574
- return;
575
- }
576
- // browse
577
652
  if (searching) {
578
653
  if (key.return || key.escape)
579
654
  setSearching(false);
@@ -585,123 +660,211 @@ export function App({ outDir, anyProject, onDone }) {
585
660
  }
586
661
  return;
587
662
  }
588
- if (input === 'q') {
589
- exit();
590
- onDone(0);
591
- return;
592
- }
593
- if (input === '/') {
594
- setSearching(true);
595
- setQuery('');
596
- return;
597
- }
598
- if (input === 'c') {
599
- setAgentFilter('claude');
663
+ if (key.leftArrow || key.rightArrow || key.tab) {
664
+ setFocus((f) => (f === 'menu' ? 'content' : 'menu'));
600
665
  return;
601
666
  }
602
- if (input === 'x') {
603
- setAgentFilter('codex');
604
- return;
605
- }
606
- if (input === 'p') {
607
- setAgentFilter('pi');
667
+ if (key.upArrow)
668
+ return move(-1);
669
+ if (key.downArrow)
670
+ return move(1);
671
+ if (key.escape)
672
+ return back();
673
+ if (key.return)
674
+ return activate();
675
+ if (input === '?')
676
+ return setModal({ kind: 'help' });
677
+ if (input === 'q' && !modal) {
678
+ exit();
679
+ onDone(0);
608
680
  return;
609
681
  }
610
- // `a` used to clear the agent filter here and open actions in the preview
611
- // pane, so the hint line was lying on one of the two screens. One key, one
612
- // meaning: `a` is actions everywhere, and `0` is the whole list.
682
+ if (input === '/')
683
+ return beginSearch();
613
684
  if (input === '0') {
614
685
  setAgentsOff(new Set());
615
686
  setCursor(0);
616
687
  return;
617
688
  }
618
- if (key.upArrow)
619
- setCursor((c) => Math.max(0, c - 1));
620
- if (key.downArrow)
621
- setCursor((c) => Math.min(filtered.length - 1, c + 1));
622
- if (key.return || input === ' ')
623
- openPreview();
624
- if (input === 'a' || input === 'e' || input === 'h')
625
- openActions();
689
+ if (input === 'c')
690
+ return setAgentFilter('claude');
691
+ if (input === 'x')
692
+ return setAgentFilter('codex');
693
+ if (input === 'p')
694
+ return setAgentFilter('pi');
695
+ // The bottom bar is the single definition of what a key does: the key
696
+ // handler looks the key up in the same list the buttons are drawn from, so
697
+ // a shortcut cannot do one thing while its button does another.
698
+ const act = actButtons.find((b) => b.key === input);
699
+ if (act)
700
+ act.run();
626
701
  });
627
- function setAgentFilter(agent) {
628
- const known = agents.map(([name]) => name);
629
- setAgentsOff(new Set(known.filter((name) => name !== agent)));
630
- setCursor(0);
631
- }
632
702
  // ---- render -------------------------------------------------------------
633
- if (loading)
634
- return _jsx(Text, { color: THEME.yellow, children: loading });
635
- const layout = layoutFor(columns, filtered.length > 0 ? filtered : sessions);
636
- const chips = (_jsx(Box, { children: chipSpans.map((span, i) => {
637
- const prefix = i > 0 ? ' ' : '';
638
- if (span.id === 'title') {
639
- return (_jsx(Text, { bold: true, backgroundColor: THEME.yellow, color: THEME.onYellow, children: span.text }, span.id));
640
- }
641
- if (span.id === 'count') {
642
- return (_jsx(Text, { color: THEME.muted, children: span.text }, span.id));
643
- }
644
- const agent = span.id.slice('agent:'.length);
645
- const on = agent === 'all' ? agentsOff.size === 0 : !agentsOff.has(agent);
646
- return (_jsx(Text, { ...(on
703
+ if (loading) {
704
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, backgroundColor: THEME.yellow, color: THEME.onYellow, children: ' ccompactor ' }), _jsx(Text, { color: THEME.yellowInk, children: loading })] }));
705
+ }
706
+ const menuWidth = SIDEBAR_WIDTH - 2;
707
+ const menu = (_jsx(Box, { flexDirection: "column", width: SIDEBAR_WIDTH, borderStyle: "round", borderColor: focus === 'menu' ? THEME.yellow : THEME.stripeDim, children: MENU.map((item, i) => {
708
+ const active = page === item.id;
709
+ const cursorHere = focus === 'menu' && menuCursor === i;
710
+ return (_jsxs(Text, { ...(active
647
711
  ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true }
648
- : { color: THEME.muted }), children: prefix + span.text }, span.id));
712
+ : { color: THEME.muted }), children: [cursorHere ? '❯' : ' ', item.label.padEnd(menuWidth - 1)] }, item.id));
649
713
  }) }));
650
- // The action bar. Every shortcut on this screen is also a button, so the
651
- // screen can be driven without knowing any of them.
652
- const actionBar = (_jsxs(Box, { children: [barSpans.map((span, i) => (_jsx(Text, { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true, children: (i > 0 ? ' ' : '') + span.text }, span.id))), barSpans.length === 0 && _jsx(Text, { children: " " })] }));
653
- // The bar carries the actions, so this line only has to explain the things
654
- // that are not buttons: moving around.
655
- const hints = screen === 'browse'
656
- ? `${sessions.length > visibleRows ? `${cursor + 1}/${filtered.length} · ` : ''}click a row, scroll, or type c/x/p// to narrow`
657
- : screen === 'preview'
658
- ? 'scroll with the wheel or ↑↓'
659
- : screen === 'actions' || screen === 'target'
660
- ? 'click an item, or ↑↓ then enter'
661
- : '';
662
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: hazard(columns).map((cell, i) => (_jsx(Text, { color: cell.dim ? THEME.stripeDim : THEME.yellow, children: cell.ch }, i))) }), chips, actionBar, screen === 'browse' && (_jsxs(_Fragment, { children: [_jsx(Columns, { layout: layout, width: columns }), _jsx(Box, { flexDirection: "column", children: filtered.slice(offset, offset + visibleRows).map((ref, i) => {
663
- const index = offset + i;
664
- const active = index === cursor;
665
- const meta = cache.get(`${ref.agent}:${ref.id}`);
666
- return (_jsx(Row, { ref_: ref, active: active, layout: layout, details: meta }, ref.path));
667
- }) })] })), screen === 'preview' && selected && (_jsx(Preview, { ref_: selected, details: cache.get(`${selected.agent}:${selected.id}`), scroll: previewScroll, height: visibleRows + 2, width: columns })), screen === 'actions' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: THEME.yellowInk, children: ["What do you want to do with ", selected?.agent, ":", selected?.id.slice(0, 8), "?"] }), actions.map((action, i) => (_jsxs(Text, { ...(i === actionIndex
668
- ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true }
669
- : {}), children: [i === actionIndex ? '❯ ' : ' ', action.label, _jsxs(Text, { color: i === actionIndex ? THEME.onYellow : THEME.muted, children: [' ', action.detail] })] }, action.id)))] })), screen === 'target' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: THEME.yellowInk, children: "Continue this session in which agent?" }), targets.length === 0 && _jsx(Text, { color: THEME.muted, children: "none found on PATH" }), targets.map((target, i) => (_jsxs(Text, { ...(i === targetIndex
670
- ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true }
671
- : {}), children: [i === targetIndex ? '❯ ' : ' ', target] }, target)))] })), screen === 'running' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: THEME.yellowInk, children: ["working on ", selected?.agent, ":", selected?.id.slice(0, 8), " \u2026 ", elapsedLabel()] }), progress.slice(-6).map((line, i) => (_jsxs(Text, { color: THEME.muted, children: [' ', line] }, i)))] })), screen === 'done' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [error ? (_jsxs(Text, { color: "#FF6B6B", children: ["error: ", error] })) : (_jsx(Text, { bold: true, color: THEME.yellowInk, children: "done" })), results.map((line, i) => (_jsxs(Text, { color: THEME.muted, children: [' ', line] }, i))), lastArtifact && (_jsxs(Text, { color: THEME.muted, children: [' ', "read it: ", lastArtifact] })), _jsx(Text, { color: THEME.muted, children: "press enter to go back" })] })), status.length > 0 && _jsx(Text, { color: THEME.yellowInk, children: status }), searching && screen === 'browse' && (_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: THEME.yellow, children: ["search:", ' '] }), _jsx(Text, { children: query }), _jsx(Text, { color: THEME.yellowInk, children: "\u258F" })] })), screen !== 'done' && screen !== 'running' && hints.length > 0 && (_jsx(Text, { color: THEME.muted, children: hints }))] }));
672
- function elapsedLabel() {
673
- return '';
714
+ const contentWidth = Math.max(20, columns - SIDEBAR_WIDTH - 1);
715
+ const content = (_jsx(Box, { flexDirection: "column", width: contentWidth, borderStyle: "round", borderColor: focus === 'content' ? THEME.yellow : THEME.stripeDim, children: contentBody() }));
716
+ function contentBody() {
717
+ if (page === 'sessions')
718
+ return sessionsPage();
719
+ if (page === 'artifacts') {
720
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: THEME.muted, bold: true, children: [' ', artifacts.length === 0 ? `nothing in ${outDir}` : `${outDir} ${artifacts.length} file(s)`, ' '] }), artifacts.map((file) => (_jsxs(Text, { color: THEME.muted, children: [' ', file.name.padEnd(24), sizeLabel(file.size).padStart(10)] }, file.name))), _jsxs(Text, { color: THEME.muted, children: [' ', "V verifies the artifact against the transcript; r reloads this list", ' '] })] }));
721
+ }
722
+ if (page === 'settings') {
723
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: THEME.muted, bold: true, children: [' ', "Where things are written", ' '] }), _jsxs(Text, { children: [' ', "output directory ", ' ', _jsx(Text, { color: THEME.yellowInk, children: outDir })] }), _jsxs(Text, { children: [' ', "session scope ", ' ', _jsx(Text, { color: THEME.yellowInk, children: anyProject ? 'every project' : 'this project only' })] }), _jsxs(Text, { children: [' ', "working directory", ' ', _jsx(Text, { color: THEME.yellowInk, children: process.cwd() })] }), _jsxs(Text, { color: THEME.muted, children: [' ', "Change these with --out and --project on the command line.", ' '] })] }));
724
+ }
725
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: THEME.muted, bold: true, children: [' ', MENU[menuIndex(page)]?.label ?? page, ' '] }), _jsx(Text, { color: THEME.muted, children: " press enter or click the menu to load this page " })] }));
674
726
  }
675
- }
676
- function layoutFor(columns, sessions) {
677
- const longest = sessions.reduce((n, ref) => Math.max(n, ref.id.length), 0);
678
- const id = Math.min(Math.max(longest, 36), 64);
679
- const fixed = 2 + 8 + id + 10 + 18;
680
- return {
681
- id,
682
- meta: columns - fixed >= 14,
683
- size: columns - fixed >= 10,
684
- cwd: columns - fixed - (columns - fixed >= 14 ? 14 : 0) >= 42,
685
- };
686
- }
687
- function Columns({ layout, width: columns }) {
688
- return (_jsxs(Text, { color: THEME.muted, bold: true, children: [' ', 'AGENT'.padEnd(8), 'SESSION ID'.padEnd(layout.id), layout.meta ? 'MSGS'.padStart(7) + 'TURNS'.padStart(7) : '', layout.size ? 'SIZE'.padStart(10) : '', 'MODIFIED'.padStart(18), layout.cwd ? ` ${'CWD'}` : '', columns < 0 ? '' : ''] }));
689
- }
690
- function Row({ ref_, active, layout, details, }) {
691
- const cwd = details?.cwd ?? '';
692
- const shortCwd = cwd.length > 44 ? `…${cwd.slice(-42)}` : cwd;
693
- const style = active ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true } : {};
694
- return (_jsxs(Text, { ...style, children: [active ? '❯ ' : ' ', ref_.agent.padEnd(8), ref_.id.padEnd(layout.id), layout.meta
695
- ? `${details ? String(details.messages) : '·'}`.padStart(7) +
696
- `${details ? String(details.userTurns) : '·'}`.padStart(7)
697
- : '', layout.size ? sizeLabel(ref_.bytes).padStart(10) : '', new Date(ref_.mtime).toISOString().slice(0, 16).replace('T', ' ').padStart(18), layout.cwd ? ` ${shortCwd}` : ''] }));
698
- }
699
- function Preview({ ref_, details, scroll, height, width: columns, }) {
700
- if (!details) {
701
- return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: THEME.yellow, children: ["reading ", ref_.id, " \u2026"] }) }));
727
+ function sessionsPage() {
728
+ // One width, used by the header and every row. An agent id is 36 characters
729
+ // and a codex id is 60, so a fixed column either wastes the difference or
730
+ // lets the long ones run into the date.
731
+ const idWidth = Math.max(12, Math.min(38, contentWidth - 34));
732
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: THEME.muted, bold: true, children: [' ', searching ? `search: ${query}▏` : `${filtered.length} of ${sessions.length} session(s)`, agentsOff.size > 0 && !searching ? ' · filtered' : '', ' '] }), _jsxs(Text, { color: THEME.muted, children: [' ', 'AGENT'.padEnd(8), 'SESSION ID'.padEnd(idWidth + 2), 'MODIFIED'] }), filtered.slice(offset, offset + visibleRows).map((ref, i) => {
733
+ const index = offset + i;
734
+ const active = index === cursor && focus === 'content';
735
+ const details = cache.get(`${ref.agent}:${ref.id}`);
736
+ return (_jsxs(Text, { ...(active ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true } : {}), children: [active ? '❯ ' : ' ', ref.agent.padEnd(8), (ref.id.length > idWidth ? `…${ref.id.slice(-(idWidth - 1))}` : ref.id).padEnd(idWidth), ' ', new Date(ref.mtime).toISOString().slice(0, 16).replace('T', ' '), details && contentWidth > 74 ? ` ${details.messages} msg` : ''] }, ref.path));
737
+ }), filtered.length === 0 && (_jsx(Text, { color: THEME.muted, children: " nothing matches. Press esc to clear, or / to search again. " }))] }));
738
+ }
739
+ /**
740
+ * What a dialog contains.
741
+ *
742
+ * One function, used by the drawing *and* by the click mapping, so the row a
743
+ * dialog item is painted on is the row it answers on. Written as two places
744
+ * this is the bug the clickable bar already had once.
745
+ */
746
+ function modalContent() {
747
+ const width = Math.max(40, Math.min(columns - 8, 96));
748
+ const inner = width - 6;
749
+ const lines = [];
750
+ let title = '';
751
+ let hints = 'esc or close';
752
+ let actionable = 0;
753
+ if (!modal)
754
+ return { title, lines, hints, actionable };
755
+ if (modal.kind === 'help') {
756
+ title = 'Keys';
757
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', "\u2190\u2192 or tab move between the menu and the content"] }, "h1"), _jsxs(Text, { color: THEME.muted, children: [' ', "\u2191\u2193 move within the focused pane"] }, "h2"), _jsxs(Text, { color: THEME.muted, children: [' ', "\u23CE select \u00B7 / filter \u00B7 esc back \u00B7 ? help \u00B7 q quit"] }, "h3"), _jsxs(Text, { color: THEME.muted, children: [' ', "Every cell of the top, side and bottom bars is clickable,"] }, "h4"), _jsxs(Text, { color: THEME.muted, children: [' ', "and the mouse wheel scrolls the list and the quick look."] }, "h5"));
758
+ }
759
+ else if (modal.kind === 'actions') {
760
+ title = `What to do with ${selected?.agent}:${selected?.id.slice(0, 8)}`;
761
+ hints = '↑↓ choose · ⏎ run · esc close · click an item to run it';
762
+ actions.forEach((action, i) => {
763
+ const active = i === actionIndex;
764
+ lines.push(_jsxs(Text, { ...(active ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true } : {}), children: [active ? '❯ ' : ' ', action.label] }, action.id));
765
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', action.detail.slice(0, inner - 4)] }, `${action.id}-d`));
766
+ });
767
+ actionable = actions.length;
768
+ }
769
+ else if (modal.kind === 'target') {
770
+ title = 'Continue this session in which agent?';
771
+ hints = '↑↓ choose · ⏎ continue · esc back';
772
+ if (targets.length === 0) {
773
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', "none found on PATH"] }, "none"));
774
+ }
775
+ targets.forEach((target, i) => {
776
+ const active = i === targetIndex;
777
+ lines.push(_jsxs(Text, { ...(active ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true } : {}), children: [active ? '❯ ' : ' ', target] }, target));
778
+ });
779
+ actionable = targets.length;
780
+ }
781
+ else if (modal.kind === 'running') {
782
+ title = `working on ${selected?.agent}:${selected?.id.slice(0, 8)} …`;
783
+ hints = 'this closes itself when the work is done';
784
+ for (const line of progress.slice(-8)) {
785
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', line.slice(0, inner)] }, line));
786
+ }
787
+ if (progress.length === 0) {
788
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', "starting \u2026"] }, "start"));
789
+ }
790
+ }
791
+ else if (modal.kind === 'result') {
792
+ title = error ? 'Something went wrong' : 'Done';
793
+ if (error) {
794
+ lines.push(_jsxs(Text, { color: "#FF6B6B", children: [' ', error.slice(0, inner)] }, "err"));
795
+ }
796
+ results.forEach((line, i) => lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', line.slice(0, inner)] }, i)));
797
+ if (lastArtifact) {
798
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', "read it: ", lastArtifact] }, "art"));
799
+ }
800
+ }
801
+ else if (modal.kind === 'report') {
802
+ title = modal.title;
803
+ for (const line of modal.lines) {
804
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', line.slice(0, inner)] }, line));
805
+ }
806
+ }
807
+ else if (modal.kind === 'preview') {
808
+ const details = selected ? cache.get(`${selected.agent}:${selected.id}`) : undefined;
809
+ title = selected ? `${selected.agent}:${selected.id}` : 'quick look';
810
+ hints = 'wheel or ↑↓ scroll · a actions · esc close';
811
+ if (!details) {
812
+ lines.push(_jsxs(Text, { color: THEME.yellow, children: [' ', "reading \u2026"] }, "l"));
813
+ }
814
+ else {
815
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', details.messages, " msg \u00B7 ", details.userTurns, " user turns \u00B7 ", details.toolCalls, " tool calls \u00B7", ' ', details.tokens, " tokens \u00B7 ", details.compactBoundaries, " compaction(s)"] }, "m"));
816
+ if (details.cwd) {
817
+ lines.push(_jsxs(Text, { color: THEME.muted, children: [' ', "cwd: ", details.cwd.slice(0, inner)] }, "c"));
818
+ }
819
+ const room = Math.max(2, bodyHeight - 8);
820
+ const all = details.preview;
821
+ const shown = all.slice(Math.max(0, all.length - room - previewScroll), all.length - previewScroll);
822
+ shown.forEach((line, i) => lines.push(_jsxs(Text, { children: [_jsx(Text, { color: line.role === 'user' ? THEME.yellow : THEME.muted, bold: line.role === 'user', children: line.role === 'user' ? ' user ' : line.role === 'agent' ? ' agent ' : ' tool ' }), _jsx(Text, { children: line.text.slice(0, inner - 8) })] }, `${line.evt}-${i}`)));
823
+ }
824
+ }
825
+ return { title, lines, hints, actionable };
702
826
  }
703
- const lines = details.preview;
704
- const shown = lines.slice(Math.max(0, lines.length - height - scroll + 1), lines.length - scroll);
705
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: THEME.muted, children: [ref_.agent, ":", ref_.id, ' ', details.messages, " msg \u00B7 ", details.userTurns, " user turns \u00B7 ", details.toolCalls, " tool calls \u00B7", ' ', details.tokens, " tokens \u00B7 ", details.compactBoundaries, " compaction(s)"] }), details.cwd && _jsxs(Text, { color: THEME.muted, children: ["cwd: ", details.cwd] }), details.branch && (_jsxs(Text, { color: THEME.muted, children: ["branch: ", details.branch, details.model ? ` · model: ${details.model}` : '', details.first ? ` · ${details.first.slice(0, 16).replace('T', ' ')} → ${details.last?.slice(0, 16).replace('T', ' ')}` : ''] })), _jsx(Text, { color: THEME.yellowInk, children: '─'.repeat(Math.max(10, Math.min(columns - 2, 100))) }), shown.map((line, i) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: line.role === 'user' ? THEME.yellow : THEME.muted, bold: line.role === 'user', children: line.role === 'user' ? 'user ' : line.role === 'agent' ? 'agent ' : 'tool ' }), _jsx(Text, { children: line.text.slice(0, Math.max(20, columns - 10)) })] }, `${line.evt}-${i}`))), lines.length === 0 && _jsx(Text, { color: THEME.muted, children: "no user or agent turns in this session" })] }));
827
+ /**
828
+ * The row the first clickable line of a dialog lands on.
829
+ *
830
+ * Derived from the same numbers the drawing uses rather than guessed: top bar
831
+ * (1) + the padding above the box + the border + the title.
832
+ */
833
+ function modalItemTop(lineCount) {
834
+ const box = lineCount + 4;
835
+ const padTop = Math.max(0, Math.floor((bodyHeight - box) / 2));
836
+ return padTop + 4;
837
+ }
838
+ /** A dialog, centred in the body. The frame stays visible around it. */
839
+ function modalBox() {
840
+ const { title, lines, hints } = modalContent();
841
+ const width = Math.max(40, Math.min(columns - 8, 96));
842
+ const room = Math.max(3, bodyHeight - 6);
843
+ const shown = lines.slice(0, room);
844
+ const box = shown.length + 4;
845
+ const padTop = Math.max(0, Math.floor((bodyHeight - box) / 2));
846
+ const padLeft = Math.max(0, Math.floor((columns - width) / 2));
847
+ return (_jsxs(Box, { flexDirection: "column", height: bodyHeight, children: [Array.from({ length: padTop }, (_, i) => (_jsx(Text, { children: " " }, `pad${i}`))), _jsxs(Box, { marginLeft: padLeft, flexDirection: "column", width: Math.min(width, columns - padLeft), borderStyle: "double", borderColor: THEME.yellow, children: [_jsx(Text, { bold: true, backgroundColor: THEME.yellow, color: THEME.onYellow, children: ` ${title} `.slice(0, width - 2).padEnd(width - 2) }), shown, _jsx(Text, { color: THEME.muted, children: ` ${hints}`.slice(0, width - 2).padEnd(width - 2) })] })] }));
848
+ }
849
+ return (
850
+ // One row short of the terminal. A frame exactly `rows` tall is written with
851
+ // a trailing newline, which scrolls the top bar off the screen.
852
+ _jsxs(Box, { flexDirection: "column", height: Math.max(6, rows - 1), children: [_jsxs(Box, { children: [topSpans.map((cell, i) => {
853
+ const isTab = cell.id.startsWith('agent:');
854
+ const agent = cell.id.slice('agent:'.length);
855
+ const on = agent === 'all' ? agentsOff.size === 0 : !agentsOff.has(agent);
856
+ const separator = i > 0 ? ' ' : '';
857
+ if (cell.id === 'brand') {
858
+ return (_jsx(Text, { bold: true, backgroundColor: THEME.yellow, color: THEME.onYellow, children: cell.text }, cell.id));
859
+ }
860
+ return (_jsx(Text, { ...(isTab && on
861
+ ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true }
862
+ : { color: THEME.muted }), children: separator + cell.text }, `${cell.id}-${i}`));
863
+ }), pad > 0 && _jsx(Text, { color: THEME.muted, children: ' '.repeat(pad) }), right.length > 0 && (_jsx(Text, { color: THEME.muted, children: ` ${right.slice(0, Math.max(0, columns - leftWidth - pad - 1))}` }))] }), modal ? (modalBox()) : (_jsxs(Box, { height: bodyHeight, children: [menu, content] })), _jsx(Box, { children: bar.cells.map((cell, i) => {
864
+ const isLabel = cell.id === 'navlabel' || cell.id === 'actlabel';
865
+ return (_jsx(Text, { ...(isLabel
866
+ ? { backgroundColor: THEME.yellow, color: THEME.onYellow, bold: true }
867
+ : { color: THEME.muted }), children: (i > 0 && !isLabel ? '' : '') + cell.text }, `${cell.id}-${i}`));
868
+ }) }), status.length > 0 && _jsx(Text, { color: THEME.yellowInk, children: status })] }));
706
869
  }
707
870
  //# sourceMappingURL=App.js.map