@promptctl/cc-candybar 1.8.2 → 1.8.4

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.8.2",
3
+ "version": "1.8.4",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.8.2",
95
- "@promptctl/cc-candybar-darwin-x64": "1.8.2",
96
- "@promptctl/cc-candybar-linux-x64": "1.8.2",
97
- "@promptctl/cc-candybar-linux-arm64": "1.8.2"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.8.4",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.8.4",
96
+ "@promptctl/cc-candybar-linux-x64": "1.8.4",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.8.4"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -496,10 +496,12 @@ export const DEFAULT_DSL_CONFIG = {
496
496
  // and the render's read are one value. Empty default ⇒ the daemon's
497
497
  // "powerline" floor is in effect and styleControl shows "(default)".
498
498
  activeStyle: { kind: "state", key: "style", default: "" },
499
- // The style menu's page cursor: −1 closed / 0..N open, mirroring how a theme
500
- // picker's page key gates its reveal row. The stylePage action declares the
501
- // int gate; this var reads it back for the reveal `when`.
502
- stylePage: { kind: "state", key: "style-page", default: "-1" },
499
+ // The style picker's page index. Open/closed is the synthesized menus.* key
500
+ // the styleControl's {{ menu }} owns NOT this var, which is purely the page
501
+ // the picker paginates by (default page 0; the disclosure resets it to 0 on
502
+ // every toggle, so a reopened menu never lands on a stale page). The stylePage
503
+ // action below declares the int gate this var reads back.
504
+ stylePage: { kind: "state", key: "style-page", default: "0" },
503
505
  },
504
506
 
505
507
  // ─── Segments ──────────────────────────────────────────────────────────────
@@ -736,26 +738,22 @@ export const DEFAULT_DSL_CONFIG = {
736
738
  " .metrics.sessionDuration .metrics.messageCount" +
737
739
  " .metrics.linesAdded .metrics.linesRemoved }}",
738
740
  },
739
- // Style control — the powerline-shape switcher's trigger. [LAW:locality-or-
740
- // seam] The ✦ glyph + current-style label is the REPRESENTATION; the
741
- // `openStyleMenu` action is the BEHAVIOR; the name is the seam. Shows the
742
- // active shape (or "(default)" when unset) and a that opens the picker
743
- // row. [LAW:dataflow-not-control-flow] No display state from the provider —
744
- // the label is the one "style" value the click writes and the render reads.
741
+ // Style control — the powerline-shape switcher. A self-contained {{ menu }}
742
+ // disclosure: the ✦ glyph + current-style label is the REPRESENTATION, and the
743
+ // ▸/▾ disclosure (whose identity is DERIVED from this segment + the applyStyle
744
+ // action, see menu-keys.ts) toggles the picker body, which DROPS full-width
745
+ // onto the line below this row when open. [LAW:one-type-per-behavior] "a menu
746
+ // that opens and closes" is one behavior the substrate already expresses no
747
+ // bespoke open-action + page-cursor-as-open-state + when-gated reveal row.
748
+ // paged omitted ⇒ false ⇒ one full-width page (the 3 powerline shapes are a
749
+ // small domain; FlexStrip soft-wraps if needed). closeOnPick omitted ⇒ false
750
+ // ⇒ stay-open, so shapes can be tried in a row; the ▾ collapses.
751
+ // [LAW:dataflow-not-control-flow] No display state from the provider — the
752
+ // label is the one "style" value the click writes and the render reads.
745
753
  styleControl: {
746
754
  template:
747
755
  "✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
748
- '{{ action "openStyleMenu" "" }}',
749
- bg: "surface",
750
- fg: "foreground",
751
- },
752
- // The expanded style picker: one full-width page (paged=false) over the
753
- // `applyStyle` option domain — the 3 powerline shapes. closeOnPick defaults
754
- // false (omitted here), so a pick reshapes the bar live and LEAVES THE ROW
755
- // OPEN — shapes can be tried in a row; the ✕ affordance closes. The active
756
- // shape is marked by the picker helper.
757
- stylePicker: {
758
- template: '{{ picker "applyStyle" "stylePage" }}',
756
+ '{{ menu "applyStyle" "stylePage" }}',
759
757
  bg: "surface",
760
758
  fg: "foreground",
761
759
  },
