@promptctl/cc-candybar 1.29.0 → 1.31.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.
@@ -15,8 +15,11 @@ import {
15
15
  } from "../dsl-types.js";
16
16
  import {
17
17
  actionBindsPersist,
18
+ actionBindsRedo,
18
19
  actionBindsReset,
19
20
  actionBindsSet,
21
+ actionBindsUndo,
22
+ type ActionDecl,
20
23
  } from "../action.js";
21
24
  import {
22
25
  knownOptionDomainNames,
@@ -24,6 +27,7 @@ import {
24
27
  } from "../option-domain.js";
25
28
  import { listGlobalsFieldNames } from "./globals.js";
26
29
  import { parsePersistTarget } from "./persist-target.js";
30
+ import { presetNames } from "../presets.js";
27
31
  import { findKeyLine } from "./diagnostics.js";
28
32
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
29
33
  import {
@@ -116,11 +120,23 @@ export function validateCrossReferences(
116
120
  if (target === null) {
117
121
  ctx.issues.push({
118
122
  path: `actions.${name}.${discriminator}`,
119
- message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}) or a "segments.<name>.palette" target`,
123
+ message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}), a "segments.<name>.palette" target, or a "presets.<name>.rootOps" target`,
120
124
  line: findKeyLine(ctx.source, ["actions", name, discriminator]),
121
125
  });
122
126
  continue;
123
127
  }
128
+ if (target.scope === "preset-root-ops") {
129
+ checkPresetRootOpsTarget(
130
+ ctx,
131
+ cfg,
132
+ name,
133
+ discriminator,
134
+ key,
135
+ target.preset,
136
+ a,
137
+ );
138
+ continue;
139
+ }
124
140
  if (target.scope !== "segment-palette") continue;
125
141
  if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
126
142
  ctx.issues.push({
@@ -345,6 +361,76 @@ export function validateCrossReferences(
345
361
  }
346
362
  }
347
363
 
364
+ // [LAW:no-silent-failure] brandon-layout-edit-2gc.1's structural-edit target
365
+ // check, one arm of the persist/reset key cross-ref above. Three things must
366
+ // hold at load time, same spirit as the segment-palette check just above it:
367
+ // the preset name must be real (mirrors globals.preset's check earlier in
368
+ // this function), the arm pairing must make sense for this scope (only
369
+ // removeSegment/insertSegment address a tree — a `to`/`from`/cycle/bounded
370
+ // literal has no meaning as "the current op log"), and every segment name
371
+ // the op names must be declared.
372
+ function checkPresetRootOpsTarget(
373
+ ctx: ValidateCtx,
374
+ cfg: DslConfig,
375
+ name: string,
376
+ discriminator: "persist" | "reset",
377
+ key: string,
378
+ presetName: string,
379
+ a: ActionDecl,
380
+ ): void {
381
+ const at = `actions.${name}.${discriminator}`;
382
+ const line = findKeyLine(ctx.source, ["actions", name, discriminator]);
383
+ if (!presetNames(cfg.presets).includes(presetName)) {
384
+ ctx.issues.push({
385
+ path: at,
386
+ message: `actions.${name}: "${key}" names preset "${presetName}" which is not declared (have: ${presetNames(cfg.presets).join(", ")})`,
387
+ line,
388
+ });
389
+ return;
390
+ }
391
+ // [LAW:one-source-of-truth] `reset` has no value-source arm to check — its
392
+ // shape is a bare `{ reset: key }` — so the arm-pairing/segment checks
393
+ // below are `persist`-only, exactly as the "reset" action's clean-slate
394
+ // undo is meant to be: it clears the whole op log regardless of what wrote
395
+ // it.
396
+ if (discriminator === "reset") return;
397
+ const hasRemove = "removeSegment" in a;
398
+ const hasInsert = "insertSegment" in a;
399
+ if (!hasRemove && !hasInsert) {
400
+ ctx.issues.push({
401
+ path: at,
402
+ message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment" or "insertSegment" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
403
+ line,
404
+ });
405
+ return;
406
+ }
407
+ const missing = (segName: string): boolean =>
408
+ !Object.prototype.hasOwnProperty.call(cfg.segments, segName);
409
+ if (hasRemove && "removeSegment" in a && missing(a.removeSegment)) {
410
+ ctx.issues.push({
411
+ path: at,
412
+ message: `actions.${name}: removeSegment "${a.removeSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
413
+ line,
414
+ });
415
+ }
416
+ if (hasInsert && "insertSegment" in a) {
417
+ if (missing(a.insertSegment)) {
418
+ ctx.issues.push({
419
+ path: at,
420
+ message: `actions.${name}: insertSegment "${a.insertSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
421
+ line,
422
+ });
423
+ }
424
+ if (missing(a.anchor)) {
425
+ ctx.issues.push({
426
+ path: at,
427
+ message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
428
+ line,
429
+ });
430
+ }
431
+ }
432
+ }
433
+
348
434
  function hasStateKind(cfg: DslConfig): boolean {
349
435
  for (const v of Object.values(cfg.variables)) {
350
436
  if (v.kind === "state") return true;
@@ -358,15 +444,22 @@ function hasStateKind(cfg: DslConfig): boolean {
358
444
  return false;
359
445
  }
360
446
 
361
- // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config, OR
362
- // reset-config click — and so needs session.id — when any declared action is
363
- // a `set` (literal/option/bounded/cycle), a `persist` (its config-overrides
364
- // twin), or a `reset` (persist's gated undo) all three carry session.id on
365
- // the wire for click-error surfacing. copy/open actions write nothing, so
366
- // they embed no session.id.
447
+ // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
448
+ // reset-config, undo, OR redo click — and so needs session.id — when any
449
+ // declared action is a `set` (literal/option/bounded/cycle), a `persist`
450
+ // (its config-overrides twin), a `reset` (persist's gated undo), or an
451
+ // `undo`/`redo` (the overrides layer's global history step) all five
452
+ // carry session.id on the wire for click-error surfacing (an empty history
453
+ // stack is a loud, session-scoped miss, not a silent no-op). copy/open
454
+ // actions write nothing, so they embed no session.id.
367
455
  function hasActionSetAction(cfg: DslConfig): boolean {
368
456
  return Object.values(cfg.actions).some(
369
- (a) => actionBindsSet(a) || actionBindsPersist(a) || actionBindsReset(a),
457
+ (a) =>
458
+ actionBindsSet(a) ||
459
+ actionBindsPersist(a) ||
460
+ actionBindsReset(a) ||
461
+ actionBindsUndo(a) ||
462
+ actionBindsRedo(a),
370
463
  );
371
464
  }
372
465
 
@@ -16,17 +16,33 @@ import { isGlobalsField } from "./globals.js";
16
16
 
17
17
  export type PersistTarget =
18
18
  | { readonly scope: "globals"; readonly field: keyof Globals }
19
- | { readonly scope: "segment-palette"; readonly segment: string };
19
+ | { readonly scope: "segment-palette"; readonly segment: string }
20
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's structural-edit
21
+ // target — the accumulated op LOG for one preset's root (see
22
+ // src/config/layout-ops.ts), never the tree itself: a scalar-shaped value
23
+ // (a JSON-encoded string[] of op tokens) so it rides the SAME flat-dict
24
+ // overrides file with no shape change to that store's core writer.
25
+ | { readonly scope: "preset-root-ops"; readonly preset: string };
20
26
 
21
27
  // [LAW:locality-or-seam] `segments.<name>.palette` reuses the SAME dotted
22
28
  // namespacing SegmentDecl.vars already uses for segment-local variables
23
29
  // (`<segment>.<var>`, declared in src/dsl/render.ts) — one idiom for "a name
24
30
  // scoped under a segment", not a bespoke second syntax invented for persist
25
- // targets alone.
31
+ // targets alone. `presets.<name>.rootOps` mirrors it one level up (a name
32
+ // scoped under a preset) — deliberately spelled `rootOps`, not `root`, so it
33
+ // never reads as the same string as presetRoot()'s load-time diagnostic path
34
+ // `presets.<name>.root` (src/config/presets.ts), a different namespace this
35
+ // key must never be confused with.
26
36
  const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
37
+ const PRESET_ROOT_OPS_KEY = /^presets\.([^.]+)\.rootOps$/;
27
38
 
28
39
  export function parsePersistTarget(key: string): PersistTarget | null {
29
40
  if (isGlobalsField(key)) return { scope: "globals", field: key };
30
- const match = SEGMENT_PALETTE_KEY.exec(key);
31
- return match ? { scope: "segment-palette", segment: match[1]! } : null;
41
+ const segmentMatch = SEGMENT_PALETTE_KEY.exec(key);
42
+ if (segmentMatch)
43
+ return { scope: "segment-palette", segment: segmentMatch[1]! };
44
+ const presetMatch = PRESET_ROOT_OPS_KEY.exec(key);
45
+ return presetMatch
46
+ ? { scope: "preset-root-ops", preset: presetMatch[1]! }
47
+ : null;
32
48
  }
@@ -37,6 +37,7 @@ import type {
37
37
  PresetDecl,
38
38
  } from "./dsl-types.js";
39
39
  import { effectiveMemberName } from "../themes/policy.js";
40
+ import { applyLayoutOps, decodeLayoutOp } from "./layout-ops.js";
40
41
 
41
42
  // [LAW:one-source-of-truth] The floor preset's name, spelled once. `looks` has
42
43
  // `"none"` (the identity adaptation); presets have `"default"` (the identity
@@ -186,3 +187,44 @@ export function sanitizePersistedPresetOverride(
186
187
  delete rest.preset;
187
188
  return rest;
188
189
  }
190
+
191
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step — the
192
+ // SAME "patch an already-merged config" shape applySegmentPaletteOverrides
193
+ // (src/config/loader/merge.ts) uses one field over, run at the SAME point in
194
+ // RenderCache.buildState (after the globals/segment-palette overrides, before
195
+ // validateConfig): for every preset with an accumulated op log, resolve its
196
+ // CURRENT root the normal way (presetRoot — bundled/user root, or the
197
+ // preset's own declared fragment) and replay the ops on top, writing the
198
+ // result back as that preset's `root`. Every later reader (presetRoot,
199
+ // registerDslConfig's per-preset compile, validateConfig's cross-ref walk)
200
+ // sees the patched tree as if it had been authored that way — no second
201
+ // resolution path [LAW:locality-or-seam].
202
+ //
203
+ // [LAW:no-silent-failure] exception: an op whose target/anchor names a
204
+ // segment absent from the CURRENT tree is a no-op (layout-ops.ts's own
205
+ // documented policy) — a validated action can only ever name a segment the
206
+ // config declares at the time it was clicked, so a miss here only happens
207
+ // after a LATER edit (a config change, or an earlier op in the same list)
208
+ // already removed it. A malformed individual token (decodeLayoutOp -> null;
209
+ // can only arise from hand-edited or previous-version state, never from this
210
+ // process's own encodeLayoutOp) is filtered the same way, never applied.
211
+ export function applyPresetRootOpsOverrides(
212
+ config: DslConfig,
213
+ presetRootOps: Readonly<Record<string, readonly string[]>>,
214
+ ): DslConfig {
215
+ const entries = Object.entries(presetRootOps).filter(
216
+ ([, tokens]) => tokens.length > 0,
217
+ );
218
+ if (entries.length === 0) return config;
219
+ const presets: Record<string, PresetDecl> = { ...config.presets };
220
+ for (const [name, tokens] of entries) {
221
+ const ops = tokens.map(decodeLayoutOp).filter((op) => op !== null);
222
+ if (ops.length === 0) continue;
223
+ const { node } = presetRoot(config, name);
224
+ presets[name] = {
225
+ ...presetByName(config.presets, name),
226
+ root: applyLayoutOps(node, ops),
227
+ };
228
+ }
229
+ return { ...config, presets };
230
+ }
@@ -25,7 +25,10 @@ import {
25
25
  } from "../verbs/config-validators.js";
26
26
  import { loadOverrides } from "../config-overrides-store.js";
27
27
  import { configOverridesPath } from "../paths.js";
28
- import { sanitizePersistedPresetOverride } from "../../config/presets.js";
28
+ import {
29
+ applyPresetRootOpsOverrides,
30
+ sanitizePersistedPresetOverride,
31
+ } from "../../config/presets.js";
29
32
  import { VariableStore } from "../../var-system/store.js";
30
33
  import { SourceRegistry } from "../../var-system/sources.js";
31
34
  import type { GitDataProvider } from "./git.js";
@@ -324,8 +327,20 @@ export class RenderCache {
324
327
  withGlobalsOverrides,
325
328
  overrides.segmentPalette,
326
329
  );
327
- const config = validateConfig(
330
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step —
331
+ // the SAME "patch an already-merged config" cascade as the segment-
332
+ // palette overlay above, one field over (a preset's `root` instead of a
333
+ // segment's `palette`). Runs last so validateConfig's cross-ref walk
334
+ // proves the OPS-PATCHED tree, not the pre-edit one — a structural edit
335
+ // that referenced a segment removed by a later config change is caught
336
+ // exactly like a hand-authored preset root naming the same segment would
337
+ // be.
338
+ const withPresetRootOps = applyPresetRootOpsOverrides(
328
339
  withOverrides,
340
+ overrides.presetRootOps,
341
+ );
342
+ const config = validateConfig(
343
+ withPresetRootOps,
329
344
  resolvedPath ?? "<default>",
330
345
  source,
331
346
  );