@v1nvn/statusline 0.27.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
- import { Command, Option } from "commander";
5
+ import { Command } from "commander";
6
6
  import { execFileSync, spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  //#region ../core/dist/index.js
@@ -23,6 +23,113 @@ function printUsageAndExit(program) {
23
23
  }
24
24
  Number.MAX_SAFE_INTEGER;
25
25
  //#endregion
26
+ //#region src/themes.ts
27
+ var THEME_NAMES = [
28
+ "classic",
29
+ "custom",
30
+ "lean",
31
+ "quiet",
32
+ "rich"
33
+ ];
34
+ var CUSTOM_SEEDS = {
35
+ branch: "last",
36
+ cwd: "base",
37
+ model: "zen",
38
+ style: "bare"
39
+ };
40
+ function offers(item, variant) {
41
+ return item.default === variant || item.alternatives.includes(variant);
42
+ }
43
+ function absenceSeed(item) {
44
+ if (offers(item, "none")) return "none";
45
+ if (offers(item, "hidden")) return "hidden";
46
+ const named = CUSTOM_SEEDS[item.item];
47
+ if (named === void 0) throw new Error(`no absence variant for '${item.item}' — name its seed`);
48
+ return named;
49
+ }
50
+ function themesFor(runtime) {
51
+ function variantsOf(pick) {
52
+ return Object.fromEntries(runtime.items.map((item) => [item.item, pick(item)]));
53
+ }
54
+ return {
55
+ quiet: {
56
+ layout: "{model cwd}",
57
+ summary: "model and directory, nothing else",
58
+ variants: {
59
+ cwd: "tail",
60
+ model: "zen",
61
+ style: "bare"
62
+ }
63
+ },
64
+ classic: {
65
+ layout: runtime.defaultLayout,
66
+ summary: "the shipped defaults, named",
67
+ variants: variantsOf((item) => item.default)
68
+ },
69
+ lean: {
70
+ layout: runtime.defaultLayout,
71
+ summary: "text only, no graphics",
72
+ variants: {
73
+ model: "plain",
74
+ effort: "dim",
75
+ state: "none",
76
+ cwd: "init",
77
+ branch: "initials",
78
+ status: "counts",
79
+ ahead: "arrows",
80
+ pr: "badge",
81
+ bar: "percent",
82
+ tokens: "full",
83
+ cache: "hit",
84
+ cost: "plain",
85
+ duration: "clock",
86
+ lines: "diffstat",
87
+ rate: "none",
88
+ style: "dots"
89
+ }
90
+ },
91
+ rich: {
92
+ layout: runtime.defaultLayout,
93
+ summary: "every gauge and counter",
94
+ variants: {
95
+ model: "pill",
96
+ effort: "plain",
97
+ state: "pills",
98
+ cwd: "icon",
99
+ branch: "icon",
100
+ status: "icons",
101
+ ahead: "arrows",
102
+ pr: "badge",
103
+ bar: "gauge",
104
+ tokens: "full",
105
+ cache: "fuse",
106
+ cost: "burn",
107
+ duration: "clock",
108
+ lines: "diffstat",
109
+ rate: "strip",
110
+ style: "plain"
111
+ }
112
+ },
113
+ custom: {
114
+ layout: runtime.defaultLayout,
115
+ summary: "bare; you decide everything",
116
+ variants: variantsOf(absenceSeed)
117
+ }
118
+ };
119
+ }
120
+ //#endregion
121
+ //#region src/live-theme.ts
122
+ function sameAssignments(key, theme) {
123
+ return Object.entries(key).every(([item, variant]) => theme[item] === variant) && Object.entries(theme).every(([item, variant]) => key[item] === variant);
124
+ }
125
+ function liveTheme(key, runtime) {
126
+ const themes = themesFor(runtime);
127
+ for (const name of Object.keys(themes)) {
128
+ const { layout, variants } = themes[name];
129
+ if (key.layout === layout && sameAssignments(key.values, variants)) return name;
130
+ }
131
+ }
132
+ //#endregion
26
133
  //#region src/resolve.ts
27
134
  var DATA_REL = join(".claude", "plugins", "data", "statusline-agentic");
28
135
  function capturePath(home, surface) {
@@ -136,34 +243,46 @@ function readDefaultLayout(dir) {
136
243
  if (match === null) throw new Error(`lib.sh in ${dir} declares no DEFAULT_LAYOUT`);
137
244
  return match[1];
138
245
  }
139
- function resolveRuntime({ home }) {
140
- const pluginDir = newestCacheDir(join(home, ".claude", "plugins", "cache", "agentic", "statusline"));
141
- const dir = pluginDir === null ? null : join(pluginDir, "runtime");
142
- if (dir === null || !existsSync(join(dir, "statusline.sh"))) throw new Error(`no statusline runtime under ${home} — install the plugin first: claude plugin install statusline@agentic`);
246
+ function resolveRuntimeDir(dir) {
143
247
  return {
144
248
  defaultLayout: readDefaultLayout(dir),
145
249
  dir,
146
250
  items: readItems(dir)
147
251
  };
148
252
  }
253
+ function resolveRuntime({ home }) {
254
+ const pluginDir = newestCacheDir(join(home, ".claude", "plugins", "cache", "agentic", "statusline"));
255
+ const dir = pluginDir === null ? null : join(pluginDir, "runtime");
256
+ if (dir === null || !existsSync(join(dir, "statusline.sh"))) throw new Error(`no statusline runtime under ${home} — install the plugin first: claude plugin install statusline@agentic`);
257
+ return resolveRuntimeDir(dir);
258
+ }
149
259
  //#endregion
150
260
  //#region src/catalog.ts
151
261
  function catalog(options) {
262
+ if (options.themes === true && options.items !== void 0) throw new Error("--themes cannot combine with item flags");
152
263
  const runtime = resolveRuntime({ home: options.home });
153
- const live = readKeyConfig(options.home).values;
264
+ const key = readKeyConfig(options.home);
265
+ const live = liveTheme(key, runtime);
266
+ const block = Object.entries(themesFor(runtime)).map(([name, theme]) => `${name}${live === name ? "*" : ""}: ${theme.summary}`);
267
+ if (options.themes === true) return block.join("\n");
154
268
  const byItem = new Map(runtime.items.map((item) => [item.item, item]));
155
- return (options.items ?? runtime.items.map((item) => item.item)).map((item) => {
269
+ const itemLines = (options.items ?? runtime.items.map((item) => item.item)).map((item) => {
156
270
  const entry = byItem.get(item);
157
271
  if (entry === void 0) throw new Error(`unknown item '${item}' — valid items: ${[...byItem.keys()].join(" ")}`);
158
- const current = live[item] ?? entry.default;
272
+ const current = key.values[item] ?? entry.default;
159
273
  return `${item}: ${entry.alternatives.map((alt) => alt === current ? `${alt}*` : alt).join(" | ")}`;
160
- }).join("\n");
274
+ });
275
+ return [
276
+ ...block,
277
+ "",
278
+ ...itemLines
279
+ ].join("\n");
161
280
  }
162
281
  //#endregion
163
282
  //#region src/cli.ts
164
283
  var VERSION = {
165
284
  name: "@v1nvn/statusline",
166
- version: "0.27.2",
285
+ version: "0.28.0",
167
286
  description: "Browse statusline variants and configure the live status line and agent panel — the statusline CLI behind the statusline plugin.",
168
287
  type: "module",
169
288
  bin: "dist/index.js",
@@ -236,17 +355,22 @@ function quiet(name, command) {
236
355
  }).configureOutput(QUIET).allowExcessArguments(false);
237
356
  }
238
357
  function buildProgram(onSubcommand) {
239
- const catalog = quiet("catalog", new Command("catalog")).description("print one line per item — * marks the live variant").option("--home <dir>", "operate on this home instead of $HOME");
358
+ const catalog = quiet("catalog", new Command("catalog")).description("print the themes block (* marks the live theme) then one line per item — * marks the live variant").option("--home <dir>", "operate on this home instead of $HOME").option("--themes", "cut the listing to the themes block");
240
359
  for (const item of ITEM_IDS) catalog.option(`--${item}`, `cut the listing to the ${item} item`);
241
360
  catalog.action((options) => onSubcommand?.("catalog", options));
242
- const configure = quiet("configure", new Command("configure")).description("write both settings keys with the inline lab commands").option("--home <dir>", "operate on this home instead of $HOME").option("--layout <spec>", "brace clusters of item ids, e.g. '{cwd branch} {model effort}'").addOption(new Option("--fallback <mode>", "fill unflagged layout items from defaults or the existing config").choices(["default", "existing"])).option("--dry-run", "render both surfaces, write nothing").option("--force", "take over foreign settings keys");
361
+ const LAYOUT_HELP = "brace clusters of item ids, e.g. '{cwd branch} {model effort}'";
362
+ const THEME_HELP = "base design the item flags override: quiet, lean, classic, rich, custom";
363
+ const configure = quiet("configure", new Command("configure")).description("write both settings keys with the inline lab commands").option("--home <dir>", "operate on this home instead of $HOME").option("--layout <spec>", LAYOUT_HELP).option("--theme <name>", THEME_HELP).option("--force", "take over foreign settings keys");
243
364
  for (const item of ITEM_IDS) configure.option(`--${item} <alt>`, `variant for the ${item} item`);
244
365
  configure.action((options) => onSubcommand?.("configure", options));
366
+ const preview = quiet("preview", new Command("preview")).description("render a candidate line and panel row; nothing is written").option("--home <dir>", "operate on this home instead of $HOME").option("--layout <spec>", LAYOUT_HELP).option("--theme <name>", THEME_HELP).option("--plain", "strip the color escapes so glyphs survive chat");
367
+ for (const item of ITEM_IDS) preview.option(`--${item} <alt>`, `variant for the ${item} item`);
368
+ preview.action((options) => onSubcommand?.("preview", options));
245
369
  const restore = quiet("restore", new Command("restore")).description("revert both settings keys to their pre-lab values, clean the data dir").option("--home <dir>", "operate on this home instead of $HOME").option("--dry-run", "print the plan, write nothing").option("--force", "splice the saved value over a key changed after the takeover");
246
370
  restore.action((options) => onSubcommand?.("restore", options));
247
371
  const status = quiet("status", new Command("status")).description("check install, keys, config, and backup — exit 0 healthy, 1 when a row needs action").option("--home <dir>", "operate on this home instead of $HOME");
248
372
  status.action((options) => onSubcommand?.("status", options));
249
- return new Command().name("statusline").description("Configure the status line and agent panel designs").option("-V, --version", "print the version and exit").action(() => void 0).addCommand(catalog).addCommand(configure).addCommand(restore).addCommand(status);
373
+ return new Command().name("statusline").description("Configure the status line and agent panel designs").option("-V, --version", "print the version and exit").action(() => void 0).addCommand(catalog).addCommand(configure).addCommand(preview).addCommand(restore).addCommand(status);
250
374
  }
251
375
  function subcommandHelp(name) {
252
376
  const command = buildProgram().commands.find((c) => c.name() === name);
@@ -277,6 +401,7 @@ function parseArgs(args) {
277
401
  version: false,
278
402
  command: "catalog",
279
403
  ...items.length > 0 ? { items } : {},
404
+ ...typeof options.themes === "boolean" ? { themes: options.themes } : {},
280
405
  ...typeof options.home === "string" ? { home: options.home } : {}
281
406
  };
282
407
  }
@@ -294,316 +419,84 @@ function parseArgs(args) {
294
419
  };
295
420
  const variants = {};
296
421
  for (const item of ITEM_IDS) if (typeof options[item] === "string") variants[item] = options[item];
297
- return {
422
+ const common = {
298
423
  version: false,
299
- command: "configure",
424
+ command: chosen.command,
300
425
  ...Object.keys(variants).length > 0 ? { variants } : {},
301
- ...typeof options.dryRun === "boolean" ? { dryRun: options.dryRun } : {},
302
- ...typeof options.fallback === "string" ? { fallback: options.fallback } : {},
303
- ...typeof options.force === "boolean" ? { force: options.force } : {},
304
426
  ...typeof options.home === "string" ? { home: options.home } : {},
305
- ...typeof options.layout === "string" ? { layout: options.layout } : {}
427
+ ...typeof options.layout === "string" ? { layout: options.layout } : {},
428
+ ...typeof options.theme === "string" ? { theme: options.theme } : {}
429
+ };
430
+ return chosen.command === "configure" ? {
431
+ ...common,
432
+ ...typeof options.force === "boolean" ? { force: options.force } : {}
433
+ } : {
434
+ ...common,
435
+ ...typeof options.plain === "boolean" ? { plain: options.plain } : {}
306
436
  };
307
437
  }
308
438
  //#endregion
309
- //#region src/demo-repo.ts
310
- var NAME = "Lab";
311
- var EMAIL = "lab@local";
312
- var D_INIT = "2026-09-01T10:00:00+00:00";
313
- var D_REMOTE = "2026-09-01T11:00:00+00:00";
314
- var D_LOCAL1 = "2026-09-02T09:00:00+00:00";
315
- var D_LOCAL2 = "2026-09-02T09:30:00+00:00";
316
- var D_STASH = "2026-09-02T10:00:00+00:00";
317
- var API_INIT = "export const api = {\n host: 'demo.local',\n};\n";
318
- function materializeDemoRepo(home) {
319
- const dir = join(home, "demo", "atlas-web");
320
- for (const sub of [
321
- "src",
322
- "docs",
323
- "scratch",
324
- "scripts"
325
- ]) mkdirSync(join(dir, sub), { recursive: true });
326
- function git(args, date) {
327
- return execFileSync("git", [
328
- "-c",
329
- "commit.gpgsign=false",
330
- ...args
331
- ], {
332
- cwd: dir,
333
- encoding: "utf8",
334
- stdio: [
335
- "ignore",
336
- "pipe",
337
- "pipe"
338
- ],
339
- env: {
340
- PATH: process.env.PATH ?? "",
341
- HOME: home,
342
- LC_ALL: "C",
343
- TZ: "UTC",
344
- GIT_AUTHOR_NAME: NAME,
345
- GIT_AUTHOR_EMAIL: EMAIL,
346
- GIT_COMMITTER_NAME: NAME,
347
- GIT_COMMITTER_EMAIL: EMAIL,
348
- ...date ? {
349
- GIT_AUTHOR_DATE: date,
350
- GIT_COMMITTER_DATE: date
351
- } : {}
352
- }
353
- });
354
- }
355
- function write(rel, body) {
356
- writeFileSync(join(dir, rel), body);
357
- }
358
- function commit(message, date) {
359
- git(["add", "-A"]);
360
- git([
361
- "commit",
362
- "-m",
363
- message
364
- ], date);
439
+ //#region src/configure.ts
440
+ var SETTINGS_KEYS = ["statusLine", "subagentStatusLine"];
441
+ function layoutItems(layout, valid) {
442
+ const known = new Set(valid);
443
+ const items = [];
444
+ for (const cluster of parseClusters(layout)) for (const word of cluster) {
445
+ if (!known.has(word)) throw new Error(`unknown layout item '${word}' — valid items: ${valid.join(" ")}`);
446
+ if (!items.includes(word)) items.push(word);
365
447
  }
366
- git([
367
- "init",
368
- "-b",
369
- "main"
370
- ]);
371
- git([
372
- "config",
373
- "user.name",
374
- NAME
375
- ]);
376
- git([
377
- "config",
378
- "user.email",
379
- EMAIL
380
- ]);
381
- write("README.md", "atlas-web demo repo\n");
382
- write("src/index.js", "export function start() {}\n");
383
- write("src/api.js", API_INIT);
384
- write("src/style.css", "body { margin: 0; }\n");
385
- write("src/util.js", "export const id = (x) => x;\n");
386
- commit("init", D_INIT);
387
- write("src/api.js", `${API_INIT}// remote: pinned host\n`);
388
- commit("remote1", D_REMOTE);
389
- const originMain = git(["rev-parse", "HEAD"]).trim();
390
- git([
391
- "checkout",
392
- "-b",
393
- "feature/login-flow",
394
- "HEAD~1"
395
- ]);
396
- write("src/util.js", "export const id = (x) => x;\nexport const tag = (x) => x;\n");
397
- commit("local1", D_LOCAL1);
398
- write("src/render.js", "export function render() {}\n");
399
- commit("local2", D_LOCAL2);
400
- git([
401
- "update-ref",
402
- "refs/remotes/origin/main",
403
- originMain
404
- ]);
405
- git([
406
- "config",
407
- "remote.origin.url",
408
- "../atlas-web.git"
409
- ]);
410
- git([
411
- "config",
412
- "remote.origin.fetch",
413
- "+refs/heads/*:refs/remotes/origin/*"
414
- ]);
415
- git([
416
- "config",
417
- "branch.feature/login-flow.remote",
418
- "origin"
419
- ]);
420
- git([
421
- "config",
422
- "branch.feature/login-flow.merge",
423
- "refs/heads/main"
424
- ]);
425
- write("README.md", "atlas-web demo repo (wip)\n");
426
- git([
427
- "stash",
428
- "push",
429
- "-m",
430
- "wip: context refactor",
431
- "--",
432
- "README.md"
433
- ], D_STASH);
434
- write("README.md", "atlas-web demo repo\n\nstaged: pin the render loop\n");
435
- write("src/index.js", "export function start() {\n return Promise.resolve();\n}\n");
436
- git([
437
- "add",
438
- "README.md",
439
- "src/index.js"
440
- ]);
441
- write("src/api.js", `${API_INIT}// local tweak\n`);
442
- write("src/style.css", "body { margin: 0; padding: 0; }\n");
443
- write("docs/wip.txt", "");
444
- write("scratch/todo.md", "");
445
- write("scripts/deploy.sh", "");
446
- write("note1.md", "");
447
- write("note2.md", "");
448
- return dir;
448
+ return items;
449
449
  }
450
- //#endregion
451
- //#region src/payloads.ts
452
- var PAYLOADS_DIR = fileURLToPath(new URL("../assets/payloads", import.meta.url));
453
- var TICKS_DIR = fileURLToPath(new URL("../assets/ticks", import.meta.url));
454
- var P1_WARM_IN = 1920;
455
- var P1_RESETS_IN = {
456
- five_hour: 13830,
457
- seven_day: 518400,
458
- spend_limit: 950400
459
- };
460
- var PANEL_STEP_S = 300;
461
- function anchoredP1(repoDir, now) {
462
- const payload = JSON.parse(readFileSync(join(PAYLOADS_DIR, "p1.json"), "utf8"));
463
- const cache = payload.prompt_cache;
464
- if (isObject(cache)) {
465
- if (cache.warm === true) {
466
- cache.expires_at = now + P1_WARM_IN;
467
- cache.last_miss_at = now + P1_WARM_IN - 3900;
468
- } else {
469
- cache.expires_at = now - 10;
470
- delete cache.last_miss_at;
450
+ function parseClusters(layout) {
451
+ const clusters = [];
452
+ let words = [];
453
+ let word = "";
454
+ let open = false;
455
+ function pushWord() {
456
+ if (word !== "") {
457
+ words.push(word);
458
+ word = "";
471
459
  }
472
460
  }
473
- const limits = payload.rate_limits;
474
- if (isObject(limits)) for (const [limitKey, offset] of Object.entries(P1_RESETS_IN)) {
475
- const limit = limits[limitKey];
476
- if (isObject(limit)) limit.resets_at = now + offset;
461
+ for (const c of layout) if (c === "{") {
462
+ if (open) throw new Error(`layout '${layout}': '{' inside a cluster`);
463
+ open = true;
464
+ words = [];
465
+ } else if (c === "}") {
466
+ if (!open) throw new Error(`layout '${layout}': '}' outside a cluster`);
467
+ pushWord();
468
+ open = false;
469
+ if (words.length > 0) clusters.push(words);
470
+ } else if (c === " ") {
471
+ if (open) pushWord();
472
+ else if (word !== "") throw new Error(`layout '${layout}': '${word}' sits outside a cluster`);
473
+ } else if (/[a-z0-9]/.test(c)) word += c;
474
+ else throw new Error(`layout '${layout}': '${c}' is not layout grammar (braces, item ids, spaces)`);
475
+ if (open) throw new Error(`layout '${layout}': unterminated cluster`);
476
+ if (word !== "") throw new Error(`layout '${layout}': '${word}' sits outside a cluster`);
477
+ if (clusters.length === 0) throw new Error(`layout '${layout}': no clusters`);
478
+ return clusters;
479
+ }
480
+ function isObject(value) {
481
+ return typeof value === "object" && value !== null && !Array.isArray(value);
482
+ }
483
+ function readOrNull(file) {
484
+ try {
485
+ return readFileSync(file, "utf8");
486
+ } catch (e) {
487
+ if (e.code === "ENOENT") return null;
488
+ throw e;
477
489
  }
478
- payload.workspace.current_dir = repoDir;
479
- return `${JSON.stringify(payload, null, 2)}\n`;
480
490
  }
481
- function hasStart(value) {
482
- return typeof value === "number" && Number.isFinite(value) && value > 0;
483
- }
484
- function anchoredTick(now) {
485
- const tick = JSON.parse(readFileSync(join(TICKS_DIR, "multi.json"), "utf8"));
486
- const tasks = Array.isArray(tick.tasks) ? tick.tasks : [];
487
- let started = 0;
488
- const staggered = {
489
- ...tick,
490
- tasks: tasks.map((task) => {
491
- if (!hasStart(task.startTime)) return task;
492
- started += 1;
493
- return {
494
- ...task,
495
- startTime: now - PANEL_STEP_S * started
496
- };
497
- })
498
- };
499
- return `${JSON.stringify(staggered, null, 2)}\n`;
500
- }
501
- function previewSources(home, now) {
502
- const mainCapture = capturePath(home, "main");
503
- if (existsSync(mainCapture)) {
504
- const tickCapture = capturePath(home, "tick");
505
- return {
506
- cleanup: () => void 0,
507
- main: readFileSync(mainCapture, "utf8"),
508
- tick: existsSync(tickCapture) ? readFileSync(tickCapture, "utf8") : anchoredTick(now)
509
- };
510
- }
511
- const demoHome = mkdtempSync(join(tmpdir(), "statusline-preview-"));
512
- return {
513
- cleanup: () => {
514
- rmSync(demoHome, {
515
- recursive: true,
516
- force: true
517
- });
518
- },
519
- main: anchoredP1(materializeDemoRepo(demoHome), now),
520
- tick: anchoredTick(now)
521
- };
522
- }
523
- function firstPanelRow(output) {
524
- const [row] = output.split("\n");
525
- if (row === "") return "";
526
- const content = JSON.parse(row).content;
527
- return typeof content === "string" ? content : "";
528
- }
529
- function runtimeRenderer(spec) {
530
- const run = spawnSync("bash", [spec.bin], {
531
- input: spec.stdin,
532
- env: {
533
- PATH: process.env.PATH ?? "",
534
- ...spec.env,
535
- LC_ALL: "C",
536
- TZ: "UTC"
537
- },
538
- timeout: 3e4
539
- });
540
- if (run.status !== 0) throw new Error(`render failed: ${run.stderr.toString("utf8")}`);
541
- const stdout = run.stdout.toString("utf8");
542
- const warn = run.stderr.toString("utf8").trim();
543
- return warn === "" ? stdout : `${stdout}${warn}\n`;
544
- }
545
- //#endregion
546
- //#region src/configure.ts
547
- var SETTINGS_KEYS = ["statusLine", "subagentStatusLine"];
548
- function layoutItems(layout, valid) {
549
- const known = new Set(valid);
550
- const items = [];
551
- for (const cluster of parseClusters(layout)) for (const word of cluster) {
552
- if (!known.has(word)) throw new Error(`unknown layout item '${word}' — valid items: ${valid.join(" ")}`);
553
- if (!items.includes(word)) items.push(word);
554
- }
555
- return items;
556
- }
557
- function parseClusters(layout) {
558
- const clusters = [];
559
- let words = [];
560
- let word = "";
561
- let open = false;
562
- function pushWord() {
563
- if (word !== "") {
564
- words.push(word);
565
- word = "";
566
- }
567
- }
568
- for (const c of layout) if (c === "{") {
569
- if (open) throw new Error(`layout '${layout}': '{' inside a cluster`);
570
- open = true;
571
- words = [];
572
- } else if (c === "}") {
573
- if (!open) throw new Error(`layout '${layout}': '}' outside a cluster`);
574
- pushWord();
575
- open = false;
576
- if (words.length > 0) clusters.push(words);
577
- } else if (c === " ") {
578
- if (open) pushWord();
579
- else if (word !== "") throw new Error(`layout '${layout}': '${word}' sits outside a cluster`);
580
- } else if (/[a-z0-9]/.test(c)) word += c;
581
- else throw new Error(`layout '${layout}': '${c}' is not layout grammar (braces, item ids, spaces)`);
582
- if (open) throw new Error(`layout '${layout}': unterminated cluster`);
583
- if (word !== "") throw new Error(`layout '${layout}': '${word}' sits outside a cluster`);
584
- if (clusters.length === 0) throw new Error(`layout '${layout}': no clusters`);
585
- return clusters;
586
- }
587
- function isObject(value) {
588
- return typeof value === "object" && value !== null && !Array.isArray(value);
589
- }
590
- function readOrNull(file) {
591
- try {
592
- return readFileSync(file, "utf8");
593
- } catch (e) {
594
- if (e.code === "ENOENT") return null;
595
- throw e;
596
- }
597
- }
598
- function parseSettings(file, raw) {
599
- let parsed;
600
- try {
601
- parsed = JSON.parse(raw);
602
- } catch (e) {
603
- throw new Error(`${file} is not valid JSON`, { cause: e });
604
- }
605
- if (!isObject(parsed)) throw new Error(`${file} does not hold a settings object`);
606
- return parsed;
491
+ function parseSettings(file, raw) {
492
+ let parsed;
493
+ try {
494
+ parsed = JSON.parse(raw);
495
+ } catch (e) {
496
+ throw new Error(`${file} is not valid JSON`, { cause: e });
497
+ }
498
+ if (!isObject(parsed)) throw new Error(`${file} does not hold a settings object`);
499
+ return parsed;
607
500
  }
608
501
  function memberCommand(value) {
609
502
  if (!isObject(value) || value.type !== "command") return null;
@@ -682,191 +575,445 @@ function skipValue(raw, at) {
682
575
  while (i < raw.length && !",}]".includes(raw[i] ?? "") && !/\s/.test(raw[i] ?? "~")) i += 1;
683
576
  return i;
684
577
  }
685
- function rootMembers(raw) {
686
- const members = [];
687
- let i = raw.indexOf("{") + 1;
688
- while (i < raw.length) {
689
- while (/\s/.test(raw[i] ?? "~")) i += 1;
690
- if (raw[i] === "}") break;
691
- if (raw[i] !== "\"") throw new Error("malformed member in settings.json");
692
- const nameStart = i;
693
- const nameEnd = skipString(raw, i);
694
- const name = JSON.parse(raw.slice(i, nameEnd));
695
- i = nameEnd;
696
- while (/\s/.test(raw[i] ?? "~")) i += 1;
697
- if (raw[i] !== ":") throw new Error("malformed member in settings.json");
698
- i += 1;
699
- while (/\s/.test(raw[i] ?? "~")) i += 1;
700
- const valueStart = i;
701
- const valueEnd = skipValue(raw, i);
702
- members.push({
703
- name,
704
- nameStart,
705
- valueEnd,
706
- valueStart
707
- });
708
- i = valueEnd;
709
- while (/\s/.test(raw[i] ?? "~")) i += 1;
710
- if (raw[i] === ",") i += 1;
711
- else if (raw[i] !== "}") throw new Error("malformed member in settings.json");
578
+ function rootMembers(raw) {
579
+ const members = [];
580
+ let i = raw.indexOf("{") + 1;
581
+ while (i < raw.length) {
582
+ while (/\s/.test(raw[i] ?? "~")) i += 1;
583
+ if (raw[i] === "}") break;
584
+ if (raw[i] !== "\"") throw new Error("malformed member in settings.json");
585
+ const nameStart = i;
586
+ const nameEnd = skipString(raw, i);
587
+ const name = JSON.parse(raw.slice(i, nameEnd));
588
+ i = nameEnd;
589
+ while (/\s/.test(raw[i] ?? "~")) i += 1;
590
+ if (raw[i] !== ":") throw new Error("malformed member in settings.json");
591
+ i += 1;
592
+ while (/\s/.test(raw[i] ?? "~")) i += 1;
593
+ const valueStart = i;
594
+ const valueEnd = skipValue(raw, i);
595
+ members.push({
596
+ name,
597
+ nameStart,
598
+ valueEnd,
599
+ valueStart
600
+ });
601
+ i = valueEnd;
602
+ while (/\s/.test(raw[i] ?? "~")) i += 1;
603
+ if (raw[i] === ",") i += 1;
604
+ else if (raw[i] !== "}") throw new Error("malformed member in settings.json");
605
+ }
606
+ return members;
607
+ }
608
+ function rootMemberValueSpan(raw, key) {
609
+ const member = rootMembers(raw).find((m) => m.name === key);
610
+ return member === void 0 ? null : [member.valueStart, member.valueEnd];
611
+ }
612
+ function removeMembers(raw, keys) {
613
+ let text = raw;
614
+ for (const key of keys) {
615
+ const members = rootMembers(text);
616
+ const at = members.findIndex((m) => m.name === key);
617
+ if (at === -1) throw new Error(`cannot find the "${key}" member to remove in settings.json`);
618
+ if (members.length === 1) return "{}";
619
+ const member = members[at];
620
+ if (at > 0) {
621
+ let start = member.nameStart;
622
+ while (/\s/.test(text[start - 1] ?? "~")) start -= 1;
623
+ text = `${text.slice(0, start - 1)}${text.slice(member.valueEnd)}`;
624
+ } else text = `${text.slice(0, member.nameStart)}${text.slice(members[1].nameStart)}`;
625
+ }
626
+ return text;
627
+ }
628
+ function insertMembers(raw, members) {
629
+ const close = raw.lastIndexOf("}");
630
+ if (close === -1) throw new Error("settings.json has no closing brace to splice into");
631
+ let at = close;
632
+ while (/\s/.test(raw[at - 1] ?? "~")) at -= 1;
633
+ const comma = raw.replace(/\s/g, "") === "{}" ? "" : ",";
634
+ return `${raw.slice(0, at)}${comma}\n ${renderMembers(members)}${raw.slice(at)}`;
635
+ }
636
+ function renderMembers(members) {
637
+ return members.map(([key, command]) => `"${key}": ${settingsValue(command)}`).join(",\n ");
638
+ }
639
+ /** Splice each rendered value over its root member. */
640
+ function repointRootMembers(raw, entries) {
641
+ let text = raw;
642
+ for (const { key, value } of entries) {
643
+ const span = rootMemberValueSpan(text, key);
644
+ if (span === null) throw new Error(`cannot find the "${key}" member in settings.json`);
645
+ text = `${text.slice(0, span[0])}${value}${text.slice(span[1])}`;
646
+ }
647
+ return text;
648
+ }
649
+ function splicedSettings(raw, plan) {
650
+ return repointRootMembers(plan.adds.length > 0 ? insertMembers(raw, plan.adds) : raw, plan.repoints.map(([key, command]) => ({
651
+ key,
652
+ value: settingsValue(command)
653
+ })));
654
+ }
655
+ function writeBackupIfAbsent(home, plan) {
656
+ const file = backupPath(home);
657
+ if (existsSync(file)) return;
658
+ const keys = {};
659
+ if (plan.raw !== null) for (const key of SETTINGS_KEYS) {
660
+ const span = rootMemberValueSpan(plan.raw, key);
661
+ if (span === null) continue;
662
+ const memberRaw = plan.raw.slice(span[0], span[1]);
663
+ if (isOurMember(key, JSON.parse(memberRaw))) continue;
664
+ keys[key] = memberRaw;
665
+ }
666
+ const dir = dirname(file);
667
+ mkdirSync(dir, { recursive: true });
668
+ const tmp = `${file}.tmp`;
669
+ writeFileSync(tmp, `${JSON.stringify({
670
+ createdFile: plan.raw === null,
671
+ keys
672
+ }, null, 2)}\n`);
673
+ renameSync(tmp, file);
674
+ }
675
+ function commitSettings(plan) {
676
+ if (plan.raw === null) {
677
+ mkdirSync(dirname(plan.file), { recursive: true });
678
+ const members = plan.adds.map(([key, command]) => `"${key}": ${settingsValue(command)}`).join(",\n ");
679
+ writeFileSync(plan.file, `{\n ${members}\n}\n`);
680
+ return;
681
+ }
682
+ if (plan.adds.length === 0 && plan.repoints.length === 0) return;
683
+ writeFileSync(plan.file, splicedSettings(plan.raw, plan));
684
+ }
685
+ function themeNamed(name, themes) {
686
+ const theme = themes[name];
687
+ if (theme === void 0) throw new Error(`unknown theme '${name}' — valid themes: ${THEME_NAMES.join(" ")}`);
688
+ return theme;
689
+ }
690
+ function resolveSelection(runtime, options) {
691
+ const variants = options.variants ?? {};
692
+ const themes = themesFor(runtime);
693
+ const theme = options.theme === void 0 ? void 0 : themeNamed(options.theme, themes);
694
+ const layout = options.layout ?? theme?.layout;
695
+ if (layout === void 0) throw new Error("no layout — pass --layout <spec> or --theme <name>");
696
+ const byItem = new Map(runtime.items.map((item) => [item.item, item]));
697
+ const items = layoutItems(layout, runtime.items.map((item) => item.item));
698
+ const values = {
699
+ ...theme?.variants ?? {},
700
+ ...variants
701
+ };
702
+ const missing = items.filter((item) => !(item in values));
703
+ if (missing.length > 0) {
704
+ const hints = missing.map((item) => `--${item} <alt>`).join(" ");
705
+ const names = missing.map((item) => `'${item}'`).join(" ");
706
+ const plural = missing.length > 1 ? "s" : "";
707
+ throw new Error(theme === void 0 ? `no variant for layout item${plural} ${names} — pass ${hints}, or name a theme: --theme ${THEME_NAMES.join("|")}` : `theme '${options.theme}' has no pick for layout item${plural} ${names} — pass ${hints}, or drop ${missing.length > 1 ? "them" : "it"} from the layout`);
708
+ }
709
+ for (const [item, alt] of Object.entries(values)) {
710
+ const entry = byItem.get(item);
711
+ if (entry === void 0) throw new Error(`unknown item '${item}' — valid items: ${[...byItem.keys()].join(" ")}`);
712
+ if (!entry.alternatives.includes(alt)) throw new Error(`unknown variant '${alt}' for item '${item}' — valid: ${entry.alternatives.join(" | ")}`);
713
+ }
714
+ return {
715
+ layout,
716
+ ordered: [...items, ...runtime.items.map((entry) => entry.item).filter((item) => !items.includes(item) && item in values)],
717
+ values
718
+ };
719
+ }
720
+ function configure(options) {
721
+ const runtime = resolveRuntime({ home: options.home });
722
+ if (options.theme === void 0 && options.layout === void 0 && Object.keys(options.variants ?? {}).length === 0) throw new Error("no theme and no item flags — on a terminal run 'statusline configure' for the wizard; in Claude Code use the /lab skill");
723
+ const { layout, ordered, values } = resolveSelection(runtime, options);
724
+ const assignments = ordered.map((item) => `STATUSLINE_LAB_${item.toUpperCase()}=${values[item]}`);
725
+ const plan = planSettings(options.home, mainKeyValue(layout, assignments), options.force ?? false);
726
+ if (plan.adds.length > 0 || plan.repoints.length > 0) writeBackupIfAbsent(options.home, plan);
727
+ commitSettings(plan);
728
+ }
729
+ //#endregion
730
+ //#region src/demo-repo.ts
731
+ var NAME = "Lab";
732
+ var EMAIL = "lab@local";
733
+ var D_INIT = "2026-09-01T10:00:00+00:00";
734
+ var D_REMOTE = "2026-09-01T11:00:00+00:00";
735
+ var D_LOCAL1 = "2026-09-02T09:00:00+00:00";
736
+ var D_LOCAL2 = "2026-09-02T09:30:00+00:00";
737
+ var D_STASH = "2026-09-02T10:00:00+00:00";
738
+ var API_INIT = "export const api = {\n host: 'demo.local',\n};\n";
739
+ function materializeDemoRepo(home) {
740
+ const dir = join(home, "demo", "atlas-web");
741
+ for (const sub of [
742
+ "src",
743
+ "docs",
744
+ "scratch",
745
+ "scripts"
746
+ ]) mkdirSync(join(dir, sub), { recursive: true });
747
+ function git(args, date) {
748
+ return execFileSync("git", [
749
+ "-c",
750
+ "commit.gpgsign=false",
751
+ ...args
752
+ ], {
753
+ cwd: dir,
754
+ encoding: "utf8",
755
+ stdio: [
756
+ "ignore",
757
+ "pipe",
758
+ "pipe"
759
+ ],
760
+ env: {
761
+ PATH: process.env.PATH ?? "",
762
+ HOME: home,
763
+ LC_ALL: "C",
764
+ TZ: "UTC",
765
+ GIT_AUTHOR_NAME: NAME,
766
+ GIT_AUTHOR_EMAIL: EMAIL,
767
+ GIT_COMMITTER_NAME: NAME,
768
+ GIT_COMMITTER_EMAIL: EMAIL,
769
+ ...date ? {
770
+ GIT_AUTHOR_DATE: date,
771
+ GIT_COMMITTER_DATE: date
772
+ } : {}
773
+ }
774
+ });
775
+ }
776
+ function write(rel, body) {
777
+ writeFileSync(join(dir, rel), body);
778
+ }
779
+ function commit(message, date) {
780
+ git(["add", "-A"]);
781
+ git([
782
+ "commit",
783
+ "-m",
784
+ message
785
+ ], date);
786
+ }
787
+ git([
788
+ "init",
789
+ "-b",
790
+ "main"
791
+ ]);
792
+ git([
793
+ "config",
794
+ "user.name",
795
+ NAME
796
+ ]);
797
+ git([
798
+ "config",
799
+ "user.email",
800
+ EMAIL
801
+ ]);
802
+ write("README.md", "atlas-web demo repo\n");
803
+ write("src/index.js", "export function start() {}\n");
804
+ write("src/api.js", API_INIT);
805
+ write("src/style.css", "body { margin: 0; }\n");
806
+ write("src/util.js", "export const id = (x) => x;\n");
807
+ commit("init", D_INIT);
808
+ write("src/api.js", `${API_INIT}// remote: pinned host\n`);
809
+ commit("remote1", D_REMOTE);
810
+ const originMain = git(["rev-parse", "HEAD"]).trim();
811
+ git([
812
+ "checkout",
813
+ "-b",
814
+ "feature/login-flow",
815
+ "HEAD~1"
816
+ ]);
817
+ write("src/util.js", "export const id = (x) => x;\nexport const tag = (x) => x;\n");
818
+ commit("local1", D_LOCAL1);
819
+ write("src/render.js", "export function render() {}\n");
820
+ commit("local2", D_LOCAL2);
821
+ git([
822
+ "update-ref",
823
+ "refs/remotes/origin/main",
824
+ originMain
825
+ ]);
826
+ git([
827
+ "config",
828
+ "remote.origin.url",
829
+ "../atlas-web.git"
830
+ ]);
831
+ git([
832
+ "config",
833
+ "remote.origin.fetch",
834
+ "+refs/heads/*:refs/remotes/origin/*"
835
+ ]);
836
+ git([
837
+ "config",
838
+ "branch.feature/login-flow.remote",
839
+ "origin"
840
+ ]);
841
+ git([
842
+ "config",
843
+ "branch.feature/login-flow.merge",
844
+ "refs/heads/main"
845
+ ]);
846
+ write("README.md", "atlas-web demo repo (wip)\n");
847
+ git([
848
+ "stash",
849
+ "push",
850
+ "-m",
851
+ "wip: context refactor",
852
+ "--",
853
+ "README.md"
854
+ ], D_STASH);
855
+ write("README.md", "atlas-web demo repo\n\nstaged: pin the render loop\n");
856
+ write("src/index.js", "export function start() {\n return Promise.resolve();\n}\n");
857
+ git([
858
+ "add",
859
+ "README.md",
860
+ "src/index.js"
861
+ ]);
862
+ write("src/api.js", `${API_INIT}// local tweak\n`);
863
+ write("src/style.css", "body { margin: 0; padding: 0; }\n");
864
+ write("docs/wip.txt", "");
865
+ write("scratch/todo.md", "");
866
+ write("scripts/deploy.sh", "");
867
+ write("note1.md", "");
868
+ write("note2.md", "");
869
+ return dir;
870
+ }
871
+ //#endregion
872
+ //#region src/payloads.ts
873
+ var PAYLOADS_DIR = fileURLToPath(new URL("../assets/payloads", import.meta.url));
874
+ var TICKS_DIR = fileURLToPath(new URL("../assets/ticks", import.meta.url));
875
+ var P1_WARM_IN = 1920;
876
+ var P1_RESETS_IN = {
877
+ five_hour: 13830,
878
+ seven_day: 518400,
879
+ spend_limit: 950400
880
+ };
881
+ var PANEL_STEP_S = 300;
882
+ function anchoredP1(repoDir, now) {
883
+ const payload = JSON.parse(readFileSync(join(PAYLOADS_DIR, "p1.json"), "utf8"));
884
+ const cache = payload.prompt_cache;
885
+ if (isObject(cache)) {
886
+ if (cache.warm === true) {
887
+ cache.expires_at = now + P1_WARM_IN;
888
+ cache.last_miss_at = now + P1_WARM_IN - 3900;
889
+ } else {
890
+ cache.expires_at = now - 10;
891
+ delete cache.last_miss_at;
892
+ }
712
893
  }
713
- return members;
714
- }
715
- function rootMemberValueSpan(raw, key) {
716
- const member = rootMembers(raw).find((m) => m.name === key);
717
- return member === void 0 ? null : [member.valueStart, member.valueEnd];
718
- }
719
- function removeMembers(raw, keys) {
720
- let text = raw;
721
- for (const key of keys) {
722
- const members = rootMembers(text);
723
- const at = members.findIndex((m) => m.name === key);
724
- if (at === -1) throw new Error(`cannot find the "${key}" member to remove in settings.json`);
725
- if (members.length === 1) return "{}";
726
- const member = members[at];
727
- if (at > 0) {
728
- let start = member.nameStart;
729
- while (/\s/.test(text[start - 1] ?? "~")) start -= 1;
730
- text = `${text.slice(0, start - 1)}${text.slice(member.valueEnd)}`;
731
- } else text = `${text.slice(0, member.nameStart)}${text.slice(members[1].nameStart)}`;
894
+ const limits = payload.rate_limits;
895
+ if (isObject(limits)) for (const [limitKey, offset] of Object.entries(P1_RESETS_IN)) {
896
+ const limit = limits[limitKey];
897
+ if (isObject(limit)) limit.resets_at = now + offset;
732
898
  }
733
- return text;
899
+ payload.workspace.current_dir = repoDir;
900
+ return `${JSON.stringify(payload, null, 2)}\n`;
734
901
  }
735
- function insertMembers(raw, members) {
736
- const close = raw.lastIndexOf("}");
737
- if (close === -1) throw new Error("settings.json has no closing brace to splice into");
738
- let at = close;
739
- while (/\s/.test(raw[at - 1] ?? "~")) at -= 1;
740
- const comma = raw.replace(/\s/g, "") === "{}" ? "" : ",";
741
- return `${raw.slice(0, at)}${comma}\n ${renderMembers(members)}${raw.slice(at)}`;
902
+ function hasStart(value) {
903
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
742
904
  }
743
- function renderMembers(members) {
744
- return members.map(([key, command]) => `"${key}": ${settingsValue(command)}`).join(",\n ");
905
+ function anchoredTick(now) {
906
+ const tick = JSON.parse(readFileSync(join(TICKS_DIR, "multi.json"), "utf8"));
907
+ const tasks = Array.isArray(tick.tasks) ? tick.tasks : [];
908
+ let started = 0;
909
+ const staggered = {
910
+ ...tick,
911
+ tasks: tasks.map((task) => {
912
+ if (!hasStart(task.startTime)) return task;
913
+ started += 1;
914
+ return {
915
+ ...task,
916
+ startTime: now - PANEL_STEP_S * started
917
+ };
918
+ })
919
+ };
920
+ return `${JSON.stringify(staggered, null, 2)}\n`;
745
921
  }
746
- /** Splice each rendered value over its root member. */
747
- function repointRootMembers(raw, entries) {
748
- let text = raw;
749
- for (const { key, value } of entries) {
750
- const span = rootMemberValueSpan(text, key);
751
- if (span === null) throw new Error(`cannot find the "${key}" member in settings.json`);
752
- text = `${text.slice(0, span[0])}${value}${text.slice(span[1])}`;
922
+ function previewSources(home, now) {
923
+ const mainCapture = capturePath(home, "main");
924
+ if (existsSync(mainCapture)) {
925
+ const tickCapture = capturePath(home, "tick");
926
+ return {
927
+ cleanup: () => void 0,
928
+ main: readFileSync(mainCapture, "utf8"),
929
+ tick: existsSync(tickCapture) ? readFileSync(tickCapture, "utf8") : anchoredTick(now)
930
+ };
753
931
  }
754
- return text;
755
- }
756
- function splicedSettings(raw, plan) {
757
- return repointRootMembers(plan.adds.length > 0 ? insertMembers(raw, plan.adds) : raw, plan.repoints.map(([key, command]) => ({
758
- key,
759
- value: settingsValue(command)
760
- })));
932
+ const demoHome = mkdtempSync(join(tmpdir(), "statusline-preview-"));
933
+ return {
934
+ cleanup: () => {
935
+ rmSync(demoHome, {
936
+ recursive: true,
937
+ force: true
938
+ });
939
+ },
940
+ main: anchoredP1(materializeDemoRepo(demoHome), now),
941
+ tick: anchoredTick(now)
942
+ };
761
943
  }
762
- function writeBackupIfAbsent(home, plan) {
763
- const file = backupPath(home);
764
- if (existsSync(file)) return;
765
- const keys = {};
766
- if (plan.raw !== null) for (const key of SETTINGS_KEYS) {
767
- const span = rootMemberValueSpan(plan.raw, key);
768
- if (span === null) continue;
769
- const memberRaw = plan.raw.slice(span[0], span[1]);
770
- if (isOurMember(key, JSON.parse(memberRaw))) continue;
771
- keys[key] = memberRaw;
772
- }
773
- const dir = dirname(file);
774
- mkdirSync(dir, { recursive: true });
775
- const tmp = `${file}.tmp`;
776
- writeFileSync(tmp, `${JSON.stringify({
777
- createdFile: plan.raw === null,
778
- keys
779
- }, null, 2)}\n`);
780
- renameSync(tmp, file);
944
+ function firstPanelRow(output) {
945
+ const [row] = output.split("\n");
946
+ if (row === "") return "";
947
+ const content = JSON.parse(row).content;
948
+ return typeof content === "string" ? content : "";
781
949
  }
782
- function commitSettings(plan) {
783
- if (plan.raw === null) {
784
- mkdirSync(dirname(plan.file), { recursive: true });
785
- const members = plan.adds.map(([key, command]) => `"${key}": ${settingsValue(command)}`).join(",\n ");
786
- writeFileSync(plan.file, `{\n ${members}\n}\n`);
787
- return;
788
- }
789
- if (plan.adds.length === 0 && plan.repoints.length === 0) return;
790
- writeFileSync(plan.file, splicedSettings(plan.raw, plan));
950
+ function runtimeRenderer(spec) {
951
+ const run = spawnSync("bash", [spec.bin], {
952
+ input: spec.stdin,
953
+ env: {
954
+ PATH: process.env.PATH ?? "",
955
+ ...spec.env,
956
+ LC_ALL: "C",
957
+ TZ: "UTC"
958
+ },
959
+ timeout: 3e4
960
+ });
961
+ if (run.status !== 0) throw new Error(`render failed: ${run.stderr.toString("utf8")}`);
962
+ const stdout = run.stdout.toString("utf8");
963
+ const warn = run.stderr.toString("utf8").trim();
964
+ return warn === "" ? stdout : `${stdout}${warn}\n`;
791
965
  }
792
- function renderPreview(runtime, home, values, layout, now) {
793
- const sources = previewSources(home, Number(now));
794
- try {
795
- const variantEnv = Object.fromEntries(Object.entries(values).map(([item, alt]) => [`STATUSLINE_LAB_${item.toUpperCase()}`, alt]));
796
- return `dry-run at 200 columns — nothing written\n${runtimeRenderer({
797
- bin: join(runtime.dir, "statusline.sh"),
966
+ function renderPreview(bag) {
967
+ const variantEnv = Object.fromEntries(Object.entries(bag.values).map(([item, alt]) => [`STATUSLINE_LAB_${item.toUpperCase()}`, alt]));
968
+ const plain = bag.plain === true || (process.env.NO_COLOR ?? "") !== "";
969
+ const env = {
970
+ ...variantEnv,
971
+ ...plain ? { NO_COLOR: "1" } : {}
972
+ };
973
+ return {
974
+ line: runtimeRenderer({
975
+ bin: join(bag.runtime.dir, "statusline.sh"),
798
976
  env: {
799
- COLUMNS: "200",
800
- HOME: home,
801
- NOW: now,
802
- STATUSLINE_LAB_LAYOUT: layout,
803
- ...variantEnv
977
+ ...env,
978
+ COLUMNS: String(bag.width ?? 200),
979
+ HOME: bag.home,
980
+ NOW: bag.now,
981
+ STATUSLINE_LAB_LAYOUT: bag.layout
804
982
  },
805
- stdin: sources.main
806
- }).replace(/\n+$/, "")}\npanel ${firstPanelRow(runtimeRenderer({
807
- bin: join(runtime.dir, "subagent.sh"),
983
+ stdin: bag.main
984
+ }).replace(/\n+$/, ""),
985
+ panel: firstPanelRow(runtimeRenderer({
986
+ bin: join(bag.runtime.dir, "subagent.sh"),
808
987
  env: {
809
- COLUMNS: "200",
810
- HOME: home,
811
- NOW: now
988
+ ...env,
989
+ HOME: bag.home,
990
+ NOW: bag.now
812
991
  },
813
- stdin: sources.tick
814
- }).replace(/\n+$/, ""))}\n`;
815
- } finally {
816
- sources.cleanup();
817
- }
818
- }
819
- function printedConfig(runtime, home) {
820
- const existing = readKeyConfig(home);
821
- return [...[`layout='${existing.layout ?? runtime.defaultLayout}'`, ...runtime.items.map((item) => `${item.item}=${existingValue(existing.values, item.item) ?? item.default}`)], "nothing written — pass variants (`statusline configure --model block`) or run bare on a TTY for the wizard"].join("\n");
822
- }
823
- function existingValue(values, item) {
824
- return item in values ? values[item] : void 0;
992
+ stdin: bag.tick
993
+ }).replace(/\n+$/, ""))
994
+ };
825
995
  }
826
- function configure(options) {
996
+ //#endregion
997
+ //#region src/preview.ts
998
+ function preview(options) {
827
999
  const runtime = resolveRuntime({ home: options.home });
828
- const existing = readKeyConfig(options.home);
829
- const variants = options.variants ?? {};
830
- const explicit = options.layout !== void 0 || Object.keys(variants).length > 0;
831
- if (!explicit && options.fallback === void 0 && !options.dryRun) return {
832
- mode: "printed",
833
- text: printedConfig(runtime, options.home)
834
- };
835
- const layout = options.layout === void 0 || options.layout === "" ? existing.layout ?? runtime.defaultLayout : options.layout;
836
- const byItem = new Map(runtime.items.map((item) => [item.item, item]));
837
- const items = layoutItems(layout, runtime.items.map((item) => item.item));
838
- for (const item of Object.keys(variants)) {
839
- if (!byItem.has(item)) throw new Error(`unknown item '${item}' — valid items: ${[...byItem.keys()].join(" ")}`);
840
- if (!items.includes(item)) throw new Error(`variant for '${item}' is not in the layout (layout items: ${items.join(" ")})`);
841
- }
842
- const values = {};
843
- for (const item of items) {
844
- let base;
845
- if (options.fallback === "default") base = byItem.get(item)?.default;
846
- else if (options.fallback === "existing") base = existingValue(existing.values, item);
847
- else if (!explicit) base = existingValue(existing.values, item) ?? byItem.get(item)?.default;
848
- if (base !== void 0) values[item] = base;
849
- }
850
- const missing = items.filter((item) => !(item in values) && !(item in variants));
851
- if (missing.length > 0) throw new Error(`no variant for layout item${missing.length > 1 ? "s" : ""} ${missing.join(" ")} — pass --<item> <alt> for each or use --fallback=default|existing`);
852
- for (const [item, alt] of Object.entries(variants)) values[item] = alt;
853
- for (const item of items) {
854
- const alt = values[item];
855
- const offered = byItem.get(item)?.alternatives ?? [];
856
- if (!offered.includes(alt)) throw new Error(`unknown variant '${alt}' for item '${item}' — valid: ${offered.join(" | ")}`);
857
- }
858
- if (options.dryRun) {
859
- const now = String(Math.floor(Date.now() / 1e3));
860
- return {
861
- mode: "dry-run",
862
- text: renderPreview(runtime, options.home, values, layout, now)
863
- };
1000
+ const { layout, values } = resolveSelection(runtime, options);
1001
+ const sources = previewSources(options.home, Number(options.now));
1002
+ try {
1003
+ const { line, panel } = renderPreview({
1004
+ home: options.home,
1005
+ layout,
1006
+ main: sources.main,
1007
+ now: options.now,
1008
+ plain: options.plain ?? !process.stdout.isTTY,
1009
+ runtime,
1010
+ tick: sources.tick,
1011
+ values
1012
+ });
1013
+ return `preview at 200 columns — nothing written\n${line}\npanel ${panel}\n`;
1014
+ } finally {
1015
+ sources.cleanup();
864
1016
  }
865
- const assignments = items.map((item) => `STATUSLINE_LAB_${item.toUpperCase()}=${values[item]}`);
866
- const plan = planSettings(options.home, mainKeyValue(layout, assignments), options.force ?? false);
867
- if (plan.adds.length > 0 || plan.repoints.length > 0) writeBackupIfAbsent(options.home, plan);
868
- commitSettings(plan);
869
- return { mode: "written" };
870
1017
  }
871
1018
  //#endregion
872
1019
  //#region src/restore.ts
@@ -998,8 +1145,8 @@ function keyState(key, value) {
998
1145
  }
999
1146
  function keyRow(key, state, detail = "") {
1000
1147
  if (state.kind === "ours") return `${key}: ours${detail === "" ? "" : ` — ${detail}`}`;
1001
- if (state.kind === "absent") return `${key}: absent — fix: rerun configure --fallback=default`;
1002
- return `${key}: foreign${state.command === null ? "" : ` (${state.command})`} — fix: rerun configure --force --fallback=default`;
1148
+ if (state.kind === "absent") return `${key}: absent — fix: rerun configure --theme classic`;
1149
+ return `${key}: foreign${state.command === null ? "" : ` (${state.command})`} — fix: rerun configure --force --theme classic`;
1003
1150
  }
1004
1151
  function layoutItemsOf(layout) {
1005
1152
  const items = [];
@@ -1040,10 +1187,9 @@ function runtimeRow(runtime) {
1040
1187
  if (runtime === null) return `runtime: missing — fix: ${INSTALL_FIX}`;
1041
1188
  return `runtime: ${basename(dirname(runtime.dir))} — ${runtime.items.length} items`;
1042
1189
  }
1043
- function configRow(findings, runtime) {
1190
+ function configRow(findings) {
1044
1191
  if (findings.length === 0) return "config: no drift";
1045
- const fix = findings.some((finding) => finding.kind === "unknown-item") ? `rerun configure --layout '${runtime.defaultLayout}' --fallback=default` : "rerun configure --fallback=default";
1046
- return `config: drift — ${findings.map(findingText).join(", ")} — fix: ${fix}`;
1192
+ return `config: drift — ${findings.map(findingText).join(", ")} — fix: rerun configure --theme classic`;
1047
1193
  }
1048
1194
  function backupRow(home) {
1049
1195
  let backup;
@@ -1092,12 +1238,13 @@ function status(options) {
1092
1238
  const main = keyState("statusLine", members.statusLine);
1093
1239
  const subagent = keyState("subagentStatusLine", members.subagentStatusLine);
1094
1240
  const config = readKeyConfig(options.home);
1241
+ const theme = runtime === null ? void 0 : liveTheme(config, runtime);
1095
1242
  const findings = runtime === null || main.kind !== "ours" ? [] : driftFindings(config, runtime);
1096
1243
  const rows = [
1097
1244
  runtimeRow(runtime),
1098
1245
  keyRow("statusLine", main, configDetail(config)),
1099
1246
  keyRow("subagentStatusLine", subagent),
1100
- ...runtime !== null && main.kind === "ours" ? [configRow(findings, runtime)] : [],
1247
+ ...runtime !== null && main.kind === "ours" ? [configRow(findings), ...theme === void 0 ? [] : [`theme: ${theme}`]] : [],
1101
1248
  backupRow(options.home),
1102
1249
  capturesRow(options.home)
1103
1250
  ];
@@ -1165,8 +1312,8 @@ function terminalDeps(input = process.stdin, output = process.stdout) {
1165
1312
  render(frame) {
1166
1313
  output.write(`\x1b[2J\x1b[H${frame}\n`);
1167
1314
  },
1168
- preview(spec) {
1169
- return runtimeRenderer(spec);
1315
+ preview(bag) {
1316
+ return renderPreview(bag);
1170
1317
  }
1171
1318
  };
1172
1319
  }
@@ -1177,41 +1324,54 @@ var WIDTHS = [
1177
1324
  120,
1178
1325
  200
1179
1326
  ];
1180
- var KEYMAP = "j/k move · h/l variant · s none · w width · enter save · q cancel";
1327
+ var THEME_KEYMAP = "j/k focus · w width · enter pick · q cancel";
1328
+ var REFINE_KEYMAP = "j/k move · h/l variant · s none · t themes · w width · enter save · q cancel";
1329
+ function opensWizard(args, isTty) {
1330
+ return isTty && args.layout === void 0 && args.theme === void 0 && args.variants === void 0;
1331
+ }
1181
1332
  async function createWizard(options, deps) {
1182
1333
  const runtime = resolveRuntime({ home: options.home });
1183
- const existing = readKeyConfig(options.home);
1184
- const layout = existing.layout ?? runtime.defaultLayout;
1334
+ const themes = themesFor(runtime);
1335
+ const names = Object.keys(themes);
1185
1336
  const byItem = new Map(runtime.items.map((item) => [item.item, item]));
1186
- const names = layoutItems(layout, runtime.items.map((item) => item.item));
1187
- const offered = [];
1188
- const draft = /* @__PURE__ */ new Map();
1189
- for (const name of names) {
1190
- const entry = byItem.get(name);
1191
- if (entry === void 0) continue;
1192
- offered.push({
1193
- alternatives: entry.alternatives,
1194
- item: name
1195
- });
1196
- draft.set(name, name in existing.values && entry.alternatives.includes(existing.values[name]) ? existing.values[name] : entry.default);
1197
- }
1198
1337
  const sources = previewSources(options.home, Number(options.now));
1199
- const mainBin = join(runtime.dir, "statusline.sh");
1200
- const panelBin = join(runtime.dir, "subagent.sh");
1338
+ const tickBase = JSON.parse(sources.tick);
1339
+ let pass = "themes";
1340
+ let themeAt = 0;
1341
+ let draftLayout = "";
1342
+ let offered = [];
1343
+ const draft = /* @__PURE__ */ new Map();
1201
1344
  let focus = 0;
1202
1345
  let widthAt = 0;
1203
- function variantEnv() {
1204
- return Object.fromEntries([...draft].map(([item, alt]) => [`STATUSLINE_LAB_${item.toUpperCase()}`, alt]));
1205
- }
1206
- function env() {
1346
+ function bag(layout, values) {
1207
1347
  return {
1208
- COLUMNS: String(WIDTHS[widthAt]),
1209
- HOME: options.home,
1210
- NOW: options.now,
1211
- STATUSLINE_LAB_LAYOUT: layout,
1212
- ...variantEnv()
1348
+ home: options.home,
1349
+ layout,
1350
+ main: sources.main,
1351
+ now: options.now,
1352
+ runtime,
1353
+ tick: `${JSON.stringify({
1354
+ ...tickBase,
1355
+ columns: WIDTHS[widthAt]
1356
+ }, null, 2)}\n`,
1357
+ values,
1358
+ width: WIDTHS[widthAt]
1213
1359
  };
1214
1360
  }
1361
+ function enterRefine(name) {
1362
+ const theme = themes[name];
1363
+ draftLayout = theme.layout;
1364
+ offered = layoutItems(theme.layout, runtime.items.map((item) => item.item)).flatMap((item) => {
1365
+ const entry = byItem.get(item);
1366
+ return entry === void 0 ? [] : [{
1367
+ alternatives: entry.alternatives,
1368
+ item
1369
+ }];
1370
+ });
1371
+ draft.clear();
1372
+ for (const [item, alt] of Object.entries(theme.variants)) draft.set(item, alt);
1373
+ focus = 0;
1374
+ }
1215
1375
  function cycle(delta) {
1216
1376
  const focused = offered.at(focus);
1217
1377
  if (focused === void 0 || focused.alternatives.length === 0) return;
@@ -1220,63 +1380,79 @@ async function createWizard(options, deps) {
1220
1380
  draft.set(focused.item, alts[(at + delta + alts.length) % alts.length]);
1221
1381
  }
1222
1382
  function step(key) {
1223
- if (key === "\x1B[B" || key === "j") focus = (focus + 1) % offered.length;
1224
- else if (key === "\x1B[A" || key === "k") focus = (focus + offered.length - 1) % offered.length;
1383
+ const down = key === "\x1B[B" || key === "j";
1384
+ const up = key === "\x1B[A" || key === "k";
1385
+ if (key === "w") widthAt = (widthAt + 1) % WIDTHS.length;
1386
+ else if (pass === "themes") {
1387
+ if (down) themeAt = (themeAt + 1) % names.length;
1388
+ else if (up) themeAt = (themeAt + names.length - 1) % names.length;
1389
+ } else if (key === "t") return "themes";
1390
+ else if (down) focus = (focus + 1) % offered.length;
1391
+ else if (up) focus = (focus + offered.length - 1) % offered.length;
1225
1392
  else if (key === "\x1B[C" || key === "l") cycle(1);
1226
1393
  else if (key === "\x1B[D" || key === "h") cycle(-1);
1227
1394
  else if (key === "s") {
1228
1395
  const focused = offered.at(focus);
1229
1396
  if (focused?.alternatives.includes("none")) draft.set(focused.item, "none");
1230
- } else if (key === "w") widthAt = (widthAt + 1) % WIDTHS.length;
1397
+ }
1398
+ return pass;
1231
1399
  }
1232
- function draw() {
1400
+ function drawThemes() {
1401
+ const rows = [];
1402
+ let panel = "";
1403
+ for (let at = 0; at < names.length; at += 1) {
1404
+ const name = names[at];
1405
+ const theme = themes[name];
1406
+ const surfaces = deps.preview(bag(theme.layout, theme.variants));
1407
+ const focused = at === themeAt;
1408
+ if (focused) panel = surfaces.panel;
1409
+ rows.push(`${focused ? ">" : " "} ${name}: ${theme.summary}`);
1410
+ rows.push(` ${surfaces.line}`);
1411
+ }
1412
+ deps.render([
1413
+ `statusline configure · ${WIDTHS[widthAt]} columns (w cycles)`,
1414
+ "",
1415
+ ...rows,
1416
+ "",
1417
+ `panel ${panel}`,
1418
+ "",
1419
+ THEME_KEYMAP
1420
+ ].join("\n"));
1421
+ }
1422
+ function drawRefine() {
1233
1423
  const focused = offered.at(focus);
1234
1424
  if (focused === void 0) return;
1235
- const samples = focused.alternatives.map((alt) => deps.preview({
1236
- bin: mainBin,
1237
- env: {
1238
- ...env(),
1239
- STATUSLINE_LAB_LAYOUT: `{${focused.item}}`,
1240
- [`STATUSLINE_LAB_${focused.item.toUpperCase()}`]: alt
1241
- },
1242
- stdin: sources.main
1243
- }).replace(/\n+$/, ""));
1244
- const line = deps.preview({
1245
- bin: mainBin,
1246
- env: env(),
1247
- stdin: sources.main
1248
- }).replace(/\n+$/, "");
1249
- const tick = JSON.parse(sources.tick);
1250
- tick.columns = WIDTHS[widthAt];
1251
- const panel = deps.preview({
1252
- bin: panelBin,
1253
- env: {
1254
- COLUMNS: String(WIDTHS[widthAt]),
1255
- HOME: options.home,
1256
- NOW: options.now
1257
- },
1258
- stdin: `${JSON.stringify(tick, null, 2)}\n`
1259
- }).replace(/\n+$/, "");
1425
+ const values = Object.fromEntries(draft);
1426
+ const samples = focused.alternatives.map((alt) => deps.preview(bag(`{${focused.item}}`, {
1427
+ ...values,
1428
+ [focused.item]: alt
1429
+ })).line);
1430
+ const full = deps.preview(bag(draftLayout, values));
1260
1431
  const rows = offered.map((entry, at) => `${at === focus ? ">" : " "} ${entry.item.padEnd(9)} ${draft.get(entry.item)}`);
1261
1432
  deps.render([
1262
1433
  `statusline configure · ${WIDTHS[widthAt]} columns (w cycles)`,
1263
1434
  "",
1264
- ` ${line}`,
1265
- ` panel ${firstPanelRow(panel)}`,
1435
+ ` ${full.line}`,
1436
+ ` panel ${full.panel}`,
1266
1437
  "",
1267
1438
  `${focused.item}: ${focused.alternatives.join(" | ")}`,
1268
1439
  ...focused.alternatives.map((alt, at) => ` ${alt === draft.get(focused.item) ? "*" : " "} ${samples[at]}`),
1269
1440
  "",
1270
1441
  ...rows,
1271
1442
  "",
1272
- KEYMAP
1443
+ REFINE_KEYMAP
1273
1444
  ].join("\n"));
1274
1445
  }
1446
+ function draw() {
1447
+ if (pass === "themes") drawThemes();
1448
+ else drawRefine();
1449
+ }
1275
1450
  function finish() {
1276
1451
  try {
1277
1452
  configure({
1453
+ force: options.force,
1278
1454
  home: options.home,
1279
- layout,
1455
+ layout: draftLayout,
1280
1456
  variants: Object.fromEntries(draft)
1281
1457
  });
1282
1458
  } catch (e) {
@@ -1290,9 +1466,17 @@ async function createWizard(options, deps) {
1290
1466
  const keys = deps.readKeys();
1291
1467
  draw();
1292
1468
  for await (const key of keys) {
1293
- if (key === "\r") return finish();
1469
+ if (key === "\r") {
1470
+ if (pass === "themes") {
1471
+ enterRefine(names[themeAt]);
1472
+ pass = "refine";
1473
+ draw();
1474
+ continue;
1475
+ }
1476
+ return finish();
1477
+ }
1294
1478
  if (key === "q" || key === "") break;
1295
- step(key);
1479
+ pass = step(key);
1296
1480
  draw();
1297
1481
  }
1298
1482
  deps.render("cancelled — nothing written\n");
@@ -1322,28 +1506,39 @@ else if (parsed.version) console.log(VERSION);
1322
1506
  else if (parsed.command === "catalog") run(() => {
1323
1507
  console.log(catalog({
1324
1508
  home: homeOf(parsed.home),
1325
- items: parsed.items
1509
+ items: parsed.items,
1510
+ themes: parsed.themes
1326
1511
  }));
1327
1512
  });
1328
1513
  else if (parsed.command === "configure") {
1329
1514
  const home = homeOf(parsed.home);
1330
- if (parsed.layout === void 0 && parsed.variants === void 0 && parsed.fallback === void 0 && !parsed.dryRun && !parsed.force && process.stdin.isTTY) {
1515
+ if (opensWizard(parsed, process.stdin.isTTY)) {
1331
1516
  if (await createWizard({
1517
+ force: parsed.force,
1332
1518
  home,
1333
1519
  now: String(Math.floor(Date.now() / 1e3))
1334
1520
  }, terminalDeps()) === "save-failed") process.exitCode = 1;
1335
1521
  } else run(() => {
1336
- const result = configure({
1337
- dryRun: parsed.dryRun,
1338
- fallback: parsed.fallback,
1522
+ configure({
1339
1523
  force: parsed.force,
1340
1524
  home,
1341
1525
  layout: parsed.layout,
1526
+ theme: parsed.theme,
1342
1527
  variants: parsed.variants
1343
1528
  });
1344
- console.log(result.mode === "written" ? "configured — live on the next paint" : result.text);
1529
+ console.log("configured — live on the next paint");
1345
1530
  });
1346
- } else if (parsed.command === "restore") run(() => {
1531
+ } else if (parsed.command === "preview") run(() => {
1532
+ console.log(preview({
1533
+ home: homeOf(parsed.home),
1534
+ layout: parsed.layout,
1535
+ now: String(Math.floor(Date.now() / 1e3)),
1536
+ plain: parsed.plain,
1537
+ theme: parsed.theme,
1538
+ variants: parsed.variants
1539
+ }));
1540
+ });
1541
+ else if (parsed.command === "restore") run(() => {
1347
1542
  const result = restore({
1348
1543
  dryRun: parsed.dryRun,
1349
1544
  force: parsed.force,