@promptctl/cc-candybar 1.2.0 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.2.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.2.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.2.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.2.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.4.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.4.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.4.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.4.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -25,6 +25,13 @@
25
25
  },
26
26
  "palette": {
27
27
  "type": "string"
28
+ },
29
+ "style": {
30
+ "enum": [
31
+ "powerline",
32
+ "capsule",
33
+ "plain"
34
+ ]
28
35
  }
29
36
  },
30
37
  "additionalProperties": false
@@ -159,6 +159,14 @@ export const DEFAULT_DSL_CONFIG = {
159
159
  path: "workspace.project_dir",
160
160
  default: "",
161
161
  },
162
+ // Transcript path (a top-level hookData field, spread onto the payload
163
+ // root by buildRenderPayload). Read by the quick-action tray's
164
+ // openTranscript action — pass-through, no projection.
165
+ transcript_path: {
166
+ kind: "input",
167
+ path: "transcript_path",
168
+ default: "",
169
+ },
162
170
  "model.display_name": {
163
171
  kind: "input",
164
172
  path: "model.display_name",
@@ -429,6 +437,18 @@ export const DEFAULT_DSL_CONFIG = {
429
437
  type: "number",
430
438
  default: 0,
431
439
  },
440
+
441
+ // ── Style picker state (the live powerline-shape switcher) ───────────────
442
+ // [LAW:one-source-of-truth] `activeStyle` reads the SAME "style" SessionState
443
+ // key the daemon resolves into the strip joiner per render (see
444
+ // effectiveStripStyle wiring in src/daemon/server.ts) — the picker's write
445
+ // and the render's read are one value. Empty default ⇒ the daemon's
446
+ // "powerline" floor is in effect and styleControl shows "(default)".
447
+ activeStyle: { kind: "state", key: "style", default: "" },
448
+ // The style menu's page cursor: −1 closed / 0..N open, mirroring how a theme
449
+ // picker's page key gates its reveal row. The stylePage action declares the
450
+ // int gate; this var reads it back for the reveal `when`.
451
+ stylePage: { kind: "state", key: "style-page", default: "-1" },
432
452
  },
433
453
 
434
454
  // ─── Segments ──────────────────────────────────────────────────────────────
@@ -497,10 +517,16 @@ export const DEFAULT_DSL_CONFIG = {
497
517
  fg: "foreground",
498
518
  when: '{{ ne .git.branch "" }}',
499
519
  },
520
+ // Quick-action tray — copy the session id / cwd, open the project dir /
521
+ // transcript in the editor. [LAW:locality-or-seam] The glyph is the
522
+ // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
523
+ // name is the seam between them. Re-glyph without touching behavior;
524
+ // re-target without touching this template. Each `{{ action … }}` emits
525
+ // one OSC-8 clickable region whose URL the wire codec owns end-to-end.
500
526
  toolbar: {
501
527
  template:
502
- ' {{ link (printf "cc-candybar://open-vscode/%s" (urlEncode .current_dir)) "\u{1F4C2}" }}' +
503
- ' {{ link (printf "cc-candybar://copy/%s" (urlEncode (trunc 8 .session.id))) "" }} ',
528
+ ' {{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
529
+ ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }} ',
504
530
  bg: "surface",
505
531
  fg: "foreground",
506
532
  },
@@ -609,32 +635,94 @@ export const DEFAULT_DSL_CONFIG = {
609
635
  " .metrics.sessionDuration .metrics.messageCount" +
610
636
  " .metrics.linesAdded .metrics.linesRemoved }}",
611
637
  },
638
+ // Style control — the powerline-shape switcher's trigger. [LAW:locality-or-
639
+ // seam] The ✦ glyph + current-style label is the REPRESENTATION; the
640
+ // `openStyleMenu` action is the BEHAVIOR; the name is the seam. Shows the
641
+ // active shape (or "(default)" when unset) and a ▸ that opens the picker
642
+ // row. [LAW:dataflow-not-control-flow] No display state from the provider —
643
+ // the label is the one "style" value the click writes and the render reads.
644
+ styleControl: {
645
+ template:
646
+ "✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
647
+ '{{ action "openStyleMenu" "▸" }}',
648
+ bg: "surface",
649
+ fg: "foreground",
650
+ },
651
+ // The expanded style picker: one full-width page (paged=false) over the
652
+ // `applyStyle` option domain — the 3 powerline shapes. closeOnPick folds a
653
+ // page-reset into the apply, so a pick reshapes the bar and closes the row
654
+ // in one click. The active shape is marked by the picker helper.
655
+ stylePicker: {
656
+ template: '{{ picker "applyStyle" "stylePage" true false }}',
657
+ bg: "surface",
658
+ fg: "foreground",
659
+ },
612
660
  },
