@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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
|
+
}
|