@promptctl/cc-candybar 1.20.0 → 1.21.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 +56 -55
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +27 -1
- package/src/check.ts +43 -4
- package/src/config/action.ts +12 -5
- package/src/config/default-dsl-config.ts +64 -0
- package/src/config/dsl-loader.ts +3 -0
- package/src/config/dsl-types.ts +28 -0
- package/src/config/loader/cross-ref.ts +14 -0
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +6 -0
- package/src/config/loader/looks.ts +96 -0
- package/src/config/loader/merge.ts +4 -0
- package/src/config/loader/validate-core.ts +35 -0
- package/src/daemon/render-payload.ts +13 -0
- package/src/daemon/server.ts +16 -1
- package/src/daemon/verbs/state-validators.ts +21 -7
- package/src/demo/dsl.ts +15 -1
- package/src/dsl/node-registry.ts +29 -11
- package/src/dsl/render.ts +54 -12
- package/src/render/action.ts +18 -7
- package/src/themes/index.ts +2 -0
- package/src/themes/palette-resolvers.ts +33 -27
- package/src/themes/policy.ts +51 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
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",
|
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
"mobx": "^6.15.0"
|
|
96
96
|
},
|
|
97
97
|
"optionalDependencies": {
|
|
98
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
98
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.21.0",
|
|
99
|
+
"@promptctl/cc-candybar-darwin-x64": "1.21.0",
|
|
100
|
+
"@promptctl/cc-candybar-linux-x64": "1.21.0",
|
|
101
|
+
"@promptctl/cc-candybar-linux-arm64": "1.21.0"
|
|
102
102
|
},
|
|
103
103
|
"pnpm": {
|
|
104
104
|
"supportedArchitectures": {
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"palette": {
|
|
27
27
|
"type": "string"
|
|
28
28
|
},
|
|
29
|
+
"look": {
|
|
30
|
+
"type": "string"
|
|
31
|
+
},
|
|
29
32
|
"style": {
|
|
30
33
|
"enum": [
|
|
31
34
|
"powerline",
|
|
@@ -1140,7 +1143,8 @@
|
|
|
1140
1143
|
"from": {
|
|
1141
1144
|
"enum": [
|
|
1142
1145
|
"themes",
|
|
1143
|
-
"styles"
|
|
1146
|
+
"styles",
|
|
1147
|
+
"looks"
|
|
1144
1148
|
]
|
|
1145
1149
|
}
|
|
1146
1150
|
},
|
|
@@ -1239,6 +1243,28 @@
|
|
|
1239
1243
|
]
|
|
1240
1244
|
}
|
|
1241
1245
|
},
|
|
1246
|
+
"looks": {
|
|
1247
|
+
"type": "object",
|
|
1248
|
+
"additionalProperties": {
|
|
1249
|
+
"type": "object",
|
|
1250
|
+
"properties": {
|
|
1251
|
+
"hueShift": {
|
|
1252
|
+
"type": "number"
|
|
1253
|
+
},
|
|
1254
|
+
"chromaScale": {
|
|
1255
|
+
"type": "number",
|
|
1256
|
+
"minimum": 0
|
|
1257
|
+
},
|
|
1258
|
+
"lightnessScale": {
|
|
1259
|
+
"type": "number"
|
|
1260
|
+
},
|
|
1261
|
+
"lightnessShift": {
|
|
1262
|
+
"type": "number"
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
"additionalProperties": false
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1242
1268
|
"helpers": {
|
|
1243
1269
|
"type": "object",
|
|
1244
1270
|
"additionalProperties": {
|
package/src/check.ts
CHANGED
|
@@ -30,7 +30,12 @@ import { SourceRegistry } from "./var-system/sources.js";
|
|
|
30
30
|
import { SessionState } from "./daemon/session-state.js";
|
|
31
31
|
import { registerDslConfig, renderDsl } from "./dsl/render.js";
|
|
32
32
|
import { deriveActionValidators } from "./daemon/verbs/state-validators.js";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
effectiveThemeName,
|
|
35
|
+
effectiveLookName,
|
|
36
|
+
lookKeyByName,
|
|
37
|
+
effectiveStripStyle,
|
|
38
|
+
} from "./themes/policy.js";
|
|
34
39
|
import { resolverForThemeName } from "./themes/palette-resolvers.js";
|
|
35
40
|
import {
|
|
36
41
|
DEFAULT_CHARSET,
|
|
@@ -61,7 +66,10 @@ const CHECK_WIDTH = 200;
|
|
|
61
66
|
// test/example-configs.test.ts asserts rendered content against these literal
|
|
62
67
|
// values (780s → "◷ 13m", cost $0.39, version 1.15.0, …); changing one here
|
|
63
68
|
// fails that suite loudly rather than drifting silently.
|
|
64
|
-
export function checkPayload(
|
|
69
|
+
export function checkPayload(
|
|
70
|
+
effectiveTheme: string,
|
|
71
|
+
effectiveLook: string,
|
|
72
|
+
): Record<string, unknown> {
|
|
65
73
|
const home = "/home/tester";
|
|
66
74
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
67
75
|
return {
|
|
@@ -108,6 +116,7 @@ export function checkPayload(effectiveTheme: string): Record<string, unknown> {
|
|
|
108
116
|
cache: { expiresAt: nowSec + 15 * 60 },
|
|
109
117
|
tmux: { session: "work" },
|
|
110
118
|
theme: { effective: effectiveTheme },
|
|
119
|
+
look: { effective: effectiveLook },
|
|
111
120
|
};
|
|
112
121
|
}
|
|
113
122
|
|
|
@@ -265,12 +274,28 @@ function loadRegisterRender(
|
|
|
265
274
|
// resolution is null — the config default over the floor, exactly what the
|
|
266
275
|
// daemon renders for a session that has never clicked.
|
|
267
276
|
const effectiveTheme = effectiveThemeName(null, config.globals.palette);
|
|
268
|
-
|
|
277
|
+
// Same fresh-session shape one dimension over: no clicked look, so the
|
|
278
|
+
// config default over the "none" floor — exactly what the daemon renders
|
|
279
|
+
// for a session that has never clicked.
|
|
280
|
+
const effectiveLook = effectiveLookName(
|
|
281
|
+
null,
|
|
282
|
+
config.globals.look,
|
|
283
|
+
config.looks,
|
|
284
|
+
);
|
|
285
|
+
// [LAW:no-silent-failure] A segment whose template THROWS while evaluating
|
|
286
|
+
// (an `{{ action }}` display-arity mismatch, a MissingFieldError from a
|
|
287
|
+
// partially-declared variable) renders as a visible ⚠ error cell — partial
|
|
288
|
+
// rendering, the daemon's channel for a human looking at the bar. The blind
|
|
289
|
+
// authoring agent is not looking at the bar; check collects the same errors
|
|
290
|
+
// through the render's observer seam and fails the verdict, so exit 0 never
|
|
291
|
+
// blesses a bar that renders ⚠.
|
|
292
|
+
const segmentErrors: string[] = [];
|
|
293
|
+
const rendered = renderDsl(
|
|
269
294
|
config,
|
|
270
295
|
compiled,
|
|
271
296
|
store,
|
|
272
297
|
registry,
|
|
273
|
-
checkPayload(effectiveTheme),
|
|
298
|
+
checkPayload(effectiveTheme, effectiveLook),
|
|
274
299
|
resolverForThemeName(effectiveTheme),
|
|
275
300
|
{
|
|
276
301
|
style: effectiveStripStyle(null, config.globals.style),
|
|
@@ -281,7 +306,21 @@ function loadRegisterRender(
|
|
|
281
306
|
padding: config.globals.padding ?? DEFAULT_PADDING,
|
|
282
307
|
charset: config.globals.charset ?? DEFAULT_CHARSET,
|
|
283
308
|
},
|
|
309
|
+
{
|
|
310
|
+
onSegmentError: (segName, message) =>
|
|
311
|
+
segmentErrors.push(`segment "${segName}": ${message}`),
|
|
312
|
+
},
|
|
313
|
+
lookKeyByName(config.looks, effectiveLook),
|
|
284
314
|
);
|
|
315
|
+
if (segmentErrors.length > 0) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`config renders with ${segmentErrors.length} segment error${
|
|
318
|
+
segmentErrors.length === 1 ? "" : "s"
|
|
319
|
+
} (the daemon would render ⚠ error cells):\n` +
|
|
320
|
+
segmentErrors.map((m) => ` ${m}`).join("\n"),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return rendered;
|
|
285
324
|
} finally {
|
|
286
325
|
// [LAW:single-enforcer] The registry owns every async handle the config
|
|
287
326
|
// declared (timers, fs watchers, git subscriptions); a one-shot check must
|
package/src/config/action.ts
CHANGED
|
@@ -25,11 +25,18 @@
|
|
|
25
25
|
// discriminator the loader and the validator-derivation match on.
|
|
26
26
|
|
|
27
27
|
// [LAW:one-source-of-truth] The domain lists a picker draws options from. Same
|
|
28
|
-
// canonical sources the `themes()`/`styles()` bindings and the
|
|
29
|
-
// validators consult — the rendered options and the derived gate
|
|
30
|
-
// because there is no second enumeration.
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
// canonical sources the `themes()`/`styles()`/`looks()` bindings and the
|
|
29
|
+
// set-state validators consult — the rendered options and the derived gate
|
|
30
|
+
// cannot diverge because there is no second enumeration. themes/styles are
|
|
31
|
+
// static registry lists; "looks" is the one PER-CONFIG domain (the merged
|
|
32
|
+
// `looks` block's names), so its resolution sites take the config's look names
|
|
33
|
+
// as data rather than consulting a module constant.
|
|
34
|
+
export type OptionSource = "themes" | "styles" | "looks";
|
|
35
|
+
export const OPTION_SOURCES: readonly OptionSource[] = [
|
|
36
|
+
"themes",
|
|
37
|
+
"styles",
|
|
38
|
+
"looks",
|
|
39
|
+
];
|
|
33
40
|
|
|
34
41
|
// [LAW:types-are-the-program] The top-level discriminator of an ActionDecl — the
|
|
35
42
|
// click effect is keyed by which of these is present. The loader proves
|
|
@@ -197,6 +197,16 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
197
197
|
path: "theme.effective",
|
|
198
198
|
default: "",
|
|
199
199
|
},
|
|
200
|
+
// [LAW:one-type-per-behavior] The effective LOOK name, the exact twin of
|
|
201
|
+
// theme.effective one dimension over — effectiveLookName(sessionState.look,
|
|
202
|
+
// globals.look, looks), the SAME name whose ThemeKey adapts the rendered
|
|
203
|
+
// palette. A look-picker trigger reads `{{ .look.effective }}` for its
|
|
204
|
+
// label; the label and the colors trace to one resolution.
|
|
205
|
+
"look.effective": {
|
|
206
|
+
kind: "input",
|
|
207
|
+
path: "look.effective",
|
|
208
|
+
default: "",
|
|
209
|
+
},
|
|
200
210
|
|
|
201
211
|
// [LAW:one-source-of-truth] The usable terminal width for THIS render —
|
|
202
212
|
// the exact post-reserve cell count FlexStrip wraps to. renderDsl injects
|
|
@@ -887,6 +897,60 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
887
897
|
applyStyle: { set: "style", from: "styles" },
|
|
888
898
|
},
|
|
889
899
|
|
|
900
|
+
// ─── Looks ───────────────────────────────────────────────────────────────
|
|
901
|
+
// Named theme ADAPTATIONS — each is a full rich-js ThemeKey applied on top
|
|
902
|
+
// of whatever base theme is active (a transform, not a palette), so every
|
|
903
|
+
// look composes with every theme: pick theme, then pick look. Selected per
|
|
904
|
+
// session via the `look` SessionState key (an action `{ set: "look", from:
|
|
905
|
+
// "looks" }` + a `{{ menu }}`), exactly the theme/style selection seam.
|
|
906
|
+
// [LAW:one-source-of-truth] Merges by name (user wins per name), so this
|
|
907
|
+
// stdlib — including the "none" identity floor effectiveLookName collapses
|
|
908
|
+
// to — is present in every merged config by construction.
|
|
909
|
+
looks: {
|
|
910
|
+
// [LAW:dataflow-not-control-flow] "none" is just the identity look — the
|
|
911
|
+
// resolution floor as a value, not a special case (rich-js's isIdentityKey
|
|
912
|
+
// fast-path makes it free). Spelled literally (not rich-js IDENTITY /
|
|
913
|
+
// INVERT_LIGHTNESS) so the bundled default remains inert JSON-shaped data
|
|
914
|
+
// a user file can mirror axis-for-axis; the loader normalizes user specs
|
|
915
|
+
// onto the same identity axes.
|
|
916
|
+
none: { hueShift: 0, chromaScale: 1, lightnessScale: 1, lightnessShift: 0 },
|
|
917
|
+
// Saturation up/down — chroma is multiplicative, hue and lightness held.
|
|
918
|
+
vivid: {
|
|
919
|
+
hueShift: 0,
|
|
920
|
+
chromaScale: 1.35,
|
|
921
|
+
lightnessScale: 1,
|
|
922
|
+
lightnessShift: 0,
|
|
923
|
+
},
|
|
924
|
+
muted: {
|
|
925
|
+
hueShift: 0,
|
|
926
|
+
chromaScale: 0.55,
|
|
927
|
+
lightnessScale: 1,
|
|
928
|
+
lightnessShift: 0,
|
|
929
|
+
},
|
|
930
|
+
// Lightness down (scale) / up (shift) — dim compresses toward black,
|
|
931
|
+
// bright lifts everything a step; anchors stay hue-locked by rich-js.
|
|
932
|
+
dim: {
|
|
933
|
+
hueShift: 0,
|
|
934
|
+
chromaScale: 1,
|
|
935
|
+
lightnessScale: 0.85,
|
|
936
|
+
lightnessShift: 0,
|
|
937
|
+
},
|
|
938
|
+
bright: {
|
|
939
|
+
hueShift: 0,
|
|
940
|
+
chromaScale: 1,
|
|
941
|
+
lightnessScale: 1,
|
|
942
|
+
lightnessShift: 0.08,
|
|
943
|
+
},
|
|
944
|
+
// The dark↔light "octave" flip (rich-js INVERT_LIGHTNESS: L' = 1 - L) —
|
|
945
|
+
// errors stay red, dark-on-light becomes light-on-dark.
|
|
946
|
+
inverted: {
|
|
947
|
+
hueShift: 0,
|
|
948
|
+
chromaScale: 1,
|
|
949
|
+
lightnessScale: -1,
|
|
950
|
+
lightnessShift: 1,
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
|
|
890
954
|
// [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
|
|
891
955
|
// for the cost/token/budget family lives here as named template helpers, each
|
|
892
956
|
// DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { validateSegments } from "./loader/segments.js";
|
|
|
50
50
|
import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
|
|
51
51
|
import { synthesizeMenuDecls } from "./loader/menu-synth.js";
|
|
52
52
|
import { validateActions } from "./loader/actions.js";
|
|
53
|
+
import { validateLooks } from "./loader/looks.js";
|
|
53
54
|
import { validateHelpers } from "./loader/helpers.js";
|
|
54
55
|
import { validateCrossReferences } from "./loader/cross-ref.js";
|
|
55
56
|
import { validateNoCycles } from "./loader/cycles.js";
|
|
@@ -242,6 +243,7 @@ function validateTopLevel(
|
|
|
242
243
|
if (raw.root !== undefined) out.root = validateRoot(ctx, "root", raw.root);
|
|
243
244
|
if (raw.actions !== undefined)
|
|
244
245
|
out.actions = validateActions(ctx, raw.actions);
|
|
246
|
+
if (raw.looks !== undefined) out.looks = validateLooks(ctx, raw.looks);
|
|
245
247
|
if (raw.helpers !== undefined)
|
|
246
248
|
out.helpers = validateHelpers(ctx, raw.helpers);
|
|
247
249
|
// [LAW:one-source-of-truth] Group sugar synthesis runs AFTER every section
|
|
@@ -269,5 +271,6 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
269
271
|
"layout",
|
|
270
272
|
"root",
|
|
271
273
|
"actions",
|
|
274
|
+
"looks",
|
|
272
275
|
"helpers",
|
|
273
276
|
]);
|
package/src/config/dsl-types.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// references it here. The dependency is one-way (this file → action.ts), never
|
|
15
15
|
// the reverse, so that shape can be lifted out without a cycle.
|
|
16
16
|
import type { ActionDecl } from "./action.js";
|
|
17
|
+
// [LAW:one-source-of-truth] A look IS a rich-js ThemeKey (four numeric axes:
|
|
18
|
+
// hueShift / chromaScale / lightnessScale / lightnessShift) — the config type
|
|
19
|
+
// references the vocabulary owner's type verbatim, so a rich-js axis rename is
|
|
20
|
+
// a compile error here, never silent drift. Type-only: no runtime rich-js
|
|
21
|
+
// dependency enters the config layer.
|
|
22
|
+
import type { ThemeKey } from "@promptctl/rich-js";
|
|
17
23
|
import type {
|
|
18
24
|
Charset,
|
|
19
25
|
ColorCompatibility,
|
|
@@ -129,6 +135,11 @@ export interface RawDslConfig {
|
|
|
129
135
|
readonly segments?: Readonly<Record<string, SegmentDecl>>;
|
|
130
136
|
readonly root?: LayoutNode;
|
|
131
137
|
readonly actions?: Readonly<Record<string, ActionDecl>>;
|
|
138
|
+
// Named theme-adaptation bundles ("looks"): each is a full ThemeKey (the
|
|
139
|
+
// loader normalizes absent axes to identity at parse). Applied ON TOP of the
|
|
140
|
+
// active theme at render — a transform composing with every theme, selected
|
|
141
|
+
// per session exactly like theme/style (session key `look`).
|
|
142
|
+
readonly looks?: Readonly<Record<string, ThemeKey>>;
|
|
132
143
|
// [LAW:single-enforcer] Config-level shared helper templates: name → Go-template
|
|
133
144
|
// body. Each compiles to one `{{ define "name" }}body{{ end }}` block, and the
|
|
134
145
|
// whole set into a single output-neutral preamble prepended to every template
|
|
@@ -153,6 +164,13 @@ export interface DslConfig {
|
|
|
153
164
|
// template cannot smuggle an un-gated write. Empty when no config declares
|
|
154
165
|
// actions — an absent `actions` key merges to `{}`.
|
|
155
166
|
readonly actions: Readonly<Record<string, ActionDecl>>;
|
|
167
|
+
// [LAW:one-source-of-truth] The effective look set: name → full ThemeKey.
|
|
168
|
+
// Merges by name with the bundled default (user wins per name), like
|
|
169
|
+
// segments/actions/variables — so the default's `none` (the identity look and
|
|
170
|
+
// the resolution floor of effectiveLookName) is present in EVERY merged
|
|
171
|
+
// config by construction. An action `{ set: …, from: "looks" }` ranges these
|
|
172
|
+
// names; the derived click gate and the rendered options read this one map.
|
|
173
|
+
readonly looks: Readonly<Record<string, ThemeKey>>;
|
|
156
174
|
// [LAW:single-enforcer] The effective helper set: a name → template-body map
|
|
157
175
|
// compiled to a defines-preamble at registerDslConfig. Empty when no config
|
|
158
176
|
// declares helpers — an absent `helpers` key merges to `{}` (same cascade as
|
|
@@ -186,6 +204,16 @@ export interface Globals {
|
|
|
186
204
|
// `palette` is an explicit override that ignores the session theme.
|
|
187
205
|
readonly palette?: string;
|
|
188
206
|
|
|
207
|
+
// [LAW:one-type-per-behavior] The config default for the LOOK (a named
|
|
208
|
+
// theme-adaptation from the `looks` block) — the exact twin of `palette` one
|
|
209
|
+
// dimension over: the daemon resolves the live look per render as
|
|
210
|
+
// `sessionState.look ?? globals.look ?? "none"` (effectiveLookName), so a
|
|
211
|
+
// look click recolors the bar live and a config can set a default adaptation
|
|
212
|
+
// without an edit-per-session. Membership in the merged `looks` map is
|
|
213
|
+
// validated post-merge (cross-ref) — a user's globals.look may name a
|
|
214
|
+
// default-provided look.
|
|
215
|
+
readonly look?: string;
|
|
216
|
+
|
|
189
217
|
// [LAW:one-type-per-behavior] The config default for the powerline cap/
|
|
190
218
|
// separator SHAPE — the exact twin of `palette` one dimension over: the
|
|
191
219
|
// daemon resolves the live strip style per render as
|
|
@@ -36,6 +36,20 @@ export function validateCrossReferences(
|
|
|
36
36
|
ctx: ValidateCtx,
|
|
37
37
|
cfg: DslConfig,
|
|
38
38
|
): void {
|
|
39
|
+
// [LAW:locality-or-seam] globals.look names a member of the MERGED looks
|
|
40
|
+
// block (a user's default may be a bundled look — same reason every cross-ref
|
|
41
|
+
// runs post-merge). Same existence-check shape as layout→segments; an unknown
|
|
42
|
+
// name is a load error, never a silent identity fallback.
|
|
43
|
+
if (
|
|
44
|
+
cfg.globals.look !== undefined &&
|
|
45
|
+
!Object.prototype.hasOwnProperty.call(cfg.looks, cfg.globals.look)
|
|
46
|
+
) {
|
|
47
|
+
ctx.issues.push({
|
|
48
|
+
path: "globals.look",
|
|
49
|
+
message: `globals.look "${cfg.globals.look}" does not match any declared look (have: ${Object.keys(cfg.looks).join(", ")})`,
|
|
50
|
+
line: findKeyLine(ctx.source, ["globals", "look"]),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
39
53
|
// [LAW:one-source-of-truth] THE set of resolvable variable names — a
|
|
40
54
|
// faithful mirror of the runtime store's key set (declareOne in
|
|
41
55
|
// src/dsl/render.ts registers globals under their bare names and segment
|
|
@@ -16,6 +16,7 @@ import { globalsJson } from "./globals.js";
|
|
|
16
16
|
import { variablesMapJson } from "./variables.js";
|
|
17
17
|
import { segmentsJson } from "./segments.js";
|
|
18
18
|
import { actionsJson } from "./actions.js";
|
|
19
|
+
import { looksJson } from "./looks.js";
|
|
19
20
|
import {
|
|
20
21
|
layoutNodeJson,
|
|
21
22
|
LAYOUT_NODE_DEF_NAME,
|
|
@@ -46,6 +47,7 @@ export function emitConfigSchema(): JsonNode {
|
|
|
46
47
|
segments: segmentsJson(),
|
|
47
48
|
root: { $ref: LAYOUT_NODE_REF },
|
|
48
49
|
actions: actionsJson(),
|
|
50
|
+
looks: looksJson(),
|
|
49
51
|
helpers: { type: "object", additionalProperties: { type: "string" } },
|
|
50
52
|
},
|
|
51
53
|
definitions: {
|
|
@@ -62,6 +62,12 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
|
62
62
|
default_separator: optionalStringSpec(),
|
|
63
63
|
default_truncate_marker: optionalStringSpec(),
|
|
64
64
|
palette: paletteSpec(),
|
|
65
|
+
// [LAW:types-are-the-program] The config-default LOOK name. Unlike the
|
|
66
|
+
// registry-static palette set, the look domain is per-config (the merged
|
|
67
|
+
// `looks` block), so membership is a cross-ref check on the MERGED config —
|
|
68
|
+
// a user's globals.look may name a default-provided look. Shape-only here,
|
|
69
|
+
// exactly the shape/meaning split paletteSpec's schema facet keeps.
|
|
70
|
+
look: optionalStringSpec(),
|
|
65
71
|
// [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
|
|
66
72
|
// shapes the joiner can render), unlike the open-ended palette NAME — so it
|
|
67
73
|
// validates by membership and emits a JSON-Schema `enum`.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The `looks` schema: each look is a named rich-js
|
|
2
|
+
// ThemeKey — an ADAPTATION applied on top of whatever base theme is active (a
|
|
3
|
+
// transform, not a palette), so one look composes with every theme. The config
|
|
4
|
+
// spelling mirrors ThemeKey's field names VERBATIM (hueShift / chromaScale /
|
|
5
|
+
// lightnessScale / lightnessShift) and the parsed output IS a rich-js ThemeKey —
|
|
6
|
+
// no translation layer to drift, and a rich-js field rename fails this module's
|
|
7
|
+
// compile instead of silently diverging. [LAW:one-source-of-truth]
|
|
8
|
+
//
|
|
9
|
+
// The adaptation vocabulary is CAPPED at ThemeKey's four axes. Role remap (the
|
|
10
|
+
// old surface/button "role emphasis") is deferred; its exit plan is a future
|
|
11
|
+
// rich-js resolver-level role→role operation carried as an additive `roles`
|
|
12
|
+
// field here — growing the vocabulary means growing rich-js, never adding color
|
|
13
|
+
// math to cc-candybar.
|
|
14
|
+
|
|
15
|
+
import type { ThemeKey } from "@promptctl/rich-js";
|
|
16
|
+
import { IDENTITY } from "@promptctl/rich-js";
|
|
17
|
+
import {
|
|
18
|
+
describeType,
|
|
19
|
+
isPlainObject,
|
|
20
|
+
optionalNumberSpec,
|
|
21
|
+
record,
|
|
22
|
+
recordJson,
|
|
23
|
+
type JsonNode,
|
|
24
|
+
type RecordSchema,
|
|
25
|
+
type ValidateCtx,
|
|
26
|
+
} from "./validate-core.js";
|
|
27
|
+
import { findKeyLine } from "./diagnostics.js";
|
|
28
|
+
|
|
29
|
+
// [LAW:types-are-the-program] The AUTHORING shape: every axis optional, absent =
|
|
30
|
+
// identity. Distinct from ThemeKey (all fields required) so the record engine's
|
|
31
|
+
// omit-absent output is honestly typed; validateLooks normalizes each parsed
|
|
32
|
+
// spec onto IDENTITY, and past this module a partial look is unrepresentable.
|
|
33
|
+
interface LookSpec {
|
|
34
|
+
readonly hueShift?: number;
|
|
35
|
+
readonly chromaScale?: number;
|
|
36
|
+
readonly lightnessScale?: number;
|
|
37
|
+
readonly lightnessShift?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// [LAW:one-source-of-truth] The four axes, declared once as DATA the record
|
|
41
|
+
// engine interprets for both validation and schema emit. chromaScale is a
|
|
42
|
+
// multiplier on saturation — negative chroma is not a color, so the one bound.
|
|
43
|
+
const LOOK_SCHEMA: RecordSchema<LookSpec> = {
|
|
44
|
+
noun: "look key",
|
|
45
|
+
fields: {
|
|
46
|
+
hueShift: optionalNumberSpec(),
|
|
47
|
+
chromaScale: optionalNumberSpec({ min: 0 }),
|
|
48
|
+
lightnessScale: optionalNumberSpec(),
|
|
49
|
+
lightnessShift: optionalNumberSpec(),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// An absent looks block is handled by the caller (absence survives the parse);
|
|
54
|
+
// a non-object is a reported error recovering to empty — parseDslConfig throws
|
|
55
|
+
// once any issue exists, so the recovery value never renders.
|
|
56
|
+
export function validateLooks(
|
|
57
|
+
ctx: ValidateCtx,
|
|
58
|
+
raw: unknown,
|
|
59
|
+
): Readonly<Record<string, ThemeKey>> {
|
|
60
|
+
if (!isPlainObject(raw)) {
|
|
61
|
+
ctx.issues.push({
|
|
62
|
+
path: "looks",
|
|
63
|
+
message: `looks must be an object mapping look names to adaptation objects, got ${describeType(raw)}`,
|
|
64
|
+
line: findKeyLine(ctx.source, ["looks"]),
|
|
65
|
+
});
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
const out: Record<string, ThemeKey> = {};
|
|
69
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
70
|
+
// [LAW:no-silent-fallbacks] A look name is a deliverable set-state value —
|
|
71
|
+
// a look picker writes it on the wire, which rejects empty values and
|
|
72
|
+
// splits on "/". Rejecting the shape HERE surfaces the error on every
|
|
73
|
+
// config load, not only once an action ranges the "looks" domain (the same
|
|
74
|
+
// wire shape cycle members and `to` literals enforce in actions.ts).
|
|
75
|
+
if (name === "" || name.includes("/")) {
|
|
76
|
+
ctx.issues.push({
|
|
77
|
+
path: `looks.${name}`,
|
|
78
|
+
message: `look name ${JSON.stringify(name)} must be non-empty and slash-free — a look picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
|
|
79
|
+
line: findKeyLine(ctx.source, ["looks", name]),
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const parsed = record(ctx, LOOK_SCHEMA, `looks.${name}`, value);
|
|
84
|
+
// [LAW:one-source-of-truth] Normalization onto IDENTITY is the single
|
|
85
|
+
// "absent axis = identity" site — downstream consumers receive a total
|
|
86
|
+
// ThemeKey and never re-default a missing axis.
|
|
87
|
+
if (parsed !== null) out[name] = { ...IDENTITY, ...parsed };
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
|
|
93
|
+
// the validator interprets — a map of look names to the closed four-axis object.
|
|
94
|
+
export function looksJson(): JsonNode {
|
|
95
|
+
return { type: "object", additionalProperties: recordJson(LOOK_SCHEMA) };
|
|
96
|
+
}
|
|
@@ -32,6 +32,10 @@ export function mergeWithDefault(
|
|
|
32
32
|
// declares only the actions that differ from the bundled default (which
|
|
33
33
|
// ships none).
|
|
34
34
|
actions: { ...dflt.actions, ...(raw.actions ?? {}) },
|
|
35
|
+
// [LAW:one-source-of-truth] looks merge by name, same cascade — a user
|
|
36
|
+
// overrides one adaptation by re-declaring its name; the bundled stdlib
|
|
37
|
+
// (incl. the "none" identity floor) survives every merge by construction.
|
|
38
|
+
looks: { ...dflt.looks, ...(raw.looks ?? {}) },
|
|
35
39
|
// [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
|
|
36
40
|
// overrides one formatter helper by re-declaring its name; the rest inherit
|
|
37
41
|
// from the bundled default.
|
|
@@ -582,6 +582,41 @@ export function optionalIntSpec(bounds: {
|
|
|
582
582
|
};
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
+
// [LAW:dataflow-not-control-flow] An optional finite-number field: the optional
|
|
586
|
+
// lower bound is DATA feeding both interpreters — `parse` checks it and
|
|
587
|
+
// interpolates it into the one message, `json` emits it as `minimum` — so the
|
|
588
|
+
// validator and the editor-facing schema cannot describe different ranges.
|
|
589
|
+
// Finite matters: JSON5 admits `NaN`/`Infinity` literals, and a non-finite axis
|
|
590
|
+
// would corrupt every OKLCH channel it touches downstream.
|
|
591
|
+
export function optionalNumberSpec(
|
|
592
|
+
bounds: { readonly min?: number } = {},
|
|
593
|
+
): FieldSpec<number> {
|
|
594
|
+
const { min } = bounds;
|
|
595
|
+
return {
|
|
596
|
+
required: false,
|
|
597
|
+
json: { type: "number", ...(min !== undefined && { minimum: min }) },
|
|
598
|
+
parse: (ctx, path, field, raw) => {
|
|
599
|
+
const v = raw[field];
|
|
600
|
+
if (v === undefined) return undefined;
|
|
601
|
+
const bad =
|
|
602
|
+
typeof v !== "number" ||
|
|
603
|
+
!Number.isFinite(v) ||
|
|
604
|
+
(min !== undefined && v < min);
|
|
605
|
+
if (bad) {
|
|
606
|
+
const shape =
|
|
607
|
+
min !== undefined ? `a finite number >= ${min}` : "a finite number";
|
|
608
|
+
ctx.issues.push({
|
|
609
|
+
path: `${path}.${field}`,
|
|
610
|
+
message: `${field} must be ${shape}, got ${describeValue(v)}`,
|
|
611
|
+
line: findKeyLine(ctx.source, [field]),
|
|
612
|
+
});
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
615
|
+
return v;
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
585
620
|
// [LAW:single-enforcer] The palette field defers to the one palette-name
|
|
586
621
|
// authority; the field key is conventionally "palette", which validatePaletteName
|
|
587
622
|
// reads directly.
|
|
@@ -63,6 +63,12 @@ export interface RenderPayload extends ClaudeHookData {
|
|
|
63
63
|
// domain truth is "always present". A `?` here would let a callsite believe it
|
|
64
64
|
// could be undefined and guard defensively against an impossibility.
|
|
65
65
|
readonly theme: { readonly effective: string };
|
|
66
|
+
// [LAW:one-type-per-behavior] The daemon-resolved effective LOOK name —
|
|
67
|
+
// effectiveLookName(sessionState.look, globals.look, looks) — the exact twin
|
|
68
|
+
// of `theme` one dimension over: the SAME name whose ThemeKey adapts the
|
|
69
|
+
// rendered palette, surfaced so a trigger label can display the active look.
|
|
70
|
+
// Required for the same reason as theme: resolved unconditionally per render.
|
|
71
|
+
readonly look: { readonly effective: string };
|
|
66
72
|
|
|
67
73
|
// Usage-family. Each provider returns null when it has no data (no
|
|
68
74
|
// transcript yet, no rate-limit window active, etc.); we drop the field
|
|
@@ -582,6 +588,11 @@ export async function buildRenderPayload(
|
|
|
582
588
|
// in (not re-resolved here) because the daemon already computes it for the
|
|
583
589
|
// palette; this is that same value, threaded to the sole payload assembler.
|
|
584
590
|
effectiveTheme: string,
|
|
591
|
+
// [LAW:one-source-of-truth] The effective look name, resolved ONCE by the
|
|
592
|
+
// daemon beside the theme (effectiveLookName over SessionState/globals/looks)
|
|
593
|
+
// and used for BOTH the rendered adaptation and this payload field — so a
|
|
594
|
+
// trigger label reading `.look.effective` can never disagree with the colors.
|
|
595
|
+
effectiveLook: string,
|
|
585
596
|
): Promise<RenderPayload> {
|
|
586
597
|
const wants = (prefix: string): boolean =>
|
|
587
598
|
anyPathStartsWith(neededInputPaths, prefix);
|
|
@@ -788,6 +799,8 @@ export async function buildRenderPayload(
|
|
|
788
799
|
// No `wants` gate: it costs nothing (a string already in hand) and a
|
|
789
800
|
// config that reads `.theme.effective` must always find it.
|
|
790
801
|
theme: { effective: effectiveTheme },
|
|
802
|
+
// Same contract as theme: always present, a string already in hand.
|
|
803
|
+
look: { effective: effectiveLook },
|
|
791
804
|
...(sessionPayload !== undefined && { session: sessionPayload }),
|
|
792
805
|
...(todayPayload !== undefined && { today: todayPayload }),
|
|
793
806
|
...(costPerHour !== undefined && { burn: { costPerHour } }),
|
package/src/daemon/server.ts
CHANGED
|
@@ -59,6 +59,8 @@ import { renderDsl } from "../dsl/render.js";
|
|
|
59
59
|
import {
|
|
60
60
|
effectiveStripStyle,
|
|
61
61
|
effectiveThemeName,
|
|
62
|
+
effectiveLookName,
|
|
63
|
+
lookKeyByName,
|
|
62
64
|
resolverForThemeName,
|
|
63
65
|
} from "../themes/index.js";
|
|
64
66
|
import {
|
|
@@ -802,12 +804,24 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
802
804
|
sessionState.get(req.hookData.session_id, "theme"),
|
|
803
805
|
entry.state.config.globals.palette,
|
|
804
806
|
);
|
|
807
|
+
// [LAW:one-type-per-behavior] The LOOK, resolved per render the exact
|
|
808
|
+
// way the theme is: the session's clicked look (SessionState) over the
|
|
809
|
+
// config default over the "none" floor — so a look click recolors the
|
|
810
|
+
// whole bar on the next render, composing with whatever theme is
|
|
811
|
+
// active. The name feeds the payload's `look.effective`; its ThemeKey
|
|
812
|
+
// (lookKeyByName) feeds renderDsl below — one resolution, two readers.
|
|
813
|
+
const effectiveLook = effectiveLookName(
|
|
814
|
+
sessionState.get(req.hookData.session_id, "look"),
|
|
815
|
+
entry.state.config.globals.look,
|
|
816
|
+
entry.state.config.looks,
|
|
817
|
+
);
|
|
805
818
|
const payload = await buildRenderPayload(
|
|
806
819
|
req.hookData,
|
|
807
820
|
payloadDeps,
|
|
808
821
|
req.cwd,
|
|
809
822
|
entry.state.neededInputPaths,
|
|
810
823
|
effectiveTheme,
|
|
824
|
+
effectiveLook,
|
|
811
825
|
);
|
|
812
826
|
// [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
|
|
813
827
|
// is derived from the same effective theme resolved above — so a theme
|
|
@@ -869,7 +883,8 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
869
883
|
// in place. Cells are cheap (already computed during the render);
|
|
870
884
|
// the per-segment ANSI serialization happens lazily inside the
|
|
871
885
|
// debug handler so normal renders pay no extra serializer cost.
|
|
872
|
-
entry.state.lastRenderCellsBySegment,
|
|
886
|
+
{ perSegmentSink: entry.state.lastRenderCellsBySegment },
|
|
887
|
+
lookKeyByName(entry.state.config.looks, effectiveLook),
|
|
873
888
|
);
|
|
874
889
|
}
|
|
875
890
|
// [LAW:one-source-of-truth] Consume the transient click error written by
|