@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,458 @@
1
+ // [LAW:locality-or-seam] The runtime half of the actions seam. A `{{ action
2
+ // "name" display [boundValue] }}` call binds one clickable region (an OSC-8 span)
3
+ // to a statically-declared, named action. This module compiles the action table
4
+ // (pre-parsing copy/open templates once) and realizes a named action against the
5
+ // live state into ONE RichText whose span carries the click URL.
6
+ //
7
+ // [LAW:one-source-of-truth] The action NAME is the seam: the template supplies
8
+ // the REPRESENTATION (the display text), the action declaration supplies the
9
+ // BEHAVIOR (what value is written / copied / opened). The same declaration that
10
+ // realizes this click derives the wire gate (deriveActionValidators), so the
11
+ // rendered click and the gate cannot diverge.
12
+ //
13
+ // [LAW:dataflow-not-control-flow] `{{ action … }}` is ONE template expression, so
14
+ // it emits ONE value — a RichText carrying one OSC-8 span. The realization is a
15
+ // single total fold over the compiled-action union: each arm projects (effect,
16
+ // active) as DATA, never a branch that skips work.
17
+ //
18
+ // [LAW:one-way-deps] This is the action feature's runtime. It lives in render/
19
+ // (which depends on template-engine/), reads template-engine/scope, and is
20
+ // injected into the engine by the caller (registerDslConfig hands the action
21
+ // FuncMap in as data). The generic engine never imports this module.
22
+
23
+ import { RichText, Style } from "@promptctl/rich-js";
24
+ import type { FuncMap, Template } from "@promptctl/go-template-js";
25
+ import type { VariableStore } from "../var-system/store.js";
26
+ import { toString as varToString } from "../var-system/types.js";
27
+ import { buildScope } from "../template-engine/scope.js";
28
+ import type { ActionDecl, OptionSource } from "../config/action.js";
29
+ import { listResolvablePaletteNames, STYLE_ORDER } from "../themes/policy.js";
30
+ import {
31
+ effectsUrl,
32
+ VERB_COPY,
33
+ VERB_OPEN_VSCODE,
34
+ VERB_SET_STATE,
35
+ VERB_STEP_STATE,
36
+ type Effect,
37
+ } from "../click/wire.js";
38
+
39
+ // ─── Compiled shapes ───────────────────────────────────────────────────────────
40
+
41
+ // [LAW:types-are-the-program] A compiled action mirrors ActionDecl, discriminated
42
+ // by `kind`. Each `set` arm carries the SessionState `key` it writes plus the
43
+ // `stateVar` that reads it back (resolved from the key, so the displayed/active
44
+ // value and the written value are one source — the same resolution a stepper
45
+ // widget uses). A literal carries its fixed `value`; an option binds the value
46
+ // from the template at render; a bounded carries its [min,max]/by navigation.
47
+ // copy/open carry a pre-parsed template evaluated against the live scope.
48
+ export type CompiledActionDecl =
49
+ | {
50
+ readonly kind: "set-literal";
51
+ readonly key: string;
52
+ readonly value: string;
53
+ readonly stateVar: string;
54
+ }
55
+ | {
56
+ readonly kind: "set-option";
57
+ readonly key: string;
58
+ readonly stateVar: string;
59
+ // The resolved option domain. Stored at compile so a picker can iterate it
60
+ // without re-resolving the source list, and so the set-option IS
61
+ // self-describing (it knows its own domain), not just a key.
62
+ readonly options: readonly string[];
63
+ }
64
+ | {
65
+ // [LAW:types-are-the-program] A stepper affordance. It carries ONLY the
66
+ // render-invariant click intent: the state `key` and the signed delta `by`.
67
+ // It deliberately holds NO stateVar/min/max and reads NO current value at
68
+ // render — the absolute target is computed at APPLY time from live state
69
+ // (the daemon's step-state handler), so the emitted link is byte-identical
70
+ // across renders and N rapid clicks each re-read-and-step. [LAW:one-source-
71
+ // of-truth] the bounds live once in the range validator the handler reads.
72
+ readonly kind: "set-bounded";
73
+ readonly key: string;
74
+ readonly by: number;
75
+ }
76
+ | {
77
+ // [LAW:types-are-the-program] An int cursor: it writes whatever integer the
78
+ // render binds (the picker's page nav supplies -1/p±1; a bare `{{ action }}`
79
+ // supplies its display/boundValue). The gate is an unbounded int — the
80
+ // renderer owns clamping to valid pages, exactly as set-bounded owns wrap.
81
+ readonly kind: "set-int";
82
+ readonly key: string;
83
+ readonly stateVar: string;
84
+ }
85
+ | {
86
+ // [LAW:types-are-the-program] An enumerated-domain stepper: the click
87
+ // writes the SUCCESSOR of the current value in `members` (wrapping; a
88
+ // current value outside the domain counts as the first member). Unlike
89
+ // set-bounded — which emits a RELATIVE nudge so rapid clicks accumulate —
90
+ // a cycle emits the ABSOLUTE successor computed at render: the rendered
91
+ // display names the current state, so the click's meaning is "go to the
92
+ // successor of what I showed you". A stale link then lands on the state
93
+ // the user saw promised, not an extra flip past it — for toggles the
94
+ // absolute write IS the correct intent.
95
+ readonly kind: "set-cycle";
96
+ readonly key: string;
97
+ readonly stateVar: string;
98
+ readonly members: readonly string[];
99
+ }
100
+ | { readonly kind: "copy"; readonly text: Template<RichText> }
101
+ | { readonly kind: "open"; readonly target: Template<RichText> };
102
+
103
+ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
104
+
105
+ // [LAW:one-source-of-truth] An option source resolves to the SAME canonical list
106
+ // the `themes()`/`styles()` bindings and the derived gate consult — rendered
107
+ // options and the gate cannot diverge. The render-side resolver (the daemon's
108
+ // validator-derivation has its own that must agree, both reading themes/policy).
109
+ export function optionDomain(src: OptionSource): readonly string[] {
110
+ return src === "themes" ? listResolvablePaletteNames() : STYLE_ORDER;
111
+ }
112
+
113
+ // [LAW:locality-or-seam] The runtime holder the `action` template function closes
114
+ // over. Populated after the engine is constructed (the func references the
115
+ // engine, the compiled actions reference the engine — the holder breaks the
116
+ // cycle). `store` is the live VariableStore the renderer reads, so the action
117
+ // reads session.id and the current value from the same source the rest of the
118
+ // render does.
119
+ export interface ActionRuntime {
120
+ store: VariableStore | null;
121
+ compiled: CompiledActions;
122
+ }
123
+
124
+ // ─── Compilation ───────────────────────────────────────────────────────────────
125
+
126
+ // Pre-parse the copy/open templates for every action once, at config
127
+ // registration; set actions stay literal. [LAW:one-source-of-truth] parse-once,
128
+ // evaluate-many — renderAction only evaluates. `stateKeyToVar` maps a
129
+ // SessionState key → the variable that reads it (same map widgets use), so a
130
+ // set action reads its current/active value from the SAME value the templates
131
+ // read, regardless of whether the config named the variable after the key.
132
+ // [LAW:single-enforcer] `parse` is the config's ONE helper-aware parse closure
133
+ // (registerDslConfig owns it), not a bare engine — action copy/open templates
134
+ // resolve the same shared `{{ template "name" }}` helpers every segment does,
135
+ // through one boundary. compileActions needs only the ability to parse a source.
136
+ export function compileActions(
137
+ parse: (src: string) => Template<RichText>,
138
+ actions: Readonly<Record<string, ActionDecl>>,
139
+ stateKeyToVar: ReadonlyMap<string, string>,
140
+ ): CompiledActions {
141
+ const out = new Map<string, CompiledActionDecl>();
142
+ for (const [name, action] of Object.entries(actions)) {
143
+ out.set(name, compileAction(parse, name, action, stateKeyToVar));
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // [LAW:dataflow-not-control-flow] One total fold maps each ActionDecl to its
149
+ // compiled shape — the discriminator is which key is present (set's value SOURCE
150
+ // for the three set arms; copy/open otherwise). Every arm reads only its own
151
+ // fields; a new arm is one new branch.
152
+ function compileAction(
153
+ parse: (src: string) => Template<RichText>,
154
+ name: string,
155
+ action: ActionDecl,
156
+ stateKeyToVar: ReadonlyMap<string, string>,
157
+ ): CompiledActionDecl {
158
+ if ("set" in action) {
159
+ const stateVar = stateKeyToVar.get(action.set) ?? action.set;
160
+ if ("to" in action) {
161
+ return {
162
+ kind: "set-literal",
163
+ key: action.set,
164
+ value: action.to,
165
+ stateVar,
166
+ };
167
+ }
168
+ if ("from" in action) {
169
+ return {
170
+ kind: "set-option",
171
+ key: action.set,
172
+ stateVar,
173
+ options: [...optionDomain(action.from)],
174
+ };
175
+ }
176
+ if ("int" in action) {
177
+ return { kind: "set-int", key: action.set, stateVar };
178
+ }
179
+ if ("cycle" in action) {
180
+ return {
181
+ kind: "set-cycle",
182
+ key: action.set,
183
+ stateVar,
184
+ members: action.cycle,
185
+ };
186
+ }
187
+ return {
188
+ kind: "set-bounded",
189
+ key: action.set,
190
+ by: action.by,
191
+ };
192
+ }
193
+ if ("copy" in action) {
194
+ return {
195
+ kind: "copy",
196
+ text: parseActionTemplate(parse, action.copy, name),
197
+ };
198
+ }
199
+ return {
200
+ kind: "open",
201
+ target: parseActionTemplate(parse, action.open, name),
202
+ };
203
+ }
204
+
205
+ function parseActionTemplate(
206
+ parse: (src: string) => Template<RichText>,
207
+ src: string,
208
+ name: string,
209
+ ): Template<RichText> {
210
+ try {
211
+ return parse(src);
212
+ } catch (e) {
213
+ throw new Error(
214
+ `Template parse error in actions.${name}: ${(e as Error).message}`,
215
+ { cause: e },
216
+ );
217
+ }
218
+ }
219
+
220
+ // ─── Rendering ───────────────────────────────────────────────────────────────
221
+
222
+ // [LAW:one-source-of-truth] Exported so the picker reads SessionState through the
223
+ // SAME boundary (has() discriminates "never written" → "").
224
+ export function readVar(store: VariableStore, name: string): string {
225
+ // [LAW:no-defensive-null-guards] "current value may not exist" is a legitimate
226
+ // state (the key was never written) — guard the store lookup, not a downstream
227
+ // operation. has() is the discriminator; absence yields "".
228
+ return store.has(name) ? varToString(store.read(name)) : "";
229
+ }
230
+
231
+ function evalTemplate(tpl: Template<RichText>, scope: object): string {
232
+ return tpl
233
+ .evaluate(scope)
234
+ .map((f) => f.plain)
235
+ .join("");
236
+ }
237
+
238
+ // [LAW:single-enforcer] One link-span constructor for both action and picker
239
+ // cells — a Style carrying the OSC-8 url, `active` riding as bold.
240
+ export function linkFragment(
241
+ text: string,
242
+ url: string,
243
+ active: boolean,
244
+ ): RichText {
245
+ // [LAW:one-source-of-truth] Build the link span exactly as rich-js's `link`
246
+ // does: a Style carrying the OSC-8 url. `active` rides as bold so the
247
+ // currently-selected value reads as current — a value on the span, not a
248
+ // branch in the walk.
249
+ const rt = new RichText(text, {
250
+ style: new Style({ link: url, bold: active }),
251
+ });
252
+ rt.noWrap = true;
253
+ rt.end = "";
254
+ return rt;
255
+ }
256
+
257
+ // [LAW:one-source-of-truth] THE "unknown current counts as the first member"
258
+ // rule — the one resolution both the display selection and the successor write
259
+ // fold over. Members are ordered default-state-first, so an unset/foreign value
260
+ // renders the first display and clicks to the second member (an accordion
261
+ // sibling's path "counts as closed", a never-written toggle "counts as off").
262
+ function cycleIndex(
263
+ c: Extract<CompiledActionDecl, { kind: "set-cycle" }>,
264
+ store: VariableStore,
265
+ ): number {
266
+ return Math.max(c.members.indexOf(readVar(store, c.stateVar)), 0);
267
+ }
268
+
269
+ // [LAW:dataflow-not-control-flow] The single total projection of a compiled action
270
+ // onto (effect, active) — the click's wire effect plus whether this region is the
271
+ // current selection. The template supplies `display` (the clickable text) and an
272
+ // optional `boundValue` (an option picker binds each option's value); the action
273
+ // declaration supplies everything else. Consumers never re-switch on the action
274
+ // kind: this fold is the one place the union is matched.
275
+ // • set-literal: writes its fixed value; active when the key already holds it.
276
+ // • set-option: writes boundValue ?? display (the bound option); active when
277
+ // the key already holds it (the picker's current-mark).
278
+ // • set-bounded: emits a RELATIVE step-state nudge (key + signed by); never
279
+ // reads current and never "active". The wrap + bounds + the
280
+ // unset seed are applied at APPLY time by the daemon handler
281
+ // reading live state, not snapshotted into the link here.
282
+ // • copy/open: one copy/open effect of the evaluated template; never active.
283
+ // [LAW:dataflow-not-control-flow] The template scope is an input only the copy/
284
+ // open arms consume, so it is built WHERE consumed (buildScope snapshots
285
+ // store.names() into a Set per call — paying it for a set-* region, e.g. every
286
+ // cell of an option picker, is pure waste). set-* arms read individual vars
287
+ // directly. This is data locality, not a control-flow guard: the scope simply
288
+ // flows into the arms that need it.
289
+ function realize(
290
+ c: CompiledActionDecl,
291
+ display: string,
292
+ boundValue: string | undefined,
293
+ store: VariableStore,
294
+ sessionId: string,
295
+ ): { effect: Effect; active: boolean } {
296
+ switch (c.kind) {
297
+ case "set-literal": {
298
+ const current = readVar(store, c.stateVar);
299
+ return {
300
+ effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] },
301
+ active: current === c.value,
302
+ };
303
+ }
304
+ case "set-option": {
305
+ const value = boundValue ?? display;
306
+ const current = readVar(store, c.stateVar);
307
+ return {
308
+ effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
309
+ active: current === value,
310
+ };
311
+ }
312
+ case "set-int": {
313
+ // The render binds the integer to write (a picker's page nav passes the
314
+ // target page as boundValue; a bare `{{ action }}` passes its display).
315
+ // The unbounded int gate accepts it; active when the key already holds it.
316
+ const value = boundValue ?? display;
317
+ const current = readVar(store, c.stateVar);
318
+ return {
319
+ effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
320
+ active: current === value,
321
+ };
322
+ }
323
+ case "set-cycle": {
324
+ // [LAW:one-source-of-truth] The same current-index resolution that picked
325
+ // the rendered display picks the write target — display and write derive
326
+ // from one read, so the click delivers exactly the transition the glyph
327
+ // promised.
328
+ const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
329
+ return {
330
+ effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, next] },
331
+ active: false,
332
+ };
333
+ }
334
+ case "set-bounded": {
335
+ // [LAW:one-source-of-truth] Emit a RELATIVE nudge — the irreducible intent
336
+ // (key + signed delta), never an absolute target derived from a render-time
337
+ // snapshot of `current`. The daemon's step-state handler reads live state,
338
+ // applies the wrap against the registry's bounds, and writes through the
339
+ // single range gate. So the link is byte-identical across renders and N
340
+ // rapid clicks each accumulate (the idempotent absolute-write bug is gone).
341
+ return {
342
+ effect: {
343
+ verb: VERB_STEP_STATE,
344
+ args: [sessionId, c.key, String(c.by)],
345
+ },
346
+ active: false,
347
+ };
348
+ }
349
+ case "copy":
350
+ return {
351
+ effect: {
352
+ verb: VERB_COPY,
353
+ args: [evalTemplate(c.text, buildScope(store))],
354
+ },
355
+ active: false,
356
+ };
357
+ case "open":
358
+ return {
359
+ effect: {
360
+ verb: VERB_OPEN_VSCODE,
361
+ args: [evalTemplate(c.target, buildScope(store))],
362
+ },
363
+ active: false,
364
+ };
365
+ }
366
+ }
367
+
368
+ // [LAW:dataflow-not-control-flow] Which text a region shows is a pure function
369
+ // of (action kind, bound displays, current state). A cycle binds one display per
370
+ // member positionally (the toggle/N-state-cycler form: `{{ action "t" "▸" "▾"
371
+ // }}`) or one static display for all states; every other kind binds one display
372
+ // plus an optional boundValue (the option-picker form). Wrong arity is an author
373
+ // error surfaced loudly at render (composeWithDiagnostics shows it), never a
374
+ // silently dropped argument.
375
+ function selectDisplay(
376
+ name: string,
377
+ action: CompiledActionDecl,
378
+ displays: readonly string[],
379
+ store: VariableStore,
380
+ ): { display: string; boundValue: string | undefined } {
381
+ if (displays.length === 0) {
382
+ throw new Error(`action "${name}" needs a display (the clickable text)`);
383
+ }
384
+ if (action.kind === "set-cycle") {
385
+ if (displays.length !== 1 && displays.length !== action.members.length) {
386
+ throw new Error(
387
+ `action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,
388
+ );
389
+ }
390
+ const display =
391
+ displays.length === 1
392
+ ? displays[0]!
393
+ : displays[cycleIndex(action, store)]!;
394
+ return { display, boundValue: undefined };
395
+ }
396
+ if (displays.length > 2) {
397
+ throw new Error(
398
+ `action "${name}" takes a display and an optional bound value, got ${displays.length} arguments (per-state displays are a cycle action's form)`,
399
+ );
400
+ }
401
+ return { display: displays[0]!, boundValue: displays[1] };
402
+ }
403
+
404
+ // Realize a named action against the live state into ONE clickable RichText. The
405
+ // `action` template function delegates here.
406
+ export function renderAction(
407
+ name: string,
408
+ displays: readonly string[],
409
+ runtime: ActionRuntime,
410
+ ): RichText {
411
+ const action = runtime.compiled.get(name);
412
+ // [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
413
+ // reference resolves to a declared action, and compileActions compiled every
414
+ // declared action for THIS config's engine. A miss is a caller/wiring bug.
415
+ if (!action) {
416
+ throw new Error(`action "${name}" is not declared in this config`);
417
+ }
418
+ const store = runtime.store;
419
+ if (!store) {
420
+ throw new Error(
421
+ `action "${name}" rendered without a VariableStore — registerDslConfig was not given one`,
422
+ );
423
+ }
424
+ const { display, boundValue } = selectDisplay(name, action, displays, store);
425
+ const sessionId = readVar(store, "session.id");
426
+ const { effect, active } = realize(
427
+ action,
428
+ display,
429
+ boundValue,
430
+ store,
431
+ sessionId,
432
+ );
433
+ return linkFragment(display, effectsUrl([effect]), active);
434
+ }
435
+
436
+ // ─── FuncMap entry ─────────────────────────────────────────────────────────────
437
+
438
+ // [LAW:dataflow-not-control-flow] One func; the action NAME selects which declared
439
+ // effect fires, the trailing strings are the bound displays. For most kinds that
440
+ // is the clickable text plus an optional boundValue (absent ⇒ the option IS the
441
+ // display, the common picker form `{{ action "applyTheme" . }}`); for a cycle it
442
+ // is one display per member (the current member's display renders) or one static
443
+ // display. Returns T (RichText), the single fragment go-template-js emits for
444
+ // `{{ action … }}`.
445
+ //
446
+ // [LAW:one-way-deps] The caller injects this FuncMap into createCcCandybarEngine
447
+ // (capabilities-over-context) so the generic engine never imports the action
448
+ // feature.
449
+ export function actionFuncs(runtime: ActionRuntime): FuncMap {
450
+ return {
451
+ action: {
452
+ fn: (name: string, ...displays: string[]) =>
453
+ renderAction(name, displays, runtime),
454
+ argTypes: ["string", "string"],
455
+ returnType: "T",
456
+ },
457
+ };
458
+ }
@@ -0,0 +1,23 @@
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";
@@ -0,0 +1,77 @@
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
+ }
@@ -0,0 +1,53 @@
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
+ }
@@ -0,0 +1,45 @@
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
+ }