@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/daemon/server.ts
CHANGED
|
@@ -68,8 +68,9 @@ import {
|
|
|
68
68
|
effectiveThemeName,
|
|
69
69
|
effectiveLookName,
|
|
70
70
|
lookKeyByName,
|
|
71
|
-
|
|
71
|
+
paletteForThemeName,
|
|
72
72
|
} from "../themes/index.js";
|
|
73
|
+
import { effectivePresetName, presetGlobals } from "../config/presets.js";
|
|
73
74
|
import {
|
|
74
75
|
renderStripCells,
|
|
75
76
|
DEFAULT_CHARSET,
|
|
@@ -83,7 +84,7 @@ import {
|
|
|
83
84
|
} from "../render/strip.js";
|
|
84
85
|
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
85
86
|
import type { RichText } from "@promptctl/rich-js";
|
|
86
|
-
import { buildRenderPayload } from "./render-payload.js";
|
|
87
|
+
import { buildRenderPayload, type EffectiveGlobals } from "./render-payload.js";
|
|
87
88
|
import { ContextProvider } from "../segments/context.js";
|
|
88
89
|
import { MetricsProvider } from "../segments/metrics.js";
|
|
89
90
|
import { TmuxService } from "../segments/tmux.js";
|
|
@@ -855,70 +856,79 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
855
856
|
// trigger label reads it) AND the rendered basePalette below, so a label
|
|
856
857
|
// and the colors can never disagree. Resolved here, before the payload
|
|
857
858
|
// build, so it can be threaded into the sole payload assembler.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
)
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
// (
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
859
|
+
//
|
|
860
|
+
// [LAW:one-source-of-truth] Every globals field a menu/stepper can
|
|
861
|
+
// persist (candybar-config-engine-71o.3), resolved ONCE into one
|
|
862
|
+
// struct: theme/look/style compose the session's click (SessionState)
|
|
863
|
+
// over the config default over a floor — a click recolors/reshapes the
|
|
864
|
+
// whole bar on the next render; charset/colorCompatibility/autoWrap/
|
|
865
|
+
// padding have no SessionState half today, so their "effective" value
|
|
866
|
+
// is just the config global over its floor constant. This struct feeds
|
|
867
|
+
// BOTH the payload's `*.effective` fields (trigger labels) AND
|
|
868
|
+
// renderOpts below (the actual render) — one resolution, two readers,
|
|
869
|
+
// so a label can never disagree with what was rendered.
|
|
870
|
+
//
|
|
871
|
+
// [LAW:one-source-of-truth] The PRESET resolves FIRST, because every
|
|
872
|
+
// other field below reads globals — and which globals is exactly what
|
|
873
|
+
// the preset decides. `presetGlobals` is the config's globals with the
|
|
874
|
+
// active fragment's shallow-merged over them, so the preset sits at its
|
|
875
|
+
// one documented place in the precedence chain (bundled default < user
|
|
876
|
+
// file < persisted overrides < ACTIVE PRESET < session pick — see
|
|
877
|
+
// src/config/presets.ts and docs/interaction-authoring.md): later than
|
|
878
|
+
// everything read per cache ENTRY, earlier than every session click,
|
|
879
|
+
// which is the order the `??` chains below already enforce by reading
|
|
880
|
+
// SessionState first. There is no "does a preset apply?" branch — the
|
|
881
|
+
// floor preset's empty fragment merges as a no-op
|
|
882
|
+
// [LAW:dataflow-not-control-flow].
|
|
883
|
+
const preset = effectivePresetName(
|
|
884
|
+
sessionState.get(req.hookData.session_id, "preset"),
|
|
885
|
+
entry.state.config.globals.preset,
|
|
886
|
+
entry.state.config.presets,
|
|
872
887
|
);
|
|
888
|
+
const globals = presetGlobals(entry.state.config, preset);
|
|
889
|
+
const effective: EffectiveGlobals = {
|
|
890
|
+
preset,
|
|
891
|
+
theme: effectiveThemeName(
|
|
892
|
+
sessionState.get(req.hookData.session_id, "theme"),
|
|
893
|
+
globals.palette,
|
|
894
|
+
),
|
|
895
|
+
look: effectiveLookName(
|
|
896
|
+
sessionState.get(req.hookData.session_id, "look"),
|
|
897
|
+
globals.look,
|
|
898
|
+
entry.state.config.looks,
|
|
899
|
+
),
|
|
900
|
+
style: effectiveStripStyle(
|
|
901
|
+
sessionState.get(req.hookData.session_id, "style"),
|
|
902
|
+
globals.style,
|
|
903
|
+
),
|
|
904
|
+
autoWrap: globals.autoWrap ?? DEFAULT_WRAP,
|
|
905
|
+
padding: globals.padding ?? DEFAULT_PADDING,
|
|
906
|
+
charset: globals.charset ?? DEFAULT_CHARSET,
|
|
907
|
+
colorCompatibility:
|
|
908
|
+
globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
|
|
909
|
+
};
|
|
873
910
|
const payload = await buildRenderPayload(
|
|
874
911
|
req.hookData,
|
|
875
912
|
payloadDeps,
|
|
876
913
|
req.cwd,
|
|
877
914
|
entry.state.neededInputPaths,
|
|
878
|
-
|
|
879
|
-
effectiveLook,
|
|
915
|
+
effective,
|
|
880
916
|
);
|
|
881
917
|
// [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
|
|
882
918
|
// is derived from the same effective theme resolved above — so a theme
|
|
883
919
|
// click recolors the whole bar on the next render. Not frozen on the
|
|
884
|
-
// cache entry (one entry serves many sessions).
|
|
920
|
+
// cache entry (one entry serves many sessions). paletteForThemeName
|
|
885
921
|
// memoizes, so the per-render cost is one Map lookup once the theme is
|
|
886
922
|
// warm.
|
|
887
|
-
const basePalette =
|
|
888
|
-
// [LAW:one-
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
renderOpts.
|
|
895
|
-
|
|
896
|
-
entry.state.config.globals.style,
|
|
897
|
-
);
|
|
898
|
-
// [config-only] globals.autoWrap has no SessionState half (no click
|
|
899
|
-
// verb) — the config global over the base floor is the whole
|
|
900
|
-
// resolution. Width stays finite regardless: it also feeds the
|
|
901
|
-
// picker's pagination (term.cols), which no-wrap must not break.
|
|
902
|
-
renderOpts.wrap = entry.state.config.globals.autoWrap ?? DEFAULT_WRAP;
|
|
903
|
-
// [config-only] globals.padding, same shape as autoWrap: the config
|
|
904
|
-
// global over the base floor is the whole resolution — the ONE home
|
|
905
|
-
// of the value every cell builder (and the picker's pagination
|
|
906
|
-
// reserve) derives from. [LAW:one-source-of-truth]
|
|
907
|
-
renderOpts.padding =
|
|
908
|
-
entry.state.config.globals.padding ?? DEFAULT_PADDING;
|
|
909
|
-
// [config-only] globals.charset, same shape as autoWrap/padding: the
|
|
910
|
-
// config global over the base floor is the whole resolution — the ONE
|
|
911
|
-
// home of the glyph-vocabulary choice pickJoiner derives from.
|
|
912
|
-
// [LAW:one-source-of-truth]
|
|
913
|
-
renderOpts.charset =
|
|
914
|
-
entry.state.config.globals.charset ?? DEFAULT_CHARSET;
|
|
915
|
-
// [config-only] globals.colorCompatibility, same shape as charset:
|
|
916
|
-
// the config global over the base floor is the whole resolution —
|
|
917
|
-
// the ONE home of the color-depth choice rich-js downsamples to.
|
|
918
|
-
// [LAW:one-source-of-truth]
|
|
919
|
-
renderOpts.colorCompatibility =
|
|
920
|
-
entry.state.config.globals.colorCompatibility ??
|
|
921
|
-
DEFAULT_COLOR_COMPATIBILITY;
|
|
923
|
+
const basePalette = paletteForThemeName(effective.theme);
|
|
924
|
+
// [LAW:one-source-of-truth] Every renderOpts field below reuses the
|
|
925
|
+
// SAME `effective` struct the payload was just built from — no second
|
|
926
|
+
// `?? DEFAULT_X` computation to drift from it.
|
|
927
|
+
renderOpts.style = effective.style;
|
|
928
|
+
renderOpts.wrap = effective.autoWrap;
|
|
929
|
+
renderOpts.padding = effective.padding;
|
|
930
|
+
renderOpts.charset = effective.charset;
|
|
931
|
+
renderOpts.colorCompatibility = effective.colorCompatibility;
|
|
922
932
|
// [LAW:single-enforcer] renderDsl internally calls
|
|
923
933
|
// `registry.applyInput(payload)` as its first step (see step 1 in
|
|
924
934
|
// src/dsl/render.ts). The daemon must not pre-apply — doing so
|
|
@@ -939,7 +949,10 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
939
949
|
// the per-segment ANSI serialization happens lazily inside the
|
|
940
950
|
// debug handler so normal renders pay no extra serializer cost.
|
|
941
951
|
{ perSegmentSink: entry.state.lastRenderCellsBySegment },
|
|
942
|
-
|
|
952
|
+
{
|
|
953
|
+
look: lookKeyByName(entry.state.config.looks, effective.look),
|
|
954
|
+
preset: effective.preset,
|
|
955
|
+
},
|
|
943
956
|
);
|
|
944
957
|
}
|
|
945
958
|
// [LAW:one-source-of-truth] Consume the transient click error written by
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] The persistent-config-write instance of the
|
|
2
|
+
// shared keyed-validator registry (validator-registry.ts) — the twin of
|
|
3
|
+
// state-validators.ts for `persist` actions instead of `set` actions.
|
|
4
|
+
// Gates stay derived the same way as session writes: a `persist` action
|
|
5
|
+
// carries its target key and value SOURCE as literal data, so the
|
|
6
|
+
// writable-key gate DERIVES from the action table exactly like
|
|
7
|
+
// deriveActionValidators does for `set`. No baseline keys: every config
|
|
8
|
+
// globals field becomes writable ONLY when a config declares a `persist`
|
|
9
|
+
// action for it — the epic's "zero engine edits to add a menu-able field"
|
|
10
|
+
// goal, realized more strictly here than SessionState's legacy baseline
|
|
11
|
+
// theme/style/toolbar-expanded keys.
|
|
12
|
+
|
|
13
|
+
import type { ActionDecl } from "../../config/action";
|
|
14
|
+
import {
|
|
15
|
+
perConfigDomainsFor,
|
|
16
|
+
resolveOptionDomain,
|
|
17
|
+
} from "../../config/option-domain";
|
|
18
|
+
import type { DslConfig } from "../../config/dsl-types";
|
|
19
|
+
import { isGlobalsField } from "../config-overrides-store";
|
|
20
|
+
import {
|
|
21
|
+
clampSeed,
|
|
22
|
+
createValidatorRegistry,
|
|
23
|
+
mergeContributions,
|
|
24
|
+
type DerivedValidatorSpec,
|
|
25
|
+
type KeySpecContribution,
|
|
26
|
+
type RangeParams,
|
|
27
|
+
type ValidateResult,
|
|
28
|
+
} from "./validator-registry";
|
|
29
|
+
|
|
30
|
+
// [LAW:no-silent-fallbacks] No baseline entries: a config globals field is
|
|
31
|
+
// writable ONLY when some `persist` action names it.
|
|
32
|
+
const registry = createValidatorRegistry({}, "config");
|
|
33
|
+
|
|
34
|
+
export function listConfigKeys(): readonly string[] {
|
|
35
|
+
return registry.listKeys();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateConfigWrite(
|
|
39
|
+
key: string,
|
|
40
|
+
rawValue: string,
|
|
41
|
+
): ValidateResult {
|
|
42
|
+
return registry.validate(key, rawValue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function rangeParamsForConfig(key: string): RangeParams | null {
|
|
46
|
+
return registry.rangeParamsFor(key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// [LAW:one-source-of-truth] The ONE place mapping a `persist` ACTION to the
|
|
50
|
+
// validator key SPEC it declares — the mirror of state-validators.ts's
|
|
51
|
+
// actionKeySpecs for `set`. `persist` has no `int` arm: a page cursor is a
|
|
52
|
+
// UI-only paging concept with no meaning as a persisted config default.
|
|
53
|
+
function actionKeySpecs(
|
|
54
|
+
a: ActionDecl,
|
|
55
|
+
seeds: ReadonlyMap<string, number>,
|
|
56
|
+
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
57
|
+
): KeySpecContribution[] {
|
|
58
|
+
if (!("persist" in a)) return [];
|
|
59
|
+
if ("to" in a) {
|
|
60
|
+
return [{ key: a.persist, spec: { kind: "allow-list", allowed: [a.to] } }];
|
|
61
|
+
}
|
|
62
|
+
if ("from" in a) {
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
key: a.persist,
|
|
66
|
+
spec: {
|
|
67
|
+
kind: "allow-list",
|
|
68
|
+
allowed: resolveOptionDomain(a.from, perConfigDomains),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
if ("cycle" in a) {
|
|
74
|
+
return [{ key: a.persist, spec: { kind: "allow-list", allowed: a.cycle } }];
|
|
75
|
+
}
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
key: a.persist,
|
|
79
|
+
spec: {
|
|
80
|
+
kind: "range",
|
|
81
|
+
min: a.min,
|
|
82
|
+
max: a.max,
|
|
83
|
+
seed: clampSeed(seeds.get(a.persist), a.min, a.max),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// [LAW:one-source-of-truth] The unset seed for a bounded `persist` key (e.g.
|
|
90
|
+
// a padding stepper) is the value that would render with NO override yet —
|
|
91
|
+
// the merged config's OWN globals field, before this reload's overrides are
|
|
92
|
+
// applied. Mirrors stateKeySeeds' "the bar's current display, not silently
|
|
93
|
+
// min" rule for SessionState steppers.
|
|
94
|
+
function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
95
|
+
const seeds = new Map<string, number>();
|
|
96
|
+
for (const [key, value] of Object.entries(config.globals)) {
|
|
97
|
+
if (isGlobalsField(key) && typeof value === "number") {
|
|
98
|
+
seeds.set(key, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return seeds;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
105
|
+
const seeds = configKeySeeds(config);
|
|
106
|
+
const perConfigDomains = perConfigDomainsFor(config);
|
|
107
|
+
return Object.values(config.actions).flatMap((a) =>
|
|
108
|
+
actionKeySpecs(a, seeds, perConfigDomains),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// [LAW:single-enforcer] The SOLE install-site derivation: a config's
|
|
113
|
+
// persistent-config-writable-key surface is the merge of every `persist`
|
|
114
|
+
// ACTION it declares, through the SAME coherence pass deriveActionValidators
|
|
115
|
+
// uses for `set`.
|
|
116
|
+
export function deriveConfigActionValidators(
|
|
117
|
+
config: DslConfig,
|
|
118
|
+
): readonly KeySpecContribution[] {
|
|
119
|
+
return mergeContributions(actionContributions(config), "config");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function registerConfigValidator(
|
|
123
|
+
key: string,
|
|
124
|
+
spec: DerivedValidatorSpec,
|
|
125
|
+
): () => void {
|
|
126
|
+
return registry.register(key, spec);
|
|
127
|
+
}
|
|
@@ -19,12 +19,26 @@
|
|
|
19
19
|
// cc-candybar://<verb>/<value> where <value> may itself contain `/`.
|
|
20
20
|
|
|
21
21
|
import { launchSync } from "../../proc/launch";
|
|
22
|
+
import type { Globals } from "../../config/dsl-types";
|
|
22
23
|
import type { SessionStateRW } from "../session-state";
|
|
23
24
|
import {
|
|
24
25
|
listStateKeys,
|
|
25
26
|
rangeParamsFor,
|
|
26
27
|
validateStateWrite,
|
|
27
28
|
} from "./state-validators";
|
|
29
|
+
import {
|
|
30
|
+
listConfigKeys,
|
|
31
|
+
rangeParamsForConfig,
|
|
32
|
+
validateConfigWrite,
|
|
33
|
+
} from "./config-validators";
|
|
34
|
+
import {
|
|
35
|
+
clearConfigOverride,
|
|
36
|
+
coercePersistValue,
|
|
37
|
+
isGlobalsField,
|
|
38
|
+
loadConfigOverrides,
|
|
39
|
+
writeConfigOverride,
|
|
40
|
+
} from "../config-overrides-store";
|
|
41
|
+
import { configOverridesPath } from "../paths";
|
|
28
42
|
import {
|
|
29
43
|
decodeSegments,
|
|
30
44
|
parseEffects,
|
|
@@ -32,7 +46,10 @@ import {
|
|
|
32
46
|
VERB_DISPATCH,
|
|
33
47
|
VERB_OPEN_VSCODE,
|
|
34
48
|
VERB_LOAD_CONFIG,
|
|
49
|
+
VERB_RESET_CONFIG,
|
|
50
|
+
VERB_SET_CONFIG,
|
|
35
51
|
VERB_SET_STATE,
|
|
52
|
+
VERB_STEP_CONFIG,
|
|
36
53
|
VERB_STEP_STATE,
|
|
37
54
|
VERB_SHOW_CONFIG_ERROR,
|
|
38
55
|
VERB_SHOW_CONFIG_WARNING,
|
|
@@ -324,6 +341,109 @@ const stepState: VerbHandler = (rawValue, ctx) => {
|
|
|
324
341
|
);
|
|
325
342
|
};
|
|
326
343
|
|
|
344
|
+
// [LAW:no-defensive-null-guards] Range-bounded (stepper) persist targets are
|
|
345
|
+
// globals-only by construction: loader/cross-ref.ts (candybar-config-engine-
|
|
346
|
+
// 71o.6) rejects a `min`/`max`/`by` persist arm over a `segments.<name>.
|
|
347
|
+
// palette` target at config-LOAD time, so a validated write reaching
|
|
348
|
+
// stepConfig with a range spec is a real Globals field by construction —
|
|
349
|
+
// this assertion is the type-narrowing boundary (for the `overrides[key]`
|
|
350
|
+
// index below), not a runtime possibility. setConfig has no such need:
|
|
351
|
+
// coercePersistValue classifies a bare string key itself.
|
|
352
|
+
function assertGlobalsField(key: string): asserts key is keyof Globals {
|
|
353
|
+
if (!isGlobalsField(key)) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`step-config: "${key}" validated as a bounded config-writable key but ` +
|
|
356
|
+
`is not a Globals field — registration/loader invariant broken`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// [LAW:single-enforcer] `persist`'s twin of setState: the SAME validate-then-
|
|
362
|
+
// write shape, writing through config-overrides-store instead of
|
|
363
|
+
// SessionState. The write is DURABLE — RenderCache's file watcher on
|
|
364
|
+
// configOverridesPath() (src/daemon/cache/render.ts) picks it up on the next
|
|
365
|
+
// reload, exactly as an edit to the hand-authored config file would.
|
|
366
|
+
// [LAW:no-silent-fallbacks] Unknown key or out-of-domain value is a loud
|
|
367
|
+
// BAD_REQUEST — the SAME gate `set-state` uses (validateConfigWrite),
|
|
368
|
+
// derived from the SAME action table (deriveConfigActionValidators).
|
|
369
|
+
const setConfig: VerbHandler = (rawValue, ctx) => {
|
|
370
|
+
const [sessionId = "", key = "", incoming = ""] = decodeWire(() =>
|
|
371
|
+
decodeSegments(rawValue),
|
|
372
|
+
);
|
|
373
|
+
const sid = requireSessionId(sessionId);
|
|
374
|
+
if (!key) {
|
|
375
|
+
throw new BadVerbArgs(
|
|
376
|
+
`set-config: <key>/<value> is required (have keys: ${listConfigKeys().join(", ")})`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const result = validateConfigWrite(key, incoming);
|
|
380
|
+
if (!result.ok) throw new BadVerbArgs(`set-config: ${result.reason}`);
|
|
381
|
+
const typed = coercePersistValue(key, result.value);
|
|
382
|
+
writeConfigOverride(configOverridesPath(), key, typed, ctx.dlog);
|
|
383
|
+
ctx.dlog("info", `set-config: ${key}=${result.value} (session=${sid})`);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// [LAW:one-source-of-truth] `persist`'s twin of stepState: a RELATIVE nudge
|
|
387
|
+
// against the current override (or the merged config's own value when
|
|
388
|
+
// unset — rangeParamsForConfig's seed), wrapped and re-validated through the
|
|
389
|
+
// SAME range gate, then written durably.
|
|
390
|
+
const stepConfig: VerbHandler = (rawValue, ctx) => {
|
|
391
|
+
const [sessionId = "", key = "", byRaw = ""] = decodeWire(() =>
|
|
392
|
+
decodeSegments(rawValue),
|
|
393
|
+
);
|
|
394
|
+
const sid = requireSessionId(sessionId);
|
|
395
|
+
if (!key) {
|
|
396
|
+
throw new BadVerbArgs(
|
|
397
|
+
"step-config: <key> is required (shape: <sessionId>/<key>/<by>)",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (!STEP_INT_RE.test(byRaw)) {
|
|
401
|
+
throw new BadVerbArgs(
|
|
402
|
+
`step-config: delta must be an integer, got "${byRaw}"`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
const by = parseInt(byRaw, 10);
|
|
406
|
+
const params = rangeParamsForConfig(key);
|
|
407
|
+
if (!params) {
|
|
408
|
+
throw new BadVerbArgs(
|
|
409
|
+
`step-config: key "${key}" is not a bounded (range) config key ` +
|
|
410
|
+
`(have keys: ${listConfigKeys().join(", ")})`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
assertGlobalsField(key);
|
|
414
|
+
const overrides = loadConfigOverrides(configOverridesPath(), ctx.dlog);
|
|
415
|
+
const stored = overrides[key];
|
|
416
|
+
const current =
|
|
417
|
+
typeof stored === "number"
|
|
418
|
+
? Math.max(params.min, Math.min(params.max, stored))
|
|
419
|
+
: params.seed;
|
|
420
|
+
const next = wrapStep(current + by, params.min, params.max);
|
|
421
|
+
const result = validateConfigWrite(key, String(next));
|
|
422
|
+
if (!result.ok) throw new BadVerbArgs(`step-config: ${result.reason}`);
|
|
423
|
+
const typed = coercePersistValue(key, result.value);
|
|
424
|
+
writeConfigOverride(configOverridesPath(), key, typed, ctx.dlog);
|
|
425
|
+
ctx.dlog(
|
|
426
|
+
"info",
|
|
427
|
+
`step-config: ${key} ${current}→${result.value} (by ${by}, session=${sid})`,
|
|
428
|
+
);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// [LAW:one-source-of-truth] The gated undo for `persist`: clears one
|
|
432
|
+
// config-overrides key, restoring the user-file/bundled-default value on the
|
|
433
|
+
// next reload. Gated by key MEMBERSHIP (listConfigKeys) rather than a value
|
|
434
|
+
// domain — there is no value to validate, only a legitimate target to clear.
|
|
435
|
+
const resetConfig: VerbHandler = (value, ctx) => {
|
|
436
|
+
const [sessionId = "", key = ""] = decodeWire(() => decodeSegments(value));
|
|
437
|
+
const sid = requireSessionId(sessionId);
|
|
438
|
+
if (!key || !listConfigKeys().includes(key)) {
|
|
439
|
+
throw new BadVerbArgs(
|
|
440
|
+
`reset-config: unknown config key "${key}" (have: ${listConfigKeys().join(", ")})`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
clearConfigOverride(configOverridesPath(), key, ctx.dlog);
|
|
444
|
+
ctx.dlog("info", `reset-config: ${key} (session=${sid})`);
|
|
445
|
+
};
|
|
446
|
+
|
|
327
447
|
// ─── Registry ───────────────────────────────────────────────────────────────
|
|
328
448
|
|
|
329
449
|
// [LAW:one-source-of-truth] The LEAF verbs — every click effect that does real
|
|
@@ -382,6 +502,9 @@ const LEAF_VERBS = new Map<string, VerbHandler>([
|
|
|
382
502
|
[VERB_OPEN_VSCODE, openVscode],
|
|
383
503
|
[VERB_SET_STATE, setState],
|
|
384
504
|
[VERB_STEP_STATE, stepState],
|
|
505
|
+
[VERB_SET_CONFIG, setConfig],
|
|
506
|
+
[VERB_STEP_CONFIG, stepConfig],
|
|
507
|
+
[VERB_RESET_CONFIG, resetConfig],
|
|
385
508
|
[VERB_SHOW_CONFIG_ERROR, showConfigError],
|
|
386
509
|
[VERB_SHOW_CONFIG_WARNING, showConfigWarning],
|
|
387
510
|
[VERB_TOOLBAR_TOGGLE, toolbarToggle],
|
|
@@ -413,12 +536,16 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
|
|
|
413
536
|
let sessionId: string | null = null;
|
|
414
537
|
for (const { verb, value } of parseEffects(rawValue)) {
|
|
415
538
|
// Extract session ID from the first session-bearing effect for error display.
|
|
416
|
-
// set-state, step-state,
|
|
417
|
-
//
|
|
539
|
+
// set-state, step-state, set-config, step-config, reset-config, and
|
|
540
|
+
// toolbar-toggle all carry the session id as their first segment, so a
|
|
541
|
+
// failing step surfaces in the bar like any other.
|
|
418
542
|
if (
|
|
419
543
|
!sessionId &&
|
|
420
544
|
(verb === VERB_SET_STATE ||
|
|
421
545
|
verb === VERB_STEP_STATE ||
|
|
546
|
+
verb === VERB_SET_CONFIG ||
|
|
547
|
+
verb === VERB_STEP_CONFIG ||
|
|
548
|
+
verb === VERB_RESET_CONFIG ||
|
|
422
549
|
verb === VERB_TOOLBAR_TOGGLE)
|
|
423
550
|
) {
|
|
424
551
|
const parts = decodeSegments(value);
|