@@ -764,10 +762,11 @@ export const DEFAULT_DSL_CONFIG = {
764
762
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
765
763
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
766
764
  // the loader's authoring surface for user JSON, not this typed literal).
767
- // [LAW:dataflow-not-control-flow] Two rows: an always-on control row, and a
768
- // picker reveal row that EXISTS only while the style menu cursor is 0 — the
769
- // row's presence is a value test on stylePage, not a branch in render code.
770
- // The picker itself draws the ✕/←/→ affordances from the page + term width.
765
+ // [LAW:dataflow-not-control-flow] One always-on control row. The styleControl's
766
+ // {{ menu }} disclosure drops its picker body onto the line directly below this
767
+ // row when open — openness is the value of the derived menus.* key, NOT a when-
768
+ // gated reveal row. The picker draws the ✕/←/→ affordances from the page + term
769
+ // width; the vertical container stacks the dropped body under row 0.
771
770
  root: {
772
771
  kind: "container",
773
772
  direction: "vertical",
@@ -786,11 +785,6 @@ export const DEFAULT_DSL_CONFIG = {
786
785
  { kind: "segment", name: "styleControl" },
787
786
  ],
788
787
  },
789
- {
790
- kind: "segment",
791
- name: "stylePicker",
792
- when: "{{ ge (int .stylePage) 0 }}",
793
- },
794
788
  ],
795
789
  },
796
790
 
@@ -814,14 +808,16 @@ export const DEFAULT_DSL_CONFIG = {
814
808
  openProject: { open: "{{ .project_dir }}" },
815
809
  openTranscript: { open: "{{ .transcript_path }}" },
816
810
 
817
- // [LAW:locality-or-seam] The style picker's behaviors, decoupled by NAME
818
- // from styleControl/stylePicker above. Three declarations, all gated by
819
- // derivation (deriveActionValidators): openStyleMenu/stylePage write the
820
- // page cursor (a literal page-open subsumed by the int gate); applyStyle
821
- // writes the chosen shape, gated to the STRIP_STYLES allow-list because its
822
- // value source is `from: "styles"`. The rendered click and the wire gate
823
- // share that one source a template cannot smuggle an un-gated style write.
824
- openStyleMenu: { set: "style-page", to: "0" },
811
+ // [LAW:locality-or-seam] The style menu's behaviors, decoupled by NAME from the
812
+ // styleControl {{ menu }} above. The disclosure's open-state toggle + its
813
+ // backing state var are SYNTHESIZED by the menu pass (under the reserved
814
+ // menus.* namespace) no hand-authored open/close action. These two are the
815
+ // picker body's effects, both gated by derivation (deriveActionValidators):
816
+ // applyStyle writes the chosen shape, gated to the STRIP_STYLES allow-list
817
+ // because its value source is `from: "styles"`; stylePage is the page cursor's
818
+ // unbounded int gate (←/→ navigation + the disclosure's page-0 reset). The
819
+ // rendered click and the wire gate share that one source — a template cannot
820
+ // smuggle an un-gated style write.
825
821
  applyStyle: { set: "style", from: "styles" },
826
822
  stylePage: { set: "style-page", int: true },
827
823
  },
