@promptctl/cc-candybar 1.38.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +89 -89
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +61 -1
- package/src/check.ts +14 -31
- package/src/config/default-dsl-config.ts +31 -3
- package/src/config/disclosure.ts +54 -0
- package/src/config/dsl-loader.ts +8 -1
- package/src/config/dsl-types.ts +23 -0
- package/src/config/edit-chrome.ts +15 -1
- package/src/config/loader/emit-schema.ts +2 -1
- package/src/config/loader/globals.ts +66 -32
- package/src/config/loader/menu-synth.ts +69 -18
- package/src/config/loader/merge.ts +6 -0
- package/src/config/presets.ts +20 -1
- package/src/config/settings-menu.ts +2 -1
- package/src/daemon/render-payload.ts +98 -1
- package/src/daemon/server.ts +29 -88
- package/src/demo/dsl.ts +21 -37
- package/src/render/action.ts +11 -9
- package/src/render/menu.ts +75 -21
- package/src/render/picker.ts +5 -3
- package/src/themes/policy.ts +44 -4
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type MenuOptions,
|
|
43
43
|
} from "../menu-keys.js";
|
|
44
44
|
import {
|
|
45
|
+
cycleDisplayIssue,
|
|
45
46
|
DISCLOSURE_CLOSED,
|
|
46
47
|
disclosureCycleAction,
|
|
47
48
|
disclosureStateVar,
|
|
@@ -60,13 +61,16 @@ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
|
60
61
|
const MENU_FUNC = "menu";
|
|
61
62
|
|
|
62
63
|
// [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
|
|
63
|
-
// helper's signature `menu "apply" [(dict …)]`: the apply name
|
|
64
|
-
// a required string literal)
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
64
|
+
// helper's signature `menu "apply" display… [(dict …)]`: the apply name
|
|
65
|
+
// (identity member, a required string literal), the trigger's authored display
|
|
66
|
+
// text (one per state or one static — the arity is statically countable, so it
|
|
67
|
+
// is checked here), and ONE optional trailing options dict — closeOnPick /
|
|
68
|
+
// paged / key, all statically readable via `staticDictEntries`. Displays
|
|
69
|
+
// themselves are NOT required to be literals; identity does not depend on
|
|
70
|
+
// them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
|
|
71
|
+
// spelling is rejected with a migration-pointing error, never silently
|
|
68
72
|
// reinterpreted [LAW:no-silent-failure].
|
|
69
|
-
const MIGRATION = `the
|
|
73
|
+
const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
|
|
70
74
|
|
|
71
75
|
// [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
|
|
72
76
|
// every reachable argument shape lands in exactly one arm — a usable identity
|
|
@@ -80,33 +84,80 @@ type MenuAnalysis =
|
|
|
80
84
|
}
|
|
81
85
|
| { readonly kind: "issue"; readonly message: string };
|
|
82
86
|
|
|
87
|
+
type ArgExpr = ReferencedCall["argExprs"][number];
|
|
88
|
+
|
|
89
|
+
const isDictCall = (e: ArgExpr): boolean =>
|
|
90
|
+
e.kind === "call" && e.name === "dict";
|
|
91
|
+
|
|
92
|
+
// [LAW:one-source-of-truth] The two sides split the tail on different evidence —
|
|
93
|
+
// exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
|
|
94
|
+
// call sites where those two readings PROVABLY coincide. The renderer's split
|
|
95
|
+
// asks one question of the last value, "is it an object", so a literal answers
|
|
96
|
+
// it here: a parse-time constant evaluates to itself and can never become the
|
|
97
|
+
// options dict. A literal `(dict …)` always does. Everything else in that slot
|
|
98
|
+
// is classified by whatever it happens to evaluate to.
|
|
99
|
+
const isNonObjectLiteral = (e: ArgExpr): boolean =>
|
|
100
|
+
e.kind === "literal" && typeof e.value !== "object";
|
|
101
|
+
|
|
102
|
+
// The display-arity rule used as a predicate; the message is the caller's
|
|
103
|
+
// business, so the subject never surfaces. [LAW:single-enforcer] — legality is
|
|
104
|
+
// read off the disclosure primitive, never restated as a count comparison.
|
|
105
|
+
const legalDisplayCount = (count: number): boolean =>
|
|
106
|
+
cycleDisplayIssue("", count, 2) === undefined;
|
|
107
|
+
|
|
83
108
|
function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
|
|
84
109
|
const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
|
|
85
|
-
const [applyArg,
|
|
110
|
+
const [applyArg, ...tail] = call.argExprs;
|
|
86
111
|
if (applyArg === undefined) {
|
|
87
112
|
return issue(
|
|
88
|
-
`with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
|
|
113
|
+
`with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
|
|
89
114
|
);
|
|
90
115
|
}
|
|
91
|
-
if (call.argExprs.length > 2) {
|
|
92
|
-
return issue(`with more than two arguments — ${MIGRATION}`);
|
|
93
|
-
}
|
|
94
116
|
if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
|
|
95
117
|
return issue(
|
|
96
|
-
`whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" }})`,
|
|
118
|
+
`whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
// [LAW:types-are-the-program] The dict is the LAST argument when present;
|
|
122
|
+
// everything before it is a display. Splitting on that one position is the
|
|
123
|
+
// whole grammar, and it is the same split `parseMenuArgs` performs on the
|
|
124
|
+
// evaluated tail at render — one shape, read twice from the two things each
|
|
125
|
+
// side has (exprs here, values there).
|
|
126
|
+
const last = tail[tail.length - 1];
|
|
127
|
+
const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
|
|
128
|
+
const displays = optsArg === undefined ? tail : tail.slice(0, -1);
|
|
129
|
+
if (displays.some(isDictCall)) {
|
|
130
|
+
return issue(
|
|
131
|
+
`whose options (dict …) is not its last argument — ${MIGRATION}`,
|
|
97
132
|
);
|
|
98
133
|
}
|
|
134
|
+
// [LAW:no-silent-failure] The last slot is the one both readings can claim.
|
|
135
|
+
// When the expr there is not provably one or the other AND dropping it still
|
|
136
|
+
// leaves a legal display count, the renderer's value-based split can land on
|
|
137
|
+
// a DIFFERENT reading than this one — same call, two shapes, no error either
|
|
138
|
+
// side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
|
|
139
|
+
// a state key with no synthesized var behind it, and the menu never opens) or
|
|
140
|
+
// a display vanishes into the static form. Reject that call site; an explicit
|
|
141
|
+
// trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
|
|
142
|
+
// Where the alternate reading is an ILLEGAL count the renderer throws instead
|
|
143
|
+
// of diverging, so it stays accepted — loudness, not refusal, is the bar.
|
|
99
144
|
if (
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
last !== undefined &&
|
|
146
|
+
optsArg === undefined &&
|
|
147
|
+
!isNonObjectLiteral(last) &&
|
|
148
|
+
legalDisplayCount(displays.length - 1)
|
|
102
149
|
) {
|
|
103
|
-
// A literal (the old page-action string / positional bool), a dynamic value,
|
|
104
|
-
// or a non-dict call: none is an options dict — one migration error covers
|
|
105
|
-
// the whole family [LAW:one-type-per-behavior].
|
|
106
150
|
return issue(
|
|
107
|
-
`whose
|
|
151
|
+
`whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
|
|
108
152
|
);
|
|
109
153
|
}
|
|
154
|
+
// [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
|
|
155
|
+
// the same one the renderer picks through — checked HERE too because the
|
|
156
|
+
// count is statically known, so an unauthored trigger is a load error naming
|
|
157
|
+
// the fix rather than a diagnostic glyph on the next render.
|
|
158
|
+
// "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
|
|
159
|
+
const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
|
|
160
|
+
if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
|
|
110
161
|
const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
|
|
111
162
|
if (entries === null) {
|
|
112
163
|
return issue(
|
|
@@ -53,6 +53,12 @@ export function mergeWithDefault(
|
|
|
53
53
|
// (incl. the "default" empty-fragment floor effectivePresetName collapses
|
|
54
54
|
// to) survives every merge by construction, exactly as looks' "none" does.
|
|
55
55
|
presets: { ...dflt.presets, ...(raw.presets ?? {}) },
|
|
56
|
+
// [LAW:one-source-of-truth] editGlobals merges FIELD by field — the
|
|
57
|
+
// `globals` cascade above, not the by-name cascades around it, because it
|
|
58
|
+
// IS a globals fragment: a user retuning edit mode's separator says nothing
|
|
59
|
+
// about its `style`, exactly as a user setting `globals.padding` says
|
|
60
|
+
// nothing about `globals.charset`.
|
|
61
|
+
editGlobals: { ...dflt.editGlobals, ...(raw.editGlobals ?? {}) },
|
|
56
62
|
// [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
|
|
57
63
|
// overrides one formatter helper by re-declaring its name; the rest inherit
|
|
58
64
|
// from the bundled default.
|
package/src/config/presets.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// place it is written down:
|
|
16
16
|
//
|
|
17
17
|
// bundled default < user config file < persisted overrides
|
|
18
|
-
// < ACTIVE PRESET < session pick
|
|
18
|
+
// < ACTIVE PRESET < session pick < EDIT MODE
|
|
19
19
|
//
|
|
20
20
|
// The preset's position is forced by its lifetime, not chosen. Everything to
|
|
21
21
|
// its left is resolved once per RenderCache entry (an entry serves many
|
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
// "compact" arrangement must actually change padding, even for a user who once
|
|
27
27
|
// persisted a padding they liked) while a session's own click still wins over
|
|
28
28
|
// the preset (a click is later still).
|
|
29
|
+
//
|
|
30
|
+
// The same rule places the last rung (candybar-settings-ui-aok.5): edit mode's
|
|
31
|
+
// `editGlobals` fragment is decided later than ANY session pick — a user picks
|
|
32
|
+
// a style, and only afterwards enters edit mode — so it is the new rightmost
|
|
33
|
+
// layer, and a session pick of "capsule" cannot survive into a mode whose whole
|
|
34
|
+
// job is to stop segments reading as one continuous strip. It differs from
|
|
35
|
+
// every rung to its left in LIFETIME rather than in kind: nothing writes it to
|
|
36
|
+
// SessionState or the overrides layer, so leaving edit mode restores the
|
|
37
|
+
// previous look with no save/restore path — the session's own pick was never
|
|
38
|
+
// overwritten, only out-ranked [LAW:dataflow-not-control-flow]. The rung itself
|
|
39
|
+
// is the `staged` parameter of effectiveGlobal (themes/policy.ts); this comment
|
|
40
|
+
// is the ONE place the order is written down.
|
|
29
41
|
|
|
30
42
|
// [LAW:one-way-deps] Type-only, so nothing is emitted and option-domain.ts (a
|
|
31
43
|
// leaf that deliberately never imports dsl-types.ts) can import PRESET_NAMES
|
|
@@ -87,7 +99,14 @@ export function effectivePresetName(
|
|
|
87
99
|
globalsPreset: string | undefined,
|
|
88
100
|
declaredPresets: Readonly<Record<string, PresetDecl>>,
|
|
89
101
|
): string {
|
|
102
|
+
// [LAW:types-are-the-program] No staged rung, and the absence is enforced
|
|
103
|
+
// rather than assumed: every globals fragment that could stage one — a
|
|
104
|
+
// preset's own `globals`, edit mode's `editGlobals` — has `preset` swapped for
|
|
105
|
+
// a rejection in its schema (loader/globals.ts), so "a fragment selected a
|
|
106
|
+
// preset" is unrepresentable and there is nothing here to resolve against.
|
|
107
|
+
// Which preset is active keeps exactly one authority.
|
|
90
108
|
return effectiveMemberName(
|
|
109
|
+
undefined,
|
|
91
110
|
sessionPreset,
|
|
92
111
|
globalsPreset,
|
|
93
112
|
PRESET_FLOOR,
|
|
@@ -578,7 +578,8 @@ function declareSettingControls(artifacts: MenuArtifacts): void {
|
|
|
578
578
|
artifacts.segments[seg] = {
|
|
579
579
|
template:
|
|
580
580
|
`${c.glyph} {{ .${c.effectiveVar} }} ` +
|
|
581
|
-
`{{ menu "${apply}"
|
|
581
|
+
`{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
|
|
582
|
+
`(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
|
|
582
583
|
`{{ action "${controlReset(c.name)}" "↺" }}`,
|
|
583
584
|
bg: "surface",
|
|
584
585
|
fg: "foreground",
|
|
@@ -20,7 +20,20 @@ import path from "node:path";
|
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
import type { ClaudeHookData } from "../utils/claude.js";
|
|
22
22
|
import type { ClientHints } from "./protocol.js";
|
|
23
|
-
import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
|
|
23
|
+
import type { DslConfig, Globals, VariableDecl } from "../config/dsl-types.js";
|
|
24
|
+
import { effectivePresetName, presetGlobals } from "../config/presets.js";
|
|
25
|
+
import { EDIT_MODE_KEY, EDIT_MODE_OPEN } from "../config/loader/edit-mode.js";
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_CHARSET,
|
|
28
|
+
DEFAULT_COLOR_COMPATIBILITY,
|
|
29
|
+
} from "../render/strip.js";
|
|
30
|
+
import {
|
|
31
|
+
effectiveAutoWrap,
|
|
32
|
+
effectiveLookName,
|
|
33
|
+
effectivePadding,
|
|
34
|
+
effectiveStripStyle,
|
|
35
|
+
effectiveThemeName,
|
|
36
|
+
} from "../themes/policy.js";
|
|
24
37
|
import { walkNodes } from "../config/dsl-types.js";
|
|
25
38
|
import { extractTemplateRefs } from "../config/dsl-loader.js";
|
|
26
39
|
import type { GitInfo, GitInfoOptions } from "../segments/git.js";
|
|
@@ -75,12 +88,96 @@ export interface EffectiveGlobals {
|
|
|
75
88
|
// own comment.
|
|
76
89
|
readonly presetCustomized: boolean;
|
|
77
90
|
readonly style: StripStyle;
|
|
91
|
+
// [LAW:one-source-of-truth] The cell separator `plain` renders between
|
|
92
|
+
// segments (globals.default_separator). `string | undefined`, not a resolved
|
|
93
|
+
// string, precisely because its floor is NOT ours: PlainJoiner owns " | " and
|
|
94
|
+
// pickJoiner already reads undefined as "use the class default", so naming a
|
|
95
|
+
// floor here would be a second copy of a constant that lives in rich-js.
|
|
96
|
+
// Like charset it has no SessionState half — the config global (as staged by
|
|
97
|
+
// whatever fragment is on top) is its whole resolution.
|
|
98
|
+
readonly separator: string | undefined;
|
|
78
99
|
readonly charset: Charset;
|
|
79
100
|
readonly colorCompatibility: ColorCompatibility;
|
|
80
101
|
readonly autoWrap: boolean;
|
|
81
102
|
readonly padding: number;
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
// [LAW:one-source-of-truth] THE resolution — one function, so the precedence
|
|
106
|
+
// chain has one implementation rather than one per caller. It previously stood
|
|
107
|
+
// as two structurally identical struct literals (the daemon's, in server.ts,
|
|
108
|
+
// and `cc-candybar check`'s), which is two clocks: the check command's job is
|
|
109
|
+
// to render what the daemon would render, and a rung added to one copy is a
|
|
110
|
+
// rung silently missing from the other. The callers differ only in WHERE a
|
|
111
|
+
// session value comes from and whether an overrides log exists to be customized
|
|
112
|
+
// by, so both arrive as parameters and nothing else forks
|
|
113
|
+
// [LAW:dataflow-not-control-flow].
|
|
114
|
+
//
|
|
115
|
+
// `sessionPick` is the reader for one SessionState key. `check` passes a
|
|
116
|
+
// function returning null for every key — a fresh session that has never
|
|
117
|
+
// clicked — rather than a null store, so "no session" travels as a VALUE
|
|
118
|
+
// through the same chain a real session travels [LAW:no-mode-explosion].
|
|
119
|
+
export function resolveEffectiveGlobals(
|
|
120
|
+
config: DslConfig,
|
|
121
|
+
sessionPick: (key: string) => string | null,
|
|
122
|
+
presetCustomized: (preset: string) => boolean,
|
|
123
|
+
): EffectiveGlobals {
|
|
124
|
+
// The preset resolves FIRST: every field below reads globals, and which
|
|
125
|
+
// globals is exactly what the preset decides.
|
|
126
|
+
const preset = effectivePresetName(
|
|
127
|
+
sessionPick("preset"),
|
|
128
|
+
config.globals.preset,
|
|
129
|
+
config.presets,
|
|
130
|
+
);
|
|
131
|
+
const globals = presetGlobals(config, preset);
|
|
132
|
+
// [LAW:dataflow-not-control-flow] The staged fragment is a VALUE, and "edit
|
|
133
|
+
// mode is off" is the EMPTY value — the identity fragment, exactly as
|
|
134
|
+
// PRESET_FLOOR's is. Every field below is resolved by the same expression
|
|
135
|
+
// whether or not edit mode is on; only the contents of `staged` differ. This
|
|
136
|
+
// is the whole of "no render-walk branch on edit mode": there is no branch
|
|
137
|
+
// here either, so there is none to leak downstream.
|
|
138
|
+
const staged: Partial<Globals> =
|
|
139
|
+
sessionPick(EDIT_MODE_KEY) === EDIT_MODE_OPEN ? config.editGlobals : {};
|
|
140
|
+
return {
|
|
141
|
+
preset,
|
|
142
|
+
presetCustomized: presetCustomized(preset),
|
|
143
|
+
theme: effectiveThemeName(
|
|
144
|
+
staged.palette,
|
|
145
|
+
sessionPick("theme"),
|
|
146
|
+
globals.palette,
|
|
147
|
+
),
|
|
148
|
+
look: effectiveLookName(
|
|
149
|
+
staged.look,
|
|
150
|
+
sessionPick("look"),
|
|
151
|
+
globals.look,
|
|
152
|
+
config.looks,
|
|
153
|
+
),
|
|
154
|
+
style: effectiveStripStyle(
|
|
155
|
+
staged.style,
|
|
156
|
+
sessionPick("style"),
|
|
157
|
+
globals.style,
|
|
158
|
+
),
|
|
159
|
+
// [LAW:one-source-of-truth] The fields with no SessionState half resolve as
|
|
160
|
+
// `staged ?? config ?? floor` — the same chain minus the rung they do not
|
|
161
|
+
// have, spelled with the same `??` rather than a second mechanism.
|
|
162
|
+
separator: staged.default_separator ?? globals.default_separator,
|
|
163
|
+
autoWrap: effectiveAutoWrap(
|
|
164
|
+
staged.autoWrap,
|
|
165
|
+
sessionPick("autoWrap"),
|
|
166
|
+
globals.autoWrap,
|
|
167
|
+
),
|
|
168
|
+
padding: effectivePadding(
|
|
169
|
+
staged.padding,
|
|
170
|
+
sessionPick("padding"),
|
|
171
|
+
globals.padding,
|
|
172
|
+
),
|
|
173
|
+
charset: staged.charset ?? globals.charset ?? DEFAULT_CHARSET,
|
|
174
|
+
colorCompatibility:
|
|
175
|
+
staged.colorCompatibility ??
|
|
176
|
+
globals.colorCompatibility ??
|
|
177
|
+
DEFAULT_COLOR_COMPATIBILITY,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
84
181
|
// ─── Augmented payload shape ─────────────────────────────────────────────────
|
|
85
182
|
|
|
86
183
|
// [LAW:types-are-the-program] The RenderPayload extends ClaudeHookData with
|
package/src/daemon/server.ts
CHANGED
|
@@ -63,20 +63,8 @@ import { buildDebugSnapshot } from "./debug";
|
|
|
63
63
|
import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
|
|
64
64
|
import { expandHome } from "../config/dsl-loader.js";
|
|
65
65
|
import { renderDsl } from "../dsl/render.js";
|
|
66
|
-
import {
|
|
67
|
-
|
|
68
|
-
effectiveAutoWrap,
|
|
69
|
-
effectivePadding,
|
|
70
|
-
effectiveThemeName,
|
|
71
|
-
effectiveLookName,
|
|
72
|
-
lookKeyByName,
|
|
73
|
-
paletteForThemeName,
|
|
74
|
-
} from "../themes/index.js";
|
|
75
|
-
import {
|
|
76
|
-
effectivePresetName,
|
|
77
|
-
presetGlobals,
|
|
78
|
-
presetIsCustomized,
|
|
79
|
-
} from "../config/presets.js";
|
|
66
|
+
import { lookKeyByName, paletteForThemeName } from "../themes/index.js";
|
|
67
|
+
import { presetIsCustomized } from "../config/presets.js";
|
|
80
68
|
import {
|
|
81
69
|
renderStripCells,
|
|
82
70
|
DEFAULT_CHARSET,
|
|
@@ -90,7 +78,11 @@ import {
|
|
|
90
78
|
} from "../render/strip.js";
|
|
91
79
|
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
92
80
|
import type { RichText } from "@promptctl/rich-js";
|
|
93
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
buildRenderPayload,
|
|
83
|
+
resolveEffectiveGlobals,
|
|
84
|
+
type EffectiveGlobals,
|
|
85
|
+
} from "./render-payload.js";
|
|
94
86
|
import { ContextProvider } from "../segments/context.js";
|
|
95
87
|
import { MetricsProvider } from "../segments/metrics.js";
|
|
96
88
|
import { TmuxService } from "../segments/tmux.js";
|
|
@@ -860,83 +852,28 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
860
852
|
// No special-case branches — same composition every render.
|
|
861
853
|
let body = "";
|
|
862
854
|
if (entry.state !== null) {
|
|
863
|
-
// [LAW:one-source-of-truth]
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
// have no SessionState half at all, making the config global over its
|
|
877
|
-
// floor constant their whole resolution. This struct feeds
|
|
878
|
-
// BOTH the payload's `*.effective` fields (trigger labels) AND
|
|
879
|
-
// renderOpts below (the actual render) — one resolution, two readers,
|
|
880
|
-
// so a label can never disagree with what was rendered.
|
|
881
|
-
//
|
|
882
|
-
// [LAW:one-source-of-truth] The PRESET resolves FIRST, because every
|
|
883
|
-
// other field below reads globals — and which globals is exactly what
|
|
884
|
-
// the preset decides. `presetGlobals` is the config's globals with the
|
|
885
|
-
// active fragment's shallow-merged over them, so the preset sits at its
|
|
886
|
-
// one documented place in the precedence chain (bundled default < user
|
|
887
|
-
// file < persisted overrides < ACTIVE PRESET < session pick — see
|
|
888
|
-
// src/config/presets.ts and docs/interaction-authoring.md): later than
|
|
889
|
-
// everything read per cache ENTRY, earlier than every session click,
|
|
890
|
-
// which is the order the `??` chains below already enforce by reading
|
|
891
|
-
// SessionState first. There is no "does a preset apply?" branch — the
|
|
892
|
-
// floor preset's empty fragment merges as a no-op
|
|
893
|
-
// [LAW:dataflow-not-control-flow].
|
|
894
|
-
const preset = effectivePresetName(
|
|
895
|
-
sessionState.get(req.hookData.session_id, "preset"),
|
|
896
|
-
entry.state.config.globals.preset,
|
|
897
|
-
entry.state.config.presets,
|
|
898
|
-
);
|
|
899
|
-
const globals = presetGlobals(entry.state.config, preset);
|
|
900
|
-
const effective: EffectiveGlobals = {
|
|
901
|
-
preset,
|
|
855
|
+
// [LAW:one-source-of-truth] Every globals field resolved ONCE per
|
|
856
|
+
// render, here — before the payload build, so the same struct feeds
|
|
857
|
+
// BOTH the payload's `*.effective` fields (what a trigger label says)
|
|
858
|
+
// AND renderOpts below (what actually renders). One resolution, two
|
|
859
|
+
// readers, so a label can never disagree with the bar. The precedence
|
|
860
|
+
// the resolver applies, and why each rung sits where it does, lives
|
|
861
|
+
// with the chain (resolveEffectiveGlobals, and src/config/presets.ts).
|
|
862
|
+
// Read alongside the config, from the same entry, in one statement —
|
|
863
|
+
// which is exactly what the closure below claims about it.
|
|
864
|
+
const presetRootOps = entry.state.presetRootOps;
|
|
865
|
+
const effective: EffectiveGlobals = resolveEffectiveGlobals(
|
|
866
|
+
entry.state.config,
|
|
867
|
+
(key: string) => sessionState.get(req.hookData.session_id, key),
|
|
902
868
|
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — read from
|
|
903
869
|
// THIS entry's own presetRootOps (the record that fed the SAME
|
|
904
870
|
// reload that produced entry.state.config), never a fresh
|
|
905
871
|
// loadOverrides() here — a second read could race a concurrent
|
|
906
|
-
// write and disagree with the tree that actually rendered.
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
theme: effectiveThemeName(
|
|
912
|
-
sessionState.get(req.hookData.session_id, "theme"),
|
|
913
|
-
globals.palette,
|
|
914
|
-
),
|
|
915
|
-
look: effectiveLookName(
|
|
916
|
-
sessionState.get(req.hookData.session_id, "look"),
|
|
917
|
-
globals.look,
|
|
918
|
-
entry.state.config.looks,
|
|
919
|
-
),
|
|
920
|
-
style: effectiveStripStyle(
|
|
921
|
-
sessionState.get(req.hookData.session_id, "style"),
|
|
922
|
-
globals.style,
|
|
923
|
-
),
|
|
924
|
-
autoWrap: effectiveAutoWrap(
|
|
925
|
-
sessionState.get(req.hookData.session_id, "autoWrap"),
|
|
926
|
-
globals.autoWrap,
|
|
927
|
-
),
|
|
928
|
-
padding: effectivePadding(
|
|
929
|
-
sessionState.get(req.hookData.session_id, "padding"),
|
|
930
|
-
globals.padding,
|
|
931
|
-
),
|
|
932
|
-
// charset and colorCompatibility have no session half by design —
|
|
933
|
-
// they describe the terminal, not a taste (see CHARSETS in
|
|
934
|
-
// themes/policy.ts) — so the config global over its floor is their
|
|
935
|
-
// whole resolution.
|
|
936
|
-
charset: globals.charset ?? DEFAULT_CHARSET,
|
|
937
|
-
colorCompatibility:
|
|
938
|
-
globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
|
|
939
|
-
};
|
|
872
|
+
// write and disagree with the tree that actually rendered. That is
|
|
873
|
+
// why it arrives as a closure over this entry rather than being
|
|
874
|
+
// looked up inside the resolver.
|
|
875
|
+
(preset: string) => presetIsCustomized(presetRootOps, preset),
|
|
876
|
+
);
|
|
940
877
|
const payload = await buildRenderPayload(
|
|
941
878
|
req.hookData,
|
|
942
879
|
payloadDeps,
|
|
@@ -956,6 +893,10 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
956
893
|
// SAME `effective` struct the payload was just built from — no second
|
|
957
894
|
// `?? DEFAULT_X` computation to drift from it.
|
|
958
895
|
renderOpts.style = effective.style;
|
|
896
|
+
// The `plain` joiner's cell separator. Assigned unconditionally like
|
|
897
|
+
// every field around it: `undefined` is a value pickJoiner already
|
|
898
|
+
// reads as "PlainJoiner's own default", not an absence to branch on.
|
|
899
|
+
renderOpts.separator = effective.separator;
|
|
959
900
|
renderOpts.wrap = effective.autoWrap;
|
|
960
901
|
renderOpts.padding = effective.padding;
|
|
961
902
|
renderOpts.charset = effective.charset;
|
package/src/demo/dsl.ts
CHANGED
|
@@ -30,21 +30,10 @@ import { VariableStore } from "../var-system/store.js";
|
|
|
30
30
|
import { SourceRegistry } from "../var-system/sources.js";
|
|
31
31
|
import { SessionState } from "../daemon/session-state.js";
|
|
32
32
|
import { listResolvablePaletteNames } from "../themes/policy.js";
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
effectiveLookName,
|
|
36
|
-
effectiveAutoWrap,
|
|
37
|
-
effectivePadding,
|
|
38
|
-
lookKeyByName,
|
|
39
|
-
paletteForThemeName,
|
|
40
|
-
} from "../themes/index.js";
|
|
41
|
-
import { effectivePresetName, presetGlobals } from "../config/presets.js";
|
|
33
|
+
import { lookKeyByName, paletteForThemeName } from "../themes/index.js";
|
|
34
|
+
import { resolveEffectiveGlobals } from "../daemon/render-payload.js";
|
|
42
35
|
import { registerDslConfig, renderDsl } from "../dsl/render.js";
|
|
43
|
-
import {
|
|
44
|
-
DEFAULT_CHARSET,
|
|
45
|
-
DEFAULT_COLOR_COMPATIBILITY,
|
|
46
|
-
DEFAULT_TERMINAL_WIDTH,
|
|
47
|
-
} from "../render/strip.js";
|
|
36
|
+
import { DEFAULT_TERMINAL_WIDTH } from "../render/strip.js";
|
|
48
37
|
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
49
38
|
|
|
50
39
|
const FRAMES = 4;
|
|
@@ -83,18 +72,18 @@ const payload = {
|
|
|
83
72
|
// globals every other option reads — the same preset-first order server.ts and
|
|
84
73
|
// check.ts resolve in, so the demo prints the arrangement a fresh session opens
|
|
85
74
|
// in.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
config.looks,
|
|
96
|
-
effectiveLookName(null, globals.look, config.looks),
|
|
75
|
+
// [LAW:one-source-of-truth] THE daemon's resolver, not a mirror of it — a
|
|
76
|
+
// fresh-session pick reader (null for every key) and no overrides log to be
|
|
77
|
+
// customized by. The demo previously restated this chain field by field and had
|
|
78
|
+
// already drifted: it hardcoded `style: "powerline"` below and so ignored a
|
|
79
|
+
// config's own `globals.style`.
|
|
80
|
+
const effective = resolveEffectiveGlobals(
|
|
81
|
+
config,
|
|
82
|
+
() => null,
|
|
83
|
+
() => false,
|
|
97
84
|
);
|
|
85
|
+
const basePalette = paletteForThemeName(effective.theme);
|
|
86
|
+
const lookKey = lookKeyByName(config.looks, effective.look);
|
|
98
87
|
|
|
99
88
|
// A fresh store + registry for this run. (A hot-reloading daemon would
|
|
100
89
|
// dispose() the old pair and build new ones — see registerDslConfig's docs.)
|
|
@@ -128,26 +117,21 @@ try {
|
|
|
128
117
|
payload,
|
|
129
118
|
basePalette,
|
|
130
119
|
{
|
|
131
|
-
style:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
colorCompatibility:
|
|
135
|
-
globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
|
|
120
|
+
style: effective.style,
|
|
121
|
+
separator: effective.separator,
|
|
122
|
+
colorCompatibility: effective.colorCompatibility,
|
|
136
123
|
// [LAW:one-source-of-truth] Demo applies the same Claude-Code-UI
|
|
137
124
|
// reserve the daemon does so demo output matches the bytes a real
|
|
138
125
|
// statusline would emit at the same terminal width.
|
|
139
126
|
width: applyClaudeCodeReserve(
|
|
140
127
|
process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
|
|
141
128
|
),
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
wrap: effectiveAutoWrap(null, globals.autoWrap),
|
|
146
|
-
padding: effectivePadding(null, globals.padding),
|
|
147
|
-
charset: globals.charset ?? DEFAULT_CHARSET,
|
|
129
|
+
wrap: effective.autoWrap,
|
|
130
|
+
padding: effective.padding,
|
|
131
|
+
charset: effective.charset,
|
|
148
132
|
},
|
|
149
133
|
undefined,
|
|
150
|
-
{ look: lookKey, preset },
|
|
134
|
+
{ look: lookKey, preset: effective.preset },
|
|
151
135
|
);
|
|
152
136
|
process.stdout.write(` ${line}\n`);
|
|
153
137
|
if (frame < FRAMES - 1) await sleep(FRAME_INTERVAL_MS);
|
package/src/render/action.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type ActionDecl,
|
|
33
33
|
} from "../config/action.js";
|
|
34
34
|
import { resolveOptionDomain } from "../config/option-domain.js";
|
|
35
|
+
import { pickCycleDisplay } from "../config/disclosure.js";
|
|
35
36
|
import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
|
|
36
37
|
import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
|
|
37
38
|
import {
|
|
@@ -800,15 +801,16 @@ function selectDisplay(
|
|
|
800
801
|
throw new Error(`action "${name}" needs a display (the clickable text)`);
|
|
801
802
|
}
|
|
802
803
|
if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
displays
|
|
810
|
-
|
|
811
|
-
|
|
804
|
+
// [LAW:single-enforcer] The arity rule and the pick are the disclosure
|
|
805
|
+
// primitive's, not this file's — `{{ menu }}` resolves its own trigger
|
|
806
|
+
// through the same function over its `[closed, member]` cycle, so the two
|
|
807
|
+
// disclosure kinds cannot disagree about what a display binding means.
|
|
808
|
+
const display = pickCycleDisplay(
|
|
809
|
+
`action "${name}"`,
|
|
810
|
+
displays,
|
|
811
|
+
action.members.length,
|
|
812
|
+
cycleIndex(action, store),
|
|
813
|
+
);
|
|
812
814
|
return { display, boundValue: undefined };
|
|
813
815
|
}
|
|
814
816
|
if (displays.length > 2) {
|