613
661
 
614
- // Default layout — a single horizontal row of segment refs.
615
- // A-grammar equivalent: { h: ["directory","git","model","session","today","context"] }
616
- // [LAW:one-source-of-truth] The bundled default now authors the same terse
617
- // surface every user config lowers to, so the default is the reference spelling.
618
- // Adding rows = wrapping in { kind:"container", direction:"vertical", children:[...] };
619
- // every segment is already declared above.
662
+ // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
663
+ // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
664
+ // the loader's authoring surface for user JSON, not this typed literal).
665
+ // [LAW:dataflow-not-control-flow] Two rows: an always-on control row, and a
666
+ // picker reveal row that EXISTS only while the style menu cursor is ≥ 0 — the
667
+ // row's presence is a value test on stylePage, not a branch in render code.
668
+ // The picker itself draws the ✕/←/→ affordances from the page + term width.
620
669
  root: {
621
670
  kind: "container",
622
- direction: "horizontal",
671
+ direction: "vertical",
623
672
  children: [
624
- { kind: "segment", name: "directory" },
625
- { kind: "segment", name: "git" },
626
- { kind: "segment", name: "model" },
627
- { kind: "segment", name: "session" },
628
- { kind: "segment", name: "today" },
629
- { kind: "segment", name: "context" },
673
+ {
674
+ kind: "container",
675
+ direction: "horizontal",
676
+ children: [
677
+ { kind: "segment", name: "directory" },
678
+ { kind: "segment", name: "git" },
679
+ { kind: "segment", name: "model" },
680
+ { kind: "segment", name: "session" },
681
+ { kind: "segment", name: "today" },
682
+ { kind: "segment", name: "context" },
683
+ { kind: "segment", name: "toolbar" },
684
+ { kind: "segment", name: "styleControl" },
685
+ ],
686
+ },
687
+ {
688
+ kind: "segment",
689
+ name: "stylePicker",
690
+ when: "{{ ge (int .stylePage) 0 }}",
691
+ },
630
692
  ],
631
693
  },
632
694
 
633
- // [LAW:locality-or-seam] No decoupled actions in the bundled default either —
634
- // the baseline statusline binds no clickable regions. A user config declares
635
- // named actions and binds them from a segment template via
636
- // `{{ action "name" }}`; the merge cascade adds them by name.
637
- actions: {},
695
+ // [LAW:locality-or-seam] The quick-action tray's behaviors, decoupled by NAME
696
+ // from the `toolbar` segment's glyphs above. copy/open evaluate a Go-template
697
+ // against the live render scope at click time and write NO SessionState, so
698
+ // they derive no state validator (no gate) they are pure click effects.
699
+ //
700
+ // [LAW:single-enforcer] Each template emits a RAW value; the click-wire codec
701
+ // (effectsUrl → encodeSegments) owns ALL percent-encoding and the verb's
702
+ // `oneArg` owns the single matching decode — so the template never hand-rolls
703
+ // a `urlEncode`, and the path round-trips untouched through one codec.
704
+ //
705
+ // open* route through the open-vscode verb (`open -a "Visual Studio Code"
706
+ // <path>`), so they pass a bare filesystem path — a directory or a file the
707
+ // editor opens directly — NOT a `vscode://` URL (which `open -a` would treat
708
+ // as a literal filename, not a deep link).
709
+ actions: {
710
+ copySession: { copy: "{{ .session.id }}" },
711
+ copyDir: { copy: "{{ .current_dir }}" },
712
+ openProject: { open: "{{ .project_dir }}" },
713
+ openTranscript: { open: "{{ .transcript_path }}" },
714
+
715
+ // [LAW:locality-or-seam] The style picker's behaviors, decoupled by NAME
716
+ // from styleControl/stylePicker above. Three declarations, all gated by
717
+ // derivation (deriveActionValidators): openStyleMenu/stylePage write the
718
+ // page cursor (a literal page-open subsumed by the int gate); applyStyle
719
+ // writes the chosen shape, gated to the STRIP_STYLES allow-list because its
720
+ // value source is `from: "styles"`. The rendered click and the wire gate
721
+ // share that one source — a template cannot smuggle an un-gated style write.
722
+ openStyleMenu: { set: "style-page", to: "0" },
723
+ applyStyle: { set: "style", from: "styles" },
724
+ stylePage: { set: "style-page", int: true },
725
+ },
638
726
 
