@promptctl/cc-candybar 1.30.0 → 1.32.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.
@@ -322,43 +322,94 @@ export function loadOverrides(
322
322
  };
323
323
  }
324
324
 
325
- // [LAW:no-silent-failure] Atomic write shared by set/clear: read the current
326
- // overrides, apply one mutation, write-to-temp + rename. Owner-only mode,
327
- // matching every other daemon runtime file (session-state.json, pid, lease).
325
+ // [LAW:no-silent-failure] The atomic write/rename dance, generalized over ANY
326
+ // JSON-serializable value both this module's flat overrides dict and its
327
+ // history stack (below) go through this one primitive rather than each
328
+ // re-implementing mkdir+tmp+chmod+rename. Owner-only mode, matching every
329
+ // other daemon runtime file (session-state.json, pid, lease). `label` names
330
+ // the failure in the log/thrown message (the caller's own vocabulary —
331
+ // "config-overrides"/"config-overrides-history" — not derived from the path,
332
+ // so the wording a test might match on stays stable across either file).
328
333
  // Unlike session-state.json's debounced best-effort flush (no synchronous
329
- // caller waiting on it), a `persist` write is directly caused by a click that
330
- // expects a truthful ack — a swallowed failure here would let the verb
331
- // handler log "set-config: ..." as if it landed when nothing was written.
332
- // Logs at "error" for the daemon-log breadcrumb, then RETHROWS so the caller
333
- // (the click) fails loudly instead of claiming a success that didn't happen.
334
- function writeOverrides(
334
+ // caller waiting on it), a `persist`/`undo`/`redo` write is directly caused
335
+ // by a click that expects a truthful ack — a swallowed failure here would let
336
+ // the verb handler log success for a write that didn't land. Logs at "error"
337
+ // for the daemon-log breadcrumb, then RETHROWS so the caller (the click)
338
+ // fails loudly instead of claiming a success that didn't happen.
339
+ function writeJsonAtomic(
335
340
  filePath: string,
336
- overrides: Readonly<Record<string, string | number | boolean>>,
341
+ label: string,
342
+ value: unknown,
337
343
  logger: DaemonLogger,
338
344
  ): void {
339
345
  try {
340
346
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
341
347
  const tmp = `${filePath}.tmp`;
342
- fs.writeFileSync(tmp, JSON.stringify(overrides), { mode: 0o600 });
348
+ fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
343
349
  fs.chmodSync(tmp, 0o600);
344
350
  fs.renameSync(tmp, filePath);
345
351
  } catch (e) {
346
- const message = `config-overrides write failed: ${(e as Error).message}`;
352
+ const message = `${label} write failed: ${(e as Error).message}`;
347
353
  logger("error", message);
348
354
  throw new Error(message);
349
355
  }
350
356
  }
351
357
 
358
+ function writeOverrides(
359
+ filePath: string,
360
+ overrides: Readonly<Record<string, string | number | boolean>>,
361
+ logger: DaemonLogger,
362
+ ): void {
363
+ writeJsonAtomic(filePath, "config-overrides", overrides, logger);
364
+ }
365
+
366
+ // [LAW:one-source-of-truth] The one place a key's value in the flat dict
367
+ // changes (set-or-delete) — writeConfigOverride/clearConfigOverride/
368
+ // restoreConfigOverrideValue all fold through here, so "what was the value
369
+ // BEFORE this write" (the fact history needs) is captured at the one site
370
+ // that reads-then-writes it, never re-derived. `value: undefined` deletes;
371
+ // any other value sets. Returns the previous value (or undefined if the key
372
+ // was absent) — the caller decides whether that fact matters.
373
+ function mutateOverride(
374
+ filePath: string,
375
+ key: string,
376
+ value: string | number | boolean | undefined,
377
+ logger: DaemonLogger,
378
+ ): string | number | boolean | undefined {
379
+ const overrides = loadRawOverrides(filePath, logger);
380
+ const prev = overrides[key];
381
+ if (value === undefined) {
382
+ if (!(key in overrides)) return prev;
383
+ const next = { ...overrides };
384
+ delete next[key];
385
+ writeOverrides(filePath, next, logger);
386
+ } else {
387
+ writeOverrides(filePath, { ...overrides, [key]: value }, logger);
388
+ }
389
+ return prev;
390
+ }
391
+
392
+ // [LAW:one-source-of-truth] `persist`'s write, TRACKED: mutate the key, then
393
+ // record the transition on the SAME global history undo/redo step
394
+ // (brandon-layout-edit-2gc.2). This is the ONE enforcement point — every
395
+ // current and future caller of writeConfigOverride (setConfig, stepConfig,
396
+ // apply-layout-op's append) gets history for free, with zero edits to those
397
+ // verb handlers, because the recording lives here rather than at each call
398
+ // site. [LAW:locality-or-seam]
352
399
  export function writeConfigOverride(
353
400
  filePath: string,
354
401
  key: string,
355
402
  value: string | number | boolean,
356
403
  logger: DaemonLogger = quietLogger,
357
404
  ): void {
358
- const overrides = loadRawOverrides(filePath, logger);
359
- writeOverrides(filePath, { ...overrides, [key]: value }, logger);
405
+ const prev = mutateOverride(filePath, key, value, logger);
406
+ pushHistoryEntry(filePath, { key, from: prev ?? null, to: value }, logger);
360
407
  }
361
408
 
409
+ // [LAW:one-source-of-truth] `reset`'s write, TRACKED — mirrors
410
+ // writeConfigOverride above. A clear that touches nothing (the key was
411
+ // already absent) records no entry: nothing changed, so there is nothing to
412
+ // undo back to.
362
413
  export function clearConfigOverride(
363
414
  filePath: string,
364
415
  key: string,
@@ -366,7 +417,247 @@ export function clearConfigOverride(
366
417
  ): void {
367
418
  const overrides = loadRawOverrides(filePath, logger);
368
419
  if (!(key in overrides)) return;
369
- const next = { ...overrides };
370
- delete next[key];
371
- writeOverrides(filePath, next, logger);
420
+ const prev = mutateOverride(filePath, key, undefined, logger);
421
+ pushHistoryEntry(filePath, { key, from: prev ?? null, to: null }, logger);
422
+ }
423
+
424
+ // [LAW:one-source-of-truth] The UNTRACKED twin — restores a key to EXACTLY
425
+ // `value` (or clears it, for `null`) without recording a new history entry.
426
+ // The only legitimate callers are popPastEntry/popFutureEntry below: undo and
427
+ // redo already know they're moving an entry between the past/future stacks,
428
+ // so routing their own restoration back through the tracked writers would
429
+ // record the undo/redo AS a new forward edit — burying the entry it just
430
+ // popped and making the OTHER stack unreachable. This is a structurally
431
+ // distinct function, not a boolean flag on the tracked ones
432
+ // [LAW:no-mode-explosion] — its contract ("apply this exact value, no
433
+ // bookkeeping") is different from theirs ("write this value, remember how to
434
+ // undo it"), not a variant of the same one.
435
+ function restoreConfigOverrideValue(
436
+ filePath: string,
437
+ key: string,
438
+ value: string | number | boolean | null,
439
+ logger: DaemonLogger,
440
+ ): void {
441
+ mutateOverride(filePath, key, value === null ? undefined : value, logger);
442
+ }
443
+
444
+ // ─── Undo/redo history (brandon-layout-edit-2gc.2) ────────────────────────
445
+
446
+ // [LAW:types-are-the-program] ONE entry shape covers every scope the
447
+ // overrides file holds — a globals field's snapshot overwrite (setConfig), a
448
+ // segment-palette snapshot overwrite (same verb, different key shape), AND a
449
+ // preset-root-ops APPEND (apply-layout-op's read-current-append-write) —
450
+ // because at the STORAGE layer every one of those is indistinguishable from
451
+ // "the value at `key` changed from `from` to `to`". apply-layout-op computes
452
+ // its new array-of-tokens string by reading-then-appending one level up
453
+ // (verbs/index.ts); by the time that string reaches writeConfigOverride, it
454
+ // is just the next value at that key. Undo restoring `from` verbatim is
455
+ // therefore ALSO the correct "pop the last op token" behavior for a rootOps
456
+ // key, with no rootOps-specific code anywhere in this module — the ticket's
457
+ // "one history over the overrides layer, not a layout-specific feature" falls
458
+ // out of the shape, it isn't special-cased into it. `null` is the ABSENT
459
+ // sentinel (a key with no prior/no resulting value): safe because no real
460
+ // override value is ever `null` — see isValidOverrides's kind table.
461
+ export interface HistoryEntry {
462
+ readonly key: string;
463
+ readonly from: string | number | boolean | null;
464
+ readonly to: string | number | boolean | null;
465
+ }
466
+
467
+ interface HistoryState {
468
+ readonly past: readonly HistoryEntry[];
469
+ readonly future: readonly HistoryEntry[];
470
+ }
471
+
472
+ const EMPTY_HISTORY: HistoryState = { past: [], future: [] };
473
+
474
+ // [LAW:carrying-cost] Resolves the ticket's "depth of the ring" question:
475
+ // bounded so a long-running daemon's history file cannot grow without limit,
476
+ // generous enough that no realistic editing session bumps into it. Oldest
477
+ // entries fall off first (capPush below) — a silent, documented trim, not a
478
+ // failure.
479
+ const MAX_HISTORY_DEPTH = 50;
480
+
481
+ // [LAW:one-source-of-truth] Resolves the ticket's "where it lives relative to
482
+ // the overrides file" question: a SIBLING file in the same directory, derived
483
+ // as a pure function of the overrides path already passed in — no reach to
484
+ // paths.ts/global state, so every existing call site (and every existing
485
+ // test's XDG_STATE_HOME isolation, which already isolates configOverridesPath())
486
+ // isolates this file too, with zero additional test-harness surface. Kept
487
+ // SEPARATE from the overrides file itself (rather than nesting it inside a
488
+ // wrapper shape) so the overrides file's own on-disk shape — asserted by
489
+ // name in existing tests and callers — never changes
490
+ // [LAW:locality-or-seam]: a change to history storage must not ripple into
491
+ // every existing reader of the flat overrides dict.
492
+ function historyPathFor(overridesFilePath: string): string {
493
+ return path.join(
494
+ path.dirname(overridesFilePath),
495
+ "config-overrides-history.json",
496
+ );
497
+ }
498
+
499
+ function isValidHistoryValue(
500
+ v: unknown,
501
+ ): v is string | number | boolean | null {
502
+ return (
503
+ v === null ||
504
+ typeof v === "string" ||
505
+ typeof v === "number" ||
506
+ typeof v === "boolean"
507
+ );
508
+ }
509
+
510
+ function isValidHistoryEntry(v: unknown): v is HistoryEntry {
511
+ if (v === null || typeof v !== "object") return false;
512
+ const obj = v as Record<string, unknown>;
513
+ return (
514
+ typeof obj.key === "string" &&
515
+ isValidHistoryValue(obj.from) &&
516
+ isValidHistoryValue(obj.to)
517
+ );
518
+ }
519
+
520
+ // [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
521
+ // history is the DEFINED recovery (mirrors isValidOverrides/loadRawOverrides'
522
+ // identical "first-ever boot" treatment for the sibling file) — a single
523
+ // malformed entry drops the WHOLE history, never a guess at which entries to
524
+ // salvage.
525
+ function isValidHistoryState(v: unknown): v is HistoryState {
526
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
527
+ const obj = v as Record<string, unknown>;
528
+ return (
529
+ Array.isArray(obj.past) &&
530
+ obj.past.every(isValidHistoryEntry) &&
531
+ Array.isArray(obj.future) &&
532
+ obj.future.every(isValidHistoryEntry)
533
+ );
534
+ }
535
+
536
+ function loadHistoryState(
537
+ overridesFilePath: string,
538
+ logger: DaemonLogger,
539
+ ): HistoryState {
540
+ const filePath = historyPathFor(overridesFilePath);
541
+ let raw: string;
542
+ try {
543
+ raw = fs.readFileSync(filePath, "utf8");
544
+ } catch (e) {
545
+ const code = (e as NodeJS.ErrnoException).code;
546
+ if (code !== "ENOENT") {
547
+ logger(
548
+ "warn",
549
+ `config-overrides-history read failed (${code}); starting empty`,
550
+ );
551
+ }
552
+ return EMPTY_HISTORY;
553
+ }
554
+ try {
555
+ const parsed: unknown = JSON.parse(raw);
556
+ if (isValidHistoryState(parsed)) return parsed;
557
+ logger(
558
+ "warn",
559
+ `config-overrides-history load: unexpected shape, starting empty`,
560
+ );
561
+ return EMPTY_HISTORY;
562
+ } catch {
563
+ logger(
564
+ "warn",
565
+ `config-overrides-history load: corrupt JSON, starting empty`,
566
+ );
567
+ return EMPTY_HISTORY;
568
+ }
569
+ }
570
+
571
+ function writeHistoryState(
572
+ overridesFilePath: string,
573
+ state: HistoryState,
574
+ logger: DaemonLogger,
575
+ ): void {
576
+ writeJsonAtomic(
577
+ historyPathFor(overridesFilePath),
578
+ "config-overrides-history",
579
+ state,
580
+ logger,
581
+ );
582
+ }
583
+
584
+ // [LAW:no-mode-explosion] Bounded push, oldest-drops-first, shared by both
585
+ // stacks (past grows on a fresh edit or a redo; future grows on an undo) —
586
+ // one shape, not two near-duplicate arms.
587
+ function capPush<T>(arr: readonly T[], entry: T, max: number): readonly T[] {
588
+ const next = [...arr, entry];
589
+ return next.length > max ? next.slice(next.length - max) : next;
590
+ }
591
+
592
+ // [LAW:one-source-of-truth] The ONLY caller is writeConfigOverride/
593
+ // clearConfigOverride above — every tracked write lands here, so recording
594
+ // cannot drift from mutation. A fresh edit TRUNCATES `future`: the classic
595
+ // undo/redo branch — diverging from history by doing something NEW abandons
596
+ // whatever was undone, rather than silently keeping it reachable from a
597
+ // history state the new edit has already invalidated.
598
+ function pushHistoryEntry(
599
+ overridesFilePath: string,
600
+ entry: HistoryEntry,
601
+ logger: DaemonLogger,
602
+ ): void {
603
+ const state = loadHistoryState(overridesFilePath, logger);
604
+ writeHistoryState(
605
+ overridesFilePath,
606
+ { past: capPush(state.past, entry, MAX_HISTORY_DEPTH), future: [] },
607
+ logger,
608
+ );
609
+ }
610
+
611
+ // [LAW:one-source-of-truth] The daemon-GLOBAL history is ONE stack, not
612
+ // per-session: config-overrides.json already has exactly one writer (the
613
+ // daemon) and no session-scoping (candybar-config-engine-71o's own binding
614
+ // guardrail — a `persist` write is daemon-global by design), so undo/redo
615
+ // stepping that SAME single-writer file inherits the same scope rather than
616
+ // inventing a session axis the storage layer doesn't otherwise have. Two
617
+ // sessions clicking undo do see each other's edits — a real, DELIBERATE
618
+ // consequence of there being one bar default, not a bug: the alternative
619
+ // (per-session history over daemon-global state) would let one session's
620
+ // "undo" silently fail to undo what another session's click actually did.
621
+ //
622
+ // [LAW:no-silent-failure] Returns `null` at the bottom of the stack — the
623
+ // verb handler (verbs/index.ts) turns that into a loud BadVerbArgs surfaced
624
+ // through click.error, never a silent no-op.
625
+ export function undoLastOverride(
626
+ overridesFilePath: string,
627
+ logger: DaemonLogger = quietLogger,
628
+ ): HistoryEntry | null {
629
+ const state = loadHistoryState(overridesFilePath, logger);
630
+ const entry = state.past[state.past.length - 1];
631
+ if (entry === undefined) return null;
632
+ restoreConfigOverrideValue(overridesFilePath, entry.key, entry.from, logger);
633
+ writeHistoryState(
634
+ overridesFilePath,
635
+ {
636
+ past: state.past.slice(0, -1),
637
+ future: capPush(state.future, entry, MAX_HISTORY_DEPTH),
638
+ },
639
+ logger,
640
+ );
641
+ return entry;
642
+ }
643
+
644
+ // [LAW:no-silent-failure] Redo's mirror of undo above — `null` at the top of
645
+ // the stack, same loud surfacing contract.
646
+ export function redoLastOverride(
647
+ overridesFilePath: string,
648
+ logger: DaemonLogger = quietLogger,
649
+ ): HistoryEntry | null {
650
+ const state = loadHistoryState(overridesFilePath, logger);
651
+ const entry = state.future[state.future.length - 1];
652
+ if (entry === undefined) return null;
653
+ restoreConfigOverrideValue(overridesFilePath, entry.key, entry.to, logger);
654
+ writeHistoryState(
655
+ overridesFilePath,
656
+ {
657
+ past: capPush(state.past, entry, MAX_HISTORY_DEPTH),
658
+ future: state.future.slice(0, -1),
659
+ },
660
+ logger,
661
+ );
662
+ return entry;
372
663
  }
@@ -15,6 +15,7 @@ import {
15
15
  perConfigDomainsFor,
16
16
  resolveOptionDomain,
17
17
  } from "../../config/option-domain";
18
+ import { addableSegmentDomains } from "../../config/edit-chrome";
18
19
  import type { DslConfig } from "../../config/dsl-types";
19
20
  import { isGlobalsField } from "../config-overrides-store";
20
21
  import { encodeLayoutOp } from "../../config/layout-ops";
@@ -111,6 +112,34 @@ function actionKeySpecs(
111
112
  },
112
113
  ];
113
114
  }
