bytecut 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1691 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ audit
4
+ } from "./chunk-4PWCQ4NF.js";
5
+ import {
6
+ formatSummary,
7
+ optimize
8
+ } from "./chunk-P5PFI3AO.js";
9
+ import {
10
+ buildSnippet,
11
+ originalUrl,
12
+ snippetFormatFor
13
+ } from "./chunk-WZ6MMRNB.js";
14
+ import "./chunk-3DBJKHOD.js";
15
+ import {
16
+ startCompareServer
17
+ } from "./chunk-7DHE25UE.js";
18
+ import {
19
+ MANIFEST_FILE,
20
+ parseManifest
21
+ } from "./chunk-TCQFQ354.js";
22
+ import "./chunk-XUBQWBWH.js";
23
+ import {
24
+ toPosix
25
+ } from "./chunk-QOG2F4SF.js";
26
+ import {
27
+ resolveConfig,
28
+ resolveConfigData
29
+ } from "./chunk-3PF6BBFK.js";
30
+ import {
31
+ applyChoices,
32
+ canRewrite,
33
+ isEmpty,
34
+ renderChoicesSnippet,
35
+ renderConfig
36
+ } from "./chunk-4FIB6VNC.js";
37
+ import {
38
+ SafeFs,
39
+ writeConfigFile
40
+ } from "./chunk-IZ4JEL3Q.js";
41
+ import {
42
+ ALL_RULES,
43
+ BytecutError,
44
+ formatBytes,
45
+ loadConfig,
46
+ parseConfig,
47
+ percentChange,
48
+ plural
49
+ } from "./chunk-FY53B4NB.js";
50
+
51
+ // src/tui/index.tsx
52
+ import { render } from "ink";
53
+
54
+ // src/tui/app.tsx
55
+ import { Box as Box5, Text as Text5, useApp, useInput, useStdout } from "ink";
56
+ import {
57
+ useCallback,
58
+ useEffect,
59
+ useMemo,
60
+ useReducer,
61
+ useRef,
62
+ useState
63
+ } from "react";
64
+
65
+ // src/tui/commands.ts
66
+ var COMMANDS = [
67
+ { name: "audit", usage: "/audit", description: "Rescan the project" },
68
+ {
69
+ name: "optimize",
70
+ usage: "/optimize [@file\u2026]",
71
+ description: "Review the plan for the selection (or the mentioned files)"
72
+ },
73
+ {
74
+ name: "preset",
75
+ usage: "/preset <photo|hero|icon> [@file\u2026]",
76
+ description: "Set the preset for the highlighted (or mentioned) files"
77
+ },
78
+ {
79
+ name: "snippet",
80
+ usage: "/snippet @file",
81
+ description: "Copy the <picture>/<video> markup for an optimized file"
82
+ },
83
+ {
84
+ name: "compare",
85
+ usage: "/compare @file",
86
+ description: "Serve a before/after page for an optimized file on localhost"
87
+ },
88
+ {
89
+ name: "save",
90
+ usage: "/save",
91
+ description: "Keep this session's preset and folder choices in the project config"
92
+ },
93
+ { name: "out", usage: "/out <dir>", description: "Change the output folder" },
94
+ { name: "select", usage: "/select <all|none|warn>", description: "Change the selection" },
95
+ { name: "doctor", usage: "/doctor", description: "Show Node, sharp and ffmpeg status" },
96
+ { name: "help", usage: "/help", description: "Show keys and commands" },
97
+ { name: "quit", usage: "/quit", description: "Leave bytecut" }
98
+ ];
99
+ function parseCommand(text) {
100
+ const trimmed = text.trim();
101
+ if (!trimmed.startsWith("/")) {
102
+ return { ok: false, error: "Commands start with / (try /help)" };
103
+ }
104
+ const tokens = trimmed.slice(1).split(/\s+/).filter(Boolean);
105
+ const name = tokens.shift()?.toLowerCase() ?? "";
106
+ if (!name) return { ok: false, error: "Type a command after / (try /help)" };
107
+ if (!COMMANDS.some((c) => c.name === name)) {
108
+ return { ok: false, error: `Unknown command /${name} (try /help)` };
109
+ }
110
+ const args = [];
111
+ const mentions = [];
112
+ for (const token of tokens) {
113
+ if (token.startsWith("@") && token.length > 1) mentions.push(token.slice(1));
114
+ else args.push(token);
115
+ }
116
+ return { ok: true, name, args, mentions };
117
+ }
118
+ function suggestCommands(text) {
119
+ const m = /^\/(\S*)$/.exec(text.trimStart());
120
+ if (!m) return [];
121
+ const prefix = m[1].toLowerCase();
122
+ return COMMANDS.filter((c) => c.name.startsWith(prefix));
123
+ }
124
+ function activeMention(text) {
125
+ const m = /(?:^|\s)@(\S*)$/.exec(text);
126
+ return m ? m[1] : null;
127
+ }
128
+ function subsequenceScore(q, text) {
129
+ let score = 0;
130
+ let from = 0;
131
+ let previous = -2;
132
+ for (const ch of q) {
133
+ const at = text.indexOf(ch, from);
134
+ if (at === -1) return null;
135
+ score += 1;
136
+ if (at === previous + 1) score += 3;
137
+ if (at === 0 || "/-_. ".includes(text[at - 1])) score += 2;
138
+ previous = at;
139
+ from = at + 1;
140
+ }
141
+ return score;
142
+ }
143
+ function fuzzyScore(query, path) {
144
+ if (query === "") return 0;
145
+ const q = query.toLowerCase();
146
+ const p = path.toLowerCase();
147
+ const name = p.slice(p.lastIndexOf("/") + 1);
148
+ const inName = subsequenceScore(q, name);
149
+ const inPath = subsequenceScore(q, p);
150
+ if (inName === null && inPath === null) return null;
151
+ let score = inName !== null ? inName + 8 : inPath ?? 0;
152
+ if (inName !== null && inPath !== null) score = Math.max(score, inPath);
153
+ if (name.includes(q)) score += 10;
154
+ if (name.startsWith(q)) score += 4;
155
+ return score * 100 - path.length;
156
+ }
157
+ function fuzzyFiles(query, files, limit = 8) {
158
+ return files.map((file) => ({ file, score: fuzzyScore(query, file) })).filter((x) => x.score !== null).sort((a, b) => b.score - a.score || (a.file < b.file ? -1 : 1)).slice(0, limit).map((x) => x.file);
159
+ }
160
+ function completeMention(text, file) {
161
+ return text.replace(/(^|\s)@\S*$/, `$1@${file} `);
162
+ }
163
+
164
+ // src/tui/clipboard.ts
165
+ import { spawn } from "child_process";
166
+ function candidates(platform, env) {
167
+ switch (platform) {
168
+ case "darwin":
169
+ return [{ method: "pbcopy", file: "pbcopy", args: [] }];
170
+ case "win32":
171
+ return [{ method: "clip", file: "clip", args: [] }];
172
+ default: {
173
+ const list = [];
174
+ if (env.WAYLAND_DISPLAY) list.push({ method: "wl-copy", file: "wl-copy", args: [] });
175
+ list.push({ method: "xclip", file: "xclip", args: ["-selection", "clipboard"] });
176
+ list.push({ method: "xsel", file: "xsel", args: ["--clipboard", "--input"] });
177
+ if (!env.WAYLAND_DISPLAY) list.push({ method: "wl-copy", file: "wl-copy", args: [] });
178
+ return list;
179
+ }
180
+ }
181
+ }
182
+ function osc52(text) {
183
+ return `\x1B]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
184
+ }
185
+ var OSC52_MAX_BYTES = 1e5;
186
+ async function copyToClipboard(text, deps) {
187
+ for (const c of candidates(deps.platform, deps.env)) {
188
+ if (await deps.run(c.file, c.args, text)) return { ok: true, method: c.method };
189
+ }
190
+ if (deps.writeTerminal && Buffer.byteLength(text) <= OSC52_MAX_BYTES) {
191
+ deps.writeTerminal(osc52(text));
192
+ return { ok: true, method: "osc52" };
193
+ }
194
+ return { ok: false };
195
+ }
196
+ function systemClipboardDeps() {
197
+ return {
198
+ platform: process.platform,
199
+ env: process.env,
200
+ run: (file, args, input) => new Promise((resolve2) => {
201
+ let settled = false;
202
+ const done = (value) => {
203
+ if (!settled) {
204
+ settled = true;
205
+ resolve2(value);
206
+ }
207
+ };
208
+ try {
209
+ const child = spawn(file, args, { stdio: ["pipe", "ignore", "ignore"] });
210
+ const timer = setTimeout(() => {
211
+ child.kill();
212
+ done(false);
213
+ }, 2e3);
214
+ child.on("error", () => {
215
+ clearTimeout(timer);
216
+ done(false);
217
+ });
218
+ child.on("close", (code) => {
219
+ clearTimeout(timer);
220
+ done(code === 0);
221
+ });
222
+ child.stdin.on("error", () => done(false));
223
+ child.stdin.end(input);
224
+ } catch {
225
+ done(false);
226
+ }
227
+ }),
228
+ writeTerminal: process.stdout.isTTY ? (data) => void process.stdout.write(data) : null
229
+ };
230
+ }
231
+
232
+ // src/tui/components/commandbar.tsx
233
+ import { Box, Text } from "ink";
234
+ import { jsx, jsxs } from "react/jsx-runtime";
235
+ function computeSuggestions(input, files) {
236
+ const mention = activeMention(input);
237
+ if (mention !== null) {
238
+ return { kind: "file", items: fuzzyFiles(mention, files).map((value) => ({ value })) };
239
+ }
240
+ const commands = suggestCommands(input);
241
+ if (commands.length > 0) {
242
+ return {
243
+ kind: "command",
244
+ items: commands.map((c) => ({ value: c.name, hint: c.description }))
245
+ };
246
+ }
247
+ return { kind: "none", items: [] };
248
+ }
249
+ function CommandBar(props) {
250
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
251
+ /* @__PURE__ */ jsxs(Box, { children: [
252
+ /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
253
+ props.prompt,
254
+ " "
255
+ ] }),
256
+ /* @__PURE__ */ jsx(Text, { children: props.input }),
257
+ /* @__PURE__ */ jsx(Text, { inverse: true, children: " " })
258
+ ] }),
259
+ props.hint ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: props.hint }) : null,
260
+ props.suggestions.items.map((item, i) => /* @__PURE__ */ jsxs(
261
+ Text,
262
+ {
263
+ color: i === props.selected ? "cyan" : void 0,
264
+ wrap: "truncate-end",
265
+ children: [
266
+ i === props.selected ? "\u203A " : " ",
267
+ props.suggestions.kind === "command" ? "/" : "@",
268
+ item.value,
269
+ item.hint ? /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
270
+ " \u2014 ",
271
+ item.hint
272
+ ] }) : null
273
+ ]
274
+ },
275
+ item.value
276
+ ))
277
+ ] });
278
+ }
279
+
280
+ // src/tui/components/layout.tsx
281
+ import { Box as Box2, Text as Text2 } from "ink";
282
+
283
+ // src/tui/model.ts
284
+ var FIX_BY_RULE = new Map(ALL_RULES.map((r) => [r.id, r.fix]));
285
+ var RANK = { error: 0, warn: 1, info: 2 };
286
+ function buildModel(report) {
287
+ const files = new Map(report.files.map((f) => [f.source, f]));
288
+ const fixable = /* @__PURE__ */ new Map();
289
+ const reportOnly = [];
290
+ for (const finding of report.findings) {
291
+ const file = finding.source ? files.get(finding.source) : void 0;
292
+ const canFix = FIX_BY_RULE.get(finding.ruleId) === "optimize" && file && !file.skipped;
293
+ if (canFix && finding.source) {
294
+ fixable.set(finding.source, [...fixable.get(finding.source) ?? [], finding]);
295
+ } else {
296
+ reportOnly.push(finding);
297
+ }
298
+ }
299
+ const rows = [];
300
+ for (const [source, findings] of fixable) {
301
+ const file = files.get(source);
302
+ if (file.covered) continue;
303
+ const severity = findings.reduce(
304
+ (best, f) => RANK[f.severity] < RANK[best] ? f.severity : best,
305
+ "info"
306
+ );
307
+ rows.push({
308
+ source,
309
+ bytes: file.bytes,
310
+ width: file.width,
311
+ height: file.height,
312
+ preset: file.preset,
313
+ reason: file.reason,
314
+ estimatedBytes: file.estimatedBytes,
315
+ savingsBytes: file.savingsBytes,
316
+ plannedOutputs: file.plannedOutputs,
317
+ findings,
318
+ severity
319
+ });
320
+ }
321
+ rows.sort(
322
+ (a, b) => (b.savingsBytes ?? 0) - (a.savingsBytes ?? 0) || (a.source < b.source ? -1 : 1)
323
+ );
324
+ return {
325
+ rows,
326
+ reportOnly,
327
+ covered: report.files.filter((f) => f.covered).length,
328
+ skipped: report.files.filter((f) => f.skipped).map((f) => ({ source: f.source, reason: f.skipped })),
329
+ notes: report.notes
330
+ };
331
+ }
332
+ function defaultSelection(rows) {
333
+ return new Set(rows.filter((r) => r.severity !== "info").map((r) => r.source));
334
+ }
335
+ function selectionTotals(rows, selected) {
336
+ let bytes = 0;
337
+ let savings = 0;
338
+ let count = 0;
339
+ for (const row of rows) {
340
+ if (!selected.has(row.source)) continue;
341
+ count++;
342
+ bytes += row.bytes;
343
+ savings += row.savingsBytes ?? 0;
344
+ }
345
+ return { count, bytes, savings };
346
+ }
347
+
348
+ // src/tui/components/layout.tsx
349
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
350
+ function Header(props) {
351
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginBottom: 1, children: /* @__PURE__ */ jsxs2(Text2, { children: [
352
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "bytecut" }),
353
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \xB7 " }),
354
+ /* @__PURE__ */ jsx2(Text2, { children: props.framework }),
355
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \xB7 scanning " }),
356
+ /* @__PURE__ */ jsx2(Text2, { children: props.scanning }),
357
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \u2192 " }),
358
+ /* @__PURE__ */ jsx2(Text2, { children: props.outDir })
359
+ ] }) });
360
+ }
361
+ var HINTS = {
362
+ list: "\u2191\u2193 move \xB7 space select \xB7 p preset \xB7 d details \xB7 o out dir \xB7 enter review \xB7 / cmds \xB7 ? help",
363
+ review: "enter run \xB7 esc back",
364
+ running: "esc cancel",
365
+ done: "\u2191\u2193 select \xB7 c copy markup \xB7 s save choices \xB7 enter/esc back \xB7 q quit",
366
+ scanning: "q quit",
367
+ error: "q quit"
368
+ };
369
+ function StatusLine(props) {
370
+ const { state, model } = props;
371
+ let left = "";
372
+ if (state.view === "list") {
373
+ const t = selectionTotals(model.rows, state.selected);
374
+ left = `${t.count} of ${plural(model.rows.length, "file")} selected`;
375
+ if (t.savings > 0) left += ` \xB7 est. save ~${formatBytes(t.savings)}`;
376
+ if (model.covered > 0) left += ` \xB7 ${model.covered} already optimized`;
377
+ }
378
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: 1, children: [
379
+ state.status ? /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: state.status }) : null,
380
+ left ? /* @__PURE__ */ jsx2(Text2, { children: left }) : null,
381
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: HINTS[state.view] })
382
+ ] });
383
+ }
384
+ function Help() {
385
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
386
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Keys" }),
387
+ /* @__PURE__ */ jsx2(Text2, { children: "\u2191/\u2193 or k/j move \xB7 space toggle \xB7 a select all/none" }),
388
+ /* @__PURE__ */ jsx2(Text2, { children: "enter review the plan, then run it \xB7 esc go back / cancel a run" }),
389
+ /* @__PURE__ */ jsx2(Text2, { children: "p cycle preset for the highlighted file \xB7 o change output folder" }),
390
+ /* @__PURE__ */ jsx2(Text2, { children: "d details \xB7 / command bar \xB7 @ jump to a file \xB7 ? this help \xB7 q quit" }),
391
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Commands" }),
392
+ /* @__PURE__ */ jsxs2(Text2, { children: [
393
+ "/audit /optimize [@file\u2026] /preset ",
394
+ "<name>",
395
+ " [@file\u2026] /out ",
396
+ "<dir>"
397
+ ] }),
398
+ /* @__PURE__ */ jsx2(Text2, { children: "/select all|none|warn /snippet @file /compare @file /save /doctor /help /quit" }),
399
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "esc closes this" })
400
+ ] });
401
+ }
402
+
403
+ // src/tui/components/list.tsx
404
+ import { Box as Box3, Text as Text3 } from "ink";
405
+
406
+ // src/tui/state.ts
407
+ var PRESET_CYCLE = ["photo", "hero", "icon"];
408
+ var EMPTY_MODEL = { rows: [], reportOnly: [], covered: 0, skipped: [], notes: [] };
409
+ function initialState(outDir, presetNames = PRESET_CYCLE) {
410
+ return {
411
+ view: "scanning",
412
+ presetNames: [...presetNames],
413
+ baseline: { presets: {}, outDir },
414
+ save: null,
415
+ snippet: null,
416
+ doneCursor: 0,
417
+ scanId: 0,
418
+ overlay: null,
419
+ scan: null,
420
+ model: EMPTY_MODEL,
421
+ files: [],
422
+ selected: /* @__PURE__ */ new Set(),
423
+ cursor: 0,
424
+ presets: {},
425
+ outDir,
426
+ input: "",
427
+ suggestion: 0,
428
+ review: null,
429
+ run: null,
430
+ result: null,
431
+ cancelling: false,
432
+ status: null,
433
+ error: null
434
+ };
435
+ }
436
+ function unsavedChoices(state) {
437
+ const presets = Object.fromEntries(
438
+ Object.entries(state.presets).filter(
439
+ ([source, preset]) => state.baseline.presets[source] !== preset
440
+ )
441
+ );
442
+ return {
443
+ ...state.outDir !== state.baseline.outDir ? { outDir: state.outDir } : {},
444
+ ...Object.keys(presets).length > 0 ? { presets } : {}
445
+ };
446
+ }
447
+ var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
448
+ function rowAt(state) {
449
+ return state.model.rows[state.cursor];
450
+ }
451
+ function effectivePreset(state, row) {
452
+ return state.presets[row.source] ?? row.preset;
453
+ }
454
+ function reducer(state, action) {
455
+ switch (action.type) {
456
+ case "scan-progress":
457
+ return { ...state, scan: { phase: action.phase, done: action.done, total: action.total } };
458
+ case "scan-done":
459
+ return {
460
+ ...state,
461
+ view: "list",
462
+ overlay: null,
463
+ scan: null,
464
+ model: action.model,
465
+ files: action.files,
466
+ selected: defaultSelection(action.model.rows),
467
+ cursor: 0,
468
+ review: null,
469
+ result: null,
470
+ run: null,
471
+ cancelling: false,
472
+ status: null
473
+ };
474
+ case "fatal":
475
+ return { ...state, view: "error", error: action.error };
476
+ case "move": {
477
+ const last = Math.max(0, state.model.rows.length - 1);
478
+ return { ...state, cursor: clamp(state.cursor + action.delta, 0, last) };
479
+ }
480
+ case "toggle": {
481
+ const row = rowAt(state);
482
+ if (!row) return state;
483
+ const selected = new Set(state.selected);
484
+ if (selected.has(row.source)) selected.delete(row.source);
485
+ else selected.add(row.source);
486
+ return { ...state, selected };
487
+ }
488
+ case "toggle-all": {
489
+ const all = state.model.rows.every((r) => state.selected.has(r.source));
490
+ return {
491
+ ...state,
492
+ selected: all ? /* @__PURE__ */ new Set() : new Set(state.model.rows.map((r) => r.source))
493
+ };
494
+ }
495
+ case "select": {
496
+ const rows = state.model.rows;
497
+ const sources = action.mode === "all" ? rows.map((r) => r.source) : action.mode === "none" ? [] : rows.filter((r) => r.severity !== "info").map((r) => r.source);
498
+ return { ...state, selected: new Set(sources) };
499
+ }
500
+ case "select-files": {
501
+ const known = new Set(state.model.rows.map((r) => r.source));
502
+ return { ...state, selected: new Set(action.sources.filter((s) => known.has(s))) };
503
+ }
504
+ case "cursor-to": {
505
+ const at = state.model.rows.findIndex((r) => r.source === action.source);
506
+ return at === -1 ? state : { ...state, cursor: at };
507
+ }
508
+ case "cycle-preset": {
509
+ const targets = action.sources ?? (rowAt(state) ? [rowAt(state).source] : []);
510
+ if (targets.length === 0) return state;
511
+ const presets = { ...state.presets };
512
+ for (const source of targets) {
513
+ const row = state.model.rows.find((r) => r.source === source);
514
+ if (!row) continue;
515
+ const current = presets[source] ?? row.preset;
516
+ const names = state.presetNames;
517
+ const next = action.to ?? names[(names.indexOf(current) + 1) % names.length];
518
+ if (next === row.preset) delete presets[source];
519
+ else presets[source] = next;
520
+ }
521
+ return { ...state, presets };
522
+ }
523
+ case "overlay":
524
+ return {
525
+ ...state,
526
+ overlay: action.overlay,
527
+ input: action.input ?? "",
528
+ suggestion: 0,
529
+ status: null
530
+ };
531
+ case "input":
532
+ return { ...state, input: action.text, suggestion: 0 };
533
+ case "suggest":
534
+ return {
535
+ ...state,
536
+ suggestion: clamp(state.suggestion + action.delta, 0, Math.max(0, action.max - 1))
537
+ };
538
+ case "set-out-dir":
539
+ return { ...state, outDir: action.outDir, overlay: null, input: "", review: null };
540
+ case "review-ready":
541
+ return { ...state, view: "review", overlay: null, review: action.review, status: null };
542
+ case "run-start":
543
+ return {
544
+ ...state,
545
+ view: "running",
546
+ overlay: null,
547
+ cancelling: false,
548
+ run: { total: action.total, done: 0, current: null, log: [], progress: {} }
549
+ };
550
+ case "run-item-start":
551
+ return state.run ? { ...state, run: { ...state.run, current: action.source } } : state;
552
+ case "run-output":
553
+ return state.run ? {
554
+ ...state,
555
+ run: {
556
+ ...state.run,
557
+ log: [...state.run.log, { source: action.source, output: action.output }]
558
+ }
559
+ } : state;
560
+ case "run-progress":
561
+ return state.run ? {
562
+ ...state,
563
+ run: {
564
+ ...state.run,
565
+ progress: { ...state.run.progress, [action.path]: action.fraction }
566
+ }
567
+ } : state;
568
+ case "run-item-done":
569
+ return state.run ? { ...state, run: { ...state.run, done: state.run.done + 1 } } : state;
570
+ case "run-done":
571
+ return { ...state, view: "done", result: action.result, cancelling: false, doneCursor: 0 };
572
+ case "save-open":
573
+ return {
574
+ ...state,
575
+ overlay: "save",
576
+ save: { choices: action.choices, outcome: null },
577
+ input: "",
578
+ status: null
579
+ };
580
+ case "save-result": {
581
+ if (!state.save) return state;
582
+ const saved = action.outcome.kind === "written";
583
+ return {
584
+ ...state,
585
+ save: { ...state.save, outcome: action.outcome },
586
+ // Once written, the config holds these, so they are no longer news.
587
+ baseline: saved ? {
588
+ presets: { ...state.baseline.presets, ...state.save.choices.presets },
589
+ outDir: state.outDir
590
+ } : state.baseline
591
+ };
592
+ }
593
+ case "snippet":
594
+ return { ...state, overlay: "snippet", snippet: action.snippet, input: "", status: null };
595
+ case "snippet-copied":
596
+ return state.snippet ? { ...state, snippet: { ...state.snippet, copied: action.copied } } : state;
597
+ case "done-move":
598
+ return {
599
+ ...state,
600
+ doneCursor: Math.max(0, Math.min(action.max, state.doneCursor + action.delta))
601
+ };
602
+ case "rescan":
603
+ return { ...state, view: "scanning", scanId: state.scanId + 1, scan: null, overlay: null };
604
+ case "cancel":
605
+ return state.view === "running" ? { ...state, cancelling: true } : state;
606
+ case "back": {
607
+ if (state.overlay) return { ...state, overlay: null, input: "", status: null };
608
+ switch (state.view) {
609
+ case "review":
610
+ return { ...state, view: "list", review: null };
611
+ case "running":
612
+ return { ...state, cancelling: true };
613
+ case "done":
614
+ return {
615
+ ...state,
616
+ view: "scanning",
617
+ scanId: state.scanId + 1,
618
+ result: null,
619
+ run: null,
620
+ scan: null
621
+ };
622
+ default:
623
+ return state;
624
+ }
625
+ }
626
+ case "status":
627
+ return { ...state, status: action.message };
628
+ }
629
+ }
630
+
631
+ // src/tui/components/list.tsx
632
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
633
+ var SEVERITY_COLOR = { error: "red", warn: "yellow", info: "gray" };
634
+ function windowStart(cursor, total, height) {
635
+ if (total <= height) return 0;
636
+ return Math.max(0, Math.min(cursor - Math.floor(height / 2), total - height));
637
+ }
638
+ var BADGE_WIDTH = 26;
639
+ function badges(row) {
640
+ const names = [...new Set(row.findings.map((f) => f.ruleId.split("/")[1] ?? f.ruleId))];
641
+ const text = names.length > 1 ? `${names[0]} +${names.length - 1}` : names[0] ?? "";
642
+ return text.length > BADGE_WIDTH ? `${text.slice(0, BADGE_WIDTH - 1)}\u2026` : text;
643
+ }
644
+ function FileList(props) {
645
+ const { state, height, width } = props;
646
+ const rows = state.model.rows;
647
+ if (rows.length === 0) {
648
+ return /* @__PURE__ */ jsxs3(Text3, { color: "green", children: [
649
+ "Nothing to optimize",
650
+ state.model.covered > 0 ? ` (${state.model.covered} already optimized)` : "",
651
+ "."
652
+ ] });
653
+ }
654
+ const start = windowStart(state.cursor, rows.length, height);
655
+ const visible = rows.slice(start, start + height);
656
+ const sizeText = (row) => row.estimatedBytes !== null ? `${formatBytes(row.bytes)} \u2192 ~${formatBytes(row.estimatedBytes)} (${percentChange(row.bytes, row.estimatedBytes)})` : formatBytes(row.bytes);
657
+ const sizeWidth = Math.max(...rows.map((r) => sizeText(r).length));
658
+ const presetWidth = Math.max(...rows.map((r) => effectivePreset(state, r).length + 1));
659
+ const fixed = 2 + 4 + 1 + sizeWidth + 1 + 1 + presetWidth + 1 + BADGE_WIDTH;
660
+ const pathWidth = Math.max(12, width - 1 - fixed);
661
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
662
+ start > 0 ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
663
+ " \u2191 ",
664
+ start,
665
+ " more"
666
+ ] }) : null,
667
+ visible.map((row, i) => {
668
+ const index = start + i;
669
+ const here = index === state.cursor;
670
+ const on = state.selected.has(row.source);
671
+ const preset = effectivePreset(state, row);
672
+ const overridden = state.presets[row.source] !== void 0;
673
+ const size = sizeText(row).padStart(sizeWidth);
674
+ const presetText = `${preset}${overridden ? "*" : ""}`.padEnd(presetWidth);
675
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
676
+ /* @__PURE__ */ jsxs3(Text3, { color: here ? "cyan" : void 0, children: [
677
+ here ? "\u203A" : " ",
678
+ " "
679
+ ] }),
680
+ /* @__PURE__ */ jsxs3(Text3, { color: on ? "green" : "gray", children: [
681
+ on ? "[x]" : "[ ]",
682
+ " "
683
+ ] }),
684
+ /* @__PURE__ */ jsx3(Box3, { width: pathWidth, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { bold: here, wrap: "truncate-middle", children: row.source }) }),
685
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
686
+ " ",
687
+ size,
688
+ " "
689
+ ] }),
690
+ /* @__PURE__ */ jsxs3(Text3, { color: overridden ? "magenta" : "blue", children: [
691
+ " ",
692
+ presetText
693
+ ] }),
694
+ /* @__PURE__ */ jsxs3(Text3, { color: SEVERITY_COLOR[row.severity], children: [
695
+ " ",
696
+ badges(row)
697
+ ] })
698
+ ] }, row.source);
699
+ }),
700
+ start + height < rows.length ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
701
+ " \u2193 ",
702
+ rows.length - start - height,
703
+ " more"
704
+ ] }) : null
705
+ ] });
706
+ }
707
+ function ReportOnly(props) {
708
+ const { findings, skipped } = props;
709
+ if (findings.length === 0 && skipped.length === 0) return null;
710
+ const byRule = /* @__PURE__ */ new Map();
711
+ for (const f of findings) byRule.set(f.ruleId, (byRule.get(f.ruleId) ?? 0) + 1);
712
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, children: [
713
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Report only (optimize can't fix these yet):" }),
714
+ [...byRule.entries()].map(([rule, count]) => /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
715
+ " ",
716
+ rule,
717
+ " \xD7",
718
+ count
719
+ ] }, rule)),
720
+ skipped.map((s) => /* @__PURE__ */ jsxs3(Text3, { dimColor: true, wrap: "truncate-middle", children: [
721
+ " ",
722
+ "skipped ",
723
+ s.source,
724
+ ": ",
725
+ s.reason
726
+ ] }, s.source))
727
+ ] });
728
+ }
729
+ function Details(props) {
730
+ const { state, row } = props;
731
+ if (!row) return null;
732
+ const preset = effectivePreset(state, row);
733
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
734
+ /* @__PURE__ */ jsx3(Text3, { bold: true, wrap: "truncate-middle", children: row.source }),
735
+ /* @__PURE__ */ jsxs3(Text3, { children: [
736
+ formatBytes(row.bytes),
737
+ " \xB7 ",
738
+ row.width,
739
+ "x",
740
+ row.height,
741
+ " \xB7 preset ",
742
+ preset,
743
+ state.presets[row.source] ? " (your override)" : ""
744
+ ] }),
745
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
746
+ "classified: ",
747
+ row.reason
748
+ ] }),
749
+ row.estimatedBytes !== null ? /* @__PURE__ */ jsxs3(Text3, { children: [
750
+ "estimate: ~",
751
+ formatBytes(row.estimatedBytes),
752
+ " at the largest width (",
753
+ row.plannedOutputs,
754
+ " ",
755
+ "outputs planned)"
756
+ ] }) : null,
757
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Findings" }),
758
+ row.findings.map((f) => /* @__PURE__ */ jsxs3(Text3, { children: [
759
+ /* @__PURE__ */ jsx3(Text3, { color: SEVERITY_COLOR[f.severity], children: f.severity.padEnd(5) }),
760
+ " ",
761
+ f.ruleId,
762
+ ":",
763
+ " ",
764
+ f.message
765
+ ] }, `${f.ruleId}${f.message}`)),
766
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "esc closes this" })
767
+ ] });
768
+ }
769
+
770
+ // src/tui/components/run.tsx
771
+ import { Box as Box4, Text as Text4 } from "ink";
772
+ import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
773
+ var BAR_WIDTH = 30;
774
+ function bar(done, total) {
775
+ const filled = total === 0 ? 0 : Math.round(done / total * BAR_WIDTH);
776
+ return "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
777
+ }
778
+ var STATUS_COLOR = {
779
+ written: "green",
780
+ unchanged: "gray",
781
+ "already-optimal": "gray",
782
+ protected: "yellow",
783
+ unavailable: "yellow",
784
+ failed: "red",
785
+ unmet: "red",
786
+ cancelled: "yellow"
787
+ };
788
+ function ReviewView(props) {
789
+ const { review } = props;
790
+ const LIMIT = 12;
791
+ const shown = review.items.slice(0, LIMIT);
792
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
793
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
794
+ "Review: ",
795
+ plural(review.willEncode, "image"),
796
+ " \u2192 ",
797
+ plural(review.outputs, "output"),
798
+ " in",
799
+ " ",
800
+ props.outDir
801
+ ] }),
802
+ review.unchanged > 0 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
803
+ review.unchanged,
804
+ " already up to date (will be skipped)"
805
+ ] }) : null,
806
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginTop: 1, children: [
807
+ shown.map((item) => /* @__PURE__ */ jsxs4(Box4, { children: [
808
+ /* @__PURE__ */ jsx4(Box4, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx4(Text4, { wrap: "truncate-middle", dimColor: item.state !== "encode", children: item.source }) }),
809
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
810
+ " ",
811
+ item.state === "encode" ? `${item.preset} \xB7 ${plural(item.outputs, "output")}` : item.state === "unchanged" ? "unchanged" : `skipped: ${item.note}`
812
+ ] })
813
+ ] }, item.source)),
814
+ review.items.length > LIMIT ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
815
+ "\u2026 and ",
816
+ review.items.length - LIMIT,
817
+ " more"
818
+ ] }) : null
819
+ ] }),
820
+ review.findings.length > 0 ? /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginTop: 1, children: [
821
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Heads up:" }),
822
+ review.findings.slice(0, 4).map((f) => /* @__PURE__ */ jsxs4(Text4, { color: "yellow", wrap: "truncate-end", children: [
823
+ " ",
824
+ f.source,
825
+ ": ",
826
+ f.message
827
+ ] }, `${f.source}${f.path}${f.message}`))
828
+ ] }) : null,
829
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "enter to write these files \xB7 esc to go back" })
830
+ ] });
831
+ }
832
+ function RunView(props) {
833
+ const run = props.state.run;
834
+ if (!run) return null;
835
+ const tail = run.log.slice(-8);
836
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
837
+ /* @__PURE__ */ jsxs4(Text4, { children: [
838
+ bar(run.done, run.total),
839
+ " ",
840
+ run.done,
841
+ "/",
842
+ run.total
843
+ ] }),
844
+ run.current ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, wrap: "truncate-middle", children: [
845
+ props.state.cancelling ? "finishing " : "optimizing ",
846
+ run.current
847
+ ] }) : null,
848
+ props.state.cancelling ? /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Cancelling: finishing the current encode so no partial file is left behind\u2026" }) : null,
849
+ Object.entries(run.progress).filter(([, f]) => f > 0 && f < 1).slice(-2).map(([path, f]) => /* @__PURE__ */ jsxs4(Text4, { dimColor: true, wrap: "truncate-end", children: [
850
+ path,
851
+ " ",
852
+ Math.round(f * 100),
853
+ "%"
854
+ ] }, path)),
855
+ /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", marginTop: 1, children: tail.map(({ source, output }, i) => /* @__PURE__ */ jsxs4(
856
+ Text4,
857
+ {
858
+ color: STATUS_COLOR[output.status],
859
+ wrap: "truncate-end",
860
+ children: [
861
+ output.status === "written" ? "\u2713" : output.status === "failed" ? "\u2717" : "\xB7",
862
+ " ",
863
+ output.path,
864
+ " ",
865
+ output.bytes > 0 ? formatBytes(output.bytes) : "",
866
+ " ",
867
+ output.status
868
+ ]
869
+ },
870
+ `${source}${output.path}${i}`
871
+ )) })
872
+ ] });
873
+ }
874
+ function SnippetOverlay(props) {
875
+ const { snippet } = props;
876
+ const lines = snippet.code.split("\n");
877
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
878
+ /* @__PURE__ */ jsx4(Text4, { bold: true, wrap: "truncate-middle", children: snippet.source }),
879
+ lines.map((line, i) => /* @__PURE__ */ jsx4(Text4, { wrap: "truncate-end", children: line }, i)),
880
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginTop: 1, children: [
881
+ snippet.copied ? /* @__PURE__ */ jsxs4(Text4, { color: "green", children: [
882
+ "Copied to the clipboard (",
883
+ snippet.copied,
884
+ ")."
885
+ ] }) : /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Could not reach the clipboard: select the text above to copy it." }),
886
+ snippet.hints.map((h) => /* @__PURE__ */ jsxs4(Text4, { dimColor: true, wrap: "truncate-end", children: [
887
+ "Note: ",
888
+ h
889
+ ] }, h)),
890
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "c copy again \xB7 esc close" })
891
+ ] })
892
+ ] });
893
+ }
894
+ function SaveOverlay(props) {
895
+ const { save } = props;
896
+ const { choices, outcome } = save;
897
+ const entries = Object.entries(choices.presets ?? {});
898
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
899
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Save these choices to the project config?" }),
900
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginTop: 1, children: [
901
+ entries.slice(0, 10).map(([source, preset]) => /* @__PURE__ */ jsxs4(Text4, { wrap: "truncate-middle", children: [
902
+ source,
903
+ " \u2192 ",
904
+ preset
905
+ ] }, source)),
906
+ entries.length > 10 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
907
+ "\u2026 and ",
908
+ entries.length - 10,
909
+ " more"
910
+ ] }) : null,
911
+ choices.outDir !== void 0 ? /* @__PURE__ */ jsxs4(Text4, { children: [
912
+ "output folder \u2192 ",
913
+ choices.outDir
914
+ ] }) : null
915
+ ] }),
916
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginTop: 1, children: [
917
+ outcome === null ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "y save \xB7 n not now" }) : outcome.kind === "written" ? /* @__PURE__ */ jsxs4(Text4, { color: "green", children: [
918
+ outcome.created ? "Created" : "Updated",
919
+ " ",
920
+ outcome.file,
921
+ ". Later runs use it."
922
+ ] }) : outcome.kind === "manual" ? /* @__PURE__ */ jsxs4(Fragment, { children: [
923
+ /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
924
+ outcome.file,
925
+ " is a file you maintain, so it was not rewritten. Add:"
926
+ ] }),
927
+ outcome.snippet.split("\n").map((line, i) => /* @__PURE__ */ jsx4(Text4, { children: line }, i))
928
+ ] }) : outcome.kind === "error" ? /* @__PURE__ */ jsx4(Text4, { color: "red", children: outcome.message }) : /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Nothing to save." }),
929
+ outcome !== null ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "any key closes this" }) : null
930
+ ] })
931
+ ] });
932
+ }
933
+ function DoneView(props) {
934
+ const { result } = props.state;
935
+ if (!result) return null;
936
+ const summary = formatSummary(result).split("\n");
937
+ const split = summary.lastIndexOf("");
938
+ const totals = summary.slice(split + 1);
939
+ const files = summary.slice(0, Math.max(0, split)).filter((l) => !l.startsWith(" "));
940
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
941
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: result.cancelled ? "yellow" : "green", children: result.cancelled ? "Cancelled" : "Done" }),
942
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginTop: 1, children: [
943
+ files.slice(0, 12).map((line, i) => /* @__PURE__ */ jsxs4(
944
+ Text4,
945
+ {
946
+ wrap: "truncate-end",
947
+ color: i === props.state.doneCursor ? "cyan" : void 0,
948
+ children: [
949
+ i === props.state.doneCursor ? "\u203A " : " ",
950
+ line
951
+ ]
952
+ },
953
+ i
954
+ )),
955
+ files.length > 12 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
956
+ "\u2026 and ",
957
+ files.length - 12,
958
+ " more files"
959
+ ] }) : null
960
+ ] }),
961
+ /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", marginTop: 1, children: totals.map((line, i) => /* @__PURE__ */ jsx4(Text4, { children: line }, i)) }),
962
+ result.warnings.map((w) => /* @__PURE__ */ jsxs4(Text4, { color: "yellow", wrap: "truncate-end", children: [
963
+ "warning: ",
964
+ w
965
+ ] }, w))
966
+ ] });
967
+ }
968
+
969
+ // src/tui/session.ts
970
+ import { stat } from "fs/promises";
971
+ import { relative, resolve } from "path";
972
+ import { readFile } from "fs/promises";
973
+ function createSession(options) {
974
+ const { projectRoot, engine } = options;
975
+ const compareServers = [];
976
+ let config = options.config ?? resolveConfigData({}, options.project, null);
977
+ const common = (outDir) => ({
978
+ projectRoot,
979
+ engine,
980
+ toolVersion: options.toolVersion,
981
+ videoEngine: options.videoEngine ?? null,
982
+ ...options.needsFfmpeg ? { needsFfmpeg: options.needsFfmpeg } : {},
983
+ outDir,
984
+ staticRoot: config.project.staticRoot,
985
+ publicPath: config.project.publicPath,
986
+ policy: config.policy,
987
+ exclude: config.exclude,
988
+ ...config.placeholder ? { placeholder: config.placeholder } : {},
989
+ ...options.makeScorer ? { makeScorer: options.makeScorer } : {}
990
+ });
991
+ return {
992
+ async scan(outDir, presets, onProgress) {
993
+ const report = await audit({
994
+ projectRoot,
995
+ engine,
996
+ svgEngine: options.svgEngine,
997
+ inputs: options.inputs,
998
+ outDir,
999
+ staticRoot: config.project.staticRoot,
1000
+ policy: config.policy,
1001
+ exclude: config.exclude,
1002
+ ignore: config.auditIgnore,
1003
+ settings: config.rules,
1004
+ budgets: config.budgets,
1005
+ ffmpeg: options.ffmpeg,
1006
+ videoEngine: options.videoEngine ?? null,
1007
+ ...options.needsFfmpeg ? { needsFfmpeg: options.needsFfmpeg } : {},
1008
+ persistCache: true,
1009
+ presetOverrides: presets,
1010
+ onProgress
1011
+ });
1012
+ return { model: buildModel(report), files: report.files.map((f) => f.source) };
1013
+ },
1014
+ async review(sources, presets, outDir) {
1015
+ const outcome = await optimize({
1016
+ ...common(outDir),
1017
+ inputs: sources,
1018
+ presetOverrides: presets,
1019
+ execute: false
1020
+ });
1021
+ const videoItems = (outcome.plan.videos ?? []).map((item) => {
1022
+ if (item.skip) {
1023
+ return {
1024
+ source: item.source,
1025
+ preset: item.preset,
1026
+ state: "skipped",
1027
+ outputs: 0,
1028
+ note: item.skip.reason
1029
+ };
1030
+ }
1031
+ const unchanged = outcome.cache.get(item.source)?.kind === "hit";
1032
+ return {
1033
+ source: item.source,
1034
+ preset: item.preset,
1035
+ state: unchanged ? "unchanged" : "encode",
1036
+ outputs: item.outputs.length
1037
+ };
1038
+ });
1039
+ const items = [
1040
+ ...outcome.plan.items.map((item) => {
1041
+ if (item.skip) {
1042
+ return {
1043
+ source: item.source,
1044
+ preset: item.preset,
1045
+ state: "skipped",
1046
+ outputs: 0,
1047
+ note: item.skip.reason
1048
+ };
1049
+ }
1050
+ const unchanged = outcome.cache.get(item.source)?.kind === "hit";
1051
+ return {
1052
+ source: item.source,
1053
+ preset: item.preset,
1054
+ state: unchanged ? "unchanged" : "encode",
1055
+ outputs: item.outputs.length
1056
+ };
1057
+ }),
1058
+ ...videoItems
1059
+ ];
1060
+ return {
1061
+ plan: outcome.plan,
1062
+ items,
1063
+ willEncode: items.filter((i) => i.state === "encode").length,
1064
+ unchanged: items.filter((i) => i.state === "unchanged").length,
1065
+ outputs: items.filter((i) => i.state === "encode").reduce((n, i) => n + i.outputs, 0),
1066
+ findings: outcome.findings
1067
+ };
1068
+ },
1069
+ async run(sources, presets, outDir, handlers, signal) {
1070
+ const outcome = await optimize({
1071
+ ...common(outDir),
1072
+ inputs: sources,
1073
+ presetOverrides: presets,
1074
+ execute: true,
1075
+ signal,
1076
+ concurrency: options.concurrency ?? config.concurrency ?? 2,
1077
+ onItemStart: handlers.onItemStart,
1078
+ onOutputDone: handlers.onOutput,
1079
+ onItemDone: (result) => handlers.onItemDone(result),
1080
+ onVideoItemStart: handlers.onItemStart,
1081
+ onVideoOutputDone: handlers.onOutput,
1082
+ onVideoItemDone: (result) => handlers.onItemDone(result),
1083
+ ...handlers.onProgress ? { onVideoProgress: handlers.onProgress } : {}
1084
+ });
1085
+ if (!outcome.result) {
1086
+ throw new BytecutError("BAD_ARGUMENTS", "Nothing was run.");
1087
+ }
1088
+ return outcome.result;
1089
+ },
1090
+ async snippet(source, outDir) {
1091
+ let text;
1092
+ try {
1093
+ text = await readFile(resolve(projectRoot, outDir, MANIFEST_FILE), "utf8");
1094
+ } catch {
1095
+ return { error: `No manifest in ${outDir} yet: run the optimizer first.` };
1096
+ }
1097
+ const manifest = parseManifest(text);
1098
+ const built = buildSnippet(
1099
+ manifest,
1100
+ { source, originalUrl: originalUrl(source, config.project.staticRoot) },
1101
+ { format: snippetFormatFor(config.project) }
1102
+ );
1103
+ if (!built) return { error: `${source} has not been optimized yet.` };
1104
+ return { code: built.code, hints: built.hints };
1105
+ },
1106
+ async compare(source, outDir) {
1107
+ let text;
1108
+ try {
1109
+ text = await readFile(resolve(projectRoot, outDir, MANIFEST_FILE), "utf8");
1110
+ } catch {
1111
+ return { error: `No manifest in ${outDir} yet: run the optimizer first.` };
1112
+ }
1113
+ try {
1114
+ const server = await startCompareServer({
1115
+ projectRoot,
1116
+ outDir,
1117
+ manifest: parseManifest(text),
1118
+ source
1119
+ });
1120
+ compareServers.push(server);
1121
+ return { url: server.url };
1122
+ } catch (error) {
1123
+ return { error: error instanceof Error ? error.message : String(error) };
1124
+ }
1125
+ },
1126
+ async stop() {
1127
+ await Promise.all(compareServers.splice(0).map((s) => s.close()));
1128
+ },
1129
+ async saveChoices(choices) {
1130
+ if (isEmpty(choices)) return { kind: "nothing" };
1131
+ try {
1132
+ const loaded = await loadConfig(projectRoot);
1133
+ if (loaded && !canRewrite(loaded.format)) {
1134
+ return {
1135
+ kind: "manual",
1136
+ file: loaded.name,
1137
+ snippet: renderChoicesSnippet(loaded.format, choices)
1138
+ };
1139
+ }
1140
+ const next = applyChoices(loaded?.raw ?? {}, choices);
1141
+ const file = loaded?.name ?? "bytecut.config.json";
1142
+ parseConfig(next, file);
1143
+ await writeConfigFile(projectRoot, file, renderConfig("json", next));
1144
+ config = await resolveConfig(projectRoot, await loadConfig(projectRoot));
1145
+ return { kind: "written", file, created: loaded === null };
1146
+ } catch (error) {
1147
+ return { kind: "error", message: error instanceof Error ? error.message : String(error) };
1148
+ }
1149
+ },
1150
+ async validateOutDir(outDir, sources) {
1151
+ const absolute = resolve(projectRoot, outDir);
1152
+ try {
1153
+ await SafeFs.create({
1154
+ projectRoot,
1155
+ outDir: absolute,
1156
+ sourceFiles: sources.map((s) => resolve(projectRoot, ...s.split("/"))),
1157
+ sourceDirs: []
1158
+ });
1159
+ const info = await stat(absolute).catch(() => null);
1160
+ if (info && !info.isDirectory()) return `${outDir} is a file, not a folder`;
1161
+ return null;
1162
+ } catch (error) {
1163
+ return error instanceof Error ? error.message : String(error);
1164
+ }
1165
+ }
1166
+ };
1167
+ }
1168
+ function displayOutDir(projectRoot, outDir) {
1169
+ const rel = relative(projectRoot, resolve(projectRoot, outDir));
1170
+ return toPosix(rel === "" ? "." : rel);
1171
+ }
1172
+
1173
+ // src/tui/app.tsx
1174
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1175
+ var FRAMEWORK_NAMES = {
1176
+ next: "Next.js",
1177
+ astro: "Astro",
1178
+ vite: "Vite",
1179
+ static: "static site"
1180
+ };
1181
+ function App(props) {
1182
+ const { session, project, projectRoot } = props;
1183
+ const { exit } = useApp();
1184
+ const { stdout } = useStdout();
1185
+ const [state, dispatch] = useReducer(
1186
+ reducer,
1187
+ props.outDir,
1188
+ (outDir) => initialState(outDir, props.presetNames)
1189
+ );
1190
+ const [doctor, setDoctor] = useState(null);
1191
+ const stateRef = useRef(state);
1192
+ stateRef.current = state;
1193
+ const abortRef = useRef(null);
1194
+ const declinedRef = useRef(null);
1195
+ const term = stdout;
1196
+ const rows = term?.rows ?? 24;
1197
+ const columns = term?.columns ?? 100;
1198
+ const listHeight = Math.max(5, rows - 12);
1199
+ useEffect(() => {
1200
+ let cancelled = false;
1201
+ void (async () => {
1202
+ try {
1203
+ const { model: model2, files } = await session.scan(
1204
+ stateRef.current.outDir,
1205
+ stateRef.current.presets,
1206
+ (p) => {
1207
+ if (!cancelled) dispatch({ type: "scan-progress", ...p });
1208
+ }
1209
+ );
1210
+ if (!cancelled) dispatch({ type: "scan-done", model: model2, files });
1211
+ } catch (error) {
1212
+ if (!cancelled) {
1213
+ dispatch({
1214
+ type: "fatal",
1215
+ error: error instanceof Error ? error.message : String(error)
1216
+ });
1217
+ }
1218
+ }
1219
+ })();
1220
+ return () => {
1221
+ cancelled = true;
1222
+ };
1223
+ }, [session, state.scanId]);
1224
+ const startReview = useCallback(
1225
+ async (only) => {
1226
+ const s = stateRef.current;
1227
+ const sources = only ?? s.model.rows.filter((r) => s.selected.has(r.source)).map((r) => r.source);
1228
+ if (sources.length === 0) {
1229
+ dispatch({ type: "status", message: "Nothing selected: press space to select files." });
1230
+ return;
1231
+ }
1232
+ dispatch({ type: "status", message: "Planning\u2026" });
1233
+ try {
1234
+ const review = await session.review(sources, s.presets, s.outDir);
1235
+ dispatch({ type: "review-ready", review });
1236
+ } catch (error) {
1237
+ dispatch({
1238
+ type: "status",
1239
+ message: error instanceof Error ? error.message : String(error)
1240
+ });
1241
+ }
1242
+ },
1243
+ [session]
1244
+ );
1245
+ const startRun = useCallback(async () => {
1246
+ const s = stateRef.current;
1247
+ if (!s.review) return;
1248
+ const sources = s.review.items.filter((i) => i.state === "encode").map((i) => i.source);
1249
+ if (sources.length === 0) {
1250
+ dispatch({ type: "status", message: "Everything is already up to date." });
1251
+ return;
1252
+ }
1253
+ const controller = new AbortController();
1254
+ abortRef.current = controller;
1255
+ dispatch({ type: "run-start", total: sources.length });
1256
+ try {
1257
+ const result = await session.run(
1258
+ sources,
1259
+ s.presets,
1260
+ s.outDir,
1261
+ {
1262
+ onItemStart: (source) => dispatch({ type: "run-item-start", source }),
1263
+ onOutput: (source, output) => dispatch({ type: "run-output", source, output }),
1264
+ onItemDone: () => dispatch({ type: "run-item-done" }),
1265
+ onProgress: (_source, path, fraction) => dispatch({ type: "run-progress", path, fraction })
1266
+ },
1267
+ controller.signal
1268
+ );
1269
+ dispatch({ type: "run-done", result });
1270
+ const unsaved = unsavedChoices(stateRef.current);
1271
+ if (!result.cancelled && (unsaved.outDir !== void 0 || unsaved.presets !== void 0) && JSON.stringify(unsaved) !== declinedRef.current) {
1272
+ dispatch({ type: "save-open", choices: unsaved });
1273
+ }
1274
+ } catch (error) {
1275
+ dispatch({ type: "fatal", error: error instanceof Error ? error.message : String(error) });
1276
+ } finally {
1277
+ abortRef.current = null;
1278
+ }
1279
+ }, [session]);
1280
+ const cancelRun = useCallback(() => {
1281
+ dispatch({ type: "cancel" });
1282
+ abortRef.current?.abort();
1283
+ }, []);
1284
+ const applyOutDir = useCallback(
1285
+ async (text) => {
1286
+ const value = text.trim();
1287
+ if (!value) return;
1288
+ const s = stateRef.current;
1289
+ const reason = await session.validateOutDir(
1290
+ value,
1291
+ s.model.rows.map((r) => r.source)
1292
+ );
1293
+ if (reason) {
1294
+ dispatch({ type: "status", message: reason });
1295
+ return;
1296
+ }
1297
+ dispatch({ type: "set-out-dir", outDir: displayOutDir(projectRoot, value) });
1298
+ dispatch({ type: "rescan" });
1299
+ },
1300
+ [session, projectRoot]
1301
+ );
1302
+ const resolveMentions = useCallback((mentions) => {
1303
+ const known = stateRef.current.model.rows.map((r) => r.source);
1304
+ const out = [];
1305
+ for (const m of mentions) {
1306
+ const hit = known.includes(m) ? m : fuzzyFiles(m, known, 1)[0];
1307
+ if (hit) out.push(hit);
1308
+ }
1309
+ return out;
1310
+ }, []);
1311
+ const copy = props.copy ?? ((text) => copyToClipboard(text, systemClipboardDeps()));
1312
+ const showSnippet = useCallback(
1313
+ async (source) => {
1314
+ const s = stateRef.current;
1315
+ const built = await session.snippet(source, s.outDir);
1316
+ if ("error" in built) {
1317
+ dispatch({ type: "status", message: built.error });
1318
+ return;
1319
+ }
1320
+ dispatch({
1321
+ type: "snippet",
1322
+ snippet: { source, code: built.code, hints: built.hints, copied: null }
1323
+ });
1324
+ const result = await copy(built.code);
1325
+ dispatch({ type: "snippet-copied", copied: result.ok ? result.method : null });
1326
+ },
1327
+ [session, copy]
1328
+ );
1329
+ const askToSave = useCallback(() => {
1330
+ const unsaved = unsavedChoices(stateRef.current);
1331
+ if (unsaved.outDir === void 0 && unsaved.presets === void 0) {
1332
+ dispatch({
1333
+ type: "status",
1334
+ message: "Nothing to save: no choice differs from the project config."
1335
+ });
1336
+ return;
1337
+ }
1338
+ dispatch({ type: "save-open", choices: unsaved });
1339
+ }, []);
1340
+ const confirmSave = useCallback(async () => {
1341
+ const s = stateRef.current;
1342
+ if (!s.save) return;
1343
+ dispatch({ type: "save-result", outcome: await session.saveChoices(s.save.choices) });
1344
+ }, [session]);
1345
+ const doneFiles = useCallback(() => {
1346
+ const r = stateRef.current.result;
1347
+ return r ? [...r.items.map((i) => i.source), ...(r.videos ?? []).map((v) => v.source)] : [];
1348
+ }, []);
1349
+ const execute = useCallback(
1350
+ async (text) => {
1351
+ const s = stateRef.current;
1352
+ if (!text.trim().startsWith("/")) {
1353
+ const mention = /@(\S+)/.exec(text)?.[1];
1354
+ const [hit] = mention ? resolveMentions([mention]) : [];
1355
+ dispatch({ type: "overlay", overlay: null });
1356
+ if (hit) dispatch({ type: "cursor-to", source: hit });
1357
+ else dispatch({ type: "status", message: "No such file in the list." });
1358
+ return;
1359
+ }
1360
+ const parsed = parseCommand(text);
1361
+ if (!parsed.ok) {
1362
+ dispatch({ type: "status", message: parsed.error });
1363
+ return;
1364
+ }
1365
+ const targets = resolveMentions(parsed.mentions);
1366
+ dispatch({ type: "overlay", overlay: null });
1367
+ switch (parsed.name) {
1368
+ case "audit":
1369
+ dispatch({ type: "rescan" });
1370
+ return;
1371
+ case "optimize":
1372
+ if (targets.length > 0) dispatch({ type: "select-files", sources: targets });
1373
+ await startReview(targets.length > 0 ? targets : void 0);
1374
+ return;
1375
+ case "preset": {
1376
+ const name = parsed.args[0];
1377
+ if (!name || !s.presetNames.includes(name)) {
1378
+ dispatch({
1379
+ type: "status",
1380
+ message: `Usage: /preset <${s.presetNames.join("|")}> [@file\u2026]`
1381
+ });
1382
+ return;
1383
+ }
1384
+ const sources = targets.length > 0 ? targets : rowAt(s) ? [rowAt(s).source] : [];
1385
+ dispatch({ type: "cycle-preset", sources, to: name });
1386
+ return;
1387
+ }
1388
+ case "save":
1389
+ askToSave();
1390
+ return;
1391
+ case "out":
1392
+ if (parsed.args[0]) await applyOutDir(parsed.args[0]);
1393
+ else dispatch({ type: "status", message: "Usage: /out <dir>" });
1394
+ return;
1395
+ case "select": {
1396
+ const mode = parsed.args[0];
1397
+ if (mode === "all" || mode === "none" || mode === "warn") {
1398
+ dispatch({ type: "select", mode });
1399
+ } else dispatch({ type: "status", message: "Usage: /select <all|none|warn>" });
1400
+ return;
1401
+ }
1402
+ case "snippet": {
1403
+ const known = s.files;
1404
+ const wanted = parsed.mentions[0];
1405
+ const hit = wanted ? known.includes(wanted) ? wanted : fuzzyFiles(wanted, known, 1)[0] : rowAt(s)?.source;
1406
+ if (!hit) {
1407
+ dispatch({ type: "status", message: "Usage: /snippet @file (an optimized file)" });
1408
+ return;
1409
+ }
1410
+ await showSnippet(hit);
1411
+ return;
1412
+ }
1413
+ case "compare": {
1414
+ const wanted = parsed.mentions[0];
1415
+ const hit = wanted ? s.files.includes(wanted) ? wanted : fuzzyFiles(wanted, s.files, 1)[0] : rowAt(s)?.source;
1416
+ if (!hit) {
1417
+ dispatch({ type: "status", message: "Usage: /compare @file (an optimized file)" });
1418
+ return;
1419
+ }
1420
+ const started = await session.compare(hit, s.outDir);
1421
+ dispatch({
1422
+ type: "status",
1423
+ message: "error" in started ? started.error : `Compare ${hit}: ${started.url} (local only; stops when you quit)`
1424
+ });
1425
+ return;
1426
+ }
1427
+ case "doctor":
1428
+ dispatch({ type: "overlay", overlay: "doctor" });
1429
+ void props.loadDoctor().then(setDoctor);
1430
+ return;
1431
+ case "help":
1432
+ dispatch({ type: "overlay", overlay: "help" });
1433
+ return;
1434
+ case "quit":
1435
+ exit();
1436
+ return;
1437
+ }
1438
+ },
1439
+ [applyOutDir, askToSave, exit, props, resolveMentions, showSnippet, startReview]
1440
+ );
1441
+ const suggestions = useMemo(
1442
+ () => computeSuggestions(state.input, state.files),
1443
+ [state.input, state.files]
1444
+ );
1445
+ useInput((input, key) => {
1446
+ const s = stateRef.current;
1447
+ if (key.ctrl && input === "c") {
1448
+ if (s.view === "running") cancelRun();
1449
+ else exit();
1450
+ return;
1451
+ }
1452
+ const clean = (t) => t.replace(/[\u0000-\u001f\u007f]/g, "");
1453
+ const chunk = input.length > 1 && !key.ctrl && !key.meta ? input : null;
1454
+ if (chunk !== null) {
1455
+ const submit = /[\r\n]$/.test(chunk);
1456
+ const text = clean(chunk);
1457
+ if (s.overlay === "command" || s.overlay === "outdir") {
1458
+ const next = s.input + text;
1459
+ dispatch({ type: "input", text: next });
1460
+ if (submit) {
1461
+ if (s.overlay === "outdir") void applyOutDir(next);
1462
+ else void execute(next);
1463
+ }
1464
+ return;
1465
+ }
1466
+ if (!s.overlay && s.view === "list" && (text.startsWith("/") || text.startsWith("@"))) {
1467
+ dispatch({ type: "overlay", overlay: "command", input: text });
1468
+ if (submit) void execute(text);
1469
+ return;
1470
+ }
1471
+ }
1472
+ if (s.overlay === "command" || s.overlay === "outdir") {
1473
+ if (key.escape) {
1474
+ dispatch({ type: "back" });
1475
+ } else if (key.return) {
1476
+ if (s.overlay === "outdir") void applyOutDir(s.input);
1477
+ else {
1478
+ const picked = suggestions.items[s.suggestion];
1479
+ if (picked && suggestions.kind === "command" && /^\/\S*$/.test(s.input.trim())) {
1480
+ const complete = `/${picked.value}`;
1481
+ if (s.input.trim() !== complete) {
1482
+ dispatch({ type: "input", text: `${complete} ` });
1483
+ return;
1484
+ }
1485
+ }
1486
+ void execute(s.input);
1487
+ }
1488
+ } else if (key.tab) {
1489
+ const picked = suggestions.items[s.suggestion];
1490
+ if (!picked) return;
1491
+ dispatch({
1492
+ type: "input",
1493
+ text: suggestions.kind === "file" ? completeMention(s.input, picked.value) : `/${picked.value} `
1494
+ });
1495
+ } else if (key.upArrow) {
1496
+ dispatch({ type: "suggest", delta: -1, max: suggestions.items.length });
1497
+ } else if (key.downArrow) {
1498
+ dispatch({ type: "suggest", delta: 1, max: suggestions.items.length });
1499
+ } else if (key.backspace || key.delete) {
1500
+ dispatch({ type: "input", text: s.input.slice(0, -1) });
1501
+ } else if (input && !key.ctrl && !key.meta) {
1502
+ dispatch({ type: "input", text: s.input + input.replace(/[\u0000-\u001f\u007f]/g, "") });
1503
+ }
1504
+ return;
1505
+ }
1506
+ if (s.overlay === "snippet") {
1507
+ if (input === "c" && s.snippet) {
1508
+ void copy(s.snippet.code).then(
1509
+ (r) => dispatch({ type: "snippet-copied", copied: r.ok ? r.method : null })
1510
+ );
1511
+ } else if (key.escape || key.return || input === "q") {
1512
+ dispatch({ type: "back" });
1513
+ }
1514
+ return;
1515
+ }
1516
+ if (s.overlay === "save") {
1517
+ if (s.save?.outcome != null) dispatch({ type: "back" });
1518
+ else if (input === "y" || key.return) void confirmSave();
1519
+ else if (input === "n" || input === "q" || key.escape) {
1520
+ if (s.save) declinedRef.current = JSON.stringify(s.save.choices);
1521
+ dispatch({ type: "back" });
1522
+ }
1523
+ return;
1524
+ }
1525
+ if (s.overlay) {
1526
+ if (key.escape || key.return || input === "q" || input === "?" || input === "d") {
1527
+ dispatch({ type: "back" });
1528
+ }
1529
+ return;
1530
+ }
1531
+ if (input === "q" && s.view !== "running") {
1532
+ exit();
1533
+ return;
1534
+ }
1535
+ switch (s.view) {
1536
+ case "list":
1537
+ if (key.upArrow || input === "k") dispatch({ type: "move", delta: -1 });
1538
+ else if (key.downArrow || input === "j") dispatch({ type: "move", delta: 1 });
1539
+ else if (key.pageUp) dispatch({ type: "move", delta: -listHeight });
1540
+ else if (key.pageDown) dispatch({ type: "move", delta: listHeight });
1541
+ else if (input === " ") dispatch({ type: "toggle" });
1542
+ else if (input === "a") dispatch({ type: "toggle-all" });
1543
+ else if (input === "p") dispatch({ type: "cycle-preset" });
1544
+ else if (input === "d") {
1545
+ if (rowAt(s)) dispatch({ type: "overlay", overlay: "details" });
1546
+ } else if (input === "o") dispatch({ type: "overlay", overlay: "outdir", input: s.outDir });
1547
+ else if (input === "?") dispatch({ type: "overlay", overlay: "help" });
1548
+ else if (input === "/") dispatch({ type: "overlay", overlay: "command", input: "/" });
1549
+ else if (input === "@") dispatch({ type: "overlay", overlay: "command", input: "@" });
1550
+ else if (key.return) void startReview();
1551
+ break;
1552
+ case "review":
1553
+ if (key.return) void startRun();
1554
+ else if (key.escape) dispatch({ type: "back" });
1555
+ break;
1556
+ case "running":
1557
+ if (key.escape) cancelRun();
1558
+ break;
1559
+ case "done": {
1560
+ const files = doneFiles();
1561
+ if (key.upArrow || input === "k")
1562
+ dispatch({ type: "done-move", delta: -1, max: files.length - 1 });
1563
+ else if (key.downArrow || input === "j")
1564
+ dispatch({ type: "done-move", delta: 1, max: files.length - 1 });
1565
+ else if (input === "c") {
1566
+ const file = files[s.doneCursor];
1567
+ if (file) void showSnippet(file);
1568
+ } else if (input === "s") askToSave();
1569
+ else if (key.return || key.escape) dispatch({ type: "back" });
1570
+ break;
1571
+ }
1572
+ case "scanning":
1573
+ case "error":
1574
+ break;
1575
+ }
1576
+ });
1577
+ const model = state.model;
1578
+ const scanLabel = props.inputs.join(", ");
1579
+ let body;
1580
+ if (state.view === "scanning") {
1581
+ const sc = state.scan;
1582
+ body = /* @__PURE__ */ jsxs5(Text5, { children: [
1583
+ "Scanning",
1584
+ sc && sc.total > 0 ? ` ${Math.min(sc.done, sc.total)}/${sc.total} images (${sc.phase})` : "",
1585
+ "\u2026"
1586
+ ] });
1587
+ } else if (state.view === "error") {
1588
+ body = /* @__PURE__ */ jsx5(Text5, { color: "red", children: state.error });
1589
+ } else if (state.view === "review" && state.review) {
1590
+ body = /* @__PURE__ */ jsx5(ReviewView, { review: state.review, outDir: state.outDir });
1591
+ } else if (state.view === "running") {
1592
+ body = /* @__PURE__ */ jsx5(RunView, { state });
1593
+ } else if (state.overlay === "save" && state.save) {
1594
+ body = state.view === "done" ? /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1595
+ /* @__PURE__ */ jsx5(DoneView, { state }),
1596
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(SaveOverlay, { save: state.save, outDir: state.outDir }) })
1597
+ ] }) : /* @__PURE__ */ jsx5(SaveOverlay, { save: state.save, outDir: state.outDir });
1598
+ } else if (state.overlay === "snippet" && state.snippet) {
1599
+ body = /* @__PURE__ */ jsx5(SnippetOverlay, { snippet: state.snippet });
1600
+ } else if (state.view === "done") {
1601
+ body = /* @__PURE__ */ jsx5(DoneView, { state });
1602
+ } else if (state.overlay === "help") {
1603
+ body = /* @__PURE__ */ jsx5(Help, {});
1604
+ } else if (state.overlay === "details") {
1605
+ body = /* @__PURE__ */ jsx5(Details, { state, row: rowAt(state) });
1606
+ } else if (state.overlay === "doctor") {
1607
+ body = /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1608
+ /* @__PURE__ */ jsx5(Text5, { children: doctor ?? "Checking\u2026" }),
1609
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "esc closes this" })
1610
+ ] });
1611
+ } else {
1612
+ body = /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1613
+ /* @__PURE__ */ jsx5(FileList, { state, height: listHeight, width: columns }),
1614
+ /* @__PURE__ */ jsx5(ReportOnly, { findings: model.reportOnly, skipped: model.skipped }),
1615
+ model.notes.map((n) => /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: n }, n)),
1616
+ state.overlay === "command" ? /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(
1617
+ CommandBar,
1618
+ {
1619
+ prompt: "\u203A",
1620
+ input: state.input,
1621
+ suggestions,
1622
+ selected: state.suggestion,
1623
+ hint: "tab completes \xB7 enter runs \xB7 esc cancels \xB7 @ mentions a file"
1624
+ }
1625
+ ) }) : null,
1626
+ state.overlay === "outdir" ? /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(
1627
+ CommandBar,
1628
+ {
1629
+ prompt: "Output folder:",
1630
+ input: state.input,
1631
+ suggestions: { kind: "none", items: [] },
1632
+ selected: 0,
1633
+ hint: "enter applies (the project is rescanned) \xB7 esc cancels"
1634
+ }
1635
+ ) }) : null
1636
+ ] });
1637
+ }
1638
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1639
+ /* @__PURE__ */ jsx5(
1640
+ Header,
1641
+ {
1642
+ framework: FRAMEWORK_NAMES[project.framework] ?? project.framework,
1643
+ scanning: scanLabel,
1644
+ outDir: state.outDir
1645
+ }
1646
+ ),
1647
+ body,
1648
+ /* @__PURE__ */ jsx5(StatusLine, { state, model })
1649
+ ] });
1650
+ }
1651
+
1652
+ // src/tui/index.tsx
1653
+ import { jsx as jsx6 } from "react/jsx-runtime";
1654
+ async function runTui(options) {
1655
+ const session = options.session ?? createSession({
1656
+ projectRoot: options.projectRoot,
1657
+ engine: options.engine,
1658
+ svgEngine: options.svgEngine,
1659
+ project: options.project,
1660
+ ...options.config ? { config: options.config } : {},
1661
+ inputs: options.inputs,
1662
+ toolVersion: options.toolVersion,
1663
+ ffmpeg: options.ffmpeg,
1664
+ videoEngine: options.videoEngine ?? null,
1665
+ ...options.makeScorer ? { makeScorer: options.makeScorer } : {},
1666
+ ...options.needsFfmpeg ? { needsFfmpeg: options.needsFfmpeg } : {}
1667
+ });
1668
+ const instance = render(
1669
+ /* @__PURE__ */ jsx6(
1670
+ App,
1671
+ {
1672
+ session,
1673
+ project: options.project,
1674
+ projectRoot: options.projectRoot,
1675
+ inputs: options.inputs,
1676
+ outDir: options.outDir,
1677
+ presetNames: options.config ? Object.keys(options.config.catalog.image) : void 0,
1678
+ loadDoctor: options.loadDoctor
1679
+ }
1680
+ ),
1681
+ { exitOnCtrlC: false }
1682
+ );
1683
+ try {
1684
+ await instance.waitUntilExit();
1685
+ } finally {
1686
+ await session.stop();
1687
+ }
1688
+ }
1689
+ export {
1690
+ runTui
1691
+ };