639
727
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
640
728
  // for the cost/token/budget family lives here as named template helpers, each
@@ -14,6 +14,7 @@
14
14
  // references it here. The dependency is one-way (this file → action.ts), never
15
15
  // the reverse, so that shape can be lifted out without a cycle.
16
16
  import type { ActionDecl } from "./action.js";
17
+ import type { StripStyle } from "../themes/policy.js";
17
18
 
18
19
  // [LAW:types-are-the-program] Three stages, three names.
19
20
  //
@@ -180,6 +181,14 @@ export interface Globals {
180
181
  // `sessionState.theme ?? globals.palette ?? default`, and a per-segment
181
182
  // `palette` is an explicit override that ignores the session theme.
182
183
  readonly palette?: string;
184
+
185
+ // [LAW:one-type-per-behavior] The config default for the powerline cap/
186
+ // separator SHAPE — the exact twin of `palette` one dimension over: the
187
+ // daemon resolves the live strip style per render as
188
+ // `sessionState.style ?? globals.style ?? "powerline"` (effectiveStripStyle),
189
+ // so a style click reshapes the bar live and a config can set the default
190
+ // shape without an edit-per-session.
191
+ readonly style?: StripStyle;
183
192
  }
184
193
 
185
194
  // [LAW:one-type-per-behavior] One discriminated union covers every source
@@ -4,7 +4,9 @@
4
4
  // add a key to GLOBALS_SCHEMA and Globals; the engine does the rest.
5
5
 
6
6
  import { type Globals } from "../dsl-types.js";
7
+ import { STRIP_STYLES } from "../../themes/policy.js";
7
8
  import {
9
+ optionalEnumSpec,
8
10
  optionalStringSpec,
9
11
  paletteSpec,
10
12
  record,
@@ -23,6 +25,10 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
23
25
  default_separator: optionalStringSpec(),
24
26
  default_truncate_marker: optionalStringSpec(),
25
27
  palette: paletteSpec(),
28
+ // [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
29
+ // shapes the joiner can render), unlike the open-ended palette NAME — so it
30
+ // validates by membership and emits a JSON-Schema `enum`.
31
+ style: optionalEnumSpec(STRIP_STYLES),
26
32
  },
27
33
  };
28
34
 
