@promptctl/cc-candybar 1.36.0 → 1.38.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 +96 -93
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +124 -0
- package/src/check.ts +4 -4
- package/src/click/wire.ts +10 -1
- package/src/config/action.ts +110 -1
- package/src/config/default-dsl-config.ts +64 -228
- package/src/config/loader/actions.ts +138 -61
- package/src/config/loader/cross-ref.ts +48 -10
- package/src/config/loader/globals.ts +46 -1
- package/src/config/presets.ts +8 -3
- package/src/config/settings-menu.ts +338 -16
- package/src/daemon/render-payload.ts +7 -5
- package/src/daemon/server.ts +20 -7
- package/src/daemon/verbs/config-validators.ts +14 -11
- package/src/daemon/verbs/index.ts +39 -3
- package/src/daemon/verbs/state-validators.ts +26 -8
- package/src/demo/dsl.ts +7 -6
- package/src/help-text.ts +6 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +199 -45
- package/src/render/picker.ts +34 -32
- package/src/render/strip.ts +6 -10
- package/src/themes/index.ts +2 -0
- package/src/themes/policy.ts +199 -40
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
// `persist` action's keyspace) — two keyspaces, one mechanism.
|
|
17
17
|
|
|
18
18
|
import { listResolvablePaletteNames, STRIP_STYLES } from "../../themes/policy";
|
|
19
|
-
import type
|
|
19
|
+
import { actionDestinations, type ActionDecl } from "../../config/action";
|
|
20
20
|
import {
|
|
21
21
|
perConfigDomainsFor,
|
|
22
22
|
resolveOptionDomain,
|
|
23
23
|
} from "../../config/option-domain";
|
|
24
24
|
import type { DslConfig } from "../../config/dsl-types";
|
|
25
|
+
import { numericGlobalsSeeds } from "../../config/loader/globals";
|
|
25
26
|
import {
|
|
26
27
|
clampSeed,
|
|
27
28
|
createValidatorRegistry,
|
|
@@ -191,10 +192,18 @@ function dropBaselineAllowLists(
|
|
|
191
192
|
);
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
// [LAW:one-source-of-truth]
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
195
|
+
// [LAW:one-source-of-truth] The value a bounded key renders with before any
|
|
196
|
+
// click, from the two places that can define it: a `state` variable's integer
|
|
197
|
+
// `default` (the only source for a key of the config's own invention, like a
|
|
198
|
+
// hue stepper), and — winning for the fields it covers — what the config
|
|
199
|
+
// resolves for a GLOBALS field, since a session stepper over `padding` starts
|
|
200
|
+
// from the padding the bar is showing, not from a state var nobody declared.
|
|
201
|
+
//
|
|
202
|
+
// The globals half is the SAME function the config-overrides gate seeds from
|
|
203
|
+
// (numericGlobalsSeeds), so a session stepper and its durable twin cannot
|
|
204
|
+
// start from different numbers. Before it existed, the settings menu's session
|
|
205
|
+
// padding stepper seeded from `min`: a bar reading `padding 1` answered its
|
|
206
|
+
// first ◀ by wrapping to 16.
|
|
198
207
|
function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
199
208
|
const seeds = new Map<string, number>();
|
|
200
209
|
const INT_RE = /^-?\d+$/;
|
|
@@ -205,6 +214,9 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
|
205
214
|
seeds.set(decl.key, parseInt(raw, 10));
|
|
206
215
|
}
|
|
207
216
|
}
|
|
217
|
+
for (const [key, seed] of numericGlobalsSeeds(config.globals)) {
|
|
218
|
+
seeds.set(key, seed);
|
|
219
|
+
}
|
|
208
220
|
return seeds;
|
|
209
221
|
}
|
|
210
222
|
|
|
@@ -214,10 +226,16 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
|
214
226
|
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
215
227
|
const seeds = stateKeySeeds(config);
|
|
216
228
|
const perConfigDomains = perConfigDomainsFor(config);
|
|
229
|
+
// [LAW:single-enforcer] Every action is exploded into the
|
|
230
|
+
// single-destination declarations it writes through BEFORE the fold, so a
|
|
231
|
+
// dual-destination action (candybar-settings-ui-aok.3) contributes exactly
|
|
232
|
+
// the `set` spec its session half would have contributed on its own — the
|
|
233
|
+
// gate is derived by the code that has always derived it, from the same
|
|
234
|
+
// declaration the click realizes, and a dual can widen nothing.
|
|
217
235
|
return dropBaselineAllowLists(
|
|
218
|
-
Object.values(config.actions)
|
|
219
|
-
|
|
220
|
-
|
|
236
|
+
Object.values(config.actions)
|
|
237
|
+
.flatMap(actionDestinations)
|
|
238
|
+
.flatMap((a) => actionKeySpecs(a, seeds, perConfigDomains)),
|
|
221
239
|
);
|
|
222
240
|
}
|
|
223
241
|
|
package/src/demo/dsl.ts
CHANGED
|
@@ -33,6 +33,8 @@ import { listResolvablePaletteNames } from "../themes/policy.js";
|
|
|
33
33
|
import {
|
|
34
34
|
effectiveThemeName,
|
|
35
35
|
effectiveLookName,
|
|
36
|
+
effectiveAutoWrap,
|
|
37
|
+
effectivePadding,
|
|
36
38
|
lookKeyByName,
|
|
37
39
|
paletteForThemeName,
|
|
38
40
|
} from "../themes/index.js";
|
|
@@ -41,9 +43,7 @@ import { registerDslConfig, renderDsl } from "../dsl/render.js";
|
|
|
41
43
|
import {
|
|
42
44
|
DEFAULT_CHARSET,
|
|
43
45
|
DEFAULT_COLOR_COMPATIBILITY,
|
|
44
|
-
DEFAULT_PADDING,
|
|
45
46
|
DEFAULT_TERMINAL_WIDTH,
|
|
46
|
-
DEFAULT_WRAP,
|
|
47
47
|
} from "../render/strip.js";
|
|
48
48
|
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
49
49
|
|
|
@@ -139,10 +139,11 @@ try {
|
|
|
139
139
|
width: applyClaudeCodeReserve(
|
|
140
140
|
process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
|
|
141
141
|
),
|
|
142
|
-
// Same
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
// Same resolvers the daemon applies, with a null session pick — the
|
|
143
|
+
// demo has no SessionState, so both land on the config default over
|
|
144
|
+
// their floor.
|
|
145
|
+
wrap: effectiveAutoWrap(null, globals.autoWrap),
|
|
146
|
+
padding: effectivePadding(null, globals.padding),
|
|
146
147
|
charset: globals.charset ?? DEFAULT_CHARSET,
|
|
147
148
|
},
|
|
148
149
|
undefined,
|
package/src/help-text.ts
CHANGED
|
@@ -23,9 +23,12 @@ Configuration:
|
|
|
23
23
|
to point at a specific file. See the default config for all available options:
|
|
24
24
|
node dist/index.mjs debug --project-dir . --cwd .
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
Every bar carries a settings menu, whatever your config says — no config
|
|
27
|
+
needed, and writing your own \`root\` cannot delete it. Click
|
|
28
|
+
☰ ${DISCLOSURE_GLYPH_CLOSED} on the bar for preset switching, edit mode, and a config menu
|
|
29
|
+
of clickable theme/look/style/wrap/padding controls. The \`persist?\`
|
|
30
|
+
checkbox there chooses where a change lands: unchecked it applies to this
|
|
31
|
+
session only, checked it becomes the default every session opens with.
|
|
29
32
|
|
|
30
33
|
Subcommands:
|
|
31
34
|
install One-shot setup: stages the runtime (native render
|
package/src/install/index.ts
CHANGED
|
@@ -451,8 +451,8 @@ function installSuccessMessage(): string {
|
|
|
451
451
|
return (
|
|
452
452
|
`✓ install complete.\n` +
|
|
453
453
|
` Restart Claude Code to pick up the new statusline.\n` +
|
|
454
|
-
` Tip:
|
|
455
|
-
`
|
|
454
|
+
` Tip: every bar carries a settings menu — click ☰ ${DISCLOSURE_GLYPH_CLOSED} for preset\n` +
|
|
455
|
+
` switching, edit mode, and clickable theme/look/style/wrap/padding controls.\n`
|
|
456
456
|
);
|
|
457
457
|
}
|
|
458
458
|
|
package/src/render/action.ts
CHANGED
|
@@ -25,10 +25,15 @@ import type { FuncMap, Template } from "@promptctl/go-template-js";
|
|
|
25
25
|
import type { VariableStore } from "../var-system/store.js";
|
|
26
26
|
import { toString as varToString } from "../var-system/types.js";
|
|
27
27
|
import { buildScope } from "../template-engine/scope.js";
|
|
28
|
-
import
|
|
28
|
+
import {
|
|
29
|
+
actionDestinations,
|
|
30
|
+
actionIsDual,
|
|
31
|
+
PERSIST_WHEN,
|
|
32
|
+
type ActionDecl,
|
|
33
|
+
} from "../config/action.js";
|
|
29
34
|
import { resolveOptionDomain } from "../config/option-domain.js";
|
|
30
35
|
import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
|
|
31
|
-
import type
|
|
36
|
+
import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
|
|
32
37
|
import {
|
|
33
38
|
effectsUrl,
|
|
34
39
|
VERB_APPLY_LAYOUT_OP,
|
|
@@ -166,7 +171,24 @@ export type CompiledActionDecl =
|
|
|
166
171
|
// there is nothing to carry, since the history stack (not this action) is
|
|
167
172
|
// what decides which entry moves.
|
|
168
173
|
| { readonly kind: "undo" }
|
|
169
|
-
| { readonly kind: "redo" }
|
|
174
|
+
| { readonly kind: "redo" }
|
|
175
|
+
// [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.3's ONE control
|
|
176
|
+
// per setting. Both destinations are compiled here as the ordinary
|
|
177
|
+
// single-destination shapes they are, and `selector` names the session key
|
|
178
|
+
// whose boolean value picks between them at click time. The destination is
|
|
179
|
+
// therefore a VALUE flowing through `activeDestination` — every consumer
|
|
180
|
+
// (realize, the picker, selectDisplay) resolves it once at the top and then
|
|
181
|
+
// runs the code it has always run, so nothing downstream branches on
|
|
182
|
+
// "is this dual".
|
|
183
|
+
| {
|
|
184
|
+
readonly kind: "dual";
|
|
185
|
+
readonly selector: string;
|
|
186
|
+
readonly session: CompiledActionDecl;
|
|
187
|
+
readonly durable: CompiledActionDecl;
|
|
188
|
+
// The SessionState key the session half writes, carried so a durable
|
|
189
|
+
// click can clear it in the same dispatch (see realize's dual arm).
|
|
190
|
+
readonly sessionKey: string;
|
|
191
|
+
};
|
|
170
192
|
|
|
171
193
|
export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
172
194
|
|
|
@@ -185,6 +207,13 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
|
185
207
|
// highlight is inert (readVar falls back to "" since no such var exists).
|
|
186
208
|
const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
|
|
187
209
|
["palette", "theme.effective"],
|
|
210
|
+
// [LAW:one-source-of-truth] `preset` earns its entry here the moment a DUAL
|
|
211
|
+
// control writes it: compileDual makes BOTH halves read back through this
|
|
212
|
+
// map, so a field missing from it loses its current-selection mark on the
|
|
213
|
+
// session side too — and the preset picker sits on the settings menu's
|
|
214
|
+
// always-visible first row, where "which arrangement am I in" is the whole
|
|
215
|
+
// question the control answers.
|
|
216
|
+
["preset", "preset.effective"],
|
|
188
217
|
["look", "look.effective"],
|
|
189
218
|
["style", "style.effective"],
|
|
190
219
|
["charset", "charset.effective"],
|
|
@@ -263,6 +292,20 @@ function compileAction(
|
|
|
263
292
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
264
293
|
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
265
294
|
): CompiledActionDecl {
|
|
295
|
+
// [LAW:one-source-of-truth] A dual compiles as its own two destinations —
|
|
296
|
+
// the SAME explosion the validator derivations fold over
|
|
297
|
+
// (actionDestinations), so the click a dual realizes and the gate it derives
|
|
298
|
+
// come from one statement of what the two halves are. It is matched BEFORE
|
|
299
|
+
// the `set` arm because a dual carries `set` too.
|
|
300
|
+
if (actionIsDual(action)) {
|
|
301
|
+
const [session, durable] = actionDestinations(action);
|
|
302
|
+
return compileDual(
|
|
303
|
+
stateKeyToVar.get(action[PERSIST_WHEN]) ?? action[PERSIST_WHEN],
|
|
304
|
+
action.set,
|
|
305
|
+
compileAction(parse, name, session!, stateKeyToVar, perConfigDomains),
|
|
306
|
+
compileAction(parse, name, durable!, stateKeyToVar, perConfigDomains),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
266
309
|
if ("set" in action) {
|
|
267
310
|
const stateVar = stateKeyToVar.get(action.set) ?? action.set;
|
|
268
311
|
if ("to" in action) {
|
|
@@ -379,6 +422,55 @@ function compileAction(
|
|
|
379
422
|
return "undo" in action ? { kind: "undo" } : { kind: "redo" };
|
|
380
423
|
}
|
|
381
424
|
|
|
425
|
+
// [LAW:one-source-of-truth] A dual control shows ONE current value and writes
|
|
426
|
+
// relative to the value it showed — so both destinations read back through the
|
|
427
|
+
// DURABLE half's variable, which is the `.effective` projection the daemon
|
|
428
|
+
// resolved for this render (CONFIG_KEY_TO_EFFECTIVE_VAR above): the value the
|
|
429
|
+
// bar is actually rendering with, whatever chain produced it. Reading the
|
|
430
|
+
// session key instead would let a cycle's glyph name the effective state while
|
|
431
|
+
// its click stepped from an unwritten session key — the toggle would render
|
|
432
|
+
// "wrap: off" and write "false", a click that visibly does nothing. Arms that
|
|
433
|
+
// carry no `stateVar` (the bounded steppers) read nothing at render by design:
|
|
434
|
+
// their step is relative and resolved daemon-side.
|
|
435
|
+
function compileDual(
|
|
436
|
+
selectorVar: string,
|
|
437
|
+
sessionKey: string,
|
|
438
|
+
session: CompiledActionDecl,
|
|
439
|
+
durable: CompiledActionDecl,
|
|
440
|
+
): CompiledActionDecl {
|
|
441
|
+
const readBack =
|
|
442
|
+
"stateVar" in session && "stateVar" in durable
|
|
443
|
+
? { ...session, stateVar: durable.stateVar }
|
|
444
|
+
: session;
|
|
445
|
+
return {
|
|
446
|
+
kind: "dual",
|
|
447
|
+
selector: selectorVar,
|
|
448
|
+
session: readBack,
|
|
449
|
+
durable,
|
|
450
|
+
sessionKey,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// [LAW:dataflow-not-control-flow] THE destination fold: which store a dual
|
|
455
|
+
// action writes is the boolean value of its selector key, read from the same
|
|
456
|
+
// live store the rest of the render reads. Total over every compiled action —
|
|
457
|
+
// a single-destination action IS its own destination — so callers resolve
|
|
458
|
+
// through it unconditionally and never test for the dual kind.
|
|
459
|
+
//
|
|
460
|
+
// [LAW:one-source-of-truth] `parseSessionBoolean` is the one spelling of a
|
|
461
|
+
// boolean in SessionState (themes/policy.ts), the same parse `autoWrap`'s own
|
|
462
|
+
// session half goes through: an unwritten, malformed, or "false" selector all
|
|
463
|
+
// mean the session destination, and only a canonical "true" means durable.
|
|
464
|
+
export function activeDestination(
|
|
465
|
+
c: CompiledActionDecl,
|
|
466
|
+
store: VariableStore,
|
|
467
|
+
): CompiledActionDecl {
|
|
468
|
+
if (c.kind !== "dual") return c;
|
|
469
|
+
return parseSessionBoolean(readVar(store, c.selector)) === true
|
|
470
|
+
? c.durable
|
|
471
|
+
: c.session;
|
|
472
|
+
}
|
|
473
|
+
|
|
382
474
|
function parseActionTemplate(
|
|
383
475
|
parse: (src: string) => Template<RichText>,
|
|
384
476
|
src: string,
|
|
@@ -463,18 +555,18 @@ function cycleIndex(
|
|
|
463
555
|
// cell of an option picker, is pure waste). set-* arms read individual vars
|
|
464
556
|
// directly. This is data locality, not a control-flow guard: the scope simply
|
|
465
557
|
// flows into the arms that need it.
|
|
466
|
-
function realize(
|
|
558
|
+
export function realize(
|
|
467
559
|
c: CompiledActionDecl,
|
|
468
560
|
display: string,
|
|
469
561
|
boundValue: string | undefined,
|
|
470
562
|
store: VariableStore,
|
|
471
563
|
sessionId: string,
|
|
472
|
-
): {
|
|
564
|
+
): { effects: readonly Effect[]; active: boolean } {
|
|
473
565
|
switch (c.kind) {
|
|
474
566
|
case "set-literal": {
|
|
475
567
|
const current = readVar(store, c.stateVar);
|
|
476
568
|
return {
|
|
477
|
-
|
|
569
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] }],
|
|
478
570
|
active: current === c.value,
|
|
479
571
|
};
|
|
480
572
|
}
|
|
@@ -482,7 +574,7 @@ function realize(
|
|
|
482
574
|
const value = boundValue ?? display;
|
|
483
575
|
const current = readVar(store, c.stateVar);
|
|
484
576
|
return {
|
|
485
|
-
|
|
577
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
|
|
486
578
|
active: current === value,
|
|
487
579
|
};
|
|
488
580
|
}
|
|
@@ -499,7 +591,7 @@ function realize(
|
|
|
499
591
|
const value = boundValue ?? display;
|
|
500
592
|
const current = readVar(store, c.stateVar);
|
|
501
593
|
return {
|
|
502
|
-
|
|
594
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
|
|
503
595
|
active: current === value,
|
|
504
596
|
};
|
|
505
597
|
}
|
|
@@ -510,7 +602,7 @@ function realize(
|
|
|
510
602
|
// promised.
|
|
511
603
|
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
512
604
|
return {
|
|
513
|
-
|
|
605
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, next] }],
|
|
514
606
|
active: false,
|
|
515
607
|
};
|
|
516
608
|
}
|
|
@@ -522,27 +614,33 @@ function realize(
|
|
|
522
614
|
// single range gate. So the link is byte-identical across renders and N
|
|
523
615
|
// rapid clicks each accumulate (the idempotent absolute-write bug is gone).
|
|
524
616
|
return {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
617
|
+
effects: [
|
|
618
|
+
{
|
|
619
|
+
verb: VERB_STEP_STATE,
|
|
620
|
+
args: [sessionId, c.key, String(c.by)],
|
|
621
|
+
},
|
|
622
|
+
],
|
|
529
623
|
active: false,
|
|
530
624
|
};
|
|
531
625
|
}
|
|
532
626
|
case "copy":
|
|
533
627
|
return {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
628
|
+
effects: [
|
|
629
|
+
{
|
|
630
|
+
verb: VERB_COPY,
|
|
631
|
+
args: [evalTemplate(c.text, buildScope(store))],
|
|
632
|
+
},
|
|
633
|
+
],
|
|
538
634
|
active: false,
|
|
539
635
|
};
|
|
540
636
|
case "open":
|
|
541
637
|
return {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
638
|
+
effects: [
|
|
639
|
+
{
|
|
640
|
+
verb: VERB_OPEN_VSCODE,
|
|
641
|
+
args: [evalTemplate(c.target, buildScope(store))],
|
|
642
|
+
},
|
|
643
|
+
],
|
|
546
644
|
active: false,
|
|
547
645
|
};
|
|
548
646
|
// [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
|
|
@@ -553,7 +651,7 @@ function realize(
|
|
|
553
651
|
case "persist-literal": {
|
|
554
652
|
const current = readVar(store, c.stateVar);
|
|
555
653
|
return {
|
|
556
|
-
|
|
654
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] }],
|
|
557
655
|
active: current === c.value,
|
|
558
656
|
};
|
|
559
657
|
}
|
|
@@ -561,29 +659,31 @@ function realize(
|
|
|
561
659
|
const value = boundValue ?? display;
|
|
562
660
|
const current = readVar(store, c.stateVar);
|
|
563
661
|
return {
|
|
564
|
-
|
|
662
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] }],
|
|
565
663
|
active: current === value,
|
|
566
664
|
};
|
|
567
665
|
}
|
|
568
666
|
case "persist-cycle": {
|
|
569
667
|
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
570
668
|
return {
|
|
571
|
-
|
|
669
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] }],
|
|
572
670
|
active: false,
|
|
573
671
|
};
|
|
574
672
|
}
|
|
575
673
|
case "persist-bounded": {
|
|
576
674
|
return {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
675
|
+
effects: [
|
|
676
|
+
{
|
|
677
|
+
verb: VERB_STEP_CONFIG,
|
|
678
|
+
args: [sessionId, c.key, String(c.by)],
|
|
679
|
+
},
|
|
680
|
+
],
|
|
581
681
|
active: false,
|
|
582
682
|
};
|
|
583
683
|
}
|
|
584
684
|
case "reset":
|
|
585
685
|
return {
|
|
586
|
-
|
|
686
|
+
effects: [{ verb: VERB_RESET_CONFIG, args: [sessionId, c.key] }],
|
|
587
687
|
active: false,
|
|
588
688
|
};
|
|
589
689
|
// [LAW:one-source-of-truth] No key to carry — the click just says "step
|
|
@@ -592,12 +692,12 @@ function realize(
|
|
|
592
692
|
// a history step is a one-shot trigger, not a current-selection toggle.
|
|
593
693
|
case "undo":
|
|
594
694
|
return {
|
|
595
|
-
|
|
695
|
+
effects: [{ verb: VERB_UNDO, args: [sessionId] }],
|
|
596
696
|
active: false,
|
|
597
697
|
};
|
|
598
698
|
case "redo":
|
|
599
699
|
return {
|
|
600
|
-
|
|
700
|
+
effects: [{ verb: VERB_REDO, args: [sessionId] }],
|
|
601
701
|
active: false,
|
|
602
702
|
};
|
|
603
703
|
// [LAW:one-source-of-truth] The op is fixed at compile time (see
|
|
@@ -608,10 +708,12 @@ function realize(
|
|
|
608
708
|
// trigger, not a current-selection toggle.
|
|
609
709
|
case "layout-op":
|
|
610
710
|
return {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
711
|
+
effects: [
|
|
712
|
+
{
|
|
713
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
714
|
+
args: [sessionId, c.key, encodeLayoutOp(c.op)],
|
|
715
|
+
},
|
|
716
|
+
],
|
|
615
717
|
active: false,
|
|
616
718
|
};
|
|
617
719
|
// [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
|
|
@@ -620,6 +722,46 @@ function realize(
|
|
|
620
722
|
// emits, so the daemon's apply-layout-op handler and undo/redo need no
|
|
621
723
|
// knowledge of where the segment name came from. Never "active": a
|
|
622
724
|
// structural edit is a one-shot trigger, not a current-selection toggle.
|
|
725
|
+
// [LAW:dataflow-not-control-flow] The destination is resolved to a value
|
|
726
|
+
// and the SAME fold runs on it — a dual's realization is its chosen
|
|
727
|
+
// half's realization, with nothing about persistence duplicated here.
|
|
728
|
+
// Depth is structurally one: a dual's halves are the single-destination
|
|
729
|
+
// decls actionDestinations built, which can never be dual themselves.
|
|
730
|
+
//
|
|
731
|
+
// [LAW:no-silent-failure] A DURABLE click carries the session key to
|
|
732
|
+
// RELEASE as a trailing arg on its own write, so the daemon drops it only
|
|
733
|
+
// after that write succeeded. Without the release the write would be
|
|
734
|
+
// invisible to the session that made it — every settable global resolves
|
|
735
|
+
// session pick OVER durable default, so the workflow this menu invites
|
|
736
|
+
// ("try it here, then tick persist? to commit it") would set a default the
|
|
737
|
+
// user cannot see and leave the control dead for the rest of the session.
|
|
738
|
+
// Riding the write rather than sitting beside it is what makes the pair
|
|
739
|
+
// unsplittable: a click runs every effect it carries, so a rejected write
|
|
740
|
+
// must not be able to drop the pick on its own.
|
|
741
|
+
case "dual": {
|
|
742
|
+
const chosen = activeDestination(c, store);
|
|
743
|
+
const { effects, active } = realize(
|
|
744
|
+
chosen,
|
|
745
|
+
display,
|
|
746
|
+
boundValue,
|
|
747
|
+
store,
|
|
748
|
+
sessionId,
|
|
749
|
+
);
|
|
750
|
+
// The durable write carries the session key to RELEASE as one more
|
|
751
|
+
// segment on itself, so the daemon clears it only after its own write
|
|
752
|
+
// succeeded. A second effect beside it would not do: `dispatch` runs
|
|
753
|
+
// every effect in a click by design, so a rejected write would still
|
|
754
|
+
// wipe the session pick and leave nothing durable in its place.
|
|
755
|
+
return chosen === c.durable
|
|
756
|
+
? {
|
|
757
|
+
effects: effects.map((e) => ({
|
|
758
|
+
...e,
|
|
759
|
+
args: [...e.args, c.sessionKey],
|
|
760
|
+
})),
|
|
761
|
+
active,
|
|
762
|
+
}
|
|
763
|
+
: { effects, active };
|
|
764
|
+
}
|
|
623
765
|
case "layout-op-option": {
|
|
624
766
|
const segment = boundValue ?? display;
|
|
625
767
|
const op: LayoutOp = {
|
|
@@ -629,10 +771,12 @@ function realize(
|
|
|
629
771
|
relation: c.relation,
|
|
630
772
|
};
|
|
631
773
|
return {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
774
|
+
effects: [
|
|
775
|
+
{
|
|
776
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
777
|
+
args: [sessionId, c.key, encodeLayoutOp(op)],
|
|
778
|
+
},
|
|
779
|
+
],
|
|
636
780
|
active: false,
|
|
637
781
|
};
|
|
638
782
|
}
|
|
@@ -682,24 +826,34 @@ export function renderAction(
|
|
|
682
826
|
displays: readonly string[],
|
|
683
827
|
runtime: ActionRuntime,
|
|
684
828
|
): RichText {
|
|
685
|
-
const
|
|
829
|
+
const declared = runtime.compiled.get(name);
|
|
686
830
|
// [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
|
|
687
831
|
// reference resolves to a declared action, and compileActions compiled every
|
|
688
832
|
// declared action for THIS config's engine. A miss is a caller/wiring bug.
|
|
689
|
-
if (!
|
|
833
|
+
if (!declared) {
|
|
690
834
|
throw new Error(`action "${name}" is not declared in this config`);
|
|
691
835
|
}
|
|
692
836
|
const store = runtime.store;
|
|
693
|
-
|
|
837
|
+
// [LAW:dataflow-not-control-flow] DISPLAY selection reads the resolved half
|
|
838
|
+
// (a cycle's glyph is the current member's, whichever store it will write),
|
|
839
|
+
// while REALIZATION is handed the declaration itself — a dual realizes as
|
|
840
|
+
// its chosen half PLUS the session clear that keeps a durable write visible,
|
|
841
|
+
// and that pairing belongs to the one fold that owns the union.
|
|
842
|
+
const { display, boundValue } = selectDisplay(
|
|
843
|
+
name,
|
|
844
|
+
activeDestination(declared, store),
|
|
845
|
+
displays,
|
|
846
|
+
store,
|
|
847
|
+
);
|
|
694
848
|
const sessionId = readVar(store, "session.id");
|
|
695
|
-
const {
|
|
696
|
-
|
|
849
|
+
const { effects, active } = realize(
|
|
850
|
+
declared,
|
|
697
851
|
display,
|
|
698
852
|
boundValue,
|
|
699
853
|
store,
|
|
700
854
|
sessionId,
|
|
701
855
|
);
|
|
702
|
-
return linkFragment(display, effectsUrl(
|
|
856
|
+
return linkFragment(display, effectsUrl(effects), active);
|
|
703
857
|
}
|
|
704
858
|
|
|
705
859
|
// ─── FuncMap entry ─────────────────────────────────────────────────────────────
|
package/src/render/picker.ts
CHANGED
|
@@ -28,15 +28,11 @@ import type { FuncMap } from "@promptctl/go-template-js";
|
|
|
28
28
|
import { toNumber } from "../var-system/types.js";
|
|
29
29
|
import { stripChromeCols } from "./strip.js";
|
|
30
30
|
import { TERM_COLS_VAR } from "../config/dsl-types.js";
|
|
31
|
+
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
31
32
|
import {
|
|
32
|
-
|
|
33
|
-
VERB_APPLY_LAYOUT_OP,
|
|
34
|
-
VERB_SET_CONFIG,
|
|
35
|
-
VERB_SET_STATE,
|
|
36
|
-
} from "../click/wire.js";
|
|
37
|
-
import { encodeLayoutOp } from "../config/layout-ops.js";
|
|
38
|
-
import {
|
|
33
|
+
activeDestination,
|
|
39
34
|
linkFragment,
|
|
35
|
+
realize,
|
|
40
36
|
readVar,
|
|
41
37
|
type ActionRuntime,
|
|
42
38
|
type CompiledActionDecl,
|
|
@@ -160,7 +156,15 @@ function requireOptionKind(
|
|
|
160
156
|
CompiledActionDecl,
|
|
161
157
|
{ kind: "set-option" | "persist-option" | "layout-op-option" }
|
|
162
158
|
> {
|
|
163
|
-
|
|
159
|
+
// [LAW:dataflow-not-control-flow] A dual-destination action resolves to the
|
|
160
|
+
// half its selector names BEFORE the kind check, so a picker over a dual is
|
|
161
|
+
// a picker over whichever option kind is live — the grid, its current-mark,
|
|
162
|
+
// and its close folding are the code they already were. The check below then
|
|
163
|
+
// still names a real, single-destination kind in its error.
|
|
164
|
+
const declared = runtime.compiled.get(name);
|
|
165
|
+
const action = declared
|
|
166
|
+
? activeDestination(declared, runtime.store)
|
|
167
|
+
: declared;
|
|
164
168
|
if (
|
|
165
169
|
!action ||
|
|
166
170
|
(action.kind !== "set-option" &&
|
|
@@ -194,6 +198,13 @@ export function renderPicker(
|
|
|
194
198
|
runtime: ActionRuntime,
|
|
195
199
|
): RichText {
|
|
196
200
|
const apply = requireOptionKind(runtime, applyName);
|
|
201
|
+
// [LAW:one-source-of-truth] The GRID reads the resolved half above (its
|
|
202
|
+
// options, its current-mark); the CLICK is realized from the declaration
|
|
203
|
+
// itself, through the same fold `{{ action }}` uses. That is what carries a
|
|
204
|
+
// dual's session clear into a picked option — the picker never learns what a
|
|
205
|
+
// dual is, and there is no second projection of "what does this option
|
|
206
|
+
// write" to drift from realize's.
|
|
207
|
+
const declared = runtime.compiled.get(applyName)!;
|
|
197
208
|
const store = runtime.store;
|
|
198
209
|
const sessionId = readVar(store, "session.id");
|
|
199
210
|
// [LAW:no-defensive-null-guards] layout-op-option carries no `stateVar` —
|
|
@@ -275,30 +286,21 @@ export function renderPicker(
|
|
|
275
286
|
? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
|
|
276
287
|
: [];
|
|
277
288
|
const optionUrl = (option: string): string => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
...closeEffect,
|
|
294
|
-
]);
|
|
295
|
-
}
|
|
296
|
-
return effectsUrl([
|
|
297
|
-
{
|
|
298
|
-
verb: VERB_SET_STATE,
|
|
299
|
-
args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
|
|
300
|
-
},
|
|
301
|
-
]);
|
|
289
|
+
const { effects } = realize(declared, option, option, store, sessionId);
|
|
290
|
+
// [LAW:one-source-of-truth] A plain session pick folds its close pairs into
|
|
291
|
+
// the SAME set-state (setState is variadic), so closing and applying are
|
|
292
|
+
// one write. Every other shape — a durable write, a structural op, a dual
|
|
293
|
+
// carrying its session clear — takes more than one effect already, so its
|
|
294
|
+
// close rides as its own effect in the same atomic dispatch.
|
|
295
|
+
const solo = effects.length === 1 ? effects[0]! : undefined;
|
|
296
|
+
return solo?.verb === VERB_SET_STATE
|
|
297
|
+
? effectsUrl([
|
|
298
|
+
{
|
|
299
|
+
verb: VERB_SET_STATE,
|
|
300
|
+
args: [...solo.args, ...(closeOnPick ? closeFlat : [])],
|
|
301
|
+
},
|
|
302
|
+
])
|
|
303
|
+
: effectsUrl([...effects, ...closeEffect]);
|
|
302
304
|
};
|
|
303
305
|
|
|
304
306
|
const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
|
package/src/render/strip.ts
CHANGED
|
@@ -37,16 +37,12 @@ export type { Charset, ColorCompatibility, StripStyle };
|
|
|
37
37
|
// applyClaudeCodeReserve from src/utils/terminal-width).
|
|
38
38
|
export const DEFAULT_TERMINAL_WIDTH = 120;
|
|
39
39
|
|
|
40
|
-
// [LAW:one-source-of-truth]
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// default (one space per side inside each segment cell — current behavior,
|
|
47
|
-
// matching the legacy display.padding). Every resolver of the config global
|
|
48
|
-
// (`globals.padding ?? DEFAULT_PADDING`) derives from this constant.
|
|
49
|
-
export const DEFAULT_PADDING = 1;
|
|
40
|
+
// [LAW:one-source-of-truth] autoWrap's and padding's defaults live in
|
|
41
|
+
// themes/policy.ts beside their resolvers and padding's range, because the
|
|
42
|
+
// config loader needs them too and config must not import render
|
|
43
|
+
// [LAW:one-way-deps]. Re-exported here on the same terms as the types above, so
|
|
44
|
+
// render-layer consumers keep importing them from the strip module.
|
|
45
|
+
export { DEFAULT_WRAP, DEFAULT_PADDING } from "../themes/policy.js";
|
|
50
46
|
|
|
51
47
|
// [LAW:one-source-of-truth] The one statement of the globals.charset default
|
|
52
48
|
// (powerline unicode glyphs — current behavior, matching the legacy
|