@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.
- package/dist/index.mjs +86 -85
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +156 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +61 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +3 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- 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
|
+
}
|
package/src/daemon/paths.ts
CHANGED
|
@@ -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]
|
|
585
|
-
// daemon (
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
// in (not re-resolved here) because the
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
798
|
-
//
|
|
799
|
-
// No `wants` gate:
|
|
800
|
-
// config
|
|
801
|
-
theme: { effective:
|
|
802
|
-
|
|
803
|
-
|
|
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,
|