package/src/dsl/render.ts CHANGED
@@ -282,6 +282,10 @@ export function registerDslConfig(
282
282
  const actionRuntime: ActionRuntime = {
283
283
  store: registry.variableStore,
284
284
  compiled: new Map(),
285
+ // [LAW:types-are-the-program] Always present — renderDsl republishes the live
286
+ // style each render; "powerline" is the registration-time default so a
287
+ // compile-only path (no render) still has a valid value.
288
+ stripStyle: "powerline",
285
289
  };
286
290
  // [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
287
291
  // stays generic. The picker shares the ACTION runtime (it resolves its
@@ -517,11 +521,22 @@ export function renderDsl(
517
521
  perSegmentSink?: Map<string, readonly RichText[]>,
518
522
  ): string {
519
523
  // [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
520
- // SAME opts.width the strip wraps to (below), so a width-paginated widget
521
- // reads the exact wrap width — never a cached or independently-measured copy.
524
+ // SAME opts.width the strip wraps to (below), so a width-paginated widget reads
525
+ // the exact wrap width — never a cached or independently-measured copy. This is
526
+ // the RAW usable width (terminal cols minus the Claude-Code reserve), the honest
527
+ // meaning every template — incl. user configs reading `.term.cols` — expects.
528
+ // The picker's strip-chrome reservation is NOT folded in here: that is a
529
+ // picker-local concern (the strip's end-caps wrap the picker's row, not every
530
+ // segment), applied at the pagination seam in renderPicker. [LAW:locality-or-seam]
522
531
  // Spreading a non-object payload yields no keys (compile-only callers), so the
523
532
  // width is set regardless without a trust-boundary guard.
524
533
  registry.applyInput({ ...(payload as object), term: { cols: opts.width } });
534
+ // [LAW:single-enforcer] Publish the render's strip style onto the shared action
535
+ // runtime so the picker can reserve the joiner's end-cap chrome at its
536
+ // pagination seam (the menu body renders through the same renderPicker). Set
537
+ // once per render here — the same one-owner, per-render-mutation idiom as the
538
+ // menu placement cursor below. [LAW:no-ambient-temporal-coupling]
539
+ compiled.menuRuntime.action.stripStyle = opts.style;
525
540
 
526
541
  const scope = buildScope(store);
527
542
  // [LAW:one-source-of-truth] hueStep is a value in the store like every other
@@ -26,7 +26,11 @@ import type { VariableStore } from "../var-system/store.js";
26
26
  import { toString as varToString } from "../var-system/types.js";
27
27
  import { buildScope } from "../template-engine/scope.js";
28
28
  import type { ActionDecl, OptionSource } from "../config/action.js";
29
- import { listResolvablePaletteNames, STRIP_STYLES } from "../themes/policy.js";
29
+ import {
30
+ listResolvablePaletteNames,
31
+ STRIP_STYLES,
32
+ type StripStyle,
33
+ } from "../themes/policy.js";
30
34
  import {
31
35
  effectsUrl,
32
36
  VERB_COPY,
@@ -123,6 +127,13 @@ export interface ActionRuntime {
123
127
  // values from the same store the renderer reads.
124
128
  store: VariableStore;
125
129
  compiled: CompiledActions;
130
+ // [LAW:locality-or-seam] The current render's strip style, published per render
131
+ // by renderDsl. The picker reads it to reserve the joiner's end-cap chrome at
132
+ // its pagination seam — the one place that needs strip geometry, kept off the
133
+ // shared `term.cols` budget. Defaulted at registration; renders are sequential
134
+ // and synchronous, so the per-render write never leaks across renders.
135
+ // [LAW:no-ambient-temporal-coupling]
136
+ stripStyle: StripStyle;
126
137
  }
127
138
 
128
139
  // ─── Compilation ───────────────────────────────────────────────────────────────
@@ -1,10 +1,10 @@
1
1
  // [LAW:locality-or-seam] The runtime half of the `{{ menu }}` seam — sibling to
2
2
  // `{{ action }}`/`{{ picker }}`. A menu is a self-contained disclosure: an inline
3
3
  // glyph that toggles open/closed, and (when open) its body — a picker grid —
4
- // that DROPS onto the line(s) below the enclosing row. It composes the two
5
- // existing helpers, adding only the disclosure: the glyph is realized through the
6
- // synthesized cycle action (`renderAction`), the body through the one picker
7
- // renderer (`renderPicker`).
4
+ // that DROPS onto the line(s) below the enclosing row. The body is the one picker
5
+ // renderer (`renderPicker`); the glyph is a coupled set-state the menu composes
6
+ // directly (like the picker's closeOnPick) it toggles the open-state AND resets
7
+ // the page cursor in one atomic batch, gated by the synthesized cycle action.
8
8
  //
9
9
  // [LAW:effects-at-boundaries] The helper is a PURE function of its inputs (the
10
10
  // walk-published placement + the live store): it computes the inline glyph and,
@@ -36,13 +36,14 @@
36
36
  import type { RichText } from "@promptctl/rich-js";
37
37
  import type { FuncMap } from "@promptctl/go-template-js";
38
38
  import {
39
+ MENU_CLOSED,
39
40
  MENU_GLYPH_CLOSED,
40
41
  MENU_GLYPH_OPEN,
41
- menuActionName,
42
42
  menuMember,
43
43
  menuStateKey,
44
44
  } from "../config/menu-keys.js";
45
- import { readVar, renderAction, type ActionRuntime } from "./action.js";
45
+ import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
46
+ import { linkFragment, readVar, type ActionRuntime } from "./action.js";
46
47
  import { renderPicker } from "./picker.js";
47
48
 
48
49
  // [LAW:types-are-the-program] One menu placement: the structural fact a context-
@@ -82,6 +83,22 @@ export function collectMenuDrops(
82
83
  return fragments.flatMap((f) => (f as GlyphWithDrop)[MENU_DROP] ?? []);
83
84
  }
84
85
 
86
+ // [LAW:single-enforcer] The page cursor's SessionState key, resolved from the page
87
+ // ACTION name the menu binds (its second arg). renderPicker proves pageName is a
88
+ // set-int when it builds the body, but the disclosure must reset the page even
89
+ // while CLOSED (no body is built then), so it resolves the key here too — the SAME
90
+ // set-int action, one source. A non-int page arg is an author error surfaced
91
+ // loudly (composeWithDiagnostics shows it), never a silent skipped reset.
92
+ function pageKeyOf(action: ActionRuntime, pageName: string): string {
93
+ const page = action.compiled.get(pageName);
94
+ if (!page || page.kind !== "set-int") {
95
+ throw new Error(
96
+ `{{ menu }} page action "${pageName}" must be an int action ({ set, int: true })`,
97
+ );
98
+ }
99
+ return page.key;
100
+ }
101
+
85
102
  // Realize a `{{ menu }}` against the live placement + state: return its inline
86
103
  // glyph, carrying the (open) body as out-of-band metadata for the boundary.
87
104
  function renderMenu(
@@ -106,25 +123,51 @@ function renderMenu(
106
123
  const stateKey = menuStateKey(placement.segName, applyName, sharedKey);
107
124
  const member = menuMember(applyName);
108
125
 
109
- // [LAW:single-enforcer] The disclosure glyph IS the synthesized cycle action —
110
- // displays bound one-per-member (closed ▸ / open ▾), the current member's glyph
111
- // renders, the click writes the successor. Same toggle as group sugar.
112
- const glyph = renderAction(
113
- menuActionName(stateKey, member),
114
- [MENU_GLYPH_CLOSED, MENU_GLYPH_OPEN],
115
- action,
116
- );
117
-
118
126
  // [LAW:dataflow-not-control-flow] Open ⇔ the state key holds this menu's member.
119
- // The body is a VALUE whose length carries open/closed `[body]` open, `[]`
120
- // closed — attached to the glyph the helper returns. No shared mutation: the
121
- // boundary reads this metadata to place the body. (renderPicker is pure, so it
122
- // is only built when open skipping wasted computation, gating no effect.)
127
+ // A foreign value (an accordion sibling's member under a shared key) reads as
128
+ // closed here exactly the binary [closed, member] cycle the synthesized action
129
+ // gates. This ONE read drives both the glyph and the body, so what the glyph
130
+ // promises and what drops below cannot disagree.
123
131
  const open = readVar(action.store, stateKey) === member;
124
- const bodyLines = open
132
+
133
+ // [LAW:one-source-of-truth] / [LAW:locality-or-seam] The disclosure click is ONE
134
+ // atomic set-state that keeps the two split keys coherent: it toggles the open-
135
+ // state (the binary cycle — successor is closed when open, the member when
136
+ // closed) AND resets the page cursor to page 0, in one batch. So a reopened menu
137
+ // is never stranded on a stale page left by ←/→ before the last close. This
138
+ // mirrors the picker's closeOnPick page-reset fold: the picker builds its set-
139
+ // state URLs directly (not via renderAction) so it can couple two writes; the
140
+ // menu — the one part that knows BOTH the open-state key and the page key
141
+ // [LAW:decomposition] — does the same. The synthesized cycle action stays the
142
+ // GATE source (deriveActionValidators); both keys are independently gated, so the
143
+ // coupled batch passes the same wire gate every click does [LAW:single-enforcer].
144
+ const sessionId = readVar(action.store, "session.id");
145
+ const successor = open ? MENU_CLOSED : member;
146
+ const glyph = linkFragment(
147
+ open ? MENU_GLYPH_OPEN : MENU_GLYPH_CLOSED,
148
+ effectsUrl([
149
+ {
150
+ verb: VERB_SET_STATE,
151
+ args: [
152
+ sessionId,
153
+ stateKey,
154
+ successor,
155
+ pageKeyOf(action, pageName),
156
+ "0",
157
+ ],
158
+ },
159
+ ]),
160
+ false,
161
+ ) as GlyphWithDrop;
162
+
163
+ // [LAW:effects-at-boundaries] The body is a VALUE whose length carries open/
164
+ // closed — `[body]` open, `[]` closed — attached to the glyph the helper returns.
165
+ // No shared mutation: the boundary reads this metadata to place the body.
166
+ // (renderPicker is pure, so it is only built when open — skipping wasted
167
+ // computation, gating no effect.)
168
+ glyph[MENU_DROP] = open
125
169
  ? [renderPicker(applyName, pageName, closeOnPick, paged, action)]
126
170
  : [];
127
- (glyph as GlyphWithDrop)[MENU_DROP] = bodyLines;
128
171
  return glyph;
129
172
  }
130
173
 
@@ -26,6 +26,7 @@
26
26
  import { RichText } from "@promptctl/rich-js";
27
27
  import type { FuncMap } from "@promptctl/go-template-js";
28
28
  import { toNumber } from "../var-system/types.js";
29
+ import { stripChromeCols } from "./strip.js";
29
30
  import { TERM_COLS_VAR } from "../config/dsl-types.js";
30
31
  import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
31
32
  import {
@@ -157,7 +158,23 @@ export function renderPicker(
157
158
  // unconditionally is self-fulfilling (a run that fits with just ✕ could be
158
159
  // forced to split, making arrows appear unnecessarily). In wrap mode
159
160
  // (available = Infinity) paginate yields one page, so neither pass splits.
160
- const available = paged ? toNumber(store.read(TERM_COLS_VAR)) : Infinity;
161
+ //
162
+ // [LAW:locality-or-seam] term.cols is the raw usable width the strip wraps to;
163
+ // the picker's row is itself a styled strip segment, so the joiner brackets it
164
+ // with end-caps (powerline's trailing separator, capsule's two caps) painted
165
+ // OUTSIDE that width. A page packed to the full term.cols is pushed past it by
166
+ // the caps — the maximally-packed middle pages overflowed and the terminal ate
167
+ // the trailing → (page 0 fit, page N did not). Reserve the chrome HERE, at the
168
+ // pagination seam, rather than shrinking the shared term.cols every template
169
+ // reads. stripChromeCols owns the per-style geometry; Infinity − chrome stays
170
+ // Infinity, so wrap mode is unaffected.
171
+ const available = paged
172
+ ? Math.max(
173
+ 1,
174
+ toNumber(store.read(TERM_COLS_VAR)) -
175
+ stripChromeCols(runtime.stripStyle),
176
+ )
177
+ : Infinity;
161
178
  const closeReserve = cellWidth(PICKER_CLOSE) + 1;
162
179
  const arrowReserve = cellWidth(PICKER_PREV) + 1 + cellWidth(PICKER_NEXT) + 1;
163
180
  const firstPass = paginate(widths, available, closeReserve);
@@ -61,6 +61,35 @@ function pickJoiner(style: StripStyle, separator?: string): Joiner {
61
61
  }
62
62
  }
63
63
 
64
+ // [LAW:single-enforcer] Strip geometry has one owner — this module builds every
65
+ // joiner (pickJoiner) and so alone knows the structural chrome a styled row costs
66
+ // beyond its content: the joiner's end-caps, which FlexStrip paints OUTSIDE the
67
+ // width budget. A single full-width row's content can occupy only `width - chrome`
68
+ // before the caps push the line past `width`. Returned per style so a width-fit
69
+ // widget (the picker) can reserve it and never overflow the wrapped line.
70
+ //
71
+ // [LAW:dataflow-not-control-flow] / [LAW:types-are-the-program] Total over
72
+ // StripStyle — the `never` default makes adding a STRIP_STYLES member a compile
73
+ // error here until its chrome is declared, the same guard pickJoiner carries. The
74
+ // numbers are the default cap glyphs pickJoiner constructs: powerline appends ONE
75
+ // trailing separator (U+E0B0, 1 col); capsule brackets BOTH edges (U+E0B6 +
76
+ // U+E0B4, 2 cols); plain has no caps. test/picker-pagination.test.ts measures the
77
+ // real rendered chrome against these so the declaration cannot drift from rich-js.
78
+ export function stripChromeCols(style: StripStyle): number {
79
+ switch (style) {
80
+ case "powerline":
81
+ return 1;
82
+ case "capsule":
83
+ return 2;
84
+ case "plain":
85
+ return 0;
86
+ default: {
87
+ const _exhaustive: never = style;
88
+ return _exhaustive;
89
+ }
90
+ }
91
+ }
92
+
64
93
  function toCell(seg: RenderedSegmentLike): RichText {
65
94
  // Padding mirrors the legacy buildLineFromSegments: one space on each side
66
95
  // of the segment text. The joiners sit between cells; padding sits inside.