@@ -300,12 +300,11 @@ export class RenderCache {
300
300
  // reloadInto preserves the prior last-known-good with nothing half-installed.
301
301
  const validatorDisposers: Array<() => void> = [];
302
302
  try {
303
- // [LAW:locality-or-seam] Pass the store so the config's `widget`
304
- // references can read session.id + current picker values from the same
305
- // source the rest of the render reads.
303
+ // [LAW:one-source-of-truth] The action runtime reads session.id + current
304
+ // picker values from registry.variableStore the same store this entry's
305
+ // registry declares into so no store reference is threaded separately.
306
306
  compiled = registerDslConfig(config, registry, {
307
307
  cwd: entry.cwd,
308
- store,
309
308
  });
310
309
  // [LAW:one-source-of-truth] Derive the writable-key validators from the
311
310
  // config's action table (the sole interaction authority) through one
@@ -39,7 +39,11 @@ import { buildDebugSnapshot } from "./debug";
39
39
  import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
40
40
  import { expandHome } from "../config/dsl-loader.js";
41
41
  import { renderDsl } from "../dsl/render.js";
42
- import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
42
+ import {
43
+ effectiveStripStyle,
44
+ effectiveThemeName,
45
+ resolverForThemeName,
46
+ } from "../themes/index.js";
43
47
  import {
44
48
  renderStripCells,
45
49
  DEFAULT_TERMINAL_WIDTH,
@@ -753,6 +757,16 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
753
757
  entry.state.config.globals.palette,
754
758
  ),
755
759
  );
760
+ // [LAW:one-type-per-behavior][LAW:dataflow-not-control-flow] The powerline
761
+ // cap/separator SHAPE, resolved per render the exact way the theme is: the
762
+ // session's clicked style (SessionState) over the config default over the
763
+ // "powerline" floor — so a style click reshapes the whole bar on the next
764
+ // render. The base style on renderOpts is only the floor; this is the live
765
+ // override fed to the one joiner dispatch in renderStripCells.
766
+ renderOpts.style = effectiveStripStyle(
767
+ sessionState.get(req.hookData.session_id, "style"),
768
+ entry.state.config.globals.style,
769
+ );
756
770
  // [LAW:single-enforcer] renderDsl internally calls
757
771
  // `registry.applyInput(payload)` as its first step (see step 1 in
758
772
  // src/dsl/render.ts). The daemon must not pre-apply — doing so
@@ -24,7 +24,7 @@
24
24
  // numeric stepper with int-range bounds), this same shape extends — the
25
25
  // validator becomes the parsing boundary, the verb body the dataflow.
26
26
 
27
- import { listResolvablePaletteNames, STYLE_ORDER } from "../../themes/policy";
27
+ import { listResolvablePaletteNames, STRIP_STYLES } from "../../themes/policy";
28
28
  import type { ActionDecl, OptionSource } from "../../config/action";
29
29
  import type { DslConfig } from "../../config/dsl-types";
30
30
 
@@ -88,13 +88,13 @@ export type DerivedValidatorSpec =
88
88
  //
89
89
  // [LAW:single-enforcer] Each validator's accepted-set is one constant
90
90
  // lookup structure — a Set for O(1) `has` (matching the BOOLEAN_*
91
- // validators below). The theme registry (rich-js THEMES) and STYLE_ORDER
91
+ // validators below). The theme registry (rich-js THEMES) and STRIP_STYLES
92
92
  // are module-init-static, so caching at module load is correct by
93
93
  // construction; the (list, set) pair is built from the same source so
94
94
  // the error-message ordering and the lookup membership cannot drift.
95
95
  const RESOLVABLE_THEMES_LIST: readonly string[] = listResolvablePaletteNames();
96
96
  const RESOLVABLE_THEMES: ReadonlySet<string> = new Set(RESOLVABLE_THEMES_LIST);
97
- const RESOLVABLE_STYLES: ReadonlySet<string> = new Set(STYLE_ORDER);
97
+ const RESOLVABLE_STYLES: ReadonlySet<string> = new Set(STRIP_STYLES);
98
98
 
99
99
  const validateTheme: KeyValidator = (raw) => {
100
100
  if (!raw) return { ok: false, reason: "theme name is required" };
@@ -112,7 +112,7 @@ const validateStyle: KeyValidator = (raw) => {
112
112
  if (!RESOLVABLE_STYLES.has(raw)) {
113
113
  return {
114
114
  ok: false,
115
- reason: `unknown style "${raw}" (have: ${STYLE_ORDER.join(", ")})`,
115
+ reason: `unknown style "${raw}" (have: ${STRIP_STYLES.join(", ")})`,
116
116
  };
117
117
  }
118
118
  return { ok: true, value: raw };
@@ -446,7 +446,7 @@ export function makeRangeValidator(
446
446
  // style validators consult — the rendered options and the derived gate cannot
447
447
  // diverge because there is no second enumeration.
448
448
  function optionValuesFor(src: OptionSource): readonly string[] {
449
- return src === "themes" ? RESOLVABLE_THEMES_LIST : STYLE_ORDER;
449
+ return src === "themes" ? RESOLVABLE_THEMES_LIST : STRIP_STYLES;
450
450
  }
451
451
 
452
452
  // [LAW:types-are-the-program] Collapse one key's spec contributions into the
package/src/demo/dsl.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "../config/dsl-loader.js";
28
28
  import { VariableStore } from "../var-system/store.js";
29
29
  import { SourceRegistry } from "../var-system/sources.js";
30
+ import { SessionState } from "../daemon/session-state.js";
30
31
  import { listResolvablePaletteNames } from "../themes/policy.js";
31
32
  import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
32
33
  import { registerDslConfig, renderDsl } from "../dsl/render.js";
@@ -75,11 +76,15 @@ const basePalette = resolverForThemeName(
75
76
  // registry, so dispose() must run even if registration or rendering throws —
76
77
  // otherwise those handles keep the process alive. try/finally guarantees it.
77
78
  const store = new VariableStore();
78
- const registry = new SourceRegistry(store);
79
+ // [LAW:no-silent-failure] An EMPTY SessionState — `kind: "state"` variables
80
+ // (the default config's style picker, any interactive config) require one at
81
+ // registration; without it declareState fails and the segment renders an error
82
+ // cell. The demo never clicks, so an empty store is correct: every state var
83
+ // resolves to its declared default (closed pickers, "(default)" labels).
84
+ const registry = new SourceRegistry(store, "", undefined, new SessionState());
79
85
  try {
80
86
  const compiled = registerDslConfig(config, registry, {
81
87
  cwd: process.cwd(),
82
- store,
83
88
  });
84
89
 
85
90
  process.stdout.write(
package/src/dsl/render.ts CHANGED
@@ -252,7 +252,7 @@ function compileHelperPreamble(
252
252
  export function registerDslConfig(
253
253
  config: ValidatedConfig,
254
254
  registry: SourceRegistry,
255
- opts?: { cwd?: string; store?: VariableStore; clock?: () => Date },
255
+ opts?: { cwd?: string; clock?: () => Date },
256
256
  ): CompiledConfig {
257
257
  const cwd = opts?.cwd ?? process.cwd();
258
258
 
@@ -262,11 +262,12 @@ export function registerDslConfig(
262
262
  // because the action set is config-scoped. The runtime holder is populated below
263
263
  // — the `action`/`picker` funcs reference the engine, and the compiled actions
264
264
  // reference the engine, so the holder breaks that cycle.
265
- // [LAW:no-defensive-null-guards] store may be absent for compile-only callers
266
- // with no actions; renderAction throws loudly if an action is actually used
267
- // without a store, rather than silently rendering an empty click.
265
+ // [LAW:one-source-of-truth] The action runtime reads through the SAME store the
266
+ // registry declares into and the renderer reads back sourced from the registry
267
+ // itself, not a redundant opts field a caller could forget (or pass a divergent
268
+ // store for). Every config has a registry, so the action store is never null.
268
269
  const actionRuntime: ActionRuntime = {
269
- store: opts?.store ?? null,
270
+ store: registry.variableStore,
270
271
  compiled: new Map(),
271
272
  };
272
273
  // [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
@@ -26,7 +26,7 @@ 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
28
  import type { ActionDecl, OptionSource } from "../config/action.js";
29
- import { listResolvablePaletteNames, STYLE_ORDER } from "../themes/policy.js";
29
+ import { listResolvablePaletteNames, STRIP_STYLES } from "../themes/policy.js";
30
30
  import {
31
31
  effectsUrl,
32
32
  VERB_COPY,
@@ -107,7 +107,7 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
107
107
  // options and the gate cannot diverge. The render-side resolver (the daemon's
108
108
  // validator-derivation has its own that must agree, both reading themes/policy).
109
109
  export function optionDomain(src: OptionSource): readonly string[] {
110
- return src === "themes" ? listResolvablePaletteNames() : STYLE_ORDER;
110
+ return src === "themes" ? listResolvablePaletteNames() : STRIP_STYLES;
111
111
  }
112
112
 
113
113
  // [LAW:locality-or-seam] The runtime holder the `action` template function closes
@@ -117,7 +117,11 @@ export function optionDomain(src: OptionSource): readonly string[] {
117
117
  // reads session.id and the current value from the same source the rest of the
118
118
  // render does.
119
119
  export interface ActionRuntime {
120
- store: VariableStore | null;
120
+ // [LAW:types-are-the-program] Always present — registerDslConfig sources it
121
+ // from the registry it is handed (registry.variableStore), so "no store" is
122
+ // structurally unrepresentable. The action reads session.id and current
123
+ // values from the same store the renderer reads.
124
+ store: VariableStore;
121
125
  compiled: CompiledActions;
122
126
  }
123
127
 
@@ -416,11 +420,6 @@ export function renderAction(
416
420
  throw new Error(`action "${name}" is not declared in this config`);
417
421
  }
418
422
  const store = runtime.store;
419
- if (!store) {
420
- throw new Error(
421
- `action "${name}" rendered without a VariableStore — registerDslConfig was not given one`,
422
- );
423
- }
424
423
  const { display, boundValue } = selectDisplay(name, action, displays, store);
425
424
  const sessionId = readVar(store, "session.id");
426
425
  const { effect, active } = realize(
@@ -144,11 +144,6 @@ function renderPicker(
144
144
  "an int action ({ set, int: true })",
145
145
  );
146
146
  const store = runtime.store;
147
- if (!store) {
148
- throw new Error(
149
- `picker "${applyName}" rendered without a VariableStore — registerDslConfig was not given one`,
150
- );
151
- }
152
147
  const sessionId = readVar(store, "session.id");
153
148
  const current = readVar(store, apply.stateVar);
154
149
  const widths = apply.options.map(cellWidth);
@@ -10,6 +10,7 @@ import {
10
10
  type Joiner,
11
11
  type ColorSystemSpec,
12
12
  } from "@promptctl/rich-js";
13
+ import type { StripStyle } from "../themes/policy.js";
13
14
 
14
15
  export interface RenderedSegmentLike {
15
16
  type: string;
@@ -18,7 +19,11 @@ export interface RenderedSegmentLike {
18
19
  fgHex?: string;
19
20
  }
20
21
 
21
- export type StripStyle = "powerline" | "capsule" | "plain";
22
+ // [LAW:one-source-of-truth] `StripStyle` and its value list live in
23
+ // themes/policy.ts (the style-identifier policy module, importable by the
24
+ // option-source machinery without a render→template-engine cycle). Re-exported
25
+ // here so render-layer consumers can keep importing it from the strip module.
26
+ export type { StripStyle };
22
27
 
23
28
  // [LAW:one-source-of-truth] Raw terminal cols we assume when the wire
24
29
  // didn't give us one (older client, env-stripped spawn). RAW — not
@@ -38,13 +43,22 @@ export interface BuildLineOptions {
38
43
  }
39
44
 
40
45
  function pickJoiner(style: StripStyle, separator?: string): Joiner {
41
- // [LAW:dataflow-not-control-flow] joiner choice is data-driven; one branch
42
- // per shape, no nested conditionals.
43
- if (style === "capsule") return new CapsuleJoiner();
44
- if (style === "plain") {
45
- return new PlainJoiner(separator !== undefined ? { separator } : {});
46
+ // [LAW:dataflow-not-control-flow] joiner choice is data-driven; one arm per
47
+ // shape. [LAW:types-are-the-program] Total over StripStyle — the `never`
48
+ // default makes adding a STRIP_STYLES member a compile error here until it
49
+ // gets a joiner, so the picker's domain can never offer an unrenderable shape.
50
+ switch (style) {
51
+ case "capsule":
52
+ return new CapsuleJoiner();
53
+ case "plain":
54
+ return new PlainJoiner(separator !== undefined ? { separator } : {});
55
+ case "powerline":
56
+ return new PowerlineJoiner();
57
+ default: {
58
+ const _exhaustive: never = style;
59
+ return _exhaustive;
60
+ }
46
61
  }
47
- return new PowerlineJoiner();
48
62
  }
49
63
 
50
64
  function toCell(seg: RenderedSegmentLike): RichText {
@@ -16,22 +16,22 @@ import {
16
16
  formatModelName,
17
17
  shortenModelName,
18
18
  } from "../utils/formatters.js";
19
- import { listResolvablePaletteNames, STYLE_ORDER } from "../themes/policy.js";
19
+ import { listResolvablePaletteNames, STRIP_STYLES } from "../themes/policy.js";
20
20
 
21
21
  // [LAW:one-source-of-truth] The DSL `themes()` and `styles()` bindings
22
22
  // project the SAME canonical sources the set-state validator consults
23
- // (listResolvablePaletteNames / STYLE_ORDER). A picker (or a config that
23
+ // (listResolvablePaletteNames / STRIP_STYLES). A picker (or a config that
24
24
  // `range`s over themes() to emit OSC-8 cells) iterates the allow-list the
25
25
  // validator will enforce on the resulting click — the list and the gate cannot
26
26
  // diverge because there is no second list.
27
27
  //
28
28
  // Module-init caching is correct by construction: rich-js THEMES is a
29
29
  // static import (no dynamic palette registration at runtime) and
30
- // STYLE_ORDER is a const array. The "reactivity" requirement from the
30
+ // STRIP_STYLES is a const array. The "reactivity" requirement from the
31
31
  // ticket is satisfied vacuously — the lists never change during a
32
32
  // daemon lifetime, so a cached snapshot IS the current truth.
33
33
  const THEMES_LIST: readonly string[] = listResolvablePaletteNames();
34
- const STYLES_LIST: readonly string[] = [...STYLE_ORDER];
34
+ const STYLES_LIST: readonly string[] = [...STRIP_STYLES];
35
35
 
36
36
  // Normalize an engine-supplied numeric argument. The "number" argType admits
37
37
  // both number and bigint (per @promptctl/go-template-js); the underlying
@@ -6,12 +6,16 @@
6
6
  export {
7
7
  resolvePaletteName,
8
8
  effectiveThemeName,
9
+ effectiveStripStyle,
10
+ isStripStyle,
9
11
  listResolvablePaletteNames,
10
12
  listAvailableThemes,
11
13
  pickRandomTheme,
14
+ STRIP_STYLES,
12
15
  STYLE_ORDER,
13
16
  DISPLAY_STYLES,
14
17
  } from "./policy.js";
18
+ export type { StripStyle } from "./policy.js";
15
19
 
16
20
  export {
17
21
  resolverForThemeName,
@@ -55,8 +55,43 @@ export function listAvailableThemes(): string[] {
55
55
  return [...allNames].sort();
56
56
  }
57
57
 
58
- // --- Style identifiers ---
58
+ // --- Powerline strip-style identifiers ---
59
59
 
60
+ // [LAW:one-source-of-truth][LAW:types-are-the-program] The single canonical set
61
+ // of powerline cap/separator shapes a render can take. The `StripStyle` type is
62
+ // DERIVED from this const, so the picker's option domain, the SessionState
63
+ // validator, the `styles()` template binding, and `pickJoiner`'s dispatch all
64
+ // trace to one literal — adding a shape here forces a new `pickJoiner` arm at
65
+ // compile time (the joiner switch is total over `StripStyle`). This is where the
66
+ // drift between "what you can pick" and "what actually renders" is closed.
67
+ export const STRIP_STYLES = ["powerline", "capsule", "plain"] as const;
68
+ export type StripStyle = (typeof STRIP_STYLES)[number];
69
+
70
+ // [LAW:types-are-the-program] The trust-boundary narrowing from a raw
71
+ // SessionState string (or a config default) to the closed `StripStyle` union.
72
+ export function isStripStyle(value: string): value is StripStyle {
73
+ return (STRIP_STYLES as readonly string[]).includes(value);
74
+ }
75
+
76
+ // The strip style a render should use, as data. [LAW:dataflow-not-control-flow]
77
+ // [LAW:one-type-per-behavior] The exact shape of `effectiveThemeName`, one
78
+ // dimension over: session choice over config default over the "powerline" floor,
79
+ // no "if the session has a style" branch. A value outside the domain (a stale
80
+ // SessionState entry from a prior option vocabulary) collapses to the floor —
81
+ // `pickJoiner` would render it as powerline anyway, so the floor keeps the
82
+ // returned type honest rather than silently widening.
83
+ export function effectiveStripStyle(
84
+ sessionStyle: string | null,
85
+ globalsStyle: StripStyle | undefined,
86
+ ): StripStyle {
87
+ const chosen = sessionStyle ?? globalsStyle ?? "powerline";
88
+ return isStripStyle(chosen) ? chosen : "powerline";
89
+ }
90
+
91
+ // --- Legacy palette-preset identifiers (per-session random feature only) ---
92
+ // [LAW:no-silent-failure] NOT the picker's style domain — these were the
93
+ // pre-DSL bg/fg derivation presets, surviving only as the random pool for
94
+ // session-random.ts. The interactive style picker resolves over STRIP_STYLES.
60
95
  export const STYLE_ORDER: readonly string[] = [
61
96
  "surface",
62
97
  "muted",
@@ -580,6 +580,15 @@ export class SourceRegistry {
580
580
  this.sessionState = sessionState;
581
581
  }
582
582
 
583
+ // [LAW:one-source-of-truth] The registry IS the owner of its store — every
584
+ // variable it declares lives there, and the renderer reads back through it.
585
+ // Exposing it read-only lets a caller that already holds the registry obtain
586
+ // the one store without threading a second reference that could diverge (the
587
+ // action runtime reads session.id/current values from this exact store).
588
+ get variableStore(): VariableStore {
589
+ return this.store;
590
+ }
591
+
583
592
  // ─── Synchronous source kinds ─────────────────────────────────────────────
584
593
 
585
594
  // literal: type inferred from value; box written once at declaration and never again.