@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.
- package/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- 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
|
-
}
|
package/src/render/menu.ts
DELETED
|
@@ -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
|
-
}
|