115
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
116
+ // sibling: the allow-list is the ENCODED op token for every domain member,
117
+ // not the raw member — mirroring how a literal `insertSegment` contributes
118
+ // its own single encoded token above. A click carrying an option this
119
+ // domain never named — or naming a real segment but the wrong anchor/
120
+ // relation — cannot decode to a member of this list, so it is rejected the
121
+ // same loud way an unknown literal op token already is.
122
+ if ("insertSegmentFrom" in a) {
123
+ return [
124
+ {
125
+ key: a.persist,
126
+ spec: {
127
+ kind: "allow-list",
128
+ allowed: resolveOptionDomain(
129
+ a.insertSegmentFrom,
130
+ perConfigDomains,
131
+ ).map((segment) =>
132
+ encodeLayoutOp({
133
+ op: "insert",
134
+ segment,
135
+ anchor: a.anchor,
136
+ relation: a.relation,
137
+ }),
138
+ ),
139
+ },
140
+ },
141
+ ];
142
+ }
114
143
  return [
115
144
  {
116
145
  key: a.persist,
@@ -141,7 +170,16 @@ function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
141
170
 
142
171
  function actionContributions(config: DslConfig): KeySpecContribution[] {
143
172
  const seeds = configKeySeeds(config);
144
- const perConfigDomains = perConfigDomainsFor(config);
173
+ // [LAW:one-source-of-truth] The "addable segment" domains
174
+ // (edit-chrome.ts's `addableSegmentDomains`) merge in here alongside
175
+ // looks/presets — the same per-preset seam `insertSegmentFrom` resolves
176
+ // through at render (render.ts's registerDslConfig merges the identical
177
+ // map), so the rendered picker options and the derived click gate can
178
+ // never diverge over what's addable.
179
+ const perConfigDomains = new Map([
180
+ ...perConfigDomainsFor(config),
181
+ ...addableSegmentDomains(config),
182
+ ]);
145
183
  return Object.values(config.actions).flatMap((a) =>
146
184
  actionKeySpecs(a, seeds, perConfigDomains),
147
185
  );
@@ -37,6 +37,8 @@ import {
37
37
  isGlobalsField,
38
38
  loadConfigOverrides,
39
39
  loadOverrides,
40
+ redoLastOverride,
41
+ undoLastOverride,
40
42
  writeConfigOverride,
41
43
  } from "../config-overrides-store";
42
44
  import { configOverridesPath } from "../paths";
@@ -49,6 +51,7 @@ import {
49
51
  VERB_DISPATCH,
50
52
  VERB_OPEN_VSCODE,
51
53
  VERB_LOAD_CONFIG,
54
+ VERB_REDO,
52
55
  VERB_RESET_CONFIG,
53
56
  VERB_SET_CONFIG,
54
57
  VERB_SET_STATE,
@@ -57,6 +60,7 @@ import {
57
60
  VERB_SHOW_CONFIG_ERROR,
58
61
  VERB_SHOW_CONFIG_WARNING,
59
62
  VERB_TOOLBAR_TOGGLE,
63
+ VERB_UNDO,
60
64
  } from "../../click/wire";
61
65
 
62
66
  export interface VerbContext {
@@ -489,6 +493,35 @@ const applyLayoutOp: VerbHandler = (rawValue, ctx) => {
489
493
  );
490
494
  };
491
495
 
496
+ // [LAW:one-source-of-truth] `reset`'s fine-grained sibling: step the ONE
497
+ // global history over the overrides layer back one entry. No key, no value —
498
+ // the history store (config-overrides-store.ts) owns which entry moves and
499
+ // what it restores; this handler is pure plumbing between the wire and it.
500
+ // [LAW:no-silent-failure] An empty stack is a loud BAD_REQUEST (dispatch's
501
+ // aggregator turns it into a transient click.error), never a silent no-op —
502
+ // the ticket's own done-gate.
503
+ const undoConfig: VerbHandler = (value, ctx) => {
504
+ const [sessionId = ""] = decodeWire(() => decodeSegments(value));
505
+ const sid = requireSessionId(sessionId);
506
+ const entry = undoLastOverride(configOverridesPath(), ctx.dlog);
507
+ if (entry === null) {
508
+ throw new BadVerbArgs("undo: history is empty, nothing to undo");
509
+ }
510
+ ctx.dlog("info", `undo: ${entry.key} (session=${sid})`);
511
+ };
512
+
513
+ // [LAW:one-source-of-truth] undo's mirror — steps the same global history
514
+ // forward one entry.
515
+ const redoConfig: VerbHandler = (value, ctx) => {
516
+ const [sessionId = ""] = decodeWire(() => decodeSegments(value));
517
+ const sid = requireSessionId(sessionId);
518
+ const entry = redoLastOverride(configOverridesPath(), ctx.dlog);
519
+ if (entry === null) {
520
+ throw new BadVerbArgs("redo: nothing to redo");
521
+ }
522
+ ctx.dlog("info", `redo: ${entry.key} (session=${sid})`);
523
+ };
524
+
492
525
  // ─── Registry ───────────────────────────────────────────────────────────────
493
526
 
494
527
  // [LAW:one-source-of-truth] The LEAF verbs — every click effect that does real
@@ -551,6 +584,8 @@ const LEAF_VERBS = new Map<string, VerbHandler>([
551
584
  [VERB_STEP_CONFIG, stepConfig],
552
585
  [VERB_RESET_CONFIG, resetConfig],
553
586
  [VERB_APPLY_LAYOUT_OP, applyLayoutOp],
587
+ [VERB_UNDO, undoConfig],
588
+ [VERB_REDO, redoConfig],
554
589
  [VERB_SHOW_CONFIG_ERROR, showConfigError],
555
590
  [VERB_SHOW_CONFIG_WARNING, showConfigWarning],
556
591
  [VERB_TOOLBAR_TOGGLE, toolbarToggle],
@@ -583,8 +618,9 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
583
618
  for (const { verb, value } of parseEffects(rawValue)) {
584
619
  // Extract session ID from the first session-bearing effect for error display.
585
620
  // set-state, step-state, set-config, step-config, reset-config,
586
- // apply-layout-op, and toolbar-toggle all carry the session id as their
587
- // first segment, so a failing step surfaces in the bar like any other.
621
+ // apply-layout-op, undo, redo, and toolbar-toggle all carry the session id
622
+ // as their first segment, so a failing step surfaces in the bar like any
623
+ // other.
588
624
  if (
589
625
  !sessionId &&
590
626
  (verb === VERB_SET_STATE ||
@@ -593,6 +629,8 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
593
629
  verb === VERB_STEP_CONFIG ||
594
630
  verb === VERB_RESET_CONFIG ||
595
631
  verb === VERB_APPLY_LAYOUT_OP ||
632
+ verb === VERB_UNDO ||
633
+ verb === VERB_REDO ||
596
634
  verb === VERB_TOOLBAR_TOGGLE)
597
635
  ) {
598
636
  const parts = decodeSegments(value);
package/src/dsl/render.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  import { HUE_STEP_VAR } from "../config/dsl-types.js";
24
24
  import { perConfigDomainsFor } from "../config/option-domain.js";
25
25
  import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
26
+ import { addableSegmentDomains } from "../config/edit-chrome.js";
26
27
  import type { VariableStore } from "../var-system/store.js";
27
28
  import type { SourceRegistry } from "../var-system/sources.js";
28
29
  import {
@@ -340,7 +341,15 @@ export function registerDslConfig(
340
341
  // one source.
341
342
  const lookNames = Object.keys(config.looks);
342
343
  const presetOptions = presetNames(config.presets);
343
- const perConfigDomains = perConfigDomainsFor(config);
344
+ // [LAW:one-source-of-truth] The "addable segment" per-preset domains merge
345
+ // in here — the SAME map config-validators.ts's deriveConfigActionValidators
346
+ // merges — so a synthesized `insertSegmentFrom` action's rendered options
347
+ // and its derived click gate resolve from one source, never two
348
+ // independently-computed sets.
349
+ const perConfigDomains = new Map([
350
+ ...perConfigDomainsFor(config),
351
+ ...addableSegmentDomains(config),
352
+ ]);
344
353
  const engine = createCcCandybarEngine(
345
354
  {
346
355
  ...actionFuncs(actionRuntime),
@@ -34,11 +34,13 @@ import {
34
34
  VERB_APPLY_LAYOUT_OP,
35
35
  VERB_COPY,
36
36
  VERB_OPEN_VSCODE,
37
+ VERB_REDO,
37
38
  VERB_RESET_CONFIG,
38
39
  VERB_SET_CONFIG,
39
40
  VERB_SET_STATE,
40
41
  VERB_STEP_CONFIG,
41
42
  VERB_STEP_STATE,
43
+ VERB_UNDO,
42
44
  type Effect,
43
45
  } from "../click/wire.js";
44
46
 
@@ -142,7 +144,29 @@ export type CompiledActionDecl =
142
144
  // arms. Fully literal at compile time (the op IS the declaration — no
143
145
  // template-bound option, unlike persist-option), so `op` is precomputed
144
146
  // here rather than reconstructed from raw fields at every realize() call.
145
- | { readonly kind: "layout-op"; readonly key: string; readonly op: LayoutOp };
147
+ | { readonly kind: "layout-op"; readonly key: string; readonly op: LayoutOp }
148
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
149
+ // sibling of layout-op: `anchor`/`relation` are fixed at compile time (the
150
+ // POSITION is author-time data) but the segment name comes from the
151
+ // template's bound option — the option-picking shape `persist-option`
152
+ // already has, minus the value being written VERBATIM. `requireOptionKind`
153
+ // (render/picker.ts) admits this kind alongside set-option/persist-option
154
+ // so a `{{ menu }}`/`{{ picker }}` can drive it with zero picker changes;
155
+ // only the WRITE (realize(), below) differs — it encodes the picked option
156
+ // into a LayoutOp instead of persisting it as-is.
157
+ | {
158
+ readonly kind: "layout-op-option";
159
+ readonly key: string;
160
+ readonly anchor: string;
161
+ readonly relation: "before" | "after";
162
+ readonly options: readonly string[];
163
+ }
164
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history
165
+ // step over the overrides layer — `reset`'s fine-grained sibling. No key:
166
+ // there is nothing to carry, since the history stack (not this action) is
167
+ // what decides which entry moves.
168
+ | { readonly kind: "undo" }
169
+ | { readonly kind: "redo" };
146
170
 
147
171
  export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
148
172
 
@@ -320,6 +344,17 @@ function compileAction(
320
344
  },
321
345
  };
322
346
  }
347
+ if ("insertSegmentFrom" in action) {
348
+ return {
349
+ kind: "layout-op-option",
350
+ key: action.persist,
351
+ anchor: action.anchor,
352
+ relation: action.relation,
353
+ options: [
354
+ ...resolveOptionDomain(action.insertSegmentFrom, perConfigDomains),
355
+ ],
356
+ };
357
+ }
323
358
  return {
324
359
  kind: "persist-bounded",
325
360
  key: action.persist,
@@ -338,7 +373,10 @@ function compileAction(
338
373
  target: parseActionTemplate(parse, action.open, name),
339
374
  };
340
375
  }
341
- return { kind: "reset", key: action.reset };
376
+ if ("reset" in action) {
377
+ return { kind: "reset", key: action.reset };
378
+ }
379
+ return "undo" in action ? { kind: "undo" } : { kind: "redo" };
342
380
  }
343
381
 
344
382
  function parseActionTemplate(
@@ -548,6 +586,20 @@ function realize(
548
586
  effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
549
587
  active: false,
550
588
  };
589
+ // [LAW:one-source-of-truth] No key to carry — the click just says "step
590
+ // the history", and which entry moves is entirely server-side state
591
+ // (never wire input, so there is nothing here to gate). Never "active":
592
+ // a history step is a one-shot trigger, not a current-selection toggle.
593
+ case "undo":
594
+ return {
595
+ effect: { verb: VERB_UNDO, args: [sessionId] },
596
+ active: false,
597
+ };
598
+ case "redo":
599
+ return {
600
+ effect: { verb: VERB_REDO, args: [sessionId] },
601
+ active: false,
602
+ };
551
603
  // [LAW:one-source-of-truth] The op is fixed at compile time (see
552
604
  // compileAction) — the click just delivers it. `apply-layout-op`'s
553
605
  // handler does read-current-append-write (see verbs/index.ts), unlike
@@ -562,6 +614,28 @@ function realize(
562
614
  },
563
615
  active: false,
564
616
  };
617
+ // [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
618
+ // SAME resolution persist-option uses) becomes the op's `segment`; anchor/
619
+ // relation are the compiled literals. Same wire shape a literal layout-op
620
+ // emits, so the daemon's apply-layout-op handler and undo/redo need no
621
+ // knowledge of where the segment name came from. Never "active": a
622
+ // structural edit is a one-shot trigger, not a current-selection toggle.
623
+ case "layout-op-option": {
624
+ const segment = boundValue ?? display;
625
+ const op: LayoutOp = {
626
+ op: "insert",
627
+ segment,
628
+ anchor: c.anchor,
629
+ relation: c.relation,
630
+ };
631
+ return {
632
+ effect: {
633
+ verb: VERB_APPLY_LAYOUT_OP,
634
+ args: [sessionId, c.key, encodeLayoutOp(op)],
635
+ },
636
+ active: false,
637
+ };
638
+ }
565
639
  }
566
640
  }
567
641