@promptctl/cc-candybar 1.26.0 → 1.27.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 +86 -85
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +156 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +61 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +3 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- package/src/themes/policy.ts +37 -16
package/src/dsl/render.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the input values (kind discriminators, layout length, palette presence)
|
|
12
12
|
// govern output, not whether operations run.
|
|
13
13
|
|
|
14
|
-
import type { RichText,
|
|
14
|
+
import type { RichText, Palette, ThemeKey } from "@promptctl/rich-js";
|
|
15
15
|
import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
|
|
16
16
|
import type { Engine, Template } from "@promptctl/go-template-js";
|
|
17
17
|
import type {
|
|
@@ -21,6 +21,8 @@ import type {
|
|
|
21
21
|
LayoutNode,
|
|
22
22
|
} from "../config/dsl-types.js";
|
|
23
23
|
import { HUE_STEP_VAR } from "../config/dsl-types.js";
|
|
24
|
+
import { perConfigDomainsFor } from "../config/option-domain.js";
|
|
25
|
+
import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
|
|
24
26
|
import type { VariableStore } from "../var-system/store.js";
|
|
25
27
|
import type { SourceRegistry } from "../var-system/sources.js";
|
|
26
28
|
import {
|
|
@@ -30,11 +32,12 @@ import {
|
|
|
30
32
|
} from "../var-system/sources.js";
|
|
31
33
|
import type { BuildLineOptions } from "../render/strip.js";
|
|
32
34
|
import { DEFAULT_PADDING, renderStripCells } from "../render/strip.js";
|
|
33
|
-
import {
|
|
35
|
+
import { paletteForThemeName } from "../themes/index.js";
|
|
34
36
|
import { buildScope } from "../template-engine/scope.js";
|
|
35
37
|
import {
|
|
36
38
|
createCcCandybarEngine,
|
|
37
39
|
evaluateWhen,
|
|
40
|
+
resolveSegmentColors,
|
|
38
41
|
} from "../template-engine/index.js";
|
|
39
42
|
import {
|
|
40
43
|
compileActions,
|
|
@@ -47,6 +50,11 @@ import {
|
|
|
47
50
|
collectMenuDrops,
|
|
48
51
|
type MenuRuntime,
|
|
49
52
|
} from "../render/menu.js";
|
|
53
|
+
import {
|
|
54
|
+
createActiveSegmentRef,
|
|
55
|
+
type ActiveSegmentRef,
|
|
56
|
+
} from "../render/active-segment.js";
|
|
57
|
+
import { segmentColorFuncs } from "../render/segment-color.js";
|
|
50
58
|
// [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
|
|
51
59
|
// compiled node shapes + each kind's compile/render, dispatched via nodeType().
|
|
52
60
|
// render.ts threads the recursion (compileChild/renderChild) + the hue counter in
|
|
@@ -71,14 +79,27 @@ import {
|
|
|
71
79
|
// owns node behavior); this driver only assembles + walks them.
|
|
72
80
|
export interface CompiledConfig {
|
|
73
81
|
readonly segments: CompiledSegments;
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
82
|
+
// [LAW:dataflow-not-control-flow] EVERY preset's layout, compiled up front and
|
|
83
|
+
// keyed by preset name — the render selects one by name rather than compiling
|
|
84
|
+
// per session. This is the same move `looks` makes one level down (every
|
|
85
|
+
// look's ThemeKey is resolved at load; the render picks one), and it is what
|
|
86
|
+
// lets a per-SESSION preset pick ride a per-ENTRY compilation: one RenderCache
|
|
87
|
+
// entry serves many sessions, so nothing session-shaped may be compiled here.
|
|
88
|
+
// Total over `presetNames` — every selectable name, floor included — so the
|
|
89
|
+
// lookup needs no absent case. A preset declaring no `root` of its own maps
|
|
90
|
+
// to the config's own compiled root: the identity element, not a special
|
|
91
|
+
// case.
|
|
92
|
+
readonly roots: ReadonlyMap<string, CompiledNode>;
|
|
93
|
+
// [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over.
|
|
81
94
|
readonly menuRuntime: MenuRuntime;
|
|
95
|
+
// [LAW:one-source-of-truth] The single "which segment is rendering" record
|
|
96
|
+
// every segment-scoped template function reads — the menu's identity, the
|
|
97
|
+
// `color` func's palette, the `bgOf` func's background. Surfaced here so the
|
|
98
|
+
// walk can publish into it. One instance per compiled config; mutated
|
|
99
|
+
// synchronously within a single renderDsl walk (renders are sequential +
|
|
100
|
+
// synchronous, so no cross-render leak) — the spatial cousin of the hue
|
|
101
|
+
// cursor, one owner. [LAW:no-ambient-temporal-coupling]
|
|
102
|
+
readonly activeSegment: ActiveSegmentRef;
|
|
82
103
|
// [LAW:types-are-the-program] Variable declaration failures that did NOT
|
|
83
104
|
// prevent the config from loading (type mismatches, bad defaults). The
|
|
84
105
|
// affected variables are absent from the store; segments that reference them
|
|
@@ -296,31 +317,56 @@ export function registerDslConfig(
|
|
|
296
317
|
// [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
|
|
297
318
|
// Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
|
|
298
319
|
// createCcCandybarEngine applies its single default; no second default literal.
|
|
320
|
+
// [LAW:one-source-of-truth] ONE record for "which segment is rendering", read
|
|
321
|
+
// by every segment-scoped template function: `{{ menu }}` takes its identity
|
|
322
|
+
// from the name, `{{ color }}` its palette, `{{ bgOf }}` its background. A
|
|
323
|
+
// per-feature pointer would let two features disagree about which segment is
|
|
324
|
+
// current. Built before the engine so the funcs can close over it; `current`
|
|
325
|
+
// stays null until a render walk publishes one.
|
|
326
|
+
const activeSegment = createActiveSegmentRef();
|
|
299
327
|
// [LAW:locality-or-seam] The menu runtime shares the action runtime (a menu's
|
|
300
|
-
// glyph + body resolve from the same compiled table + store) and
|
|
301
|
-
//
|
|
302
|
-
// can close over it; `current` stays null until a render walk publishes one.
|
|
328
|
+
// glyph + body resolve from the same compiled table + store) and reads the
|
|
329
|
+
// active segment through the shared record above.
|
|
303
330
|
const menuRuntime: MenuRuntime = {
|
|
304
331
|
action: actionRuntime,
|
|
305
|
-
|
|
332
|
+
activeSegment,
|
|
306
333
|
};
|
|
307
334
|
// [LAW:one-source-of-truth] The config's look names — the one PER-CONFIG
|
|
308
|
-
// option domain.
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
335
|
+
// option domain. Fed to every consumer (the `looks()` binding below, and —
|
|
336
|
+
// via perConfigDomainsFor, the SAME construction cross-ref.ts and
|
|
337
|
+
// state-validators.ts use — the compiled set-option domains), so the
|
|
338
|
+
// rendered options, a hand-authored `range looks`, and the derived click
|
|
339
|
+
// gate (which reads the same config in deriveActionValidators) trace to
|
|
340
|
+
// one source.
|
|
312
341
|
const lookNames = Object.keys(config.looks);
|
|
342
|
+
const presetOptions = presetNames(config.presets);
|
|
343
|
+
const perConfigDomains = perConfigDomainsFor(config);
|
|
313
344
|
const engine = createCcCandybarEngine(
|
|
314
|
-
undefined,
|
|
315
345
|
{
|
|
316
346
|
...actionFuncs(actionRuntime),
|
|
317
347
|
...pickerFuncs(actionRuntime),
|
|
318
348
|
...menuFuncs(menuRuntime),
|
|
349
|
+
// [LAW:one-source-of-truth] `{{ color }}` reads the palette of the
|
|
350
|
+
// segment currently rendering — the same palette its `bg:`/`fg:` resolve
|
|
351
|
+
// from, published by the walk. Binding it to a palette captured HERE
|
|
352
|
+
// (registration runs once per config load, renders happen per tick) was
|
|
353
|
+
// the two-clocks bug this seam exists to close: a session theme click, a
|
|
354
|
+
// look, or a per-segment hue rotation moved a segment's background while
|
|
355
|
+
// every in-body color stayed where it was, so one segment painted from
|
|
356
|
+
// two palettes at once. Reading live costs nothing structurally — FuncMap
|
|
357
|
+
// bodies run at evaluate time, so parse-once/evaluate-many is untouched.
|
|
358
|
+
// `{{ bgOf }}` rides the same record. [LAW:rich-js-owns-color-math]
|
|
359
|
+
...segmentColorFuncs(activeSegment),
|
|
319
360
|
// [LAW:one-type-per-behavior] The per-config sibling of the static
|
|
320
361
|
// themes()/styles() bindings (template-engine/funcs.ts): zero-arg
|
|
321
362
|
// projection of the "looks" option domain. Injected here — not in the
|
|
322
363
|
// static FuncMap — because the domain is this config's looks block.
|
|
323
364
|
looks: { fn: () => lookNames, argTypes: [] },
|
|
365
|
+
// The presets domain's twin of the binding above — same per-config
|
|
366
|
+
// reason, same shape. A hand-authored `range presets` and a
|
|
367
|
+
// `{{ menu "applyPreset" }}` therefore enumerate the same names the
|
|
368
|
+
// derived click gate admits.
|
|
369
|
+
presets: { fn: () => presetOptions, argTypes: [] },
|
|
324
370
|
},
|
|
325
371
|
opts?.clock,
|
|
326
372
|
);
|
|
@@ -363,7 +409,7 @@ export function registerDslConfig(
|
|
|
363
409
|
parse,
|
|
364
410
|
config.actions,
|
|
365
411
|
stateKeyToVar,
|
|
366
|
-
|
|
412
|
+
perConfigDomains,
|
|
367
413
|
);
|
|
368
414
|
|
|
369
415
|
// [LAW:dataflow-not-control-flow] One variable failing to declare does not
|
|
@@ -427,9 +473,9 @@ export function registerDslConfig(
|
|
|
427
473
|
// per-render basePalette; folding globals.palette in here too would freeze
|
|
428
474
|
// it per segment and the stale copy would shadow basePalette, so a session
|
|
429
475
|
// theme change could never recolor the bar.
|
|
430
|
-
|
|
476
|
+
palette:
|
|
431
477
|
seg.palette !== undefined
|
|
432
|
-
?
|
|
478
|
+
? paletteForThemeName(seg.palette)
|
|
433
479
|
: undefined,
|
|
434
480
|
};
|
|
435
481
|
}
|
|
@@ -463,9 +509,25 @@ export function registerDslConfig(
|
|
|
463
509
|
return nodeType(node.kind).compile(node, cctx);
|
|
464
510
|
};
|
|
465
511
|
|
|
512
|
+
// [LAW:one-source-of-truth] One compiled tree per declared preset, built
|
|
513
|
+
// through the SAME compileNode the config's own root goes through —
|
|
514
|
+
// `presetRoot` resolves the fragment's `root` or falls back to the config's,
|
|
515
|
+
// so the floor preset (the empty fragment) needs no arm and no absent case
|
|
516
|
+
// downstream.
|
|
517
|
+
// Keyed by the SAME domain the menu renders and the click gate admits, so
|
|
518
|
+
// every selectable name has a compiled tree [LAW:one-source-of-truth].
|
|
519
|
+
const roots = new Map<string, CompiledNode>();
|
|
520
|
+
for (const name of presetOptions) {
|
|
521
|
+
// The path travels WITH the tree, so a preset that stages the config's own
|
|
522
|
+
// root diagnoses under `root` — the place its author actually wrote it.
|
|
523
|
+
const { node, path } = presetRoot(config, name);
|
|
524
|
+
roots.set(name, compileNode(node, path));
|
|
525
|
+
}
|
|
526
|
+
|
|
466
527
|
return {
|
|
467
528
|
segments: compiled,
|
|
468
|
-
|
|
529
|
+
roots,
|
|
530
|
+
activeSegment,
|
|
469
531
|
menuRuntime,
|
|
470
532
|
loadWarnings,
|
|
471
533
|
};
|
|
@@ -545,24 +607,44 @@ export interface RenderObservers {
|
|
|
545
607
|
readonly onSegmentError?: (segName: string, message: string) => void;
|
|
546
608
|
}
|
|
547
609
|
|
|
610
|
+
// [LAW:locality-or-seam] The per-render RESOLUTION the caller performs and hands
|
|
611
|
+
// down — the values that are neither config (compiled once) nor payload (input
|
|
612
|
+
// data), but the session's live choices resolved against the config: which
|
|
613
|
+
// theme-adaptation, which preset. Bundled as ONE named bag for exactly the
|
|
614
|
+
// reason RenderObservers is: `look` arrived as a positional tail, `preset` would
|
|
615
|
+
// have been a second one, and the next resolution a third — each a signature
|
|
616
|
+
// every caller re-counts. A new per-render choice is now one field here.
|
|
617
|
+
//
|
|
618
|
+
// Both fields default to their domain's own identity element, so an omitting
|
|
619
|
+
// caller (a compile-only test, the demo) renders the unadapted config — a true
|
|
620
|
+
// default, not a fallback [LAW:no-silent-failure].
|
|
621
|
+
export interface RenderSelection {
|
|
622
|
+
// The resolved look, as a ThemeKey: effectiveLookName over SessionState/
|
|
623
|
+
// globals, then lookKeyByName — resolved by the caller exactly how basePalette
|
|
624
|
+
// is. IDENTITY is the "none" look. Composed with each segment's hue shift into
|
|
625
|
+
// ONE transposition.
|
|
626
|
+
readonly look?: ThemeKey;
|
|
627
|
+
// The resolved preset NAME: effectivePresetName over SessionState/globals,
|
|
628
|
+
// collapsed to the floor if stale. Selects which of `compiled.roots` this
|
|
629
|
+
// render walks. The name (not the fragment) crosses this seam because the
|
|
630
|
+
// fragment's two halves land in two different places — the root here, the
|
|
631
|
+
// globals in `opts`/the payload — and one name keeps them from disagreeing.
|
|
632
|
+
readonly preset?: string;
|
|
633
|
+
}
|
|
634
|
+
|
|
548
635
|
export function renderDsl(
|
|
549
636
|
config: ValidatedConfig,
|
|
550
637
|
compiled: CompiledConfig,
|
|
551
638
|
store: VariableStore,
|
|
552
639
|
registry: SourceRegistry,
|
|
553
640
|
payload: unknown,
|
|
554
|
-
basePalette:
|
|
641
|
+
basePalette: Palette,
|
|
555
642
|
opts: BuildLineOptions,
|
|
556
643
|
observers?: RenderObservers,
|
|
557
|
-
|
|
558
|
-
// theme-adaptation), resolved per render by the caller — effectiveLookName
|
|
559
|
-
// over SessionState/globals, then lookKeyByName — exactly how basePalette
|
|
560
|
-
// resolves. IDENTITY is the domain's own no-adaptation element (the "none"
|
|
561
|
-
// look), so an omitting caller renders unadapted — a true default, not a
|
|
562
|
-
// fallback. Composed with each segment's hue shift into ONE transposition.
|
|
563
|
-
look: ThemeKey = IDENTITY,
|
|
644
|
+
selection?: RenderSelection,
|
|
564
645
|
): string {
|
|
565
646
|
const { perSegmentSink, onSegmentError } = observers ?? {};
|
|
647
|
+
const { look = IDENTITY, preset = PRESET_FLOOR } = selection ?? {};
|
|
566
648
|
// [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
|
|
567
649
|
// SAME opts.width the strip wraps to (below), so a width-paginated widget reads
|
|
568
650
|
// the exact wrap width — never a cached or independently-measured copy. This is
|
|
@@ -626,19 +708,34 @@ export function renderDsl(
|
|
|
626
708
|
: undefined;
|
|
627
709
|
};
|
|
628
710
|
|
|
629
|
-
// [LAW:single-enforcer] The
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
711
|
+
// [LAW:single-enforcer] The segment seam, owned here as a symmetric pair.
|
|
712
|
+
// `enterSegment` establishes everything a segment's templates may ask about
|
|
713
|
+
// themselves — the name `{{ menu }}` derives its identity from, the palette
|
|
714
|
+
// `{{ color }}` resolves against, the background `{{ bgOf }}` returns — and
|
|
715
|
+
// returns the resolved base Style. `exitSegment` collects the menu bodies the
|
|
716
|
+
// fragments carried as metadata and tears the record back down.
|
|
717
|
+
//
|
|
718
|
+
// [LAW:no-ambient-temporal-coupling] The record is set and cleared around each
|
|
719
|
+
// segment's evaluation by the walk ONLY, so "which segment am I in" is owned
|
|
720
|
+
// state with one writer, never ambient context a reader has to hope is
|
|
721
|
+
// current. Enter/exit are a pair by construction: every path that publishes
|
|
722
|
+
// goes through the first, every path that finishes goes through the second.
|
|
723
|
+
const enterSegment = (
|
|
724
|
+
segName: string,
|
|
725
|
+
palette: Palette,
|
|
726
|
+
bgTemplate: Template<RichText> | undefined,
|
|
727
|
+
fgTemplate: Template<RichText> | undefined,
|
|
728
|
+
): Style =>
|
|
729
|
+
resolveSegmentColors(
|
|
730
|
+
compiled.activeSegment,
|
|
731
|
+
segName,
|
|
732
|
+
palette,
|
|
733
|
+
bgTemplate,
|
|
734
|
+
fgTemplate,
|
|
735
|
+
scope,
|
|
736
|
+
);
|
|
737
|
+
const exitSegment = (fragments: readonly RichText[]): readonly RichText[] => {
|
|
738
|
+
compiled.activeSegment.current = null;
|
|
642
739
|
return collectMenuDrops(fragments);
|
|
643
740
|
};
|
|
644
741
|
|
|
@@ -662,8 +759,8 @@ export function renderDsl(
|
|
|
662
759
|
nextHueShift,
|
|
663
760
|
perSegmentSink,
|
|
664
761
|
onSegmentError,
|
|
665
|
-
|
|
666
|
-
|
|
762
|
+
enterSegment,
|
|
763
|
+
exitSegment,
|
|
667
764
|
focusTint,
|
|
668
765
|
lookupSegment,
|
|
669
766
|
renderChild: renderNode,
|
|
@@ -676,7 +773,22 @@ export function renderDsl(
|
|
|
676
773
|
// emit a "\n"-bearing string (FlexStrip width-overflow wrap); joining the per-
|
|
677
774
|
// line results with "\n" splices those in place — byte-identical to serializing
|
|
678
775
|
// each leaf row independently, since the cells and their order are unchanged.
|
|
679
|
-
|
|
776
|
+
// [LAW:no-defensive-null-guards] The active preset's compiled tree. By the
|
|
777
|
+
// time a name reaches here it must be a member: effectivePresetName collapses
|
|
778
|
+
// unknown names to the floor, and every merged config declares the floor — so
|
|
779
|
+
// the throw is the loud failure for a broken invariant (a hand-built config
|
|
780
|
+
// missing the bundled presets block), never a silent fall back to some other
|
|
781
|
+
// arrangement than the one the bar's own label claims is active.
|
|
782
|
+
const root = compiled.roots.get(preset);
|
|
783
|
+
if (root === undefined) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
`Preset "${preset}" has no compiled layout — registerDslConfig compiles ` +
|
|
786
|
+
`one per declared preset and effectivePresetName collapses unknown ` +
|
|
787
|
+
`names to "${PRESET_FLOOR}"; a miss here is merge/policy drift ` +
|
|
788
|
+
`(have: ${[...compiled.roots.keys()].join(", ")})`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
return renderNode(root, true)
|
|
680
792
|
.map((line) => renderStripCells(line, opts))
|
|
681
793
|
.join("\n");
|
|
682
794
|
}
|
package/src/help-text.ts
CHANGED
|
@@ -23,9 +23,9 @@ Configuration:
|
|
|
23
23
|
to point at a specific file. See the default config for all available options:
|
|
24
24
|
node dist/index.mjs debug --project-dir . --cwd .
|
|
25
25
|
|
|
26
|
-
The bundled default bar ships a
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
The bundled default bar ships a settings drawer — no config needed. Click
|
|
27
|
+
⚙ settings ${DISCLOSURE_GLYPH_CLOSED} on the bar to reveal a clickable theme/look picker and
|
|
28
|
+
other display options.
|
|
29
29
|
|
|
30
30
|
Subcommands:
|
|
31
31
|
install One-shot setup: stages the runtime (native render
|
package/src/install/index.ts
CHANGED
|
@@ -451,8 +451,8 @@ function installSuccessMessage(): string {
|
|
|
451
451
|
return (
|
|
452
452
|
`✓ install complete.\n` +
|
|
453
453
|
` Restart Claude Code to pick up the new statusline.\n` +
|
|
454
|
-
` Tip: the default bar has a
|
|
455
|
-
`
|
|
454
|
+
` Tip: the default bar has a settings drawer — click ⚙ settings\n` +
|
|
455
|
+
` ${DISCLOSURE_GLYPH_CLOSED} to reveal a clickable theme/look picker and other display options.\n`
|
|
456
456
|
);
|
|
457
457
|
}
|
|
458
458
|
|
package/src/render/action.ts
CHANGED
|
@@ -25,17 +25,17 @@ import type { FuncMap, Template } from "@promptctl/go-template-js";
|
|
|
25
25
|
import type { VariableStore } from "../var-system/store.js";
|
|
26
26
|
import { toString as varToString } from "../var-system/types.js";
|
|
27
27
|
import { buildScope } from "../template-engine/scope.js";
|
|
28
|
-
import type { ActionDecl
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
STRIP_STYLES,
|
|
32
|
-
type StripStyle,
|
|
33
|
-
} from "../themes/policy.js";
|
|
28
|
+
import type { ActionDecl } from "../config/action.js";
|
|
29
|
+
import { resolveOptionDomain } from "../config/option-domain.js";
|
|
30
|
+
import type { StripStyle } from "../themes/policy.js";
|
|
34
31
|
import {
|
|
35
32
|
effectsUrl,
|
|
36
33
|
VERB_COPY,
|
|
37
34
|
VERB_OPEN_VSCODE,
|
|
35
|
+
VERB_RESET_CONFIG,
|
|
36
|
+
VERB_SET_CONFIG,
|
|
38
37
|
VERB_SET_STATE,
|
|
38
|
+
VERB_STEP_CONFIG,
|
|
39
39
|
VERB_STEP_STATE,
|
|
40
40
|
type Effect,
|
|
41
41
|
} from "../click/wire.js";
|
|
@@ -102,24 +102,65 @@ export type CompiledActionDecl =
|
|
|
102
102
|
readonly members: readonly string[];
|
|
103
103
|
}
|
|
104
104
|
| { readonly kind: "copy"; readonly text: Template<RichText> }
|
|
105
|
-
| { readonly kind: "open"; readonly target: Template<RichText> }
|
|
105
|
+
| { readonly kind: "open"; readonly target: Template<RichText> }
|
|
106
|
+
// [LAW:one-source-of-truth] `persist`'s twin of the set-* kinds above,
|
|
107
|
+
// MINUS set-int (a page cursor is never persisted — see action.ts). Carries
|
|
108
|
+
// the SAME shapes for the SAME reason: a persistent write is gated and
|
|
109
|
+
// realized exactly like a session write, only the wire verb (VERB_SET_CONFIG/
|
|
110
|
+
// VERB_STEP_CONFIG) and the write's durability differ.
|
|
111
|
+
| {
|
|
112
|
+
readonly kind: "persist-literal";
|
|
113
|
+
readonly key: string;
|
|
114
|
+
readonly value: string;
|
|
115
|
+
readonly stateVar: string;
|
|
116
|
+
}
|
|
117
|
+
| {
|
|
118
|
+
readonly kind: "persist-option";
|
|
119
|
+
readonly key: string;
|
|
120
|
+
readonly stateVar: string;
|
|
121
|
+
readonly options: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
readonly kind: "persist-bounded";
|
|
125
|
+
readonly key: string;
|
|
126
|
+
readonly by: number;
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
readonly kind: "persist-cycle";
|
|
130
|
+
readonly key: string;
|
|
131
|
+
readonly stateVar: string;
|
|
132
|
+
readonly members: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
// [LAW:one-source-of-truth] The gated undo for a persistent write: clears
|
|
135
|
+
// one config-overrides key. Carries only the key — there is no value to
|
|
136
|
+
// realize, so it shares copy/open's "no gate" shape at compile time (the
|
|
137
|
+
// GATE is the key-membership check the reset-config verb handler applies).
|
|
138
|
+
| { readonly kind: "reset"; readonly key: string };
|
|
106
139
|
|
|
107
140
|
export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
108
141
|
|
|
109
|
-
// [LAW:one-source-of-truth]
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// daemon
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
// [LAW:one-source-of-truth] Globals fields whose CURRENT resolved value is
|
|
143
|
+
// exposed to templates under a different var name than the field itself (the
|
|
144
|
+
// daemon publishes this resolution once per render — e.g. `theme.effective`
|
|
145
|
+
// for `palette`, src/daemon/render-payload.ts). A `persist` action with no
|
|
146
|
+
// entry here reads back through its own key name as an input var (mirrors
|
|
147
|
+
// compileActions' stateKeyToVar fallback), so every persistable globals field
|
|
148
|
+
// needs an entry unless its `.effective` projection happens to be named
|
|
149
|
+
// exactly the bare field (none are — every projection carries the
|
|
150
|
+
// `.effective` suffix). Every field with a projection is listed
|
|
151
|
+
// (candybar-config-engine-71o.3 added style/charset/colorCompatibility/
|
|
152
|
+
// autoWrap/padding to palette/look's original two); a field with no entry
|
|
153
|
+
// here still writes correctly on `persist` — only its "current selection"
|
|
154
|
+
// highlight is inert (readVar falls back to "" since no such var exists).
|
|
155
|
+
const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
|
|
156
|
+
["palette", "theme.effective"],
|
|
157
|
+
["look", "look.effective"],
|
|
158
|
+
["style", "style.effective"],
|
|
159
|
+
["charset", "charset.effective"],
|
|
160
|
+
["colorCompatibility", "colorCompatibility.effective"],
|
|
161
|
+
["autoWrap", "autoWrap.effective"],
|
|
162
|
+
["padding", "padding.effective"],
|
|
163
|
+
]);
|
|
123
164
|
|
|
124
165
|
// [LAW:locality-or-seam] The runtime holder the `action` template function closes
|
|
125
166
|
// over. Populated after the engine is constructed (the func references the
|
|
@@ -165,13 +206,17 @@ export function compileActions(
|
|
|
165
206
|
parse: (src: string) => Template<RichText>,
|
|
166
207
|
actions: Readonly<Record<string, ActionDecl>>,
|
|
167
208
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
209
|
+
// This config's per-config option domains (currently just "looks" — the
|
|
210
|
+
// config's merged look names) — resolveOptionDomain checks these before
|
|
211
|
+
// falling back to the global registry (themes/styles).
|
|
212
|
+
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
171
213
|
): CompiledActions {
|
|
172
214
|
const out = new Map<string, CompiledActionDecl>();
|
|
173
215
|
for (const [name, action] of Object.entries(actions)) {
|
|
174
|
-
out.set(
|
|
216
|
+
out.set(
|
|
217
|
+
name,
|
|
218
|
+
compileAction(parse, name, action, stateKeyToVar, perConfigDomains),
|
|
219
|
+
);
|
|
175
220
|
}
|
|
176
221
|
return out;
|
|
177
222
|
}
|
|
@@ -185,7 +230,7 @@ function compileAction(
|
|
|
185
230
|
name: string,
|
|
186
231
|
action: ActionDecl,
|
|
187
232
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
188
|
-
|
|
233
|
+
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
189
234
|
): CompiledActionDecl {
|
|
190
235
|
if ("set" in action) {
|
|
191
236
|
const stateVar = stateKeyToVar.get(action.set) ?? action.set;
|
|
@@ -202,7 +247,7 @@ function compileAction(
|
|
|
202
247
|
kind: "set-option",
|
|
203
248
|
key: action.set,
|
|
204
249
|
stateVar,
|
|
205
|
-
options: [...
|
|
250
|
+
options: [...resolveOptionDomain(action.from, perConfigDomains)],
|
|
206
251
|
};
|
|
207
252
|
}
|
|
208
253
|
if ("int" in action) {
|
|
@@ -222,16 +267,52 @@ function compileAction(
|
|
|
222
267
|
by: action.by,
|
|
223
268
|
};
|
|
224
269
|
}
|
|
270
|
+
if ("persist" in action) {
|
|
271
|
+
const stateVar =
|
|
272
|
+
CONFIG_KEY_TO_EFFECTIVE_VAR.get(action.persist) ?? action.persist;
|
|
273
|
+
if ("to" in action) {
|
|
274
|
+
return {
|
|
275
|
+
kind: "persist-literal",
|
|
276
|
+
key: action.persist,
|
|
277
|
+
value: action.to,
|
|
278
|
+
stateVar,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if ("from" in action) {
|
|
282
|
+
return {
|
|
283
|
+
kind: "persist-option",
|
|
284
|
+
key: action.persist,
|
|
285
|
+
stateVar,
|
|
286
|
+
options: [...resolveOptionDomain(action.from, perConfigDomains)],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if ("cycle" in action) {
|
|
290
|
+
return {
|
|
291
|
+
kind: "persist-cycle",
|
|
292
|
+
key: action.persist,
|
|
293
|
+
stateVar,
|
|
294
|
+
members: action.cycle,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
kind: "persist-bounded",
|
|
299
|
+
key: action.persist,
|
|
300
|
+
by: action.by,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
225
303
|
if ("copy" in action) {
|
|
226
304
|
return {
|
|
227
305
|
kind: "copy",
|
|
228
306
|
text: parseActionTemplate(parse, action.copy, name),
|
|
229
307
|
};
|
|
230
308
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
309
|
+
if ("open" in action) {
|
|
310
|
+
return {
|
|
311
|
+
kind: "open",
|
|
312
|
+
target: parseActionTemplate(parse, action.open, name),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return { kind: "reset", key: action.reset };
|
|
235
316
|
}
|
|
236
317
|
|
|
237
318
|
function parseActionTemplate(
|
|
@@ -292,7 +373,7 @@ export function linkFragment(
|
|
|
292
373
|
// renders the first display and clicks to the second member (an accordion
|
|
293
374
|
// sibling's path "counts as closed", a never-written toggle "counts as off").
|
|
294
375
|
function cycleIndex(
|
|
295
|
-
c: Extract<CompiledActionDecl, { kind: "set-cycle" }>,
|
|
376
|
+
c: Extract<CompiledActionDecl, { kind: "set-cycle" | "persist-cycle" }>,
|
|
296
377
|
store: VariableStore,
|
|
297
378
|
): number {
|
|
298
379
|
return Math.max(c.members.indexOf(readVar(store, c.stateVar)), 0);
|
|
@@ -400,6 +481,47 @@ function realize(
|
|
|
400
481
|
},
|
|
401
482
|
active: false,
|
|
402
483
|
};
|
|
484
|
+
// [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
|
|
485
|
+
// verbatim (same literal/option/cycle/bounded semantics), only the wire
|
|
486
|
+
// verb differs (VERB_SET_CONFIG/VERB_STEP_CONFIG instead of
|
|
487
|
+
// VERB_SET_STATE/VERB_STEP_STATE) — the daemon-side handler is what makes
|
|
488
|
+
// the write durable, not the click itself.
|
|
489
|
+
case "persist-literal": {
|
|
490
|
+
const current = readVar(store, c.stateVar);
|
|
491
|
+
return {
|
|
492
|
+
effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] },
|
|
493
|
+
active: current === c.value,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
case "persist-option": {
|
|
497
|
+
const value = boundValue ?? display;
|
|
498
|
+
const current = readVar(store, c.stateVar);
|
|
499
|
+
return {
|
|
500
|
+
effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] },
|
|
501
|
+
active: current === value,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
case "persist-cycle": {
|
|
505
|
+
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
506
|
+
return {
|
|
507
|
+
effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] },
|
|
508
|
+
active: false,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
case "persist-bounded": {
|
|
512
|
+
return {
|
|
513
|
+
effect: {
|
|
514
|
+
verb: VERB_STEP_CONFIG,
|
|
515
|
+
args: [sessionId, c.key, String(c.by)],
|
|
516
|
+
},
|
|
517
|
+
active: false,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
case "reset":
|
|
521
|
+
return {
|
|
522
|
+
effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
|
|
523
|
+
active: false,
|
|
524
|
+
};
|
|
403
525
|
}
|
|
404
526
|
}
|
|
405
527
|
|
|
@@ -419,7 +541,7 @@ function selectDisplay(
|
|
|
419
541
|
if (displays.length === 0) {
|
|
420
542
|
throw new Error(`action "${name}" needs a display (the clickable text)`);
|
|
421
543
|
}
|
|
422
|
-
if (action.kind === "set-cycle") {
|
|
544
|
+
if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
|
|
423
545
|
if (displays.length !== 1 && displays.length !== action.members.length) {
|
|
424
546
|
throw new Error(
|
|
425
547
|
`action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,
|