@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,78 +0,0 @@
1
- // The one record describing the segment a template is being evaluated for.
2
- //
3
- // [LAW:one-source-of-truth] Several template features need to know something
4
- // about the enclosing segment — `{{ menu }}` derives its identity from the
5
- // segment's name, `{{ color }}` must read the segment's own (transposed)
6
- // palette, `{{ bgOf }}` must read the segment's own resolved background. Each
7
- // of those could have carried its own published "current segment" pointer, and
8
- // then two features could disagree about which segment is current. One record,
9
- // one publisher, one clock.
10
- //
11
- // [LAW:no-ambient-temporal-coupling] This is *published state*, not ambient
12
- // context: the render walk sets it before evaluating a segment's templates and
13
- // clears it after, and nothing else writes it. The phase structure within a
14
- // segment is likewise state rather than luck — `bg` is genuinely undefined
15
- // while the `bg:` template is itself being evaluated, because at that moment
16
- // the background is the thing being computed. Readers get a message naming the
17
- // phase instead of a plausible-looking wrong color.
18
-
19
- import type { ColorRgba, Palette } from "@promptctl/rich-js";
20
-
21
- export interface ActiveSegment {
22
- /** The segment's declared name — `{{ menu }}` derives its identity from it. */
23
- readonly segName: string;
24
- /**
25
- * The palette this segment's colors resolve from: the base theme (session
26
- * choice over config default, or an explicit per-segment `palette:` pin)
27
- * after the render's look and this segment's hue shift.
28
- *
29
- * Template bodies read colors through THIS, not through a palette captured
30
- * when the config was loaded — otherwise `{{ color "primary" }}` inside a
31
- * segment paints from a different palette than the cell it sits in.
32
- */
33
- readonly palette: Palette;
34
- /**
35
- * The segment's resolved background, once known.
36
- *
37
- * Undefined during evaluation of the segment's own `bg:` template — the
38
- * ordering is bg, then fg, then body, and a background cannot be an input to
39
- * computing itself.
40
- */
41
- bg: ColorRgba | undefined;
42
- }
43
-
44
- /** The published pointer. Null between segments. */
45
- export interface ActiveSegmentRef {
46
- current: ActiveSegment | null;
47
- }
48
-
49
- export function createActiveSegmentRef(): ActiveSegmentRef {
50
- return { current: null };
51
- }
52
-
53
- /**
54
- * Read the active segment, or fail with a message that says *why* nothing is
55
- * active rather than what is missing.
56
- *
57
- * [LAW:no-defensive-null-guards] Null here is never a state to route around —
58
- * it means a segment-scoped template function fired outside a segment render,
59
- * which is either a wiring bug or an author using the function somewhere it
60
- * cannot mean anything (a variable template, a node `when`). Both need to be
61
- * seen, and in cc-candybar a thrown template error surfaces as a visible ⚠
62
- * cell that `cc-candybar check` fails on. [LAW:no-silent-failure]
63
- */
64
- export function requireActiveSegment(
65
- ref: ActiveSegmentRef,
66
- func: string,
67
- ): ActiveSegment {
68
- const active = ref.current;
69
- if (active === null) {
70
- throw new Error(
71
- `{{ ${func} }} is only available inside a segment's templates — ` +
72
- `there is no active segment here. Segment-scoped functions cannot be ` +
73
- `used in variable declarations or layout-node "when" predicates, ` +
74
- `which are evaluated outside any segment.`,
75
- );
76
- }
77
- return active;
78
- }
@@ -1,23 +0,0 @@
1
- // The cc-candybar diagnostic visual identity: the ANSI style constants for
2
- // every error/warning surface we draw (the client's permanent-failure glyph
3
- // and the daemon's per-render diagnostic strip).
4
- //
5
- // [LAW:one-source-of-truth] This leaf module is the single TS definition of
6
- // the diagnostic style. Recoloring diagnostics is an edit here, nowhere else
7
- // — a partial restyle that ships an inconsistent error language is no longer
8
- // expressible within the Node runtime.
9
- //
10
- // [LAW:one-way-deps] A leaf: imports nothing, so both the daemon
11
- // (src/daemon/server.ts) and the client render path
12
- // (src/render/error-glyph.ts) can import it without daemon↔client coupling —
13
- // the same direction already used for ./diagnostic-text.
14
- //
15
- // The Rust client (rust-client/src/error_glyph.rs) cannot import a TS module,
16
- // so it mirrors the error trio as literal consts; scripts/check-protocol.mjs
17
- // diffs that mirror against this file and fails prepublishOnly on drift.
18
-
19
- export const DIAGNOSTIC_ERROR_FG = "\x1b[38;2;255;255;255m";
20
- export const DIAGNOSTIC_ERROR_BG = "\x1b[48;2;200;40;40m";
21
- export const DIAGNOSTIC_WARNING_FG = "\x1b[38;2;0;0;0m";
22
- export const DIAGNOSTIC_WARNING_BG = "\x1b[48;2;220;160;40m";
23
- export const ANSI_RESET = "\x1b[0m";
@@ -1,77 +0,0 @@
1
- // [LAW:one-source-of-truth] The single sanitize-and-truncate primitive for
2
- // any diagnostic text we embed inside a single-line, ANSI-styled envelope.
3
- // Two callers today:
4
- // - src/render/error-glyph.ts (permanent client-side glyph; budget 60)
5
- // - src/daemon/server.ts composeWithDiagnostics (per-render diagnostic
6
- // strip carrying the actual config error/warning message)
7
- // A third copy in the daemon would duplicate the security-critical
8
- // control-char neutralization rules; sharing the primitive guarantees the
9
- // rules cannot drift between callers.
10
- //
11
- // [LAW:types-are-the-program] Two functions, both pure (string, number)→
12
- // string|boolean. The contract is exactly: "make this text safe to splice
13
- // into a single-line ANSI-styled cell, clipped to maxLen visible code
14
- // points." Anything else is the caller's responsibility (which icon, which
15
- // colors, which click verb).
16
-
17
- const ELLIPSIS = "…";
18
-
19
- // [LAW:dataflow-not-control-flow] Every code point flows through the same
20
- // predicate. No special-case branches per kind of control; the Unicode Cc
21
- // class is the single discriminator that decides "this byte/code point
22
- // could hijack the envelope and must be neutralized."
23
- //
24
- // Why the C1 range matters (0x80..0x9F):
25
- // ESC (U+001B) is the obvious ANSI-escape entry point, but some
26
- // terminals interpret U+009B as 8-bit CSI directly — i.e. equivalent to
27
- // ESC `[`. Sanitizing only the C0 range (≤0x1F + 0x7F) would leave the
28
- // 8-bit bypass open. With diagnostic messages echoing user-supplied
29
- // data (config paths, key names, parse errors), that's reachable from
30
- // crafted input even without a malicious daemon.
31
- //
32
- // Mirrors rust-client/src/error_glyph.rs's truncate(): `char::is_control()`
33
- // matches the exact same Unicode Cc set, so the TS and Rust runtimes
34
- // neutralize the same byte classes. The Rust side also mirrors the
35
- // collapse-whitespace-runs + trim pass below, so the two runtimes produce
36
- // byte-identical output — both suites pin the same fixtures.
37
- export function isControlChar(code: number): boolean {
38
- return code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
39
- }
40
-
41
- // Sanitize control characters (→ single space) and clip to maxLen visible
42
- // code points, ending with an ellipsis if the input was longer. Runs of
43
- // whitespace (post-sanitize) collapse to a single space so multi-line
44
- // indented messages don't display as awkwardly-spaced single lines.
45
- //
46
- // [LAW:dataflow-not-control-flow] One pass over the input; the trailing
47
- // `.replace(/.$/u, ELLIPSIS)` is the only branch and only fires when we
48
- // hit the cap. The cap-then-ellipsis is the same pattern error-glyph used
49
- // pre-extraction (preserved byte-for-byte: visible length stays at maxLen
50
- // when truncation happens).
51
- export function sanitizeAndTruncate(text: string, maxLen: number): string {
52
- // First pass: sanitize control chars to spaces. We do this before the
53
- // length count because a control char and its replacement space are both
54
- // one visible code point in the output — equal contributions to length.
55
- let sanitized = "";
56
- for (const ch of text) {
57
- sanitized += isControlChar(ch.codePointAt(0) ?? 0) ? " " : ch;
58
- }
59
- // Collapse whitespace runs (introduced by newline→space + the existing
60
- // indentation in multi-line error messages). One space conveys the same
61
- // "this was a break in the source" information without wasting cells.
62
- sanitized = sanitized.replace(/\s+/g, " ").trim();
63
-
64
- // Truncate-with-ellipsis. The /.$/u regex matches a full code point
65
- // (unicode flag), not a UTF-16 unit — important for emoji and other
66
- // astral-plane chars in user-supplied paths.
67
- let out = "";
68
- let count = 0;
69
- for (const ch of sanitized) {
70
- if (count === maxLen) {
71
- return out.replace(/.$/u, ELLIPSIS);
72
- }
73
- out += ch;
74
- count++;
75
- }
76
- return out;
77
- }
@@ -1,53 +0,0 @@
1
- // One-line styled diagnostic glyph emitted on permanent daemon failures.
2
- //
3
- // [LAW:single-enforcer] One formatter per runtime. The Node entry in
4
- // src/index.ts calls formatPermanentGlyph; nothing else builds this string.
5
- //
6
- // [LAW:one-type-per-behavior] The Rust mirror at rust-client/src/error_glyph.rs
7
- // must produce byte-identical output for the same logical cause — both
8
- // runtimes show the same diagnostic so the user's experience does not depend
9
- // on which client is on the hot path. The mirrored constants are diffed by
10
- // scripts/check-protocol.mjs; the sanitize/collapse/truncate behavior is
11
- // pinned by paired fixture tests on both sides.
12
- //
13
- // [LAW:one-source-of-truth] Both shared primitives live in leaf modules under
14
- // ./: the style constants in ./diagnostic-style (shared with the daemon's
15
- // composeWithDiagnostics so the diagnostic visual language cannot drift) and
16
- // the sanitize-and-truncate primitive in ./diagnostic-text (shared with
17
- // src/daemon/server.ts so the security-critical control-char neutralization
18
- // (C0 + DEL + C1/8-bit-CSI) cannot drift between the two callers).
19
-
20
- import type { PermanentOutcome } from "../daemon/client-transport";
21
- import {
22
- ANSI_RESET,
23
- DIAGNOSTIC_ERROR_BG,
24
- DIAGNOSTIC_ERROR_FG,
25
- } from "./diagnostic-style";
26
- import { sanitizeAndTruncate } from "./diagnostic-text";
27
-
28
- const OPEN = `${DIAGNOSTIC_ERROR_BG}${DIAGNOSTIC_ERROR_FG}`;
29
- const PREFIX = "⚠ cc-candybar: ";
30
-
31
- // Long messages from the daemon (parse errors, internal exception strings)
32
- // can be arbitrarily long. The glyph must fit on a single statusline row, so
33
- // truncate to a budget that leaves room for the prefix at typical widths.
34
- const MAX_MESSAGE_LEN = 60;
35
-
36
- export function formatPermanentGlyph(outcome: PermanentOutcome): string {
37
- return `${OPEN}${PREFIX}${describe(outcome)}${ANSI_RESET}\n`;
38
- }
39
-
40
- function describe(outcome: PermanentOutcome): string {
41
- switch (outcome.cause) {
42
- case "version_mismatch": {
43
- const daemon = outcome.daemonV === 0 ? "unknown" : `v${outcome.daemonV}`;
44
- return `protocol mismatch (client v${outcome.clientV} ≠ daemon ${daemon})`;
45
- }
46
- case "bad_request":
47
- return `daemon rejected request: ${sanitizeAndTruncate(outcome.message, MAX_MESSAGE_LEN)}`;
48
- case "render_failed":
49
- return `render failed: ${sanitizeAndTruncate(outcome.message, MAX_MESSAGE_LEN)}`;
50
- case "malformed_response":
51
- return `malformed daemon response: ${sanitizeAndTruncate(outcome.message, MAX_MESSAGE_LEN)}`;
52
- }
53
- }
@@ -1,257 +0,0 @@
1
- // [LAW:locality-or-seam] The runtime half of the `{{ menu }}` seam — sibling to
2
- // `{{ action }}`/`{{ picker }}`. A menu is a self-contained disclosure: an inline
3
- // TRIGGER that toggles open/closed, and (when open) its body — a picker grid —
4
- // that DROPS onto the line(s) below the enclosing row. The body is the one picker
5
- // renderer (`renderPicker`); the trigger 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
- //
9
- // [LAW:one-source-of-truth] The trigger's TEXT is authored, never emitted here.
10
- // This module used to append ▸/▾ from the glyph constants, while the codebase's
11
- // other disclosure — group sugar — spliced those same constants into the
12
- // template it synthesized, where an author could see and change them. Two
13
- // policies for one fact; the docs sided with the visible one ("the trigger is
14
- // any template content you like") while a menu appended a glyph nobody wrote,
15
- // which is why edit mode's `+` rendered `+▸`. A menu's disclosure IS a
16
- // two-member cycle, so its trigger now binds displays exactly as a cycle
17
- // `{{ action }}` does, through the same `pickCycleDisplay` (candybar-settings-
18
- // ui-aok.4).
19
- //
20
- // [LAW:effects-at-boundaries] The helper is a PURE function of its inputs (the
21
- // walk-published placement + the live store): it computes the inline glyph and,
22
- // when open, the body, and RETURNS them together — the glyph as the fragment, the
23
- // body carried as out-of-band metadata on that returned RichText (a symbol the
24
- // segment boundary reads). It mutates no shared sink; the EFFECT of placing the
25
- // body below the row is performed at the boundary (collectMenuDrops + the segment
26
- // walk). Pure core returns a description; the edge performs it.
27
- //
28
- // [LAW:decomposition] The glyph and the body travel on SEPARATE channels: the
29
- // glyph is the visible fragment, the body rides as metadata invisible to the
30
- // inline render. This is the fix for the old `\n`-in-the-stream representation —
31
- // the body never enters the visible inline text, so a menu may sit ANYWHERE in a
32
- // template (content after it stays inline on row 0), and a segment may contain
33
- // ANY NUMBER of menus (each returned glyph carries its own body).
34
- //
35
- // [LAW:one-source-of-truth] A menu is CONTEXT-FREE about its NAME in the template
36
- // (it cannot see the segment it sits in), so the host segment name is published
37
- // into this runtime by the render walk before each segment's template evaluates.
38
- // The helper combines that segment name with its own apply-action arg (and an
39
- // optional shared key) to derive identity via menu-keys — the SAME derivation the
40
- // loader synthesis uses — so the rendered toggle and the loader-synthesized state
41
- // var + gate share one source.
42
- //
43
- // [LAW:dataflow-not-control-flow] Openness is the value of the menu's state key,
44
- // not a when-gated reveal: open ⇔ the state key holds THIS menu's member name.
45
- // The body metadata is a list whose length carries open/closed (1 open, 0 closed).
46
-
47
- import type { RichText } from "@promptctl/rich-js";
48
- import type { FuncMap } from "@promptctl/go-template-js";
49
- import {
50
- menuMember,
51
- menuPageKey,
52
- menuStateKey,
53
- parseMenuOptions,
54
- type MenuOptions,
55
- } from "../config/menu-keys.js";
56
- import { DISCLOSURE_CLOSED, pickCycleDisplay } from "../config/disclosure.js";
57
- import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
58
- import { linkFragment, readVar, type ActionRuntime } from "./action.js";
59
- import { renderPicker } from "./picker.js";
60
- import type { ActiveSegmentRef } from "./active-segment.js";
61
-
62
- // [LAW:one-type-per-behavior] A `{{ menu }}` needs one structural fact it cannot
63
- // see about itself — the name of the segment it renders inside. That used to be
64
- // its own `MenuPlacement` type; it is now a field on the ONE active-segment
65
- // record the walk publishes (see render/active-segment.ts), because "which
66
- // segment is rendering" is a single fact and a per-feature copy of it is a
67
- // second clock. The menu reads `segName` and ignores the rest.
68
-
69
- // [LAW:locality-or-seam] The runtime the `menu` func closes over. It shares the
70
- // ACTION runtime (the menu's glyph and body resolve their actions/state from the
71
- // same compiled table + store as every other helper) and READS the walk-published
72
- // active segment — both inputs, never written by the helper. The record is
73
- // mutated only by the single owner (the render walk, around each segment eval) —
74
- // the spatial cousin of the hue cursor, one mutator, never ambient.
75
- // [LAW:no-ambient-temporal-coupling]
76
- export interface MenuRuntime {
77
- readonly action: ActionRuntime;
78
- // [LAW:one-source-of-truth] The menu does not publish its own "which segment
79
- // is current" pointer — it reads the ONE record the render walk publishes for
80
- // every segment-scoped feature (the palette `{{ color }}` resolves against and
81
- // the background `{{ bgOf }}` returns ride the same record). A second pointer
82
- // would be a second clock for the same fact.
83
- readonly activeSegment: ActiveSegmentRef;
84
- }
85
-
86
- // [LAW:effects-at-boundaries] The body a `{{ menu }}` drops below its row rides as
87
- // out-of-band metadata on the returned glyph (a symbol the boundary reads), so the
88
- // helper returns a description rather than mutating a shared sink. A list whose
89
- // length carries open/closed — `[body]` open, `[]` closed.
90
- const MENU_DROP = Symbol("cc-candybar.menuDrop");
91
- type GlyphWithDrop = RichText & { [MENU_DROP]?: readonly RichText[] };
92
-
93
- // [LAW:single-enforcer] THE reader of the drop metadata, used by the segment
94
- // boundary (injected by the driver — node-registry never imports this module).
95
- // Scans a segment's evaluated fragments in template order and returns every menu
96
- // body carried on them; a fragment with no metadata contributes nothing.
97
- export function collectMenuDrops(
98
- fragments: readonly RichText[],
99
- ): readonly RichText[] {
100
- return fragments.flatMap((f) => (f as GlyphWithDrop)[MENU_DROP] ?? []);
101
- }
102
-
103
- // Realize a `{{ menu }}` against the live placement + state: return its inline
104
- // trigger, carrying the (open) body as out-of-band metadata for the boundary.
105
- function renderMenu(
106
- applyName: string,
107
- displays: readonly string[],
108
- options: MenuOptions,
109
- runtime: MenuRuntime,
110
- ): RichText {
111
- const placement = runtime.activeSegment.current;
112
- // [LAW:no-defensive-null-guards] The walk publishes a placement before every
113
- // segment template evaluates; a `{{ menu }}` only renders inside a segment. A
114
- // null here is a wiring bug (the func fired with no current segment), surfaced
115
- // loudly rather than rendering a placeless menu.
116
- if (placement === null) {
117
- throw new Error(
118
- "{{ menu }} rendered with no active segment placement — the render walk must publish one before evaluating a segment template",
119
- );
120
- }
121
- const action = runtime.action;
122
- // [LAW:one-source-of-truth] Identity — and the page-cursor key derived from it
123
- // — comes from the SAME menu-keys derivation the loader synthesis used, so the
124
- // key this render reads/writes is the key whose state var + int gate the
125
- // loader emitted. No page-action argument to mis-wire.
126
- const stateKey = menuStateKey(placement.segName, applyName, options.key);
127
- const pageKey = menuPageKey(stateKey);
128
- const member = menuMember(applyName);
129
-
130
- // [LAW:dataflow-not-control-flow] Open ⇔ the state key holds this menu's member.
131
- // A foreign value (an accordion sibling's member under a shared key) reads as
132
- // closed here — exactly the binary [closed, member] cycle the synthesized action
133
- // gates. This ONE read drives both the glyph and the body, so what the glyph
134
- // promises and what drops below cannot disagree.
135
- const open = readVar(action.store, stateKey) === member;
136
-
137
- // [LAW:one-source-of-truth] / [LAW:locality-or-seam] The disclosure click is ONE
138
- // atomic set-state that keeps the two split keys coherent: it toggles the open-
139
- // state (the binary cycle — successor is closed when open, the member when
140
- // closed) AND resets the page cursor to page 0, in one batch. So a reopened menu
141
- // is never stranded on a stale page left by ←/→ before the last close. This
142
- // mirrors the picker's closeOnPick page-reset fold: the picker builds its set-
143
- // state URLs directly (not via renderAction) so it can couple two writes; the
144
- // menu — the one part that knows BOTH the open-state key and the page key
145
- // [LAW:decomposition] — does the same. The synthesized cycle action stays the
146
- // GATE source (deriveActionValidators); both keys are independently gated, so the
147
- // coupled batch passes the same wire gate every click does [LAW:single-enforcer].
148
- const sessionId = readVar(action.store, "session.id");
149
- const successor = open ? DISCLOSURE_CLOSED : member;
150
- // [LAW:one-source-of-truth] The trigger's text is AUTHORED, resolved through
151
- // the one display rule a cycle `{{ action }}` uses — a menu's disclosure is a
152
- // two-member cycle, so binding `"▸" "▾"` gives the per-state form and binding
153
- // `"+"` gives the static one. Nothing is appended here: a disclosure glyph an
154
- // author never wrote is a glyph they cannot decline, which is exactly how
155
- // edit mode's `+` came to read `+▸`.
156
- const glyph = linkFragment(
157
- pickCycleDisplay(`{{ menu "${applyName}" }}`, displays, 2, open ? 1 : 0),
158
- effectsUrl([
159
- {
160
- verb: VERB_SET_STATE,
161
- args: [sessionId, stateKey, successor, pageKey, "0"],
162
- },
163
- ]),
164
- false,
165
- ) as GlyphWithDrop;
166
-
167
- // [LAW:effects-at-boundaries] The body is a VALUE whose length carries open/
168
- // closed — `[body]` open, `[]` closed — attached to the glyph the helper returns.
169
- // No shared mutation: the boundary reads this metadata to place the body.
170
- // (renderPicker is pure, so it is only built when open — skipping wasted
171
- // computation, gating no effect.)
172
- // [LAW:one-source-of-truth] The body's page cursor is the identity-derived
173
- // key (its synthesized state var is named by it, the disclosure-var
174
- // convention), and CLOSING — the ✕ affordance or a closeOnPick pick — writes
175
- // the disclosure back to the closed sentinel and resets the page, the same
176
- // coupled pair the toggle glyph above writes. What the ▾ promised, ✕ delivers.
177
- glyph[MENU_DROP] = open
178
- ? [
179
- renderPicker(
180
- applyName,
181
- { key: pageKey, stateVar: pageKey },
182
- [
183
- [stateKey, DISCLOSURE_CLOSED],
184
- [pageKey, "0"],
185
- ],
186
- options.closeOnPick,
187
- options.paged,
188
- action,
189
- ),
190
- ]
191
- : [];
192
- return glyph;
193
- }
194
-
195
- // [LAW:parse-dont-validate] THE crossing for a `{{ menu }}`'s argument tail.
196
- // The engine cannot type these slots for us — displays are strings and the
197
- // optional trailing knobs are a dict, so one declared slot type would refuse
198
- // one of them — so the tail arrives as opaque values and leaves here as a
199
- // record whose shape the renderer can no longer doubt: displays are strings,
200
- // options are parsed. Every rejected shape names the legal one.
201
- interface MenuArgs {
202
- readonly displays: readonly string[];
203
- readonly options: MenuOptions;
204
- }
205
- // [LAW:one-source-of-truth] This splits the tail on VALUES; the loader splits
206
- // the same tail on EXPRS (`menu-synth.ts`). They agree because the loader admits
207
- // only call sites where they provably must: a last argument that is neither a
208
- // string literal nor a literal `(dict …)` is a load error whenever both readings
209
- // would be legal, so what reaches here can only match the loader's reading or
210
- // throw below.
211
- const isDict = (v: unknown): v is Record<string, unknown> =>
212
- typeof v === "object" && v !== null && !Array.isArray(v);
213
-
214
- function parseMenuArgs(applyName: string, tail: readonly unknown[]): MenuArgs {
215
- // The dict is the LAST argument when present; everything before it is a
216
- // display. One position, so a reader never has to count.
217
- const last = tail[tail.length - 1];
218
- const optsArg = isDict(last) ? last : undefined;
219
- const displayArgs = optsArg === undefined ? tail : tail.slice(0, -1);
220
- const bad = displayArgs.findIndex((d) => typeof d !== "string");
221
- if (bad !== -1) {
222
- throw new Error(
223
- `{{ menu "${applyName}" }} display #${bad + 1} is not text (${JSON.stringify(displayArgs[bad])}) — a menu binds its trigger text, then an optional trailing (dict …) of options`,
224
- );
225
- }
226
- return {
227
- displays: displayArgs as readonly string[],
228
- // [LAW:one-source-of-truth] The same option reader the loader folds over
229
- // the static dict — vocabulary, types, defaults live once.
230
- options: parseMenuOptions(optsArg ?? {}),
231
- };
232
- }
233
-
234
- // [LAW:dataflow-not-control-flow] One func; the apply-action NAME is the menu's
235
- // whole identity (the page cursor is derived from it, not passed), the TRIGGER
236
- // TEXT is bound like a cycle action's display (one per state, or one static),
237
- // and the rare knobs travel as ONE optional trailing `(dict …)` — closeOnPick
238
- // (default false: stay-open), paged (default true: a drop menu wants bounded
239
- // height), key (accordion grouping: omitted ⇒ independent, present ⇒ mutually
240
- // exclusive with siblings sharing it). Values, not modes. The loader gates the
241
- // same dict statically (staticDictEntries), so an old positional tail never
242
- // reaches this fn — it is a migration-pointing load error.
243
- //
244
- // [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
245
- // generic engine never imports this module.
246
- export function menuFuncs(runtime: MenuRuntime): FuncMap {
247
- return {
248
- menu: {
249
- fn: (applyName: string, ...tail: unknown[]) => {
250
- const { displays, options } = parseMenuArgs(applyName, tail);
251
- return renderMenu(applyName, displays, options, runtime);
252
- },
253
- argTypes: ["string", "value"],
254
- returnType: "T",
255
- },
256
- };
257
- }
@@ -1,45 +0,0 @@
1
- // Pure mapping from ClientOutcome to a render plan: what to write, whether
2
- // to kick a fresh daemon, and (if relevant) the debug message that describes
3
- // why. All variability lives in the returned value — including the debug
4
- // string — so this module has no observable side effects. The runtime
5
- // composition in src/index.ts consumes the plan and owns every side effect
6
- // (debug logging, kick, write, exit).
7
- //
8
- // [LAW:dataflow-not-control-flow] Variability lives in the returned values
9
- // (output string, kick flag, debug message). The caller's debug/kick/write/
10
- // exit run unconditionally against those values — no caller-side branching
11
- // on outcome.kind.
12
- //
13
- // [LAW:types-are-the-program] Exhaustive over ClientOutcome.kind. Adding a
14
- // new variant fails typecheck rather than silently falling out the bottom
15
- // of the switch.
16
-
17
- import { formatPermanentGlyph } from "./error-glyph";
18
- import type { ClientOutcome } from "../daemon/client";
19
-
20
- export interface OutcomePlan {
21
- output: string;
22
- kick: boolean;
23
- // Debug message the caller should log, or null when there is nothing
24
- // worth logging (the "ok" path). Held as data so this module stays pure.
25
- debug: string | null;
26
- }
27
-
28
- export function planOutcome(outcome: ClientOutcome): OutcomePlan {
29
- switch (outcome.kind) {
30
- case "ok":
31
- return { output: outcome.value, kick: false, debug: null };
32
- case "transient":
33
- return {
34
- output: "\n",
35
- kick: true,
36
- debug: `daemon unavailable (transient: ${outcome.cause}: ${outcome.message}) — kicking daemon`,
37
- };
38
- case "permanent":
39
- return {
40
- output: formatPermanentGlyph(outcome),
41
- kick: false,
42
- debug: `daemon refused request (permanent: ${outcome.cause}) — not kicking`,
43
- };
44
- }
45
- }