@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.
Files changed (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
@@ -68,8 +68,9 @@ import {
68
68
  effectiveThemeName,
69
69
  effectiveLookName,
70
70
  lookKeyByName,
71
- resolverForThemeName,
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
- const effectiveTheme = effectiveThemeName(
859
- sessionState.get(req.hookData.session_id, "theme"),
860
- entry.state.config.globals.palette,
861
- );
862
- // [LAW:one-type-per-behavior] The LOOK, resolved per render the exact
863
- // way the theme is: the session's clicked look (SessionState) over the
864
- // config default over the "none" floor so a look click recolors the
865
- // whole bar on the next render, composing with whatever theme is
866
- // active. The name feeds the payload's `look.effective`; its ThemeKey
867
- // (lookKeyByName) feeds renderDsl below — one resolution, two readers.
868
- const effectiveLook = effectiveLookName(
869
- sessionState.get(req.hookData.session_id, "look"),
870
- entry.state.config.globals.look,
871
- entry.state.config.looks,
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
- effectiveTheme,
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). resolverForThemeName
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 = resolverForThemeName(effectiveTheme);
888
- // [LAW:one-type-per-behavior][LAW:dataflow-not-control-flow] The powerline
889
- // cap/separator SHAPE, resolved per render the exact way the theme is: the
890
- // session's clicked style (SessionState) over the config default over the
891
- // "powerline" floor — so a style click reshapes the whole bar on the next
892
- // render. The base style on renderOpts is only the floor; this is the live
893
- // override fed to the one joiner dispatch in renderStripCells.
894
- renderOpts.style = effectiveStripStyle(
895
- sessionState.get(req.hookData.session_id, "style"),
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
- lookKeyByName(entry.state.config.looks, effectiveLook),
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, and toolbar-toggle all carry the session id as their
417
- // first segment, so a failing step surfaces in the bar like any other.
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);