@promptctl/cc-candybar 1.0.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,117 @@
1
+ // Minimal end-to-end demo of the segment DSL render spine.
2
+ //
3
+ // pnpm demo:dsl # renders src/demo/statusline.json5
4
+ // pnpm demo:dsl path/to/other.json5 # renders any DSL config
5
+ //
6
+ // [LAW:single-enforcer] This renders through registerDslConfig + renderDsl
7
+ // — the exact spine the daemon calls. There is no demo-only render path; what
8
+ // prints here is what production produces.
9
+ //
10
+ // [LAW:dataflow-not-control-flow] The body is straight-line: read config →
11
+ // register → render frames → dispose. The config file and payload are data;
12
+ // swapping either changes the output without changing this code. Rendering N
13
+ // frames over time is not branching — it lets the asynchronous sources (shell,
14
+ // time) populate the store and shows the line come alive, exactly as the daemon
15
+ // re-renders on each status-line tick.
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { dirname, join } from "node:path";
20
+ import process from "node:process";
21
+ import { setTimeout as sleep } from "node:timers/promises";
22
+
23
+ import {
24
+ parseDslConfig,
25
+ mergeWithDefault,
26
+ validateConfig,
27
+ } from "../config/dsl-loader.js";
28
+ import { VariableStore } from "../var-system/store.js";
29
+ import { SourceRegistry } from "../var-system/sources.js";
30
+ import { listResolvablePaletteNames } from "../themes/policy.js";
31
+ import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
32
+ import { registerDslConfig, renderDsl } from "../dsl/render.js";
33
+ import { DEFAULT_TERMINAL_WIDTH } from "../render/strip.js";
34
+ import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
35
+
36
+ const FRAMES = 4;
37
+ const FRAME_INTERVAL_MS = 450;
38
+
39
+ const here = dirname(fileURLToPath(import.meta.url));
40
+ const configPath = process.argv[2] ?? join(here, "statusline.json5");
41
+ const source = readFileSync(configPath, "utf-8");
42
+
43
+ // [LAW:one-source-of-truth] The palette names the loader accepts are exactly
44
+ // the names the renderer can resolve — both derive from the same registry, so
45
+ // we hand the loader the live set rather than a hand-maintained copy.
46
+ //
47
+ // Full three-stage pipeline: parse → merge → validate. The renderer accepts
48
+ // only `ValidatedConfig`, so the chain is type-enforced.
49
+ const ALLOWED = new Set(listResolvablePaletteNames());
50
+ const raw = parseDslConfig(configPath, source, ALLOWED);
51
+ const merged = mergeWithDefault(raw);
52
+ const config = validateConfig(merged, configPath, source, ALLOWED);
53
+
54
+ // One Claude Code status-line hook event, faked. The `input` vars in the
55
+ // config (cwd, model, session) read their values out of this object.
56
+ const payload = {
57
+ hook_event_name: "Status",
58
+ session_id: "demo0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b",
59
+ cwd: process.cwd(),
60
+ model: { id: "claude-opus-4-7", display_name: "Opus 4.7" },
61
+ workspace: {
62
+ current_dir: process.cwd(),
63
+ project_dir: process.cwd(),
64
+ },
65
+ };
66
+
67
+ // The demo has no SessionState; the effective theme is just the config default.
68
+ const basePalette = resolverForThemeName(
69
+ effectiveThemeName(null, config.globals.palette),
70
+ );
71
+
72
+ // A fresh store + registry for this run. (A hot-reloading daemon would
73
+ // dispose() the old pair and build new ones — see registerDslConfig's docs.)
74
+ // registerDslConfig wires the time/shell sources' timers and watchers onto the
75
+ // registry, so dispose() must run even if registration or rendering throws —
76
+ // otherwise those handles keep the process alive. try/finally guarantees it.
77
+ const store = new VariableStore();
78
+ const registry = new SourceRegistry(store);
79
+ try {
80
+ const compiled = registerDslConfig(config, registry, {
81
+ cwd: process.cwd(),
82
+ store,
83
+ });
84
+
85
+ process.stdout.write(
86
+ `\n DSL demo — ${configPath}\n` +
87
+ ` rendered through registerDslConfig + renderDsl (the daemon's spine)\n` +
88
+ ` watch the git branch segment appear and the clock tick:\n\n`,
89
+ );
90
+
91
+ for (let frame = 0; frame < FRAMES; frame++) {
92
+ const line = renderDsl(
93
+ config,
94
+ compiled,
95
+ store,
96
+ registry,
97
+ payload,
98
+ basePalette,
99
+ {
100
+ style: "powerline",
101
+ colorCompatibility: "truecolor",
102
+ // [LAW:one-source-of-truth] Demo applies the same Claude-Code-UI
103
+ // reserve the daemon does so demo output matches the bytes a real
104
+ // statusline would emit at the same terminal width.
105
+ width: applyClaudeCodeReserve(
106
+ process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
107
+ ),
108
+ },
109
+ );
110
+ process.stdout.write(` ${line}\n`);
111
+ if (frame < FRAMES - 1) await sleep(FRAME_INTERVAL_MS);
112
+ }
113
+
114
+ process.stdout.write("\n");
115
+ } finally {
116
+ registry.dispose();
117
+ }
@@ -0,0 +1,67 @@
1
+ export interface MockSegment {
2
+ type: string;
3
+ text: string;
4
+ }
5
+
6
+ export interface MockSample {
7
+ name: string;
8
+ segments: MockSegment[];
9
+ }
10
+
11
+ export const MOCK_SAMPLES: MockSample[] = [
12
+ {
13
+ name: "Default Session",
14
+ segments: [
15
+ { type: "directory", text: "~/projects/my-app" },
16
+ { type: "git", text: "main ✗" },
17
+ { type: "model", text: "Claude Sonnet 4" },
18
+ { type: "session", text: "$2.34 · 45k tok" },
19
+ { type: "context", text: "38%" },
20
+ ],
21
+ },
22
+ {
23
+ name: "Heavy Usage",
24
+ segments: [
25
+ { type: "directory", text: "~/work/api-server" },
26
+ { type: "git", text: "feat/auth ✗ +3" },
27
+ { type: "model", text: "Claude Opus 4" },
28
+ { type: "session", text: "$12.87 · 156k tok" },
29
+ { type: "context", text: "72%" },
30
+ { type: "block", text: "blk #3 $4.20" },
31
+ { type: "today", text: "today $18.50" },
32
+ ],
33
+ },
34
+ {
35
+ name: "Critical Context",
36
+ segments: [
37
+ { type: "directory", text: "~/big-project" },
38
+ { type: "git", text: "main ✓" },
39
+ { type: "model", text: "Claude Opus 4" },
40
+ { type: "session", text: "$45.20 · 198k tok" },
41
+ { type: "contextCritical", text: "95% ⚠" },
42
+ { type: "metrics", text: "1.2s 42msg" },
43
+ ],
44
+ },
45
+ {
46
+ name: "Full Segments",
47
+ segments: [
48
+ { type: "directory", text: "~/code/toolkit" },
49
+ { type: "git", text: "fix/bug-123 ↑2 ↓1" },
50
+ { type: "model", text: "Claude Sonnet 4" },
51
+ { type: "session", text: "$5.67 · 78k tok" },
52
+ { type: "block", text: "blk #7 $1.23" },
53
+ { type: "today", text: "today $22.10" },
54
+ { type: "context", text: "45%" },
55
+ { type: "metrics", text: "0.8s 89msg +420 -180" },
56
+ { type: "version", text: "v1.2.3" },
57
+ { type: "weekly", text: "wk $87.30" },
58
+ ],
59
+ },
60
+ {
61
+ name: "Minimal",
62
+ segments: [
63
+ { type: "directory", text: "~" },
64
+ { type: "model", text: "Claude" },
65
+ ],
66
+ },
67
+ ];
@@ -0,0 +1,92 @@
1
+ // Demo DSL statusline — a runnable example of the segment DSL.
2
+ //
3
+ // Run it: pnpm demo:dsl
4
+ // Hack it: edit a template / palette / layout entry below and re-run.
5
+ // No rebuild — the config IS the program.
6
+ //
7
+ // This file is rendered by the SAME two functions the daemon calls
8
+ // (registerDslConfig + renderDsl in src/dsl/render.ts). There is no
9
+ // demo-only render path — what you see here is what production produces.
10
+ {
11
+ globals: {
12
+ // Base palette for every segment that doesn't pull its own (see `branch`).
13
+ palette: 'textual-dark',
14
+ },
15
+
16
+ // ── Variables: where each segment's data comes from ──────────────────────
17
+ // Every `kind` maps to one runtime source. Templates reference these by name
18
+ // with a leading dot (`.user`, `.cwd`, …). A referenced-but-undeclared name
19
+ // is a load-time error, not a silent blank.
20
+ variables: {
21
+ // env — read straight from the process environment.
22
+ user: { kind: 'env', name: 'USER', default: 'anon' },
23
+
24
+ // input — pulled from the JSON payload Claude Code sends each render tick.
25
+ cwd: { kind: 'input', path: 'workspace.current_dir', default: '?' },
26
+ model: { kind: 'input', path: 'model.display_name', default: '' },
27
+ session: { kind: 'input', path: 'session_id', default: '' },
28
+
29
+ // template — derived from other vars; recomputed reactively when they move.
30
+ sid: { kind: 'template', template: '{{ trunc 8 .session }}' },
31
+ here: { kind: 'template', template: '{{ basename .cwd }}' },
32
+
33
+ // shell — run a command, cache the result for a TTL window. Populates
34
+ // asynchronously: until the command returns, `.branch` is the default ''.
35
+ branch: { kind: 'shell', command: 'git branch --show-current', cache: { ttl: '5s' }, default: '' },
36
+
37
+ // time — the system clock, formatted with a Go reference-time layout.
38
+ clock: { kind: 'time', layout: '15:04:05', cache: { ttl: '1s' } },
39
+
40
+ // literal — a fixed value read like any other var. Here it drives the
41
+ // per-segment hue rotation (HUE_STEP_VAR): adjacent segments rotate 14° so
42
+ // they stay distinct. Make it a `state` var + a stepper widget to adjust it
43
+ // live; a literal is the fixed-value form.
44
+ 'hue.step': { kind: 'literal', value: 14 },
45
+
46
+ // (file and git kinds exist too — omitted here to stay minimal.)
47
+ },
48
+
49
+ // ── Segments: how each piece of data is drawn ────────────────────────────
50
+ // `bg`/`fg` are palette spec strings. `fg: 'auto'` picks a readable foreground
51
+ // for the segment's background. `when` hides a segment when it evaluates false.
52
+ segments: {
53
+ user: {
54
+ template: ' {{ .user }} ',
55
+ bg: 'primary',
56
+ fg: 'auto',
57
+ },
58
+ directory: {
59
+ template: ' {{ .here }} ',
60
+ bg: 'surface',
61
+ fg: 'foreground',
62
+ },
63
+ branch: {
64
+ template: ' {{ .branch }} ',
65
+ bg: 'accent',
66
+ fg: 'auto',
67
+ when: '{{ ne .branch "" }}', // hidden entirely outside a git repo
68
+ palette: 'gruvbox', // this segment pulls its OWN palette (per-segment switch)
69
+ },
70
+ model: {
71
+ template: ' {{ .model }} ',
72
+ bg: 'secondary',
73
+ fg: 'auto',
74
+ when: '{{ ne .model "" }}',
75
+ },
76
+ session: {
77
+ template: ' ⌗{{ .sid }} ',
78
+ bg: 'surface',
79
+ fg: 'foreground',
80
+ },
81
+ clock: {
82
+ template: ' {{ .clock }} ',
83
+ bg: 'primary',
84
+ fg: 'auto',
85
+ },
86
+ },
87
+
88
+ // ── Layout: Option A shape grammar — one horizontal row of segment refs.
89
+ // { v: [...] } for multiple rows; { h: ['a', 'b'], when: '...' } for a gated row;
90
+ // bare string 'segname' for a single segment ref.
91
+ root: { h: ['user', 'directory', 'branch', 'model', 'session', 'clock'] },
92
+ }
@@ -0,0 +1,281 @@
1
+ // [LAW:single-enforcer] THE node-type registry: the one place each layout node
2
+ // kind's render-time behavior (compile + render) is defined, dispatched through a
3
+ // single typed lookup. The walk is ONE uniform dispatch:
4
+ // nodeType(node.kind).render(node, ctx).
5
+ //
6
+ // [LAW:one-type-per-behavior] The layout is exactly two kinds — `container`
7
+ // (arranges children) and `segment` (THE unit of rendering: one ref into the
8
+ // named segments map, rendered to ONE strip item). Interaction, state-driven
9
+ // display, and multi-region clickability all live in a segment's TEMPLATE, not
10
+ // in extra node kinds — so there is no inline/stepper/picker node arm to add.
11
+ // A horizontal run of segments is spelled `{ h: ["seg1", "seg2"] }` in the
12
+ // A-grammar (the `cells` form and `layout` rows were deleted in 2de.19).
13
+ //
14
+ // [LAW:one-way-deps] This module sits BELOW render.ts (the driver): it imports
15
+ // the leaf render/template helpers directly and receives the two recursive
16
+ // capabilities (compileChild, renderChild) + the hue counter as DATA from the
17
+ // driver. It must NOT import render.ts — that would invert the layering. render.ts
18
+ // imports the compiled types + nodeType() from here, one-way.
19
+ //
20
+ // Hue is per-segment DECORATIVE only: each `segment` advances the cursor by one
21
+ // unit (a container advances none), so colors stay positionally stable. It
22
+ // carries NO structural meaning — unit cohesion is structural (one segment = one
23
+ // strip item), not a function of matching backgrounds.
24
+
25
+ import { RichText } from "@promptctl/rich-js";
26
+ import type { PaletteResolver } from "@promptctl/rich-js";
27
+ import type { Template } from "@promptctl/go-template-js";
28
+ import type {
29
+ LayoutNode,
30
+ Direction,
31
+ SegmentDecl,
32
+ } from "../config/dsl-types.js";
33
+ import { splitCellsIntoLines } from "../render/split-lines.js";
34
+ import { transposedResolver } from "../themes/index.js";
35
+ import {
36
+ fragmentsToCells,
37
+ evaluateWhen,
38
+ applySegmentLayout,
39
+ resolveSegmentColors,
40
+ } from "../template-engine/index.js";
41
+
42
+ // ─── Compiled node shapes ──────────────────────────────────────────────────────
43
+
44
+ // [LAW:dataflow-not-control-flow] The compiled mirror of a LayoutNode: the same
45
+ // recursive shape with every `when` parsed ONCE at registration. renderDsl walks
46
+ // this compiled tree — never the raw config — so the parse-once guarantee covers
47
+ // every node.
48
+ export interface CompiledSegmentNode {
49
+ readonly kind: "segment";
50
+ readonly when?: Template<RichText>;
51
+ readonly name: string;
52
+ }
53
+ export interface CompiledContainerNode {
54
+ readonly kind: "container";
55
+ readonly direction: Direction;
56
+ readonly when?: Template<RichText>;
57
+ readonly children: readonly CompiledNode[];
58
+ }
59
+ export type CompiledNode = CompiledSegmentNode | CompiledContainerNode;
60
+
61
+ // Pre-parsed templates and pre-resolved palette for one segment, built once at
62
+ // registration. A `segment` node names one; render looks it up via
63
+ // ctx.lookupSegment.
64
+ export interface CompiledSegment {
65
+ readonly when?: Template<RichText>;
66
+ readonly template: Template<RichText>;
67
+ readonly bg?: Template<RichText>;
68
+ readonly fg?: Template<RichText>;
69
+ readonly paletteResolver?: PaletteResolver;
70
+ }
71
+ export type CompiledSegments = Readonly<Record<string, CompiledSegment>>;
72
+
73
+ // A rendered node is a LIST OF LINES, each line a list of cells — NOT yet
74
+ // serialized. [LAW:types-are-the-program] Cells (not ANSI bytes) are the
75
+ // composition substrate: the powerline joiner caps between adjacent cells, so
76
+ // serializing a node before composition would freeze its last cell's edge and
77
+ // make a cap across a sibling seam unrecoverable. Serialization (the single
78
+ // joiner pass) runs exactly once, at the root, after the whole tree composes.
79
+ export type RenderedLines = ReadonlyArray<readonly RichText[]>;
80
+
81
+ // ─── Compile / render contexts (the injected capabilities) ──────────────────────
82
+
83
+ // [LAW:locality-or-seam] The compile-time context the driver hands each node
84
+ // type. `when` is PRE-COMPILED by the driver (walk-owned, uniform across kinds);
85
+ // the type only assembles it in. compileChild is the recursion, injected so this
86
+ // module needn't import the driver.
87
+ export interface NodeCompileCtx {
88
+ readonly path: string;
89
+ // The node's own `when`, already parsed by the driver (one parse-when site).
90
+ readonly when?: Template<RichText>;
91
+ // Compile a child node (the recursion, injected so this module needn't import
92
+ // the driver).
93
+ compileChild(node: LayoutNode, path: string): CompiledNode;
94
+ }
95
+
96
+ // [LAW:single-enforcer] The render-time context. The hue COUNTER lives in the
97
+ // driver; ctx exposes only nextHueShift() (advance + return this unit's shift) so
98
+ // there is exactly one mutator. `visible` is THIS node's computed visibility
99
+ // (the driver ANDs node.when with the parent's). renderChild continues the walk.
100
+ export interface NodeRenderCtx {
101
+ readonly scope: object;
102
+ readonly basePalette: PaletteResolver;
103
+ readonly visible: boolean;
104
+ // Advance the walk-owned hue cursor by one unit and return that unit's shift.
105
+ nextHueShift(): number;
106
+ readonly perSegmentSink?: Map<string, readonly RichText[]>;
107
+ // Resolve a segment name to its decl + compiled form (the driver closes over
108
+ // config.segments + the compiled segments).
109
+ lookupSegment(
110
+ name: string,
111
+ ):
112
+ | { readonly seg: SegmentDecl; readonly compiled: CompiledSegment }
113
+ | undefined;
114
+ // Continue the walk into a child node (parentVisible = this node's visibility).
115
+ renderChild(node: CompiledNode, parentVisible: boolean): RenderedLines;
116
+ }
117
+
118
+ // ─── Composition ───────────────────────────────────────────────────────────────
119
+
120
+ // [LAW:dataflow-not-control-flow] A container's `direction` is the projection it
121
+ // applies to its already-rendered child blocks — DATA selecting a fold, not a
122
+ // branch that skips work. `vertical` STACKS (concatenate the children's line-
123
+ // lists); `horizontal` ZIPS (row i is every child's row-i cells concatenated, so
124
+ // the joiner caps ACROSS the seam — there is no abut). The switch is exhaustive
125
+ // over `Direction`; adding `outline` to DIRECTIONS forces a new arm here.
126
+ function composeBlocks(
127
+ direction: Direction,
128
+ blocks: readonly RenderedLines[],
129
+ ): RenderedLines {
130
+ switch (direction) {
131
+ case "vertical":
132
+ return blocks.flatMap((b) => b);
133
+ case "horizontal": {
134
+ const height = blocks.reduce((m, b) => Math.max(m, b.length), 0);
135
+ const rows: RichText[][] = [];
136
+ for (let i = 0; i < height; i++) {
137
+ rows.push(blocks.flatMap((b) => b[i] ?? []));
138
+ }
139
+ return rows;
140
+ }
141
+ }
142
+ }
143
+
144
+ // ─── The node-type contract + registry ──────────────────────────────────────────
145
+
146
+ type NodeKind = LayoutNode["kind"];
147
+
148
+ // [LAW:types-are-the-program] One contract per node kind, generic over the kind so
149
+ // each entry's compile/render see their OWN narrowed node arm — never the union,
150
+ // so no internal re-narrow guard. compile (registration: LayoutNode → compiled,
151
+ // parse-once) and render (per-render: compiled → lines) are co-located per kind.
152
+ export interface NodeType<K extends NodeKind> {
153
+ compile(
154
+ node: Extract<LayoutNode, { kind: K }>,
155
+ cctx: NodeCompileCtx,
156
+ ): Extract<CompiledNode, { kind: K }>;
157
+ render(
158
+ node: Extract<CompiledNode, { kind: K }>,
159
+ ctx: NodeRenderCtx,
160
+ ): RenderedLines;
161
+ }
162
+
163
+ const containerType: NodeType<"container"> = {
164
+ compile(node, cctx) {
165
+ return {
166
+ kind: "container",
167
+ direction: node.direction,
168
+ when: cctx.when,
169
+ children: node.children.map((child, i) =>
170
+ cctx.compileChild(child, `${cctx.path}.children[${i}]`),
171
+ ),
172
+ };
173
+ },
174
+ render(node, ctx) {
175
+ // [LAW:dataflow-not-control-flow] A container advances NO hue unit itself; its
176
+ // children do, walked in order so positional hue stays stable. Hidden or not,
177
+ // every child is rendered (parentVisible threads the gate) so hidden subtrees
178
+ // still advance the cursor.
179
+ return composeBlocks(
180
+ node.direction,
181
+ node.children.map((child) => ctx.renderChild(child, ctx.visible)),
182
+ );
183
+ },
184
+ };
185
+
186
+ const segmentType: NodeType<"segment"> = {
187
+ compile(node, cctx) {
188
+ return { kind: "segment", when: cctx.when, name: node.name };
189
+ },
190
+ render(node, ctx) {
191
+ const found = ctx.lookupSegment(node.name);
192
+ // [LAW:no-defensive-null-guards] The loader validates every segment ref
193
+ // against the segments map and registerDslConfig compiles every declared
194
+ // segment; a miss is a caller bug (renderDsl given a mismatched compiled
195
+ // object).
196
+ if (!found) {
197
+ throw new Error(`Layout segment "${node.name}" has no matching segment`);
198
+ }
199
+ const { seg, compiled: segCompiled } = found;
200
+
201
+ // [LAW:single-enforcer] Advance the hue cursor BEFORE the visibility gate so a
202
+ // hidden segment still consumes its unit — siblings after it keep their
203
+ // positionally-stable colors regardless of which segments are hidden.
204
+ const hueShift = ctx.nextHueShift();
205
+ if (!ctx.visible) return [];
206
+
207
+ // [LAW:no-silent-failure] Wrap the whole render body in a try/catch so a
208
+ // partial-load consequence (e.g. a variable that failed to declare, leaving a
209
+ // MissingFieldError when the template or when-predicate accesses it) surfaces
210
+ // as a visible error cell rather than crashing the whole bar. The remaining
211
+ // segments render normally. This is the render-time complement to the per-
212
+ // variable catch in registerDslConfig — together they implement option-2
213
+ // partial rendering: the new config stays active, working segments render, and
214
+ // broken segments show an error cell.
215
+ try {
216
+ if (!evaluateWhen(segCompiled.when, ctx.scope)) return [];
217
+
218
+ // [LAW:dataflow-not-control-flow] The per-segment variability is WHICH
219
+ // palette — the base resolver (per-segment override or basePalette)
220
+ // transposed by hueShift. bg and fg then resolve from this one palette.
221
+ const resolver = transposedResolver(
222
+ segCompiled.paletteResolver ?? ctx.basePalette,
223
+ hueShift,
224
+ );
225
+ const baseStyle = resolveSegmentColors(
226
+ resolver,
227
+ segCompiled.bg,
228
+ segCompiled.fg,
229
+ ctx.scope,
230
+ );
231
+
232
+ const fragments = segCompiled.template.evaluate(ctx.scope);
233
+ const segCells = fragmentsToCells(fragments, baseStyle);
234
+
235
+ // [LAW:single-enforcer] Partition the segment's authored "\n" into visual
236
+ // lines BEFORE per-segment layout — width/justify/truncate then measure each
237
+ // line cleanly. A newline-free segment is the degenerate one-line case. Each
238
+ // laid line is ONE strip item: applySegmentLayout collapses a line's cells to
239
+ // 0-or-1 item (OSC-8 links survive as interior spans), so the joiner caps only
240
+ // at the segment's edges, never inside it.
241
+ const laidLines = splitCellsIntoLines(segCells).map((line) =>
242
+ applySegmentLayout(line, {
243
+ width: seg.width ?? "auto",
244
+ justify: seg.justify ?? "left",
245
+ truncate: seg.truncate ?? "right",
246
+ baseStyle,
247
+ }),
248
+ );
249
+
250
+ if (ctx.perSegmentSink !== undefined) {
251
+ ctx.perSegmentSink.set(node.name, laidLines.flat());
252
+ }
253
+ return laidLines;
254
+ } catch (err) {
255
+ return [
256
+ [
257
+ new RichText(
258
+ `⚠ ${node.name}: ${(err as Error).message ?? String(err)}`,
259
+ ),
260
+ ],
261
+ ];
262
+ }
263
+ },
264
+ };
265
+
266
+ // [LAW:single-enforcer] THE registry. `satisfies` forces an entry for every
267
+ // LayoutNode kind — adding a kind to the union breaks compilation here until its
268
+ // behavior is registered, so "register a type" is one mechanically-enforced act.
269
+ const REGISTRY = {
270
+ container: containerType,
271
+ segment: segmentType,
272
+ } satisfies { [K in NodeKind]: NodeType<K> };
273
+
274
+ // [LAW:types-are-the-program] The one dispatch primitive. Indexing by a node's OWN
275
+ // kind returns the entry built FOR that kind, so the pairing is sound by
276
+ // construction; the cast only widens the static K to the union (TS cannot prove
277
+ // the index/arm link across a heterogeneous registry). Every consumer calls
278
+ // nodeType(node.kind).method(node) — no consumer re-switches on kind.
279
+ export function nodeType(kind: NodeKind): NodeType<NodeKind> {
280
+ return REGISTRY[kind] as unknown as NodeType<NodeKind>;
281
+ }