@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.34.1",
3
+ "version": "1.36.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,9 +91,9 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.34.1",
95
- "@promptctl/cc-candybar-darwin-x64": "1.34.1",
96
- "@promptctl/cc-candybar-linux-x64": "1.34.1",
97
- "@promptctl/cc-candybar-linux-arm64": "1.34.1"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.36.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.36.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.36.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.36.0"
98
98
  }
99
99
  }
package/src/check.ts CHANGED
@@ -119,6 +119,11 @@ export function checkPayload(
119
119
  weekly: { percentage: 21, resetsAt: nowSec + 5 * 86400 },
120
120
  cache: { expiresAt: nowSec + 15 * 60 },
121
121
  tmux: { session: "work" },
122
+ // `ssh: true` for the same reason `tmux.session` is populated: this
123
+ // fixture deliberately satisfies every gate so a when-gated segment
124
+ // RENDERS and its template gets checked. A local-looking fixture would
125
+ // gate the host segment off and let a typo inside it ship.
126
+ host: { name: "tester-box", user: "tester", ssh: true },
122
127
  theme: { effective: effective.theme },
123
128
  look: { effective: effective.look },
124
129
  // [LAW:one-source-of-truth] Was missing here even though EffectiveGlobals
@@ -465,6 +465,31 @@ export const RAW_DEFAULT_DSL_CONFIG = {
465
465
  // gates the input variant so unused segments cost nothing.
466
466
  "tmux.session": { kind: "input", path: "tmux.session", default: "" },
467
467
 
468
+ // Host identity — which machine this session is on, and whether the user
469
+ // arrived over SSH. All three come through the augmented payload rather
470
+ // than `kind: "env"` / `kind: "shell"`, and that is not a style choice:
471
+ //
472
+ // • `host.ssh` CANNOT be an env var here. Variables are evaluated in the
473
+ // DAEMON, which is detached and serves every session for this user at
474
+ // once — its `SSH_*` env describes whichever shell happened to spawn
475
+ // it. The fact is captured by the live client and carried as a wire
476
+ // hint (the `termCols` pattern); the payload is the only honest source.
477
+ // • `host.name`/`host.user` are machine facts the daemon reads directly,
478
+ // so they cost two syscalls instead of a per-render subprocess.
479
+ //
480
+ // Defaults are the "unknown" values, and for `ssh` that is `false`: an
481
+ // absent field (a client too old to send the hint) renders as local, which
482
+ // is the pre-feature behavior, while the input-fallback chain records a
483
+ // `last_error` so `cc-candybar debug vars` can still tell the two apart.
484
+ "host.name": { kind: "input", path: "host.name", default: "" },
485
+ "host.user": { kind: "input", path: "host.user", default: "" },
486
+ "host.ssh": {
487
+ kind: "input",
488
+ path: "host.ssh",
489
+ type: "boolean",
490
+ default: false,
491
+ },
492
+
468
493
  // Git — every field flows from the daemon's projected GitInfo payload.
469
494
  // The DSL's native `kind: "git"` source covers a 6-field subset
470
495
  // (branch/sha/dirty/ahead/behind/stash); using `input` here gives the
@@ -803,6 +828,34 @@ export const RAW_DEFAULT_DSL_CONFIG = {
803
828
  fg: "foreground",
804
829
  when: '{{ ne .tmux.session "" }}',
805
830
  },
831
+ // "You are not on your own machine." Modelled on the git-taculous zsh
832
+ // theme, which prepends `(%n@%m)` to the prompt under SSH and shows
833
+ // nothing locally — you already know your own hostname.
834
+ //
835
+ // [LAW:dataflow-not-control-flow] Presence IS the signal. There is no SSH
836
+ // "mode" and no force-on flag (git-taculous's GITTACULOUS_ENABLE_SSH_THEME
837
+ // would be a flag with no deletion date, [LAW:no-mode-explosion]); the cell
838
+ // exists exactly when the value says so, like tmux/block/weekly. A user who
839
+ // wants it always-on overrides this one segment's `when` to `"true"`.
840
+ //
841
+ // `bg: "warning"` is load-bearing, not decoration: warning is one of the
842
+ // hue-ANCHORED palette roots, so it survives every theme, look, and
843
+ // per-segment hue transposition still reading as an alert. Any other slot
844
+ // would drift with the hue stepper and could land camouflaged against its
845
+ // neighbours — exactly what a "wrong machine" warning must never do.
846
+ // `contrastOn (bgOf)` then derives a readable foreground from whatever that
847
+ // resolves to, rather than betting a fixed `foreground` stays legible.
848
+ //
849
+ // Each half falls back to "?" so a failed hostname/username read renders
850
+ // `⇄ ?@?` — still unmistakably "remote", and legibly missing its identity
851
+ // rather than a blank that reads as a rendering bug ([LAW:no-silent-failure]).
852
+ host: {
853
+ template:
854
+ '⇄ {{ .host.user | default "?" }}@{{ .host.name | default "?" }}',
855
+ bg: "warning",
856
+ fg: "{{ contrastOn (bgOf) }}",
857
+ when: "{{ .host.ssh }}",
858
+ },
806
859
  git: {
807
860
  template: GIT_TEMPLATE,
808
861
  bg: "surface-active",
@@ -1268,6 +1321,12 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1268
1321
  kind: "container",
1269
1322
  direction: "horizontal",
1270
1323
  children: [
1324
+ // Leads the identity row: the first thing to read is WHICH MACHINE,
1325
+ // because it reframes every path and branch to its right. Same
1326
+ // placement git-taculous gives `(%n@%m)` — ahead of the directory.
1327
+ // Gated off entirely on a local session, so the row still opens with
1328
+ // `directory` where it always has.
1329
+ { kind: "segment", name: "host" },
1271
1330
  { kind: "segment", name: "directory" },
1272
1331
  { kind: "segment", name: "gitaculous" },
1273
1332
  { kind: "segment", name: "toolbar" },
@@ -50,6 +50,8 @@ import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
50
50
  import { synthesizeMenuDecls } from "./loader/menu-synth.js";
51
51
  import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
52
52
  import { synthesizeEditChrome } from "./edit-chrome.js";
53
+ import { SETTINGS_NS, synthesizeSettingsMenu } from "./settings-menu.js";
54
+ import { reservedNamespaceCollisions } from "./loader/reserved-namespace.js";
53
55
  import { validateActions } from "./loader/actions.js";
54
56
  import { validateLooks } from "./loader/looks.js";
55
57
  import { validatePresets } from "./loader/presets.js";
@@ -152,7 +154,12 @@ export function validateConfig(
152
154
  // into freshly-synthesized segments, actions into freshly-synthesized
153
155
  // actions) is correct by construction and does not re-enter cross-ref/
154
156
  // cycle checking, exactly as group/menu synthesis's output doesn't either.
155
- const withChrome = synthesizeEditChrome(config);
157
+ // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.1's global settings
158
+ // menu, spliced BEFORE edit chrome so edit chrome walks the final content tree
159
+ // and treats the menu's reserved `settings.` names as chrome-exempt — the
160
+ // full ordering argument lives in settings-menu.ts's header, beside the pass
161
+ // it governs.
162
+ const withChrome = synthesizeEditChrome(synthesizeSettingsMenu(config));
156
163
  return withChrome as ValidatedConfig;
157
164
  }
158
165
 
@@ -295,6 +302,18 @@ function validateTopLevel(
295
302
  // affordances) runs later, in validateConfig, once the merged/preset-
296
303
  // resolved/rootOps-replayed tree exists to derive it from.
297
304
  synthesizeEditModeToggle(ctx, out);
305
+ // [LAW:one-source-of-truth] The global settings menu reserves its namespace
306
+ // here and synthesizes NOTHING here: the tree it must be present in only
307
+ // exists after merge (a user `root` replaces the default's), so the artifacts
308
+ // are minted in validateConfig. The reservation is unconditional all the same,
309
+ // mirroring every other namespace above — "you never author settings.*" is a
310
+ // stable contract, not a rule that switches on when the pass happens to fire.
311
+ reservedNamespaceCollisions(
312
+ ctx,
313
+ out,
314
+ SETTINGS_NS,
315
+ "the global settings menu",
316
+ );
298
317
  return out;
299
318
  }
300
319
 
@@ -40,6 +40,7 @@ import {
40
40
  EDIT_TOGGLE_ACTION,
41
41
  } from "./loader/edit-mode.js";
42
42
  import { GROUP_NS } from "./loader/layout.js";
43
+ import { SETTINGS_NS } from "./settings-menu.js";
43
44
  import {
44
45
  menuActionName,
45
46
  menuMember,
@@ -61,7 +62,11 @@ import {
61
62
  // knowledge, so the banner below is spliced UNCONDITIONALLY for every
62
63
  // preset — same shape, every reload — and this predicate is what decides
63
64
  // whether it's visible, never a branch in this synthesis pass.
64
- const PRESET_CUSTOMIZED_GATE = "{{ .preset.customized }}";
65
+ // [LAW:one-source-of-truth] Exported: test/helpers/ambient-chrome.ts filters this
66
+ // ensured name out of "what did the AUTHOR declare" assertions and must read the
67
+ // same string, never a second copy that a rename here would leave behind.
68
+ export const PRESET_CUSTOMIZED_VAR = "preset.customized";
69
+ const PRESET_CUSTOMIZED_GATE = `{{ .${PRESET_CUSTOMIZED_VAR} }}`;
65
70
 
66
71
  // [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
67
72
  // `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
@@ -73,7 +78,12 @@ function isChromeExempt(name: string): boolean {
73
78
  return (
74
79
  name.startsWith(EDIT_NS) ||
75
80
  name.startsWith(MENU_NS) ||
76
- name.startsWith(GROUP_NS)
81
+ name.startsWith(GROUP_NS) ||
82
+ // The global settings menu is the entry point edit mode is REACHED from
83
+ // (candybar-settings-ui-aok.1) — offering a `-` beside it would let one
84
+ // click delete the door back in, the self-lockout the `toolbar` trigger's
85
+ // placement was chosen to avoid. Structural, like the three above it.
86
+ name.startsWith(SETTINGS_NS)
77
87
  );
78
88
  }
79
89
 
@@ -93,6 +103,11 @@ function escapeTemplateLiteral(s: string): string {
93
103
  // so cross-preset names (disambiguated by `presetIdent`) can never collide.
94
104
  interface ChromeArtifacts {
95
105
  readonly variables: Record<string, VariableDecl>;
106
+ // [LAW:no-silent-failure] Declarations this synthesis DEPENDS on rather than
107
+ // OWNS: merged UNDER the config so a user's own declaration of the same name
108
+ // wins, unlike `variables` above, which lives in a reserved namespace no user
109
+ // may write and therefore merges over.
110
+ readonly ensured: Record<string, VariableDecl>;
96
111
  readonly actions: Record<string, ActionDeclType>;
97
112
  readonly segments: Record<string, SegmentDecl>;
98
113
  }
@@ -219,7 +234,18 @@ function spliceContainer(
219
234
  posCounter: { n: number },
220
235
  ): ContainerNode {
221
236
  const children: LayoutNode[] = [];
222
- for (const child of node.children) {
237
+ // [LAW:one-source-of-truth] The trailing `+`'s position is "after the last
238
+ // CONTENT segment", not "after the last child". Those coincided until a
239
+ // synthesis started appending exempt chrome (the global settings menu,
240
+ // candybar-settings-ui-aok.1) to a row's end, at which point reading the last
241
+ // child silently dropped the row's final insert point — N segments offering
242
+ // only N insert points instead of N+1.
243
+ const lastContent = node.children.reduce(
244
+ (idx, child, i) =>
245
+ child.kind === "segment" && !isChromeExempt(child.name) ? i : idx,
246
+ -1,
247
+ );
248
+ for (const [i, child] of node.children.entries()) {
223
249
  if (child.kind === "container") {
224
250
  children.push(
225
251
  spliceContainer(
@@ -250,24 +276,19 @@ function spliceContainer(
250
276
  );
251
277
  children.push(child);
252
278
  children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
253
- }
254
- const last = node.children[node.children.length - 1];
255
- if (
256
- last !== undefined &&
257
- last.kind === "segment" &&
258
- !isChromeExempt(last.name)
259
- ) {
260
- children.push(
261
- insertChrome(
262
- presetIdent,
263
- rootOpsKey,
264
- String(posCounter.n++),
265
- domainName,
266
- last.name,
267
- "after",
268
- artifacts,
269
- ),
270
- );
279
+ if (i === lastContent) {
280
+ children.push(
281
+ insertChrome(
282
+ presetIdent,
283
+ rootOpsKey,
284
+ String(posCounter.n++),
285
+ domainName,
286
+ child.name,
287
+ "after",
288
+ artifacts,
289
+ ),
290
+ );
291
+ }
271
292
  }
272
293
  return { ...node, children };
273
294
  }
@@ -301,6 +322,20 @@ function prependCustomizedBanner(
301
322
  // edited down to zero non-exempt segments (no removeChrome/insertChrome
302
323
  // persist actions left to register it) doesn't orphan this exact click.
303
324
  artifacts.actions[actionName] = { reset: rootOpsKey };
325
+ // [LAW:one-source-of-truth] The banner reads `.preset.customized`, so THIS
326
+ // pass is what requires that variable — not whichever config happens to
327
+ // declare it. The bundled default does, which is why the dependency stayed
328
+ // invisible until the global settings menu made edit mode reachable from
329
+ // configs that never declared it, and the missing field surfaced as a ⚠ on
330
+ // the bar. Ensured, never overridden: a user declaration of the same name
331
+ // wins (see the merge in synthesizeEditChrome), so this only supplies the
332
+ // floor the synthesis itself depends on.
333
+ artifacts.ensured[PRESET_CUSTOMIZED_VAR] = {
334
+ kind: "input",
335
+ path: PRESET_CUSTOMIZED_VAR,
336
+ type: "boolean",
337
+ default: false,
338
+ };
304
339
  const label = escapeTemplateLiteral(presetName);
305
340
  artifacts.segments[chromeSegName] = {
306
341
  template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
@@ -391,6 +426,7 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
391
426
  if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
392
427
  const artifacts: ChromeArtifacts = {
393
428
  variables: {},
429
+ ensured: {},
394
430
  actions: {},
395
431
  segments: {},
396
432
  };
@@ -404,7 +440,11 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
404
440
  }
405
441
  return {
406
442
  ...config,
407
- variables: { ...config.variables, ...artifacts.variables },
443
+ variables: {
444
+ ...artifacts.ensured,
445
+ ...config.variables,
446
+ ...artifacts.variables,
447
+ },
408
448
  actions: { ...config.actions, ...artifacts.actions },
409
449
  segments: { ...config.segments, ...artifacts.segments },
410
450
  presets,
@@ -29,6 +29,13 @@ import {
29
29
  import { listGlobalsFieldNames } from "./globals.js";
30
30
  import { parsePersistTarget } from "./persist-target.js";
31
31
  import { presetNames } from "../presets.js";
32
+ import {
33
+ canHostSessionState,
34
+ countAnchors,
35
+ isSettingsAnchor,
36
+ SESSION_ID_VAR,
37
+ SETTINGS_ANCHOR,
38
+ } from "../settings-menu.js";
32
39
  import { ident } from "../ident.js";
33
40
  import { findKeyLine } from "./diagnostics.js";
34
41
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
@@ -248,11 +255,30 @@ export function validateCrossReferences(
248
255
  // never a second traversal that could learn a different idea of what a valid
249
256
  // layout is. This is what makes `cc-candybar check` catch a preset staging a
250
257
  // segment nobody declared.
258
+ //
259
+ // [LAW:single-enforcer] The menu precondition is asked once, of the same
260
+ // predicate synthesizeSettingsMenu gates on, and read by every tree walked.
261
+ const menuWillSynthesize = canHostSessionState(cfg);
251
262
  const checkLayoutTree = (
252
263
  root: LayoutNode,
253
264
  layoutKey: string,
254
265
  layoutLine: number | undefined,
255
266
  ): void => {
267
+ // [LAW:one-source-of-truth] The global settings menu's anchor is a POSITION
268
+ // an author may place and this walk must therefore accept, even though no
269
+ // config declares a segment by that name — synthesizeSettingsMenu provides
270
+ // it unconditionally, immediately after these checks pass. Two placements is
271
+ // the real error: one state key holds one open state, so a second anchor
272
+ // would be a second toggle writing one disclosure, with two bodies claiming
273
+ // to be it. Counted over the SAME census the synthesis reads, so "placed"
274
+ // means one thing [LAW:single-enforcer].
275
+ if (countAnchors(root) > 1) {
276
+ ctx.issues.push({
277
+ path: layoutKey,
278
+ message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}" ${countAnchors(root)} times — it may appear at most once per layout (it is one disclosure, and one state key holds one open state). Remove all but the placement you want; removing every placement puts the menu at its default position.`,
279
+ line: layoutLine,
280
+ });
281
+ }
256
282
  for (const node of walkNodes(root)) {
257
283
  // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
258
284
  // globals + namespaced segment vars) — the same existence-check shape as a
@@ -263,6 +289,24 @@ export function validateCrossReferences(
263
289
  });
264
290
  }
265
291
  if (node.kind !== "segment") continue;
292
+ if (isSettingsAnchor(node.name)) {
293
+ // [LAW:one-source-of-truth] Accepting the anchor asserts that
294
+ // synthesizeSettingsMenu WILL declare a segment by this name, so the
295
+ // acceptance reads the very predicate that pass decides by rather than
296
+ // assuming its answer. When it is false the reference is genuinely
297
+ // dangling, and the error names the unmet precondition: the author
298
+ // placed a documented anchor, they did not typo a segment name, and the
299
+ // generic "does not match any declared segment" would teach the wrong
300
+ // lesson [LAW:no-silent-failure].
301
+ if (!menuWillSynthesize) {
302
+ ctx.issues.push({
303
+ path: layoutKey,
304
+ message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}", but this config declares no "${SESSION_ID_VAR}" variable — the menu is a click surface and every click composes a URL from "${SESSION_ID_VAR}", so it is not synthesized for a config without it. Declare a "${SESSION_ID_VAR}" variable (any config merged onto the bundled default inherits one) or remove the anchor placement.`,
305
+ line: layoutLine,
306
+ });
307
+ }
308
+ continue;
309
+ }
266
310
  if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
267
311
  const renamed = RENAMED_SEGMENTS[node.name];
268
312
  const hint =
@@ -29,6 +29,20 @@
29
29
  // of this pass caused. The reserved namespace stays reserved unconditionally
30
30
  // (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
31
31
  // conditional.
32
+ //
33
+ // WHAT candybar-settings-ui-aok.1 CHANGED, and what it did not: the gate above
34
+ // is intact and still the only way edit mode is reached — but it now has a
35
+ // PERMANENT DEMANDER. synthesizeSettingsMenu mints a `settings.edit` segment
36
+ // referencing `edit.toggle` into every config it can host, so in practice the
37
+ // demand is satisfied for essentially every config a user writes, and reading
38
+ // this section as "most bars carry no edit mode" is no longer true. The
39
+ // separation the gate protects still holds exactly where it always mattered:
40
+ // the menu declines to synthesize for a config with no `session.id`
41
+ // (canHostSessionState in settings-menu.ts), which is precisely the static,
42
+ // non-interactive bar this comment was written to keep clean. Production blast
43
+ // radius was nil either way — the bundled default's `toolbar` segment already
44
+ // referenced `edit.toggle`, so every config merging it already demanded edit
45
+ // mode before the menu existed.
32
46
 
33
47
  import { createEngine } from "@promptctl/go-template-js";
34
48
  import type { Mutable, ValidateCtx } from "./validate-core.js";