@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
package/src/demo/dsl.ts
ADDED
|
@@ -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
|
+
}
|