@promptctl/cc-candybar 1.7.3 → 1.8.1
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.mjs +78 -77
- package/package.json +7 -7
- package/src/config/dsl-loader.ts +8 -0
- package/src/config/loader/menu-synth.ts +156 -0
- package/src/config/menu-keys.ts +101 -0
- package/src/dsl/node-registry.ts +59 -11
- package/src/dsl/render.ts +61 -0
- package/src/render/menu.ts +120 -0
- package/src/render/picker.ts +5 -1
- package/src/template-engine/funcs.ts +24 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -85,16 +85,16 @@
|
|
|
85
85
|
"typescript": "^5.0.0"
|
|
86
86
|
},
|
|
87
87
|
"dependencies": {
|
|
88
|
-
"@promptctl/go-template-js": "^0.
|
|
89
|
-
"@promptctl/rich-js": "^0.
|
|
88
|
+
"@promptctl/go-template-js": "^0.5.0",
|
|
89
|
+
"@promptctl/rich-js": "^0.6.0",
|
|
90
90
|
"json5": "^2.2.3",
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.8.1",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.8.1",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.8.1",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.8.1"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { validateGlobals } from "./loader/globals.js";
|
|
|
48
48
|
import { validateVariables } from "./loader/variables.js";
|
|
49
49
|
import { validateSegments } from "./loader/segments.js";
|
|
50
50
|
import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
|
|
51
|
+
import { synthesizeMenuDecls } from "./loader/menu-synth.js";
|
|
51
52
|
import { validateActions } from "./loader/actions.js";
|
|
52
53
|
import { validateHelpers } from "./loader/helpers.js";
|
|
53
54
|
import { validateCrossReferences } from "./loader/cross-ref.js";
|
|
@@ -249,6 +250,13 @@ function validateTopLevel(
|
|
|
249
250
|
// default and cross-ref like any user declaration), and user names under the
|
|
250
251
|
// reserved namespace are rejected against the fully-parsed sections.
|
|
251
252
|
synthesizeGroupDecls(ctx, out);
|
|
253
|
+
// [LAW:one-source-of-truth] Menu synthesis runs AFTER group synthesis (a group
|
|
254
|
+
// body may host menu-bearing segments) and after every section parsed: each
|
|
255
|
+
// menu placement detected in the root walk emits its state var + cycle action
|
|
256
|
+
// into the raw sections, so they merge over the default, derive the click gate
|
|
257
|
+
// through deriveActionValidators, and collide loudly with any user name under
|
|
258
|
+
// the reserved namespace.
|
|
259
|
+
synthesizeMenuDecls(ctx, out);
|
|
252
260
|
return out;
|
|
253
261
|
}
|
|
254
262
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] The menu synthesis pass: the load-side mirror of the
|
|
2
|
+
// `{{ menu }}` render helper. A menu is the group-accordion mechanism with its
|
|
3
|
+
// trigger living inside an arbitrary user segment rather than a synthesized
|
|
4
|
+
// toggle segment — so this emits exactly what group sugar does MINUS the segment
|
|
5
|
+
// (the helper IS the trigger): one `state` var per row key (default "closed")
|
|
6
|
+
// and one `cycle` action per (row, menu-bearing segment) under the reserved
|
|
7
|
+
// `menus.` namespace. Both land in the raw sections so they merge over the
|
|
8
|
+
// default and, crucially, so `deriveActionValidators(config.actions)` derives the
|
|
9
|
+
// click gate from them through the ONE existing path — a menu toggle is gated
|
|
10
|
+
// like every other set, no parallel verb. [LAW:single-enforcer]
|
|
11
|
+
//
|
|
12
|
+
// Runs in `parseDslConfig` after group synthesis and after every section parsed,
|
|
13
|
+
// so the reserved-namespace collision check sees the fully-parsed user sections.
|
|
14
|
+
//
|
|
15
|
+
// [LAW:types-are-the-program] WHICH segments host a menu is read from the parsed
|
|
16
|
+
// AST (`referencedFunctions`), not a source-text scan — robust against whitespace,
|
|
17
|
+
// pipelines, and `.menu`/"menu" lookalikes. Detection bare-parses the segment
|
|
18
|
+
// template; a parse failure here is treated as "no menu" because the authoritative
|
|
19
|
+
// parse error is surfaced loudly by `registerDslConfig` when it compiles the same
|
|
20
|
+
// template (so this pass never swallows a real error, it just declines to guess).
|
|
21
|
+
|
|
22
|
+
import { createEngine } from "@promptctl/go-template-js";
|
|
23
|
+
import type { ActionDecl } from "../action.js";
|
|
24
|
+
import type { Mutable, ValidateCtx } from "./validate-core.js";
|
|
25
|
+
import {
|
|
26
|
+
MENU_CLOSED,
|
|
27
|
+
MENU_NS,
|
|
28
|
+
forEachSegmentPlacement,
|
|
29
|
+
menuActionName,
|
|
30
|
+
menuStateKey,
|
|
31
|
+
} from "../menu-keys.js";
|
|
32
|
+
import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
|
|
33
|
+
import { findKeyLine } from "./diagnostics.js";
|
|
34
|
+
|
|
35
|
+
// [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
|
|
36
|
+
// string the render FuncMap registers. A segment "hosts a menu" iff its template
|
|
37
|
+
// references this function.
|
|
38
|
+
const MENU_FUNC = "menu";
|
|
39
|
+
|
|
40
|
+
// [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
|
|
41
|
+
// never evaluates, so `fromString` is identity and no funcs are registered (parse
|
|
42
|
+
// does not resolve function existence — that is an eval-time concern).
|
|
43
|
+
function segmentReferencesMenu(template: string): boolean {
|
|
44
|
+
const engine = createEngine<string>({ fromString: (s) => s });
|
|
45
|
+
try {
|
|
46
|
+
return engine.parse(template).referencedFunctions().has(MENU_FUNC);
|
|
47
|
+
} catch {
|
|
48
|
+
// A malformed template can host no usable menu; registerDslConfig re-parses
|
|
49
|
+
// and reports the real error. [LAW:no-silent-failure] — not swallowed, just
|
|
50
|
+
// not the place that reports it.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function menuIssue(ctx: ValidateCtx, path: string, message: string): void {
|
|
56
|
+
ctx.issues.push({ path, message, line: findKeyLine(ctx.source, ["root"]) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function synthesizeMenuDecls(
|
|
60
|
+
ctx: ValidateCtx,
|
|
61
|
+
out: Mutable<RawDslConfig>,
|
|
62
|
+
): void {
|
|
63
|
+
// [LAW:single-enforcer] The `menus.` namespace is reserved UNCONDITIONALLY — a
|
|
64
|
+
// user name under it is rejected whether or not any menu is placed this load, so
|
|
65
|
+
// the reservation is a stable contract ("you never author menus.*"), not a rule
|
|
66
|
+
// that only switches on when synthesis happens to collide. Runs before any early
|
|
67
|
+
// return so a `menus.*` user name can never load silently.
|
|
68
|
+
for (const section of ["variables", "actions", "segments"] as const) {
|
|
69
|
+
for (const name of Object.keys(out[section] ?? {})) {
|
|
70
|
+
if (name.startsWith(MENU_NS)) {
|
|
71
|
+
menuIssue(
|
|
72
|
+
ctx,
|
|
73
|
+
`${section}.${name}`,
|
|
74
|
+
`"${name}" is in the reserved "${MENU_NS}" namespace (synthesized by {{ menu }} helpers) — rename it`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// [LAW:no-silent-failure] A menu derives its accordion identity from its host
|
|
81
|
+
// SEGMENT's tree position; a `{{ define }}` helper is shared and placement-
|
|
82
|
+
// agnostic, so a `{{ menu }}` reached through one has no row to key on and would
|
|
83
|
+
// never get its backing state var/cycle action synthesized — failing at render.
|
|
84
|
+
// Reject it loudly at load, pointing the author to inline the menu in a segment.
|
|
85
|
+
// [LAW:no-mode-explosion] We reject rather than build helper-call-graph
|
|
86
|
+
// resolution speculatively; revisit only if a real shared-menu need appears.
|
|
87
|
+
for (const [name, body] of Object.entries(out.helpers ?? {})) {
|
|
88
|
+
if (segmentReferencesMenu(body)) {
|
|
89
|
+
ctx.issues.push({
|
|
90
|
+
path: `helpers.${name}`,
|
|
91
|
+
message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its accordion identity is derived from the segment's position in the layout, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
|
|
92
|
+
line: findKeyLine(ctx.source, ["helpers", name]),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (out.root === undefined) return;
|
|
98
|
+
const segments = out.segments ?? {};
|
|
99
|
+
|
|
100
|
+
// [LAW:dataflow-not-control-flow] Memoize detection by segment name — a segment
|
|
101
|
+
// placed in N rows is parsed once, then each placement reads the cached verdict.
|
|
102
|
+
const hostsMenu = new Map<string, boolean>();
|
|
103
|
+
const referencesMenu = (segName: string): boolean => {
|
|
104
|
+
const cached = hostsMenu.get(segName);
|
|
105
|
+
if (cached !== undefined) return cached;
|
|
106
|
+
const seg = segments[segName];
|
|
107
|
+
const result =
|
|
108
|
+
seg !== undefined ? segmentReferencesMenu(seg.template) : false;
|
|
109
|
+
hostsMenu.set(segName, result);
|
|
110
|
+
return result;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// One state key per row (default "closed"); one cycle action per (row,segment).
|
|
114
|
+
const stateKeys = new Map<string, string>(); // stateKey → rowKey (for vars)
|
|
115
|
+
const actions: Record<string, ActionDecl> = {};
|
|
116
|
+
forEachSegmentPlacement(out.root, (segName, rowKey) => {
|
|
117
|
+
if (!referencesMenu(segName)) return;
|
|
118
|
+
// [LAW:types-are-the-program] A menu's member name IS its host segment name;
|
|
119
|
+
// a segment named exactly the closed-state sentinel would make the cycle
|
|
120
|
+
// [closed, "closed"] — two identical members, so the toggle could never leave
|
|
121
|
+
// the closed state. Reject that one collision at load (the only segment name
|
|
122
|
+
// that breaks a menu), rather than silently synthesizing an unopenable menu.
|
|
123
|
+
if (segName === MENU_CLOSED) {
|
|
124
|
+
menuIssue(
|
|
125
|
+
ctx,
|
|
126
|
+
`segments.${segName}`,
|
|
127
|
+
`segment "${segName}" hosts a {{ menu }}, but a menu cannot live in a segment named "${MENU_CLOSED}" — that name collides with the menu's closed-state sentinel, leaving the menu unopenable. Rename the segment.`,
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const stateKey = menuStateKey(rowKey);
|
|
132
|
+
stateKeys.set(stateKey, rowKey);
|
|
133
|
+
// [LAW:one-source-of-truth] Members ordered closed-first: an unset/foreign
|
|
134
|
+
// value counts as the first member (the cycle's "unknown ⇒ first" rule), so a
|
|
135
|
+
// never-clicked menu renders ▸ and a click opens it, auto-closing a row-mate
|
|
136
|
+
// because the shared key can hold only one member name.
|
|
137
|
+
actions[menuActionName(rowKey, segName)] = {
|
|
138
|
+
set: stateKey,
|
|
139
|
+
cycle: [MENU_CLOSED, segName],
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (stateKeys.size === 0) return;
|
|
144
|
+
|
|
145
|
+
const variables: Record<string, VariableDecl> = {};
|
|
146
|
+
for (const stateKey of stateKeys.keys()) {
|
|
147
|
+
variables[stateKey] = {
|
|
148
|
+
kind: "state",
|
|
149
|
+
key: stateKey,
|
|
150
|
+
default: MENU_CLOSED,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
out.variables = { ...(out.variables ?? {}), ...variables };
|
|
155
|
+
out.actions = { ...(out.actions ?? {}), ...actions };
|
|
156
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] THE derivation of a menu's accordion identity from
|
|
2
|
+
// layout structure — the single place both the loader (which SYNTHESIZES the
|
|
3
|
+
// state var + cycle action) and the renderer (which READS openness + emits the
|
|
4
|
+
// toggle) agree on "which key holds which open menu". A `{{ menu }}` helper is
|
|
5
|
+
// context-free (it cannot see its own tree position), so its row key and member
|
|
6
|
+
// name are NOT in the helper string — they are derived here from where its host
|
|
7
|
+
// segment sits, and the two consumers MUST derive identical strings or a click
|
|
8
|
+
// would write a key the render never reads. Keeping the rule in one module makes
|
|
9
|
+
// that agreement structural rather than a coincidence of two copies.
|
|
10
|
+
//
|
|
11
|
+
// [LAW:decomposition] "The row IS the key": menus mutually exclude exactly when
|
|
12
|
+
// they share an enclosing horizontal container — so the row key is that
|
|
13
|
+
// container's structural path, and a menu outside any horizontal container gets
|
|
14
|
+
// its own path as a singleton key (it can only toggle itself). The member name a
|
|
15
|
+
// row key holds is the host segment's name.
|
|
16
|
+
|
|
17
|
+
import type { LayoutNode } from "./dsl-types.js";
|
|
18
|
+
|
|
19
|
+
// [LAW:one-source-of-truth] The reserved namespace every synthesized menu
|
|
20
|
+
// artifact (state var + cycle action) lives under, mirroring group sugar's
|
|
21
|
+
// `groups.`. A user-authored name under this prefix is a load error so synthesis
|
|
22
|
+
// can never silently collide.
|
|
23
|
+
export const MENU_NS = "menus.";
|
|
24
|
+
|
|
25
|
+
// The "no menu open" sentinel a row key starts from and returns to on close.
|
|
26
|
+
// A menu's member name is its host segment name; segment names cannot equal this
|
|
27
|
+
// (a row holding "closed" means every menu in it is shut).
|
|
28
|
+
export const MENU_CLOSED = "closed";
|
|
29
|
+
|
|
30
|
+
// [LAW:representation] Disclosure glyph vocabulary — identical to group sugar so
|
|
31
|
+
// every disclosure across the bar reads the same (trailing the label/content it
|
|
32
|
+
// gates, per pdu.8): collapsed ▸, expanded ▾.
|
|
33
|
+
export const MENU_GLYPH_CLOSED = "▸";
|
|
34
|
+
export const MENU_GLYPH_OPEN = "▾";
|
|
35
|
+
|
|
36
|
+
// [LAW:types-are-the-program] A row key is a structural path (e.g.
|
|
37
|
+
// `root.children[1]`); collapse it to an identifier-shaped id so the synthesized
|
|
38
|
+
// var/action/SessionState-key names carry no dots or brackets. Distinct tree
|
|
39
|
+
// paths never collide under this map (sibling indices stay distinct: `[1]` vs
|
|
40
|
+
// `[11]` → `_1_` vs `_11_`).
|
|
41
|
+
function menuRowId(rowKey: string): string {
|
|
42
|
+
return rowKey.replace(/[^A-Za-z0-9]+/g, "_");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The SessionState key (and the state-var name reading it) for one row's open
|
|
46
|
+
// menu. One key per row holds at most one open member name → accordion.
|
|
47
|
+
export function menuStateKey(rowKey: string): string {
|
|
48
|
+
return MENU_NS + menuRowId(rowKey);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The synthesized cycle action a menu's disclosure toggle realizes: writing this
|
|
52
|
+
// row key between MENU_CLOSED and the host segment's name. Named per (row,
|
|
53
|
+
// segment) so two menus sharing a row contribute two cycles on one key — the
|
|
54
|
+
// existing same-key validator merge unions their members into the gate.
|
|
55
|
+
export function menuActionName(rowKey: string, segName: string): string {
|
|
56
|
+
return MENU_NS + menuRowId(rowKey) + "." + segName;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// [LAW:single-enforcer] THE rule for a segment placement's row key: the nearest
|
|
60
|
+
// enclosing horizontal container's path, or the segment's own path when none.
|
|
61
|
+
// Both the loader walk and the compile walk call this with the values they
|
|
62
|
+
// already track, so neither restates the rule.
|
|
63
|
+
export function rowKeyFor(
|
|
64
|
+
nearestHorizontalPath: string | undefined,
|
|
65
|
+
ownPath: string,
|
|
66
|
+
): string {
|
|
67
|
+
return nearestHorizontalPath ?? ownPath;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// [LAW:single-enforcer] THE one walk that assigns every segment placement its
|
|
71
|
+
// path and row key. The path format (`root` + `.children[i]`) MUST match the
|
|
72
|
+
// compile walk's (`registerDslConfig`'s `compileNode`), since the row key a click
|
|
73
|
+
// targets is keyed by that path; this is the load-side mirror of that walk, so
|
|
74
|
+
// the two produce identical paths over the same tree. `visit` receives the
|
|
75
|
+
// segment name, its row key, and its own path (in case a consumer needs to map
|
|
76
|
+
// by exact placement).
|
|
77
|
+
export function forEachSegmentPlacement(
|
|
78
|
+
root: LayoutNode,
|
|
79
|
+
visit: (segName: string, rowKey: string, ownPath: string) => void,
|
|
80
|
+
): void {
|
|
81
|
+
const recur = (
|
|
82
|
+
node: LayoutNode,
|
|
83
|
+
path: string,
|
|
84
|
+
nearestHorizontalPath: string | undefined,
|
|
85
|
+
): void => {
|
|
86
|
+
if (node.kind === "segment") {
|
|
87
|
+
visit(node.name, rowKeyFor(nearestHorizontalPath, path), path);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// [LAW:dataflow-not-control-flow] A horizontal container REPLACES the nearest-
|
|
91
|
+
// horizontal path for its subtree; any other container PASSES it through. The
|
|
92
|
+
// direction is a value selecting which path the children inherit, not a branch
|
|
93
|
+
// that skips the recursion.
|
|
94
|
+
const childNearestHorizontal =
|
|
95
|
+
node.direction === "horizontal" ? path : nearestHorizontalPath;
|
|
96
|
+
node.children.forEach((child, i) =>
|
|
97
|
+
recur(child, `${path}.children[${i}]`, childNearestHorizontal),
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
recur(root, "root", undefined);
|
|
101
|
+
}
|
package/src/dsl/node-registry.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// strip item), not a function of matching backgrounds.
|
|
24
24
|
|
|
25
25
|
import { RichText } from "@promptctl/rich-js";
|
|
26
|
-
import type { PaletteResolver } from "@promptctl/rich-js";
|
|
26
|
+
import type { PaletteResolver, Style } from "@promptctl/rich-js";
|
|
27
27
|
import type { Template } from "@promptctl/go-template-js";
|
|
28
28
|
import type {
|
|
29
29
|
LayoutNode,
|
|
@@ -49,6 +49,12 @@ export interface CompiledSegmentNode {
|
|
|
49
49
|
readonly kind: "segment";
|
|
50
50
|
readonly when?: Template<RichText>;
|
|
51
51
|
readonly name: string;
|
|
52
|
+
// [LAW:one-source-of-truth] The menu accordion row key for THIS placement —
|
|
53
|
+
// the nearest enclosing horizontal container's path, or this segment's own path
|
|
54
|
+
// (singleton). Computed once at compile from the SAME walk the loader synthesis
|
|
55
|
+
// uses (menu-keys/forEachSegmentPlacement), so the key a `{{ menu }}` toggle
|
|
56
|
+
// writes is provably the key the loader declared a state var + gate for.
|
|
57
|
+
readonly rowKey: string;
|
|
52
58
|
}
|
|
53
59
|
export interface CompiledContainerNode {
|
|
54
60
|
readonly kind: "container";
|
|
@@ -88,6 +94,11 @@ export interface NodeCompileCtx {
|
|
|
88
94
|
readonly path: string;
|
|
89
95
|
// The node's own `when`, already parsed by the driver (one parse-when site).
|
|
90
96
|
readonly when?: Template<RichText>;
|
|
97
|
+
// [LAW:one-source-of-truth] Path → menu row key, precomputed by the driver from
|
|
98
|
+
// the SAME structural walk the loader synthesis uses. A segment node reads its
|
|
99
|
+
// own rowKey here rather than re-deriving the nearest-horizontal rule, so the
|
|
100
|
+
// two walks cannot drift.
|
|
101
|
+
readonly rowKeyByPath: ReadonlyMap<string, string>;
|
|
91
102
|
// Compile a child node (the recursion, injected so this module needn't import
|
|
92
103
|
// the driver).
|
|
93
104
|
compileChild(node: LayoutNode, path: string): CompiledNode;
|
|
@@ -104,6 +115,15 @@ export interface NodeRenderCtx {
|
|
|
104
115
|
// Advance the walk-owned hue cursor by one unit and return that unit's shift.
|
|
105
116
|
nextHueShift(): number;
|
|
106
117
|
readonly perSegmentSink?: Map<string, readonly RichText[]>;
|
|
118
|
+
// [LAW:locality-or-seam] The menu seam, injected as a capability so this module
|
|
119
|
+
// never imports the menu feature. Called once per segment render, BEFORE its
|
|
120
|
+
// template evaluates: it publishes this placement (segName + rowKey) so a
|
|
121
|
+
// `{{ menu }}` in the template can resolve its accordion identity, and returns
|
|
122
|
+
// the baseStyle to use — lightened when this segment's own menu is open, so the
|
|
123
|
+
// whole focused segment (inline trigger + dropped body band) reads as
|
|
124
|
+
// highlighted. The driver owns the menu runtime + state read; this module only
|
|
125
|
+
// hands it the values it has (name, compiled rowKey, resolved baseStyle).
|
|
126
|
+
prepareSegment(segName: string, rowKey: string, baseStyle: Style): Style;
|
|
107
127
|
// Resolve a segment name to its decl + compiled form (the driver closes over
|
|
108
128
|
// config.segments + the compiled segments).
|
|
109
129
|
lookupSegment(
|
|
@@ -120,9 +140,17 @@ export interface NodeRenderCtx {
|
|
|
120
140
|
// [LAW:dataflow-not-control-flow] A container's `direction` is the projection it
|
|
121
141
|
// applies to its already-rendered child blocks — DATA selecting a fold, not a
|
|
122
142
|
// branch that skips work. `vertical` STACKS (concatenate the children's line-
|
|
123
|
-
// lists)
|
|
124
|
-
//
|
|
125
|
-
//
|
|
143
|
+
// lists). The switch is exhaustive over `Direction`; adding `outline` to
|
|
144
|
+
// DIRECTIONS forces a new arm here.
|
|
145
|
+
//
|
|
146
|
+
// [LAW:decomposition] `horizontal` composes ONLY row 0 across the seam — row 0 is
|
|
147
|
+
// every child's FIRST line zipped (the inline powerline run, so the joiner caps
|
|
148
|
+
// across the seam, no abut). Every line BELOW row 0 is a child's DROP (a menu body
|
|
149
|
+
// dropping below its trigger, a genuinely multi-line segment): drops STACK full-
|
|
150
|
+
// width in child order, never zipped. Multi-line side-by-side column alignment is
|
|
151
|
+
// explicitly UNSUPPORTED — aligning two children's row-i cells would require
|
|
152
|
+
// background-as-structure, and bg is never structural. For an all-single-line row
|
|
153
|
+
// (no child has a drop) this is byte-identical to a plain per-row zip.
|
|
126
154
|
function composeBlocks(
|
|
127
155
|
direction: Direction,
|
|
128
156
|
blocks: readonly RenderedLines[],
|
|
@@ -131,12 +159,15 @@ function composeBlocks(
|
|
|
131
159
|
case "vertical":
|
|
132
160
|
return blocks.flatMap((b) => b);
|
|
133
161
|
case "horizontal": {
|
|
162
|
+
// [LAW:dataflow-not-control-flow] height 0 (every child hidden/empty) ⇒ the
|
|
163
|
+
// container contributes NO line — not one empty row. This is the value-driven
|
|
164
|
+
// identity of the fold, preserved from the per-row zip it replaces; a stray
|
|
165
|
+
// [[]] here would render as a spurious blank line.
|
|
134
166
|
const height = blocks.reduce((m, b) => Math.max(m, b.length), 0);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return rows;
|
|
167
|
+
if (height === 0) return [];
|
|
168
|
+
const row0 = blocks.flatMap((b) => b[0] ?? []);
|
|
169
|
+
const drops = blocks.flatMap((b) => b.slice(1));
|
|
170
|
+
return [row0, ...drops];
|
|
140
171
|
}
|
|
141
172
|
}
|
|
142
173
|
}
|
|
@@ -185,7 +216,15 @@ const containerType: NodeType<"container"> = {
|
|
|
185
216
|
|
|
186
217
|
const segmentType: NodeType<"segment"> = {
|
|
187
218
|
compile(node, cctx) {
|
|
188
|
-
return {
|
|
219
|
+
return {
|
|
220
|
+
kind: "segment",
|
|
221
|
+
when: cctx.when,
|
|
222
|
+
name: node.name,
|
|
223
|
+
// [LAW:no-defensive-null-guards] Every segment path is visited by the
|
|
224
|
+
// driver's placement walk, so the map has it; the `?? path` is the singleton
|
|
225
|
+
// degenerate (a segment outside any horizontal container is its own row).
|
|
226
|
+
rowKey: cctx.rowKeyByPath.get(cctx.path) ?? cctx.path,
|
|
227
|
+
};
|
|
189
228
|
},
|
|
190
229
|
render(node, ctx) {
|
|
191
230
|
const found = ctx.lookupSegment(node.name);
|
|
@@ -222,12 +261,21 @@ const segmentType: NodeType<"segment"> = {
|
|
|
222
261
|
segCompiled.paletteResolver ?? ctx.basePalette,
|
|
223
262
|
hueShift,
|
|
224
263
|
);
|
|
225
|
-
const
|
|
264
|
+
const resolvedStyle = resolveSegmentColors(
|
|
226
265
|
resolver,
|
|
227
266
|
segCompiled.bg,
|
|
228
267
|
segCompiled.fg,
|
|
229
268
|
ctx.scope,
|
|
230
269
|
);
|
|
270
|
+
// [LAW:single-enforcer] Publish this placement to the menu seam and adopt
|
|
271
|
+
// its baseStyle (focus-lightened iff this segment's menu is open) BEFORE
|
|
272
|
+
// evaluating the template — a `{{ menu }}` inside reads the placement, and
|
|
273
|
+
// the (possibly tinted) baseStyle flows into every cell + the line fill.
|
|
274
|
+
const baseStyle = ctx.prepareSegment(
|
|
275
|
+
node.name,
|
|
276
|
+
node.rowKey,
|
|
277
|
+
resolvedStyle,
|
|
278
|
+
);
|
|
231
279
|
|
|
232
280
|
const fragments = segCompiled.template.evaluate(ctx.scope);
|
|
233
281
|
const segCells = fragmentsToCells(fragments, baseStyle);
|
package/src/dsl/render.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// govern output, not whether operations run.
|
|
13
13
|
|
|
14
14
|
import type { RichText, PaletteResolver } from "@promptctl/rich-js";
|
|
15
|
+
import { ColorSpec, Style, lighten } from "@promptctl/rich-js";
|
|
15
16
|
import type { Engine, Template } from "@promptctl/go-template-js";
|
|
16
17
|
import type {
|
|
17
18
|
ValidatedConfig,
|
|
@@ -38,9 +39,12 @@ import {
|
|
|
38
39
|
import {
|
|
39
40
|
compileActions,
|
|
40
41
|
actionFuncs,
|
|
42
|
+
readVar,
|
|
41
43
|
type ActionRuntime,
|
|
42
44
|
} from "../render/action.js";
|
|
43
45
|
import { pickerFuncs } from "../render/picker.js";
|
|
46
|
+
import { menuFuncs, type MenuRuntime } from "../render/menu.js";
|
|
47
|
+
import { forEachSegmentPlacement, menuStateKey } from "../config/menu-keys.js";
|
|
44
48
|
// [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
|
|
45
49
|
// compiled node shapes + each kind's compile/render, dispatched via nodeType().
|
|
46
50
|
// render.ts threads the recursion (compileChild/renderChild) + the hue counter in
|
|
@@ -66,6 +70,13 @@ import {
|
|
|
66
70
|
export interface CompiledConfig {
|
|
67
71
|
readonly segments: CompiledSegments;
|
|
68
72
|
readonly root: CompiledNode;
|
|
73
|
+
// [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over,
|
|
74
|
+
// surfaced here so renderDsl can publish each segment's placement into it before
|
|
75
|
+
// that segment's template evaluates. One instance per compiled config; its
|
|
76
|
+
// `current` is mutated synchronously within a single renderDsl walk (renders are
|
|
77
|
+
// sequential + synchronous, so no cross-render leak) — the spatial cousin of the
|
|
78
|
+
// hue cursor, one owner. [LAW:no-ambient-temporal-coupling]
|
|
79
|
+
readonly menuRuntime: MenuRuntime;
|
|
69
80
|
// [LAW:types-are-the-program] Variable declaration failures that did NOT
|
|
70
81
|
// prevent the config from loading (type mismatches, bad defaults). The
|
|
71
82
|
// affected variables are absent from the store; segments that reference them
|
|
@@ -276,11 +287,17 @@ export function registerDslConfig(
|
|
|
276
287
|
// [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
|
|
277
288
|
// Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
|
|
278
289
|
// createCcCandybarEngine applies its single default; no second default literal.
|
|
290
|
+
// [LAW:locality-or-seam] The menu runtime shares the action runtime (a menu's
|
|
291
|
+
// glyph + body resolve from the same compiled table + store) and carries the
|
|
292
|
+
// walk-published current placement. Built before the engine so the `menu` func
|
|
293
|
+
// can close over it; `current` stays null until a render walk publishes one.
|
|
294
|
+
const menuRuntime: MenuRuntime = { action: actionRuntime, current: null };
|
|
279
295
|
const engine = createCcCandybarEngine(
|
|
280
296
|
undefined,
|
|
281
297
|
{
|
|
282
298
|
...actionFuncs(actionRuntime),
|
|
283
299
|
...pickerFuncs(actionRuntime),
|
|
300
|
+
...menuFuncs(menuRuntime),
|
|
284
301
|
},
|
|
285
302
|
opts?.clock,
|
|
286
303
|
);
|
|
@@ -406,6 +423,14 @@ export function registerDslConfig(
|
|
|
406
423
|
);
|
|
407
424
|
}
|
|
408
425
|
};
|
|
426
|
+
// [LAW:one-source-of-truth] Precompute every segment placement's menu row key
|
|
427
|
+
// from the SAME walk the loader synthesis used, so the compiled segment node's
|
|
428
|
+
// rowKey is provably the key the loader declared a state var + gate for. Each
|
|
429
|
+
// segment node reads its own entry by path in compile.
|
|
430
|
+
const rowKeyByPath = new Map<string, string>();
|
|
431
|
+
forEachSegmentPlacement(config.root, (_segName, rowKey, ownPath) => {
|
|
432
|
+
rowKeyByPath.set(ownPath, rowKey);
|
|
433
|
+
});
|
|
409
434
|
const compileNode = (node: LayoutNode, path: string): CompiledNode => {
|
|
410
435
|
const cctx: NodeCompileCtx = {
|
|
411
436
|
path,
|
|
@@ -413,6 +438,7 @@ export function registerDslConfig(
|
|
|
413
438
|
node.when === undefined
|
|
414
439
|
? undefined
|
|
415
440
|
: parseNodeField(node.when, path, "when"),
|
|
441
|
+
rowKeyByPath,
|
|
416
442
|
compileChild: compileNode,
|
|
417
443
|
};
|
|
418
444
|
return nodeType(node.kind).compile(node, cctx);
|
|
@@ -421,10 +447,27 @@ export function registerDslConfig(
|
|
|
421
447
|
return {
|
|
422
448
|
segments: compiled,
|
|
423
449
|
root: compileNode(config.root, "root"),
|
|
450
|
+
menuRuntime,
|
|
424
451
|
loadWarnings,
|
|
425
452
|
};
|
|
426
453
|
}
|
|
427
454
|
|
|
455
|
+
// [LAW:dataflow-not-control-flow] The focus tint: when a segment's own menu is
|
|
456
|
+
// open it is "focused", so its base background is lightened (rich-js owns the
|
|
457
|
+
// math — see [[rich-js-owns-color-math]]). The transform is RELATIVE to the
|
|
458
|
+
// resolved background, so any host theme tints to a consistent step above its own
|
|
459
|
+
// surface; a segment with no background (transparent) has nothing to lighten and
|
|
460
|
+
// passes through unchanged. One level ≈ 10% lightness — a subtle "this is active".
|
|
461
|
+
const MENU_FOCUS_LIGHTEN_LEVELS = 1;
|
|
462
|
+
function focusTint(style: Style): Style {
|
|
463
|
+
const bg = style.bgcolor;
|
|
464
|
+
if (bg === undefined) return style;
|
|
465
|
+
const lit = ColorSpec.fromRgba(
|
|
466
|
+
lighten(bg.getTruecolor(), MENU_FOCUS_LIGHTEN_LEVELS),
|
|
467
|
+
);
|
|
468
|
+
return new Style({ bgcolor: lit, color: style.color });
|
|
469
|
+
}
|
|
470
|
+
|
|
428
471
|
// ─── renderDsl ───────────────────────────────────────────────────────────────
|
|
429
472
|
|
|
430
473
|
/**
|
|
@@ -525,6 +568,23 @@ export function renderDsl(
|
|
|
525
568
|
: undefined;
|
|
526
569
|
};
|
|
527
570
|
|
|
571
|
+
// [LAW:single-enforcer] The menu seam, owned here: publish the placement the
|
|
572
|
+
// about-to-evaluate segment template needs (so a `{{ menu }}` resolves its
|
|
573
|
+
// accordion identity) and, when this segment's own menu is open, focus-tint its
|
|
574
|
+
// baseStyle so the whole segment (inline trigger + dropped body band) reads as
|
|
575
|
+
// highlighted. [LAW:no-defensive-null-guards] "menu never opened" is a real
|
|
576
|
+
// state — has() discriminates an unset key (no menu in this row) from a value.
|
|
577
|
+
const prepareSegment = (
|
|
578
|
+
segName: string,
|
|
579
|
+
rowKey: string,
|
|
580
|
+
baseStyle: Style,
|
|
581
|
+
): Style => {
|
|
582
|
+
compiled.menuRuntime.current = { rowKey, segName };
|
|
583
|
+
const stateKey = menuStateKey(rowKey);
|
|
584
|
+
const open = store.has(stateKey) && readVar(store, stateKey) === segName;
|
|
585
|
+
return open ? focusTint(baseStyle) : baseStyle;
|
|
586
|
+
};
|
|
587
|
+
|
|
528
588
|
// [LAW:dataflow-not-control-flow] ONE walk renders any node to LINES OF CELLS
|
|
529
589
|
// (serialization deferred to the root). The driver owns the cross-cutting
|
|
530
590
|
// `when`: `visible` ANDs the node's own predicate with its ancestors'. It then
|
|
@@ -542,6 +602,7 @@ export function renderDsl(
|
|
|
542
602
|
visible,
|
|
543
603
|
nextHueShift,
|
|
544
604
|
perSegmentSink,
|
|
605
|
+
prepareSegment,
|
|
545
606
|
lookupSegment,
|
|
546
607
|
renderChild: renderNode,
|
|
547
608
|
};
|