@promptctl/cc-candybar 1.34.1 → 1.36.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 +88 -88
- package/package.json +5 -5
- package/src/check.ts +5 -0
- package/src/config/default-dsl-config.ts +59 -0
- package/src/config/dsl-loader.ts +20 -1
- package/src/config/edit-chrome.ts +62 -22
- package/src/config/loader/cross-ref.ts +44 -0
- package/src/config/loader/edit-mode.ts +14 -0
- package/src/config/settings-menu.ts +373 -0
- package/src/daemon/client.ts +6 -3
- package/src/daemon/protocol.ts +62 -5
- package/src/daemon/render-payload.ts +120 -0
- package/src/daemon/server.ts +13 -8
- package/src/index.ts +34 -4
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] candybar-settings-ui-aok.1 — THE global settings
|
|
2
|
+
// menu: one disclosure that every rendered bar carries, whatever the config
|
|
3
|
+
// says. It exists because `root` replaces wholesale: a user who writes `root:`
|
|
4
|
+
// (the ordinary reason to write a config at all) silently deletes every
|
|
5
|
+
// interactive surface the bundled default placed there — presets, edit mode,
|
|
6
|
+
// the value controls. A door the user can delete by accident is not a door.
|
|
7
|
+
//
|
|
8
|
+
// [LAW:dataflow-not-control-flow] Placement is a POSITION, never a mode. The
|
|
9
|
+
// synthesis runs the same two total functions on every preset root, in the same
|
|
10
|
+
// order, every load: `withAnchor` yields a tree that CONTAINS the anchor — the
|
|
11
|
+
// author's own placement untouched, or the default position appended — and
|
|
12
|
+
// `expandAnchor` replaces that one leaf with the lowered disclosure subtree.
|
|
13
|
+
// "The author placed it" and "the author did not" differ only in the VALUE
|
|
14
|
+
// handed to one splice; there is no second code path to keep in agreement.
|
|
15
|
+
//
|
|
16
|
+
// [LAW:one-type-per-behavior] Nothing here is a new render or interaction
|
|
17
|
+
// concept. The menu is the disclosure primitive's fourth instance, alongside
|
|
18
|
+
// group sugar, `{{ menu }}`, and edit mode's toggle: it calls the SAME
|
|
19
|
+
// `disclosureStateVar`/`disclosureCycleAction`/`menuStateKey` functions those
|
|
20
|
+
// three call, so a synthesized global menu and a hand-authored group are
|
|
21
|
+
// indistinguishable to the render walk.
|
|
22
|
+
//
|
|
23
|
+
// WHY THIS RUNS FROM validateConfig, BEFORE synthesizeEditChrome — the two
|
|
24
|
+
// passes both rewrite every preset root, so their order is a real decision:
|
|
25
|
+
// • It cannot run at parse time (loader/*.ts) like group/menu synthesis,
|
|
26
|
+
// because the tree it must splice into only exists after merge: the user's
|
|
27
|
+
// `root` replaces the bundled default's, and it is the MERGED root the menu
|
|
28
|
+
// has to be present in.
|
|
29
|
+
// • It runs BEFORE edit chrome so edit chrome walks the final content tree.
|
|
30
|
+
// Every name minted here lives under the reserved `settings.` namespace,
|
|
31
|
+
// which `isChromeExempt` excludes, so the menu never acquires a `+`/`-`
|
|
32
|
+
// affordance and can never be edited out of the bar it is the entry point
|
|
33
|
+
// to. Running after would splice the menu into an already-chromed tree,
|
|
34
|
+
// landing it between a segment and the `-` that removes it.
|
|
35
|
+
// • It also GUARANTEES `edit.toggle` (see ensureEditToggle below), which is
|
|
36
|
+
// precisely what edit chrome's own demand gate reads — so the ordering is
|
|
37
|
+
// load-bearing in that direction too, not merely tidy.
|
|
38
|
+
|
|
39
|
+
import type { ActionDecl } from "./action.js";
|
|
40
|
+
import type {
|
|
41
|
+
DslConfig,
|
|
42
|
+
LayoutNode,
|
|
43
|
+
PresetDecl,
|
|
44
|
+
SegmentDecl,
|
|
45
|
+
VariableDecl,
|
|
46
|
+
} from "./dsl-types.js";
|
|
47
|
+
import {
|
|
48
|
+
DISCLOSURE_CLOSED,
|
|
49
|
+
DISCLOSURE_GLYPH_CLOSED,
|
|
50
|
+
DISCLOSURE_GLYPH_OPEN,
|
|
51
|
+
disclosureCycleAction,
|
|
52
|
+
disclosureStateVar,
|
|
53
|
+
} from "./disclosure.js";
|
|
54
|
+
import {
|
|
55
|
+
EDIT_MODE_KEY,
|
|
56
|
+
EDIT_MODE_OPEN,
|
|
57
|
+
EDIT_TOGGLE_ACTION,
|
|
58
|
+
} from "./loader/edit-mode.js";
|
|
59
|
+
import {
|
|
60
|
+
menuActionName,
|
|
61
|
+
menuMember,
|
|
62
|
+
menuPageKey,
|
|
63
|
+
menuStateKey,
|
|
64
|
+
} from "./menu-keys.js";
|
|
65
|
+
import { presetByName, presetNames, presetRoot } from "./presets.js";
|
|
66
|
+
|
|
67
|
+
// [LAW:one-source-of-truth] The reserved namespace every artifact this pass
|
|
68
|
+
// mints lives under, mirroring `groups.`/`menus.`/`edit.`. Reserved at parse
|
|
69
|
+
// time (reservedNamespaceCollisions, from dsl-loader's validateTopLevel) so a
|
|
70
|
+
// user name under it is a loud load error rather than a silent shadowing of
|
|
71
|
+
// the one surface they cannot afford to lose.
|
|
72
|
+
export const SETTINGS_NS = "settings.";
|
|
73
|
+
|
|
74
|
+
// [LAW:one-source-of-truth] THE anchor: one string that is simultaneously the
|
|
75
|
+
// segment name an author places in `root` to choose the menu's position, the
|
|
76
|
+
// name of the toggle segment the synthesis puts there, the disclosure's state
|
|
77
|
+
// variable, and its cycle action. Group sugar already spans those four with one
|
|
78
|
+
// `groups.<name>` string for the same reason — one name means the toggle's
|
|
79
|
+
// click and the body's `when` cannot address different keys.
|
|
80
|
+
export const SETTINGS_ANCHOR = `${SETTINGS_NS}menu`;
|
|
81
|
+
|
|
82
|
+
// The disclosure's open member. Same spelling edit mode uses for its own binary
|
|
83
|
+
// toggle — a binary disclosure holds the CLOSED sentinel or this.
|
|
84
|
+
const SETTINGS_OPEN = EDIT_MODE_OPEN;
|
|
85
|
+
|
|
86
|
+
// The body's two content segments and the preset picker's apply action. `.1`
|
|
87
|
+
// scopes the body to what its acceptance names — switch presets, enter edit
|
|
88
|
+
// mode. The remaining display controls arrive with the config menu (`.3`),
|
|
89
|
+
// which is the child that owns them.
|
|
90
|
+
const PRESETS_SEG = `${SETTINGS_NS}presets`;
|
|
91
|
+
const EDIT_SEG = `${SETTINGS_NS}edit`;
|
|
92
|
+
const APPLY_PRESET_ACTION = `${SETTINGS_NS}applyPreset`;
|
|
93
|
+
|
|
94
|
+
// [LAW:one-source-of-truth] The predicate the body container gates on, derived
|
|
95
|
+
// from the same anchor string the toggle's cycle writes — spelled once here,
|
|
96
|
+
// exactly as lowerGroup derives a group body's `when` from the group's own
|
|
97
|
+
// reference name.
|
|
98
|
+
const SETTINGS_OPEN_GATE = `{{ eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}" }}`;
|
|
99
|
+
|
|
100
|
+
// [LAW:single-enforcer] The one answer to "is this segment reference the global
|
|
101
|
+
// menu's anchor". cross-ref.ts asks it to accept an authored placement of a name
|
|
102
|
+
// no config declares (this pass provides it, unconditionally, immediately after
|
|
103
|
+
// cross-ref passes), and to reject a SECOND placement — one key holds one open
|
|
104
|
+
// state, so two anchors would be two toggles writing one disclosure.
|
|
105
|
+
export function isSettingsAnchor(segmentName: string): boolean {
|
|
106
|
+
return segmentName === SETTINGS_ANCHOR;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── The anchored-root stamp ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
declare const anchored: unique symbol;
|
|
112
|
+
|
|
113
|
+
// [LAW:parse-dont-validate] A tree that is KNOWN to contain the anchor. The
|
|
114
|
+
// stamp is the proof, so `expandAnchor` has no "anchor missing" arm to guard
|
|
115
|
+
// and no answer-shaped void to return: the only way to obtain this type is to
|
|
116
|
+
// go through `withAnchor`, which establishes the fact by construction.
|
|
117
|
+
//
|
|
118
|
+
// The theorem includes the anchor inheriting no gate the DEFAULT placement
|
|
119
|
+
// descended into — a weaker stamp ("contains an anchor" alone) is what let a
|
|
120
|
+
// `when`-gated first row silently swallow the menu. Two gates are exempt
|
|
121
|
+
// because they are explicit authorial statements rather than accidents: the
|
|
122
|
+
// author's own placement of the anchor (they chose that position, gate and
|
|
123
|
+
// all) and a `when` on the root itself (there is no bar at all under that
|
|
124
|
+
// condition, so there is nothing to host a menu on).
|
|
125
|
+
type AnchoredRoot = LayoutNode & { readonly [anchored]: true };
|
|
126
|
+
|
|
127
|
+
// [LAW:dataflow-not-control-flow] The default position, as structural recursion
|
|
128
|
+
// over the LayoutNode union rather than a placement mode: descend to the bar's
|
|
129
|
+
// FIRST horizontal row and append there — where the bundled default's own
|
|
130
|
+
// settings affordance already sits, and the place a one-row user config puts
|
|
131
|
+
// everything. Total over every tree shape, including the degenerate ones: a
|
|
132
|
+
// bare-segment root (the A-grammar collapses a lone top-level ref) grows a
|
|
133
|
+
// horizontal wrapper, and an empty container simply becomes the row.
|
|
134
|
+
function appendAnchor(node: LayoutNode): LayoutNode {
|
|
135
|
+
const anchorRef: LayoutNode = { kind: "segment", name: SETTINGS_ANCHOR };
|
|
136
|
+
if (node.kind === "segment") {
|
|
137
|
+
// [LAW:no-silent-failure] A bare-segment root may carry its OWN `when` — an
|
|
138
|
+
// author gating their whole bar behind a condition. This wrapper is a brand
|
|
139
|
+
// new node, so without carrying that gate up, everything spliced beside the
|
|
140
|
+
// segment (this menu, and the reset banner edit chrome later prepends by
|
|
141
|
+
// reading `splicedRoot.when`) would render past a gate the author wrote.
|
|
142
|
+
// The identical carry-up spliceEditChromeForPreset performs, one pass over.
|
|
143
|
+
return {
|
|
144
|
+
kind: "container",
|
|
145
|
+
direction: "horizontal",
|
|
146
|
+
children: [node, anchorRef],
|
|
147
|
+
...(node.when !== undefined && { when: node.when }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const [first, ...rest] = node.children;
|
|
151
|
+
// [LAW:no-silent-failure] Descend only into an UNGATED child. A gate on an
|
|
152
|
+
// inner row is a statement about that row's content, not about the bar — an
|
|
153
|
+
// author writing an ordinary conditional first row (a git row shown only
|
|
154
|
+
// inside a repo) has no idea the default placement attaches the menu there,
|
|
155
|
+
// and inheriting that gate would silently delete the one surface this pass
|
|
156
|
+
// exists to make undeletable, under exactly their condition. When the first
|
|
157
|
+
// row is gated the anchor becomes its own ungated row on this container
|
|
158
|
+
// instead, which is a position the author can still override by placing the
|
|
159
|
+
// anchor themselves.
|
|
160
|
+
//
|
|
161
|
+
// The ROOT's own `when` is deliberately NOT lifted out of, here or in the
|
|
162
|
+
// segment arm above: gating the whole tree is an explicit statement that
|
|
163
|
+
// there is no bar under this condition, and there is no bar to host a menu
|
|
164
|
+
// on. That is the same "the author's explicit choice is the answer" rule
|
|
165
|
+
// that honors an author-placed anchor inside a gated row — and it is what
|
|
166
|
+
// keeps edit chrome's reset banner gated with the content it describes.
|
|
167
|
+
if (
|
|
168
|
+
node.direction === "vertical" &&
|
|
169
|
+
first !== undefined &&
|
|
170
|
+
first.when === undefined
|
|
171
|
+
) {
|
|
172
|
+
return { ...node, children: [appendAnchor(first), ...rest] };
|
|
173
|
+
}
|
|
174
|
+
return { ...node, children: [...node.children, anchorRef] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// [LAW:parse-dont-validate] The checkpoint: in, a tree that may or may not name
|
|
178
|
+
// the anchor; out, a tree that provably does. The author's placement passes
|
|
179
|
+
// through byte-identical — the position they chose IS the answer — and its
|
|
180
|
+
// absence is answered with the default position. One value, two sources.
|
|
181
|
+
function withAnchor(node: LayoutNode): AnchoredRoot {
|
|
182
|
+
const placed = countAnchors(node) > 0 ? node : appendAnchor(node);
|
|
183
|
+
return placed as AnchoredRoot;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// [LAW:single-enforcer] THE anchor census, read by both consumers of the count:
|
|
187
|
+
// `withAnchor` (is there a placement to honor?) and the loader's duplicate check
|
|
188
|
+
// (is there more than one?). One traversal definition, so "placed" cannot mean
|
|
189
|
+
// different things to the two.
|
|
190
|
+
export function countAnchors(node: LayoutNode): number {
|
|
191
|
+
if (node.kind === "segment") return isSettingsAnchor(node.name) ? 1 : 0;
|
|
192
|
+
return node.children.reduce((n, child) => n + countAnchors(child), 0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// [LAW:one-type-per-behavior] The lowering, identical in shape to lowerGroup's:
|
|
196
|
+
// a vertical pair of the toggle segment and a `when`-gated body. Replaces the
|
|
197
|
+
// anchor leaf wherever it sits, so the author's chosen position is the menu's
|
|
198
|
+
// position with nothing else moved.
|
|
199
|
+
function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
|
|
200
|
+
if (node.kind === "segment") {
|
|
201
|
+
return isSettingsAnchor(node.name)
|
|
202
|
+
? {
|
|
203
|
+
kind: "container",
|
|
204
|
+
direction: "vertical",
|
|
205
|
+
children: [
|
|
206
|
+
node,
|
|
207
|
+
{
|
|
208
|
+
kind: "container",
|
|
209
|
+
direction: "horizontal",
|
|
210
|
+
children: [
|
|
211
|
+
{ kind: "segment", name: PRESETS_SEG },
|
|
212
|
+
{ kind: "segment", name: EDIT_SEG },
|
|
213
|
+
],
|
|
214
|
+
when: SETTINGS_OPEN_GATE,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
: node;
|
|
219
|
+
}
|
|
220
|
+
return { ...node, children: node.children.map(expandAnchor) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── The artifacts ──────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
interface MenuArtifacts {
|
|
226
|
+
readonly variables: Record<string, VariableDecl>;
|
|
227
|
+
readonly actions: Record<string, ActionDecl>;
|
|
228
|
+
readonly segments: Record<string, SegmentDecl>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// [LAW:single-enforcer] The `{{ menu }}` disclosure a body segment hosts,
|
|
232
|
+
// synthesized by calling the SAME pure functions menu-synth.ts's parse-time pass
|
|
233
|
+
// calls — the identical move edit-chrome.ts's insertChrome makes, and for the
|
|
234
|
+
// identical reason: this pass runs too late to piggyback on that one, so parity
|
|
235
|
+
// comes from sharing the derivation, never from restating it.
|
|
236
|
+
function declareHostedMenu(
|
|
237
|
+
segName: string,
|
|
238
|
+
applyName: string,
|
|
239
|
+
artifacts: MenuArtifacts,
|
|
240
|
+
): void {
|
|
241
|
+
const member = menuMember(applyName);
|
|
242
|
+
const stateKey = menuStateKey(segName, applyName, undefined);
|
|
243
|
+
const pageKey = menuPageKey(stateKey);
|
|
244
|
+
artifacts.variables[stateKey] = disclosureStateVar(
|
|
245
|
+
stateKey,
|
|
246
|
+
DISCLOSURE_CLOSED,
|
|
247
|
+
);
|
|
248
|
+
artifacts.variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
|
|
249
|
+
artifacts.actions[menuActionName(stateKey, member)] = disclosureCycleAction(
|
|
250
|
+
stateKey,
|
|
251
|
+
member,
|
|
252
|
+
);
|
|
253
|
+
artifacts.actions[pageKey] = { set: pageKey, int: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// [LAW:one-source-of-truth] Everything the menu is, minted ONCE per config and
|
|
257
|
+
// merely REFERENCED from each preset root. This is what makes the pass
|
|
258
|
+
// idempotent across N presets for free: a preset root carries a segment
|
|
259
|
+
// reference, and a second reference to one declaration is a reuse, not the
|
|
260
|
+
// self-collision a second `kind: "group"` node would be (see the settingsDrawer
|
|
261
|
+
// comment in default-dsl-config.ts for that hazard in its original form).
|
|
262
|
+
function settingsArtifacts(): MenuArtifacts {
|
|
263
|
+
const artifacts: MenuArtifacts = {
|
|
264
|
+
variables: {
|
|
265
|
+
[SETTINGS_ANCHOR]: disclosureStateVar(SETTINGS_ANCHOR, DISCLOSURE_CLOSED),
|
|
266
|
+
},
|
|
267
|
+
actions: {
|
|
268
|
+
[SETTINGS_ANCHOR]: disclosureCycleAction(SETTINGS_ANCHOR, SETTINGS_OPEN),
|
|
269
|
+
// [LAW:single-enforcer] The picker's apply effect, gated by derivation
|
|
270
|
+
// like every other `from`-sourced set: `presets` is a per-config domain
|
|
271
|
+
// both deriveActionValidators and the rendered options resolve through
|
|
272
|
+
// one `resolveOptionDomain`, so this adds a control, never a gate.
|
|
273
|
+
[APPLY_PRESET_ACTION]: { set: "preset", from: "presets" },
|
|
274
|
+
},
|
|
275
|
+
segments: {
|
|
276
|
+
// [LAW:representation] The glyph trails the label it gates, per the
|
|
277
|
+
// disclosure vocabulary every other toggle in the bar reads by.
|
|
278
|
+
[SETTINGS_ANCHOR]: {
|
|
279
|
+
template: `{{ action "${SETTINGS_ANCHOR}" "☰ ${DISCLOSURE_GLYPH_CLOSED}" "☰ ${DISCLOSURE_GLYPH_OPEN}" }}`,
|
|
280
|
+
bg: "surface",
|
|
281
|
+
fg: "foreground",
|
|
282
|
+
},
|
|
283
|
+
[PRESETS_SEG]: {
|
|
284
|
+
template: `▦ {{ menu "${APPLY_PRESET_ACTION}" (dict "closeOnPick" true) }}`,
|
|
285
|
+
bg: "surface",
|
|
286
|
+
fg: "foreground",
|
|
287
|
+
},
|
|
288
|
+
// The entry point edit mode never had: `edit.toggle` is a reserved action
|
|
289
|
+
// whose only bundled reference lives in the `toolbar` segment, which a
|
|
290
|
+
// user config's `root` drops like everything else. Here it is reachable
|
|
291
|
+
// from a segment no config can drop.
|
|
292
|
+
[EDIT_SEG]: {
|
|
293
|
+
template: `{{ action "${EDIT_TOGGLE_ACTION}" "✎ edit" "✎ done" }}`,
|
|
294
|
+
bg: "surface",
|
|
295
|
+
fg: "foreground",
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
declareHostedMenu(PRESETS_SEG, APPLY_PRESET_ACTION, artifacts);
|
|
300
|
+
return artifacts;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// [LAW:one-source-of-truth] Edit mode's toggle, ensured rather than duplicated:
|
|
304
|
+
// both this pass and synthesizeEditModeToggle produce it by calling the same two
|
|
305
|
+
// disclosure functions on the same two exported constants, so the two mints are
|
|
306
|
+
// the same value by construction and whichever lands first is the only one.
|
|
307
|
+
// Ensuring it here is not an optional courtesy — the EDIT_SEG segment above
|
|
308
|
+
// references `edit.toggle`, and that pass is demand-driven off a scan of the
|
|
309
|
+
// segments a FILE declared, which cannot see a segment this pass mints later.
|
|
310
|
+
function ensureEditToggle(artifacts: MenuArtifacts): void {
|
|
311
|
+
artifacts.variables[EDIT_MODE_KEY] = disclosureStateVar(
|
|
312
|
+
EDIT_MODE_KEY,
|
|
313
|
+
DISCLOSURE_CLOSED,
|
|
314
|
+
);
|
|
315
|
+
artifacts.actions[EDIT_TOGGLE_ACTION] = disclosureCycleAction(
|
|
316
|
+
EDIT_MODE_KEY,
|
|
317
|
+
EDIT_MODE_OPEN,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// [LAW:one-source-of-truth] The variable whose presence IS the precondition,
|
|
322
|
+
// named once so the predicate below and the load error cross-ref.ts raises when
|
|
323
|
+
// it fails cannot describe different variables.
|
|
324
|
+
export const SESSION_ID_VAR = "session.id";
|
|
325
|
+
|
|
326
|
+
// [LAW:types-are-the-program] The menu's one structural prerequisite, read as a
|
|
327
|
+
// value: a global `session.id`. It is not a demand gate and not a preference —
|
|
328
|
+
// the menu is a CLICK surface, every click composes a URL whose first segment is
|
|
329
|
+
// `session.id` read from the store, and cross-ref.ts already rejects an AUTHORED
|
|
330
|
+
// state read or `set` write in a config that declares no such variable. A config
|
|
331
|
+
// without it describes a static, non-interactive bar, and there is no menu to
|
|
332
|
+
// place on one. Every config the daemon renders merges the bundled default,
|
|
333
|
+
// which declares `session.id`, so in production this is universally true; what
|
|
334
|
+
// it excludes is the hand-built static config, not a user.
|
|
335
|
+
//
|
|
336
|
+
// [LAW:one-source-of-truth] Exported because this is THE fact "will the anchor
|
|
337
|
+
// resolve to a segment?" — asked here to decide whether to mint the menu, and
|
|
338
|
+
// asked by cross-ref.ts to decide whether an authored placement of the anchor is
|
|
339
|
+
// a reference this pass is about to satisfy or a dangling one. Two readers, one
|
|
340
|
+
// predicate: when they were two predicates, cross-ref accepted an anchor this
|
|
341
|
+
// pass then declined to provide, and the un-lowered reference reached the render
|
|
342
|
+
// walk to throw at `lookupSegment`.
|
|
343
|
+
export function canHostSessionState(config: DslConfig): boolean {
|
|
344
|
+
return Object.prototype.hasOwnProperty.call(config.variables, SESSION_ID_VAR);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// [LAW:single-enforcer] THE synthesis entry point, called once from
|
|
348
|
+
// validateConfig after cross-ref/cycle checks pass and before edit chrome.
|
|
349
|
+
// Every declared preset — the floor `default` included — gets an explicit
|
|
350
|
+
// `presets[name].root` carrying its anchored, expanded tree; `config.root`
|
|
351
|
+
// itself is left untouched, exactly as synthesizeEditChrome leaves it, because
|
|
352
|
+
// presetRoot falls back to it only for a preset declaring no root of its own
|
|
353
|
+
// and every name now declares one.
|
|
354
|
+
export function synthesizeSettingsMenu(config: DslConfig): DslConfig {
|
|
355
|
+
if (!canHostSessionState(config)) return config;
|
|
356
|
+
const artifacts = settingsArtifacts();
|
|
357
|
+
ensureEditToggle(artifacts);
|
|
358
|
+
const presets: Record<string, PresetDecl> = { ...config.presets };
|
|
359
|
+
for (const name of presetNames(config.presets)) {
|
|
360
|
+
const { node } = presetRoot(config, name);
|
|
361
|
+
presets[name] = {
|
|
362
|
+
...presetByName(config.presets, name),
|
|
363
|
+
root: expandAnchor(withAnchor(node)),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
...config,
|
|
368
|
+
variables: { ...config.variables, ...artifacts.variables },
|
|
369
|
+
actions: { ...config.actions, ...artifacts.actions },
|
|
370
|
+
segments: { ...config.segments, ...artifacts.segments },
|
|
371
|
+
presets,
|
|
372
|
+
};
|
|
373
|
+
}
|
package/src/daemon/client.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { ClaudeHookData } from "../utils/claude";
|
|
13
13
|
import { requestOutcome } from "./client-transport";
|
|
14
14
|
import type { RoundTripBudgets, RoundTripOutcome } from "./client-transport";
|
|
15
|
-
import type { Response } from "./protocol";
|
|
15
|
+
import type { ClientHints, Response } from "./protocol";
|
|
16
16
|
|
|
17
17
|
const CONNECT_TIMEOUT_MS = 50;
|
|
18
18
|
const TOTAL_BUDGET_MS = 150;
|
|
@@ -47,14 +47,17 @@ function projectOutput(
|
|
|
47
47
|
// There is no inline render path; see src/index.ts. The caller is responsible
|
|
48
48
|
// for branching on outcome.kind and deciding whether to kick, display an
|
|
49
49
|
// error glyph, or print the rendered output.
|
|
50
|
+
// [LAW:one-source-of-truth] `hints` carries every fact the daemon cannot
|
|
51
|
+
// observe for itself; it is spread onto the request verbatim so this relay
|
|
52
|
+
// never becomes a second place that decides what the client saw.
|
|
50
53
|
export function tryRenderViaDaemon(
|
|
51
54
|
hookData: ClaudeHookData,
|
|
52
55
|
args: string[],
|
|
53
56
|
cwd: string,
|
|
54
|
-
|
|
57
|
+
hints: ClientHints,
|
|
55
58
|
): Promise<ClientOutcome> {
|
|
56
59
|
return requestOutcome(
|
|
57
|
-
{ kind: "render", hookData, args, cwd,
|
|
60
|
+
{ kind: "render", hookData, args, cwd, ...hints },
|
|
58
61
|
RENDER_BUDGETS,
|
|
59
62
|
projectOutput,
|
|
60
63
|
);
|
package/src/daemon/protocol.ts
CHANGED
|
@@ -34,12 +34,61 @@ export interface RenderRequest {
|
|
|
34
34
|
hookData: ClaudeHookData;
|
|
35
35
|
args: string[];
|
|
36
36
|
cwd: string;
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
37
|
+
// ─── Client hints ────────────────────────────────────────────────────────
|
|
38
|
+
// [LAW:single-enforcer] Facts only the LIVE CLIENT can observe, captured at
|
|
39
|
+
// the trust boundary and trusted by the daemon. The daemon is detached and
|
|
40
|
+
// one-per-user, so its own env answers for whichever shell spawned it —
|
|
41
|
+
// possibly a different session, possibly hours ago. Every field below is
|
|
42
|
+
// typed here but arrives as untrusted JSON: callers MUST route the request
|
|
43
|
+
// through parseClientHints at the receive boundary, never read these
|
|
44
|
+
// directly. See the ClientHints doc block for the absence semantics.
|
|
42
45
|
termCols?: number;
|
|
46
|
+
ssh?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// [LAW:locality-or-seam] The seam for "a fact the daemon cannot observe about
|
|
50
|
+
// the session it is rendering for". `termCols` established the pattern; `ssh`
|
|
51
|
+
// is the second member, and the documented-but-unbuilt client-aware
|
|
52
|
+
// `colorCompatibility: "auto"` is the next. Naming the set as ONE type is what
|
|
53
|
+
// keeps that third addition a field rather than another sanitizer, another
|
|
54
|
+
// wire read, and another parameter threaded through the render path.
|
|
55
|
+
//
|
|
56
|
+
// [LAW:parse-dont-validate] This is the stamped type. `RenderRequest`'s
|
|
57
|
+
// same-named fields are raw JSON of unknown provenance; a `ClientHints` has
|
|
58
|
+
// crossed the checkpoint, so nothing downstream re-checks them.
|
|
59
|
+
//
|
|
60
|
+
// [LAW:types-are-the-program] Both fields are optional, but they mean
|
|
61
|
+
// DIFFERENT things by absence, and each is the strongest true theorem for its
|
|
62
|
+
// own fact:
|
|
63
|
+
// • `termCols` absent — the client tried and could not determine a width
|
|
64
|
+
// (no COLUMNS, no TTY on stderr). A genuine "unknown", reachable from any
|
|
65
|
+
// client version.
|
|
66
|
+
// • `ssh` absent — the client did not REPORT. A current client always knows
|
|
67
|
+
// (its own env is total on this question) and so always sends `true` or
|
|
68
|
+
// `false`; absence therefore means one thing only: a client too old to
|
|
69
|
+
// carry the field — a real case, because `cc-candybar install` stages a
|
|
70
|
+
// native binary that does not turn over with the npm package. Collapsing
|
|
71
|
+
// that to `false` here would fuse "we know it's local" with "we don't
|
|
72
|
+
// know" ([LAW:no-silent-failure]); instead it travels onward as an absent
|
|
73
|
+
// payload field, where the DSL input-fallback chain emits the declared
|
|
74
|
+
// default AND records a `last_error` that `cc-candybar debug vars`
|
|
75
|
+
// surfaces.
|
|
76
|
+
export interface ClientHints {
|
|
77
|
+
readonly termCols?: number;
|
|
78
|
+
readonly ssh?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// [LAW:single-enforcer] The ONE checkpoint where wire-supplied client hints
|
|
82
|
+
// become trusted values. Per-field sanitizers stay separate (each fact has its
|
|
83
|
+
// own validity rule) but nothing outside this function calls them, so a new
|
|
84
|
+
// hint cannot reach the render path un-sanitized.
|
|
85
|
+
export function parseClientHints(req: RenderRequest): ClientHints {
|
|
86
|
+
const termCols = sanitizeTermCols(req.termCols);
|
|
87
|
+
const ssh = sanitizeSsh(req.ssh);
|
|
88
|
+
return {
|
|
89
|
+
...(termCols !== undefined && { termCols }),
|
|
90
|
+
...(ssh !== undefined && { ssh }),
|
|
91
|
+
};
|
|
43
92
|
}
|
|
44
93
|
|
|
45
94
|
// [LAW:no-defensive-null-guards] exception: trust boundary. The wire is
|
|
@@ -58,6 +107,14 @@ export function sanitizeTermCols(v: unknown): number | undefined {
|
|
|
58
107
|
return n > MAX_TERM_COLS ? MAX_TERM_COLS : n;
|
|
59
108
|
}
|
|
60
109
|
|
|
110
|
+
// [LAW:no-defensive-null-guards] exception: trust boundary, same shape as
|
|
111
|
+
// sanitizeTermCols. A non-boolean (absent, or a malformed/hostile frame) is
|
|
112
|
+
// NOT coerced to `false` — the three wire states stay three
|
|
113
|
+
// ([LAW:no-silent-failure]): true, false, and "no answer from this client".
|
|
114
|
+
export function sanitizeSsh(v: unknown): boolean | undefined {
|
|
115
|
+
return typeof v === "boolean" ? v : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
61
118
|
export interface ShutdownRequest {
|
|
62
119
|
v: number;
|
|
63
120
|
kind: "shutdown";
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
// resolve falls back to the variable's declared default).
|
|
18
18
|
|
|
19
19
|
import path from "node:path";
|
|
20
|
+
import os from "node:os";
|
|
20
21
|
import type { ClaudeHookData } from "../utils/claude.js";
|
|
22
|
+
import type { ClientHints } from "./protocol.js";
|
|
21
23
|
import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
|
|
22
24
|
import { walkNodes } from "../config/dsl-types.js";
|
|
23
25
|
import { extractTemplateRefs } from "../config/dsl-loader.js";
|
|
@@ -96,6 +98,11 @@ export interface RenderPayload extends ClaudeHookData {
|
|
|
96
98
|
|
|
97
99
|
readonly git?: GitPayload;
|
|
98
100
|
readonly tmux?: { readonly session: string };
|
|
101
|
+
// [LAW:types-are-the-program] REQUIRED for the same reason theme/look are:
|
|
102
|
+
// the daemon assembles it every render from sources that cannot be "not
|
|
103
|
+
// requested" (two syscalls and one already-parsed wire hint). The fields
|
|
104
|
+
// INSIDE it carry the real optionality — see HostPayload.
|
|
105
|
+
readonly host: HostPayload;
|
|
99
106
|
// [LAW:one-source-of-truth] The daemon-resolved effective theme name —
|
|
100
107
|
// effectiveThemeName(sessionState.theme, globals.palette). The SAME value the
|
|
101
108
|
// rendered basePalette is built from, surfaced so a trigger label can display
|
|
@@ -200,6 +207,41 @@ export interface GitPayload {
|
|
|
200
207
|
readonly prError?: string;
|
|
201
208
|
}
|
|
202
209
|
|
|
210
|
+
// Which machine this session is on, and whether the user got here over the
|
|
211
|
+
// network. The two halves have DIFFERENT provenance and that is the whole
|
|
212
|
+
// design ([LAW:one-source-of-truth]):
|
|
213
|
+
//
|
|
214
|
+
// • `name`/`user` are MACHINE facts. Client and daemon are the same machine
|
|
215
|
+
// by construction — the socket path is UID-derived and the pid mutex is
|
|
216
|
+
// per-user — so the daemon reading them directly cannot drift from what
|
|
217
|
+
// the client would have reported. Sending them over the wire would buy
|
|
218
|
+
// nothing and add a second source.
|
|
219
|
+
// • `ssh` is a SESSION fact and is the exact opposite: one daemon serves a
|
|
220
|
+
// local session and an SSH session simultaneously, so the daemon's own env
|
|
221
|
+
// answers for whichever shell spawned it. It can ONLY arrive as a client
|
|
222
|
+
// hint. This is the same reasoning that makes `globals.colorCompatibility:
|
|
223
|
+
// "auto"` deliberately unrepresentable.
|
|
224
|
+
//
|
|
225
|
+
// [LAW:no-silent-failure] Every field is optional because each can genuinely
|
|
226
|
+
// be unknown, and absence is preserved rather than defaulted here: `user` when
|
|
227
|
+
// the uid has no passwd entry, `ssh` when the client predates the hint. Both
|
|
228
|
+
// travel as missing keys to the DSL input-fallback chain, which emits the
|
|
229
|
+
// declared default AND records a `last_error` that `cc-candybar debug vars`
|
|
230
|
+
// surfaces — so "we don't know" stays distinguishable from "we know it's
|
|
231
|
+
// local", which a `?? false` here would have destroyed.
|
|
232
|
+
export interface HostPayload {
|
|
233
|
+
// The SHORT hostname — `os.hostname()` up to the first dot, the same
|
|
234
|
+
// projection zsh's `%m` makes. A statusbar cell identifies a machine to a
|
|
235
|
+
// human; the FQDN is a network address, a different fact, and a separate
|
|
236
|
+
// field the day something needs it.
|
|
237
|
+
readonly name?: string;
|
|
238
|
+
// The EFFECTIVE username from the passwd database, not `$USER`. The env var
|
|
239
|
+
// is a map that drifts (su/sudo leave it stale); the passwd entry for the
|
|
240
|
+
// running uid is the territory. Matches zsh's `%n`.
|
|
241
|
+
readonly user?: string;
|
|
242
|
+
readonly ssh?: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
203
245
|
export interface SessionPayload {
|
|
204
246
|
readonly cost?: number;
|
|
205
247
|
readonly tokens?: number;
|
|
@@ -463,6 +505,72 @@ function projectSpeedHistory(obs: SpeedObservation): string | undefined {
|
|
|
463
505
|
return rates.join(",");
|
|
464
506
|
}
|
|
465
507
|
|
|
508
|
+
// ─── Host identity ───────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The short hostname: everything before the first dot, or the whole string when
|
|
512
|
+
* there is no dot. `os.hostname()` yields an FQDN on some hosts (macOS's
|
|
513
|
+
* `mymachine.local`, a DNS-configured server's `web1.prod.example.com`) and a
|
|
514
|
+
* bare name on others; this makes the rendered cell identify the machine the
|
|
515
|
+
* same way on both, which is zsh's `%m` and the projection git-taculous shows.
|
|
516
|
+
*
|
|
517
|
+
* [LAW:effects-at-boundaries] Pure and total — the syscall stays in readHost.
|
|
518
|
+
*/
|
|
519
|
+
export function shortHostname(hostname: string): string {
|
|
520
|
+
const dot = hostname.indexOf(".");
|
|
521
|
+
return dot < 0 ? hostname : hostname.slice(0, dot);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Assemble the host identity for one render.
|
|
526
|
+
*
|
|
527
|
+
* [LAW:effects-at-boundaries] Named `read*`, not `project*`, because it is not
|
|
528
|
+
* pure: two syscalls happen here. That is in bounds — this module IS the
|
|
529
|
+
* daemon's data-assembly edge, the same edge that reads `process.env.HOME`
|
|
530
|
+
* below — and the point of the seam is that nothing downstream reads them
|
|
531
|
+
* again.
|
|
532
|
+
*
|
|
533
|
+
* [LAW:no-silent-failure] A throwing syscall (a uid with no passwd entry is the
|
|
534
|
+
* realistic case, in a stripped container) yields an ABSENT field plus a
|
|
535
|
+
* description for the caller to log, exactly like a failed git lane — never a
|
|
536
|
+
* fabricated name, and never an exception: the whole bar must not blank over a
|
|
537
|
+
* cosmetic cell.
|
|
538
|
+
*/
|
|
539
|
+
function readHost(hints: ClientHints): {
|
|
540
|
+
readonly host: HostPayload;
|
|
541
|
+
readonly failures: readonly string[];
|
|
542
|
+
} {
|
|
543
|
+
const failures: string[] = [];
|
|
544
|
+
const attempt = (field: string, read: () => string): string | undefined => {
|
|
545
|
+
try {
|
|
546
|
+
const value = read();
|
|
547
|
+
// "" is not a usable identity; treat it as absence so the DSL default
|
|
548
|
+
// applies rather than rendering an empty `@host` fragment.
|
|
549
|
+
return value === "" ? undefined : value;
|
|
550
|
+
} catch (e) {
|
|
551
|
+
failures.push(`host.${field}: ${String(e)}`);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const name = attempt("name", () => shortHostname(os.hostname()));
|
|
557
|
+
const user = attempt("user", () => os.userInfo().username);
|
|
558
|
+
return {
|
|
559
|
+
host: {
|
|
560
|
+
...(name !== undefined && { name }),
|
|
561
|
+
...(user !== undefined && { user }),
|
|
562
|
+
// [LAW:one-source-of-truth] Passed straight through from the parsed
|
|
563
|
+
// hint. The daemon deliberately does NOT consult its own SSH_* env as a
|
|
564
|
+
// fallback: that env belongs to whichever shell spawned it, so a
|
|
565
|
+
// "helpful" fallback would confidently mislabel every session that
|
|
566
|
+
// daemon serves. Absent hint → absent field → declared default + a
|
|
567
|
+
// recorded last_error, which is the honest report of "not answered".
|
|
568
|
+
...(hints.ssh !== undefined && { ssh: hints.ssh }),
|
|
569
|
+
},
|
|
570
|
+
failures,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
466
574
|
// ─── Builder ─────────────────────────────────────────────────────────────────
|
|
467
575
|
|
|
468
576
|
// ─── Config-driven provider gating ───────────────────────────────────────────
|
|
@@ -666,6 +774,13 @@ export async function buildRenderPayload(
|
|
|
666
774
|
// daemon already computes every one of these for renderDsl's options; this
|
|
667
775
|
// is that same struct, threaded to the sole payload assembler.
|
|
668
776
|
effective: EffectiveGlobals,
|
|
777
|
+
// [LAW:locality-or-seam] The parsed client hints, NOT the raw request — this
|
|
778
|
+
// function is downstream of the wire checkpoint and never re-sanitizes.
|
|
779
|
+
// Separate from `effective` on purpose: that struct is resolved globals (what
|
|
780
|
+
// the config and the session chose), this is observed session context (what
|
|
781
|
+
// the client saw). Fusing them would put a config-precedence chain and a
|
|
782
|
+
// trust boundary behind one name.
|
|
783
|
+
hints: ClientHints,
|
|
669
784
|
): Promise<RenderPayload> {
|
|
670
785
|
const wants = (prefix: string): boolean =>
|
|
671
786
|
anyPathStartsWith(neededInputPaths, prefix);
|
|
@@ -756,6 +871,10 @@ export async function buildRenderPayload(
|
|
|
756
871
|
|
|
757
872
|
const gitProjection = projectGitInfo(gitOutcome);
|
|
758
873
|
failures.push(...gitProjection.failures);
|
|
874
|
+
// Ungated, like `home` below: two syscalls and a hint already in hand, so a
|
|
875
|
+
// `wants` gate would add a branch and save nothing.
|
|
876
|
+
const hostProjection = readHost(hints);
|
|
877
|
+
failures.push(...hostProjection.failures);
|
|
759
878
|
const usageValue = take(usage);
|
|
760
879
|
const todayValue = take(today);
|
|
761
880
|
const contextValue = take(context);
|
|
@@ -867,6 +986,7 @@ export async function buildRenderPayload(
|
|
|
867
986
|
...(home !== undefined && { home }),
|
|
868
987
|
...(gitProjection.git !== undefined && { git: gitProjection.git }),
|
|
869
988
|
...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
|
|
989
|
+
host: hostProjection.host,
|
|
870
990
|
// [LAW:one-source-of-truth] Always present — the daemon resolves every
|
|
871
991
|
// one of these each render (for BuildLineOptions/basePalette), and these
|
|
872
992
|
// are those exact values. No `wants` gate: each costs nothing (already in
|