@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
@@ -0,0 +1,322 @@
1
+ // [LAW:one-source-of-truth] The daemon is the SOLE writer of
2
+ // configOverridesPath() — the hand-authored user config file is never
3
+ // machine-edited (candybar-config-engine-71o's binding guardrail). A
4
+ // persistent config write (the `persist` action, distinct from `set`'s
5
+ // per-session write) lands here; RenderCache merges it on top of the user
6
+ // file every reload (src/daemon/cache/render.ts), so this module owns only
7
+ // the read/write/shape of the override layer, never the merge.
8
+ //
9
+ // [LAW:single-enforcer] Read-modify-write + atomic rename, synchronous. Writes
10
+ // here are click-rate (rare), not render-rate (SessionState's every-render
11
+ // atom) — there is no debounce to coalesce and no in-memory cache to keep
12
+ // warm; RenderCache re-reads this file fresh on every reload, exactly as it
13
+ // re-reads the user config file. One source, read where it's needed.
14
+ //
15
+ // [LAW:one-source-of-truth] The file is ONE flat dict keyed by whatever
16
+ // string a `persist`/`reset` action names (candybar-config-engine-71o.6
17
+ // generalized this from Globals-only fields to also admit
18
+ // `segments.<name>.palette` keys — see loader/persist-target.ts, the shared
19
+ // parser both this module and cross-ref.ts classify a key through). Keeping
20
+ // ONE flat dict (rather than a nested `{globals, segments}` shape) means the
21
+ // read-modify-write/atomic-rename plumbing below never needed to change
22
+ // shape — only what keys/values count as valid grew.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import type { Globals } from "../config/dsl-types.js";
27
+ import { isGlobalsField } from "../config/loader/globals.js";
28
+ import { parsePersistTarget } from "../config/loader/persist-target.js";
29
+ import { debug } from "../utils/logger.js";
30
+ import type { DaemonLogger } from "./log.js";
31
+
32
+ const quietLogger: DaemonLogger = (_level, message) => debug(message);
33
+
34
+ // [LAW:one-source-of-truth] Re-exported so every existing importer
35
+ // (verbs/index.ts, verbs/config-validators.ts) keeps reading membership
36
+ // through this module — but the membership check itself now has exactly ONE
37
+ // implementation (loader/globals.ts's isGlobalsField, derived from
38
+ // GLOBALS_SCHEMA), not two independently-authored tables that TypeScript's
39
+ // per-table exhaustiveness only coincidentally kept in agreement.
40
+ export { isGlobalsField } from "../config/loader/globals.js";
41
+
42
+ // [LAW:types-are-the-program] Every Globals field's primitive WIRE TYPE,
43
+ // keyed by `keyof Globals` — TypeScript forces this map to stay total over
44
+ // Globals, so a field added to/removed from that interface is a compile
45
+ // error here until this table is updated. This is the ONE place a `persist`
46
+ // write's canonical string is coerced to the JS type Globals actually
47
+ // declares (padding: number, autoWrap: boolean, everything else: string). A
48
+ // segment-palette target has no matching row: it's always a NAME, so its
49
+ // kind is "string" unconditionally — see coercePersistValue below. Membership
50
+ // (which keys exist) is NOT re-declared here — see the re-exported
51
+ // isGlobalsField above; this table only adds the per-field KIND membership
52
+ // alone doesn't carry.
53
+ const GLOBALS_FIELD_KIND: Readonly<
54
+ Record<keyof Globals, "string" | "number" | "boolean">
55
+ > = {
56
+ default_bg: "string",
57
+ default_fg: "string",
58
+ default_empty_value: "string",
59
+ default_separator: "string",
60
+ default_truncate_marker: "string",
61
+ palette: "string",
62
+ look: "string",
63
+ // The active arrangement — a NAME like palette/look, so `persist: "preset"`
64
+ // makes a chosen preset the default every future session opens in.
65
+ preset: "string",
66
+ style: "string",
67
+ autoWrap: "boolean",
68
+ padding: "number",
69
+ charset: "string",
70
+ colorCompatibility: "string",
71
+ };
72
+
73
+ // [LAW:one-source-of-truth] The same four canonical boolean-ish inputs
74
+ // validateBoolean (state-validators.ts) accepts — a `persist` action's gate
75
+ // is an ALLOW-LIST (the declared `to`/`cycle` members pass through
76
+ // membership-checked but otherwise VERBATIM, unlike validateBoolean's own
77
+ // bespoke normalization), so a config author writing `cycle: ["true",
78
+ // "false"]` or `to: "0"` reaches this boundary with the raw member string,
79
+ // not a pre-canonicalized "1"/"". This is the ONE place that must accept the
80
+ // full accepted-input set, not just the canonical pair.
81
+ const BOOLEAN_TRUTHY = new Set(["1", "true"]);
82
+ const BOOLEAN_FALSY = new Set(["0", "false", ""]);
83
+
84
+ // [LAW:no-silent-fallbacks] The `persist` write's validator canonicalizes to
85
+ // a STRING (the same wire currency `set` uses) — this is the boundary that
86
+ // lifts it into the typed value its scope declares. An out-of-range/non-
87
+ // numeric string for a "number" Globals field is a caller bug (the range
88
+ // validator already canonicalized it), so it throws loudly rather than
89
+ // writing a silently-wrong type into the overrides file. Replaces the old
90
+ // Globals-only `coerceGlobalsValue`: a bare `string` key (not `keyof
91
+ // Globals`) so a caller no longer needs a type-narrowing assertion before
92
+ // calling this — parsePersistTarget does the classification internally.
93
+ export function coercePersistValue(
94
+ key: string,
95
+ raw: string,
96
+ ): string | number | boolean {
97
+ const target = parsePersistTarget(key);
98
+ if (target === null) {
99
+ throw new Error(
100
+ `coercePersistValue: "${key}" is not a valid persist target`,
101
+ );
102
+ }
103
+ if (target.scope === "segment-palette") return raw;
104
+ const kind = GLOBALS_FIELD_KIND[target.field];
105
+ if (kind === "string") return raw;
106
+ if (kind === "number") {
107
+ const n = Number(raw);
108
+ if (!Number.isFinite(n)) {
109
+ throw new Error(
110
+ `coercePersistValue: "${key}" expects a number, got "${raw}"`,
111
+ );
112
+ }
113
+ return n;
114
+ }
115
+ if (BOOLEAN_TRUTHY.has(raw)) return true;
116
+ if (BOOLEAN_FALSY.has(raw)) return false;
117
+ throw new Error(
118
+ `coercePersistValue: "${key}" expects boolean-ish (1, 0, true, false), got "${raw}"`,
119
+ );
120
+ }
121
+
122
+ // [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
123
+ // override set is the *defined* recovery (identical to a first-ever boot),
124
+ // not a hidden fallback to different data. Every present key is classified
125
+ // through parsePersistTarget (globals field or segment-palette) and its
126
+ // value checked against that target's kind; a single malformed entry drops
127
+ // the WHOLE file back to empty (mirrors FileSessionStorage's all-or-nothing
128
+ // shape check) rather than guessing which entries to keep.
129
+ function isValidOverrides(
130
+ value: unknown,
131
+ ): value is Readonly<Record<string, string | number | boolean>> {
132
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
133
+ return false;
134
+ }
135
+ for (const [key, v] of Object.entries(value)) {
136
+ const target = parsePersistTarget(key);
137
+ if (target === null) return false;
138
+ const kind =
139
+ target.scope === "segment-palette"
140
+ ? "string"
141
+ : GLOBALS_FIELD_KIND[target.field];
142
+ if (kind === "number" && typeof v !== "number") return false;
143
+ if (kind === "boolean" && typeof v !== "boolean") return false;
144
+ if (kind === "string" && typeof v !== "string") return false;
145
+ }
146
+ return true;
147
+ }
148
+
149
+ // [LAW:single-enforcer] The ONE reader of the on-disk shape — every scoped
150
+ // view (loadConfigOverrides, loadSegmentPaletteOverrides) and every writer
151
+ // (writeConfigOverride, clearConfigOverride) reads through this, so the
152
+ // flat-dict shape and its recovery-to-empty behavior are decided exactly once.
153
+ function loadRawOverrides(
154
+ filePath: string,
155
+ logger: DaemonLogger,
156
+ ): Readonly<Record<string, string | number | boolean>> {
157
+ let raw: string;
158
+ try {
159
+ raw = fs.readFileSync(filePath, "utf8");
160
+ } catch (e) {
161
+ const code = (e as NodeJS.ErrnoException).code;
162
+ if (code !== "ENOENT") {
163
+ logger("warn", `config-overrides read failed (${code}); starting empty`);
164
+ }
165
+ return {};
166
+ }
167
+ try {
168
+ const parsed: unknown = JSON.parse(raw);
169
+ if (isValidOverrides(parsed)) return parsed;
170
+ logger("warn", `config-overrides load: unexpected shape, starting empty`);
171
+ return {};
172
+ } catch {
173
+ logger("warn", `config-overrides load: corrupt JSON, starting empty`);
174
+ return {};
175
+ }
176
+ }
177
+
178
+ // [LAW:one-source-of-truth] The Globals-scoped VIEW of a raw dict — kept as
179
+ // `Partial<Globals>` so every existing caller (RenderCache's
180
+ // mergeWithDefault({globals: ...}), stepConfig's range-seed lookup) keeps its
181
+ // original, precisely-typed contract unchanged. Segment-palette entries in
182
+ // the same file are invisible here by construction (isGlobalsField filters
183
+ // them out) — see projectSegmentPaletteOverrides for that half. A pure
184
+ // projection over an already-read dict (not a filePath) so a caller wanting
185
+ // BOTH views (loadOverrides below) pays for exactly one read.
186
+ //
187
+ // [LAW:no-defensive-null-guards] exception: `Object.create(null)` — the key
188
+ // being assigned comes from the on-disk overrides file, which a `persist`
189
+ // write only ever populates from a real Globals field name (isGlobalsField
190
+ // already excludes "__proto__"), but the accumulator itself gets the same
191
+ // null-prototype hygiene src/dsl/render.ts's segment-keyed accumulator uses
192
+ // ("segment names come from user config; a null-prototype object prevents
193
+ // __proto__/constructor/prototype from being treated as segment data") —
194
+ // one guard at the object, not a per-caller property-name check.
195
+ function projectGlobalsOverrides(
196
+ raw: Readonly<Record<string, string | number | boolean>>,
197
+ ): Partial<Globals> {
198
+ const out: Record<string, string | number | boolean> = Object.create(
199
+ null,
200
+ ) as Record<string, string | number | boolean>;
201
+ for (const [key, value] of Object.entries(raw)) {
202
+ if (isGlobalsField(key)) out[key] = value;
203
+ }
204
+ // [LAW:no-silent-fallbacks] exception: isValidOverrides already proved every
205
+ // entry's runtime kind matches its target's declared kind (GLOBALS_FIELD_KIND)
206
+ // before it ever reached the file — this cast states that proof, it doesn't
207
+ // paper over an unchecked one.
208
+ return out as Partial<Globals>;
209
+ }
210
+
211
+ // [LAW:one-source-of-truth] The segment-palette-scoped VIEW of the SAME raw
212
+ // dict — segment name -> persisted palette name. RenderCache overlays this
213
+ // onto the already-merged config's `segments[name].palette` field
214
+ // (applySegmentPaletteOverrides in config/loader/merge.ts), never through
215
+ // mergeWithDefault's wholesale per-name segment replacement.
216
+ //
217
+ // [LAW:no-defensive-null-guards] exception: `Object.create(null)` — unlike
218
+ // projectGlobalsOverrides, the assigned key here (`target.segment`) is NOT
219
+ // membership-checked against any closed set before the write (any string a
220
+ // config declares as a segment name is legal), so a segment genuinely named
221
+ // `__proto__` would otherwise hit the prototype setter on `out[key] =` —
222
+ // the exact crash class the render.ts precedent (see above) already guards
223
+ // against for segment-keyed objects.
224
+ function projectSegmentPaletteOverrides(
225
+ raw: Readonly<Record<string, string | number | boolean>>,
226
+ ): Readonly<Record<string, string>> {
227
+ const out: Record<string, string> = Object.create(null) as Record<
228
+ string,
229
+ string
230
+ >;
231
+ for (const [key, value] of Object.entries(raw)) {
232
+ const target = parsePersistTarget(key);
233
+ if (target?.scope === "segment-palette" && typeof value === "string") {
234
+ out[target.segment] = value;
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ export function loadConfigOverrides(
241
+ filePath: string,
242
+ logger: DaemonLogger = quietLogger,
243
+ ): Partial<Globals> {
244
+ return projectGlobalsOverrides(loadRawOverrides(filePath, logger));
245
+ }
246
+
247
+ export function loadSegmentPaletteOverrides(
248
+ filePath: string,
249
+ logger: DaemonLogger = quietLogger,
250
+ ): Readonly<Record<string, string>> {
251
+ return projectSegmentPaletteOverrides(loadRawOverrides(filePath, logger));
252
+ }
253
+
254
+ // [LAW:carrying-cost] RenderCache wants BOTH views on every reload
255
+ // (buildState merges globals overrides, then overlays segment-palette
256
+ // overrides) — calling loadConfigOverrides + loadSegmentPaletteOverrides
257
+ // back to back would read, parse, and shape-validate the same tiny file
258
+ // twice per reload for no reason. One read, two projections.
259
+ export interface Overrides {
260
+ readonly globals: Partial<Globals>;
261
+ readonly segmentPalette: Readonly<Record<string, string>>;
262
+ }
263
+
264
+ export function loadOverrides(
265
+ filePath: string,
266
+ logger: DaemonLogger = quietLogger,
267
+ ): Overrides {
268
+ const raw = loadRawOverrides(filePath, logger);
269
+ return {
270
+ globals: projectGlobalsOverrides(raw),
271
+ segmentPalette: projectSegmentPaletteOverrides(raw),
272
+ };
273
+ }
274
+
275
+ // [LAW:no-silent-failure] Atomic write shared by set/clear: read the current
276
+ // overrides, apply one mutation, write-to-temp + rename. Owner-only mode,
277
+ // matching every other daemon runtime file (session-state.json, pid, lease).
278
+ // Unlike session-state.json's debounced best-effort flush (no synchronous
279
+ // caller waiting on it), a `persist` write is directly caused by a click that
280
+ // expects a truthful ack — a swallowed failure here would let the verb
281
+ // handler log "set-config: ..." as if it landed when nothing was written.
282
+ // Logs at "error" for the daemon-log breadcrumb, then RETHROWS so the caller
283
+ // (the click) fails loudly instead of claiming a success that didn't happen.
284
+ function writeOverrides(
285
+ filePath: string,
286
+ overrides: Readonly<Record<string, string | number | boolean>>,
287
+ logger: DaemonLogger,
288
+ ): void {
289
+ try {
290
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
291
+ const tmp = `${filePath}.tmp`;
292
+ fs.writeFileSync(tmp, JSON.stringify(overrides), { mode: 0o600 });
293
+ fs.chmodSync(tmp, 0o600);
294
+ fs.renameSync(tmp, filePath);
295
+ } catch (e) {
296
+ const message = `config-overrides write failed: ${(e as Error).message}`;
297
+ logger("error", message);
298
+ throw new Error(message);
299
+ }
300
+ }
301
+
302
+ export function writeConfigOverride(
303
+ filePath: string,
304
+ key: string,
305
+ value: string | number | boolean,
306
+ logger: DaemonLogger = quietLogger,
307
+ ): void {
308
+ const overrides = loadRawOverrides(filePath, logger);
309
+ writeOverrides(filePath, { ...overrides, [key]: value }, logger);
310
+ }
311
+
312
+ export function clearConfigOverride(
313
+ filePath: string,
314
+ key: string,
315
+ logger: DaemonLogger = quietLogger,
316
+ ): void {
317
+ const overrides = loadRawOverrides(filePath, logger);
318
+ if (!(key in overrides)) return;
319
+ const next = { ...overrides };
320
+ delete next[key];
321
+ writeOverrides(filePath, next, logger);
322
+ }
@@ -149,6 +149,16 @@ export function sessionStatePath(): string {
149
149
  return path.join(stateDir(), "session-state.json");
150
150
  }
151
151
 
152
+ // [LAW:one-source-of-truth] The daemon-owned overrides layer for persistent
153
+ // config writes (candybar-config-engine-71o.2): a click that mutates the
154
+ // bundled/user-file DEFAULT (as opposed to `set`, which mutates per-session
155
+ // state) lands here — never in the hand-authored config file itself. Sibling
156
+ // of sessionStatePath(): same root, same single-writer daemon, same test
157
+ // isolation via XDG_STATE_HOME/CC_CANDYBAR_SOCKET-derived overrides.
158
+ export function configOverridesPath(): string {
159
+ return path.join(stateDir(), "config-overrides.json");
160
+ }
161
+
152
162
  // [LAW:one-source-of-truth] The fork-bomb breaker's daemon-population registry
153
163
  // (fork-bomb-breaker.ts) shares socketPath()'s UID-anchored /tmp root and, like
154
164
  // it, deliberately ignores XDG_STATE_HOME — the very isolation
@@ -33,6 +33,40 @@ import type { ContextProvider } from "../segments/context.js";
33
33
  import type { MetricsProvider } from "../segments/metrics.js";
34
34
  import type { TmuxService } from "../segments/tmux.js";
35
35
  import type { GitDataProvider } from "./cache/git.js";
36
+ import type {
37
+ Charset,
38
+ ColorCompatibility,
39
+ StripStyle,
40
+ } from "../themes/policy.js";
41
+
42
+ // ─── Effective globals ─────────────────────────────────────────────────────
43
+
44
+ // [LAW:one-source-of-truth] The daemon resolves each of these exactly ONCE
45
+ // per render (server.ts, before both the payload build and renderDsl's
46
+ // BuildLineOptions), so the value a trigger label displays and the value
47
+ // that actually shaped the render can never disagree — the same reasoning
48
+ // theme/look already followed, generalized to every globals field a menu or
49
+ // stepper can persist. `theme`/`look`/`style` compose SessionState over the
50
+ // config default (a session pick can diverge from the persisted default for
51
+ // its own session); `charset`/`colorCompatibility`/`autoWrap`/`padding` have
52
+ // no SessionState half today, so their "effective" value is just the
53
+ // resolved config global over its floor constant.
54
+ export interface EffectiveGlobals {
55
+ readonly theme: string;
56
+ readonly look: string;
57
+ // The active PRESET name — effectivePresetName(sessionState.preset,
58
+ // globals.preset, presets), collapsed to the floor if stale. Unlike every
59
+ // other field here it is not itself a display value: it is the name of the
60
+ // fragment whose `globals` were merged to PRODUCE the rest of this struct,
61
+ // carried alongside so a menu label states the arrangement that actually
62
+ // rendered [LAW:one-source-of-truth].
63
+ readonly preset: string;
64
+ readonly style: StripStyle;
65
+ readonly charset: Charset;
66
+ readonly colorCompatibility: ColorCompatibility;
67
+ readonly autoWrap: boolean;
68
+ readonly padding: number;
69
+ }
36
70
 
37
71
  // ─── Augmented payload shape ─────────────────────────────────────────────────
38
72
 
@@ -69,6 +103,29 @@ export interface RenderPayload extends ClaudeHookData {
69
103
  // rendered palette, surfaced so a trigger label can display the active look.
70
104
  // Required for the same reason as theme: resolved unconditionally per render.
71
105
  readonly look: { readonly effective: string };
106
+ // [LAW:one-type-per-behavior] The daemon-resolved effective PRESET name —
107
+ // effectivePresetName(sessionState.preset, globals.preset, presets) — theme
108
+ // and look's twin one level up: the SAME name that selected the layout this
109
+ // render walked and the globals it rendered with, surfaced so a preset
110
+ // trigger's label can never claim an arrangement the bar is not in.
111
+ readonly preset: { readonly effective: string };
112
+ // [LAW:one-type-per-behavior] style/charset/colorCompatibility/autoWrap/
113
+ // padding are theme/look's twins over the remaining persistable globals
114
+ // (candybar-config-engine-71o.3) — each REQUIRED and unconditionally
115
+ // present for the same reason: the daemon already resolves the value for
116
+ // BuildLineOptions every render, and this is that exact value, so a
117
+ // trigger's "current selection" highlight and the render it describes
118
+ // trace to one resolution. See EffectiveGlobals for how each is derived.
119
+ // [LAW:types-are-the-program] Unlike theme/look (open, registry-extensible
120
+ // names with no closed union to narrow to), style/charset/colorCompatibility
121
+ // DO have one (StripStyle/Charset/ColorCompatibility) — narrowed to it
122
+ // rather than widened to `string`, so a downstream `switch` over these
123
+ // fields gets real exhaustiveness checking.
124
+ readonly style: { readonly effective: StripStyle };
125
+ readonly charset: { readonly effective: Charset };
126
+ readonly colorCompatibility: { readonly effective: ColorCompatibility };
127
+ readonly autoWrap: { readonly effective: boolean };
128
+ readonly padding: { readonly effective: number };
72
129
 
73
130
  // Usage-family. Each provider returns null when it has no data (no
74
131
  // transcript yet, no rate-limit window active, etc.); we drop the field
@@ -99,6 +156,10 @@ export interface RenderPayload extends ClaudeHookData {
99
156
  // between "no stashes" and "stash count unknown because git failed".
100
157
  export interface GitPayload {
101
158
  readonly repoName?: string;
159
+ // The repo's browsable web page (an https/http URL, credentials stripped),
160
+ // derived from the same remotes read `repoName` is. Missing = the repo has no
161
+ // remote a browser can open, so a template gates its link on `ne … ""`.
162
+ readonly repoUrl?: string;
102
163
  readonly branch?: string;
103
164
  readonly sha?: string;
104
165
  readonly ahead?: number;
@@ -537,6 +598,7 @@ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
537
598
  ...(has("git.stash") && { showStashCount: true }),
538
599
  ...(has("git.upstream") && { showUpstream: true }),
539
600
  ...(has("git.repoName") && { showRepoName: true }),
601
+ ...(has("git.repoUrl") && { showRepoUrl: true }),
540
602
  ...(has("git.operation") && { showOperation: true }),
541
603
  ...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
542
604
  // Any PR field laid out turns on the (network) forge lookup. Keep these in
@@ -581,18 +643,14 @@ export async function buildRenderPayload(
581
643
  // registration; passing it in (rather than recomputing per render) keeps
582
644
  // the hot path free of the BFS + extractTemplateRefs cost.
583
645
  neededInputPaths: ReadonlySet<string>,
584
- // [LAW:one-source-of-truth] The effective theme name, resolved ONCE by the
585
- // daemon (effectiveThemeName(sessionState.theme, globals.palette)) and used
586
- // for BOTH the rendered basePalette and this payload field so a trigger
587
- // label reading `.theme.effective` can never disagree with the colors. Passed
588
- // in (not re-resolved here) because the daemon already computes it for the
589
- // palette; this is that same value, threaded to the sole payload assembler.
590
- effectiveTheme: string,
591
- // [LAW:one-source-of-truth] The effective look name, resolved ONCE by the
592
- // daemon beside the theme (effectiveLookName over SessionState/globals/looks)
593
- // and used for BOTH the rendered adaptation and this payload field — so a
594
- // trigger label reading `.look.effective` can never disagree with the colors.
595
- effectiveLook: string,
646
+ // [LAW:one-source-of-truth] Every globals field a menu/stepper can persist,
647
+ // resolved ONCE by the daemon (server.ts, before both this call and the
648
+ // BuildLineOptions it renders with) and used for BOTH the actual render AND
649
+ // these payload fields — so a trigger label can never disagree with what
650
+ // was actually rendered. Passed in (not re-resolved here) because the
651
+ // daemon already computes every one of these for renderDsl's options; this
652
+ // is that same struct, threaded to the sole payload assembler.
653
+ effective: EffectiveGlobals,
596
654
  ): Promise<RenderPayload> {
597
655
  const wants = (prefix: string): boolean =>
598
656
  anyPathStartsWith(neededInputPaths, prefix);
@@ -794,13 +852,18 @@ export async function buildRenderPayload(
794
852
  ...(home !== undefined && { home }),
795
853
  ...(gitProjection.git !== undefined && { git: gitProjection.git }),
796
854
  ...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
797
- // [LAW:one-source-of-truth] Always present — the daemon resolves the
798
- // effective theme every render (for basePalette), and this is that value.
799
- // No `wants` gate: it costs nothing (a string already in hand) and a
800
- // config that reads `.theme.effective` must always find it.
801
- theme: { effective: effectiveTheme },
802
- // Same contract as theme: always present, a string already in hand.
803
- look: { effective: effectiveLook },
855
+ // [LAW:one-source-of-truth] Always present — the daemon resolves every
856
+ // one of these each render (for BuildLineOptions/basePalette), and these
857
+ // are those exact values. No `wants` gate: each costs nothing (already in
858
+ // hand) and a config reading e.g. `.padding.effective` must always find it.
859
+ theme: { effective: effective.theme },
860
+ look: { effective: effective.look },
861
+ preset: { effective: effective.preset },
862
+ style: { effective: effective.style },
863
+ charset: { effective: effective.charset },
864
+ colorCompatibility: { effective: effective.colorCompatibility },
865
+ autoWrap: { effective: effective.autoWrap },
866
+ padding: { effective: effective.padding },
804
867
  ...(sessionPayload !== undefined && { session: sessionPayload }),
805
868
  ...(todayPayload !== undefined && { today: todayPayload }),
806
869
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
@@ -904,6 +967,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
904
967
  const stash = field("stash", info.stashCount);
905
968
  const upstream = field("upstream", info.upstream);
906
969
  const repoName = field("repoName", info.repoName);
970
+ const repoUrl = field("repoUrl", info.repoUrl);
907
971
 
908
972
  // [LAW:no-silent-failure] The PR deliberately breaks the `field` pattern: a
909
973
  // `failed` lookup is NOT dropped to a missing key (which the template can't
@@ -947,6 +1011,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
947
1011
  ...(stash !== undefined && { stash }),
948
1012
  ...(upstream !== undefined && { upstream }),
949
1013
  ...(repoName !== undefined && { repoName }),
1014
+ ...(repoUrl !== undefined && { repoUrl }),
950
1015
  ...prFields,
951
1016
  },
952
1017
  failures,