@promptctl/cc-candybar 1.8.4 → 1.10.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 +36 -36
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +8 -0
- package/src/config/default-dsl-config.ts +43 -31
- package/src/config/dsl-types.ts +15 -0
- package/src/config/loader/globals.ts +8 -0
- package/src/config/loader/validate-core.ts +28 -0
- package/src/daemon/server.ts +15 -0
- package/src/daemon/verbs/state-validators.ts +4 -4
- package/src/demo/dsl.ts +9 -1
- package/src/dsl/node-registry.ts +9 -0
- package/src/dsl/render.ts +9 -1
- package/src/render/action.ts +6 -0
- package/src/render/picker.ts +5 -1
- package/src/render/strip.ts +44 -15
- package/src/template-engine/layout.ts +23 -2
- package/src/themes/index.ts +0 -4
- package/src/themes/policy.ts +0 -33
- package/src/themes/session-random.ts +0 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.10.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.10.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.10.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.10.0"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
|
@@ -52,11 +52,11 @@ import type { DslConfig } from "./dsl-types.js";
|
|
|
52
52
|
// full absolute path. Same logic handles equal home & current_dir → just "~".
|
|
53
53
|
const DIR_REL = 'trimPrefix "/" (trimPrefix .project_dir .current_dir)';
|
|
54
54
|
const DIR_TEMPLATE =
|
|
55
|
-
'
|
|
55
|
+
'{{ if and (ne .home "") (or (eq .home .current_dir) (hasPrefix (printf "%s/" .home) .current_dir)) }}~{{ trimPrefix .home .current_dir }}' +
|
|
56
56
|
"{{ else }}" +
|
|
57
57
|
'{{ if or (eq .project_dir .current_dir) (hasPrefix (printf "%s/" .project_dir) .current_dir) }}' +
|
|
58
58
|
`{{ ternary (${DIR_REL}) (basename .project_dir) (ne (${DIR_REL}) "") }}` +
|
|
59
|
-
"{{ else }}{{ .current_dir }}{{ end }}{{ end }}
|
|
59
|
+
"{{ else }}{{ .current_dir }}{{ end }}{{ end }}";
|
|
60
60
|
|
|
61
61
|
// Git working-tree counts — leading-space-then-trim idiom: each present count
|
|
62
62
|
// contributes " +N", trim drops the leading space, survivors single-spaced.
|
|
@@ -74,7 +74,7 @@ const GIT_STATUS =
|
|
|
74
74
|
'{{ if eq .git.status "dirty" }}●{{ else }}✓{{ end }}{{ end }}';
|
|
75
75
|
|
|
76
76
|
const GIT_TEMPLATE =
|
|
77
|
-
'
|
|
77
|
+
'{{ if ne .git.repoName "" }}{{ .git.repoName }} {{ end }}⎇ {{ .git.branch }}' +
|
|
78
78
|
"{{ if .git.sha }} ♯ {{ .git.sha }}{{ end }}" +
|
|
79
79
|
"{{ if or (gt .git.ahead 0) (gt .git.behind 0) }}" +
|
|
80
80
|
" {{ if gt .git.ahead 0 }}↑{{ .git.ahead }}{{ end }}" +
|
|
@@ -83,8 +83,7 @@ const GIT_TEMPLATE =
|
|
|
83
83
|
"{{ if .git.upstream }} →{{ .git.upstream }}{{ end }}" +
|
|
84
84
|
"{{ if gt .git.stash 0 }} ⧇ {{ .git.stash }}{{ end }}" +
|
|
85
85
|
" " +
|
|
86
|
-
GIT_STATUS
|
|
87
|
-
" ";
|
|
86
|
+
GIT_STATUS;
|
|
88
87
|
|
|
89
88
|
// [LAW:dataflow-not-control-flow] block and weekly share the same threshold
|
|
90
89
|
// cascade (≥warningThreshold → error, ≥50 → warning, else panel) on a numeric
|
|
@@ -509,6 +508,12 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
509
508
|
// palette spec names resolved against the active theme. `when` predicates
|
|
510
509
|
// hide a segment when its primary signal is absent (no git repo, no version
|
|
511
510
|
// field, no env var, no tmux, no rate-limit window).
|
|
511
|
+
//
|
|
512
|
+
// [LAW:one-source-of-truth] Templates author CONTENT only — the intra-cell
|
|
513
|
+
// padding (the space each side of a cell) is render chrome synthesized
|
|
514
|
+
// structurally from the one resolved globals.padding (default 1), never
|
|
515
|
+
// authored here. A template with leading/trailing spaces would render them
|
|
516
|
+
// IN ADDITION to the structural padding.
|
|
512
517
|
segments: {
|
|
513
518
|
directory: {
|
|
514
519
|
template: DIR_TEMPLATE,
|
|
@@ -516,25 +521,25 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
516
521
|
fg: "foreground",
|
|
517
522
|
},
|
|
518
523
|
model: {
|
|
519
|
-
template: "
|
|
524
|
+
template: "✱ {{ formatModelName .model.display_name }}",
|
|
520
525
|
bg: "panel",
|
|
521
526
|
fg: "foreground",
|
|
522
527
|
when: '{{ ne .model.display_name "" }}',
|
|
523
528
|
},
|
|
524
529
|
sessionId: {
|
|
525
|
-
template: "
|
|
530
|
+
template: "⌗{{ trunc 8 .session.id }}",
|
|
526
531
|
bg: "surface",
|
|
527
532
|
fg: "foreground",
|
|
528
533
|
when: '{{ ne .session.id "" }}',
|
|
529
534
|
},
|
|
530
535
|
version: {
|
|
531
|
-
template: "
|
|
536
|
+
template: "◈ v{{ .version }}",
|
|
532
537
|
bg: "surface",
|
|
533
538
|
fg: "foreground",
|
|
534
539
|
when: '{{ ne .version "" }}',
|
|
535
540
|
},
|
|
536
541
|
tmux: {
|
|
537
|
-
template: '
|
|
542
|
+
template: 'tmux:{{ .tmux.session | default "none" }}',
|
|
538
543
|
bg: "surface-active",
|
|
539
544
|
fg: "foreground",
|
|
540
545
|
when: '{{ ne .tmux.session "" }}',
|
|
@@ -547,7 +552,7 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
547
552
|
},
|
|
548
553
|
gitaculous: {
|
|
549
554
|
template:
|
|
550
|
-
"
|
|
555
|
+
"(git)" +
|
|
551
556
|
'{{ if ne .git.repoName "" }} {{ .git.repoName }}{{ end }}' +
|
|
552
557
|
'{{ if ne .git.operation "" }} [{{ .git.operation }}]{{ end }}' +
|
|
553
558
|
'{{ if ne .git.sha "" }} {{ .git.sha }}{{ end }}' +
|
|
@@ -564,8 +569,7 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
564
569
|
'{{ if gt .git.behind 0 }}{{ red (printf "-%v" .git.behind) }}{{ end }}' +
|
|
565
570
|
"{{ end }}]{{ end }}" +
|
|
566
571
|
"{{ if gt .git.stash 0 }} ({{ .git.stash }} stashed){{ end }}" +
|
|
567
|
-
'{{ if gt .git.timeSinceCommit 0 }} ◷ {{ template "formatTimeSince" .git.timeSinceCommit }}{{ end }}'
|
|
568
|
-
" ",
|
|
572
|
+
'{{ if gt .git.timeSinceCommit 0 }} ◷ {{ template "formatTimeSince" .git.timeSinceCommit }}{{ end }}',
|
|
569
573
|
bg: "surface-active",
|
|
570
574
|
fg: "foreground",
|
|
571
575
|
when: '{{ ne .git.branch "" }}',
|
|
@@ -580,10 +584,12 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
580
584
|
// renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
|
|
581
585
|
// no PR (both empty) leaves the `when` gate false and the segment absent.
|
|
582
586
|
gitPr: {
|
|
587
|
+
// The pad spaces are structural chrome now, OUTSIDE the OSC-8 link
|
|
588
|
+
// region — the clickable area is the glyph text itself.
|
|
583
589
|
template:
|
|
584
590
|
'{{ if ne .git.prUrl "" }}' +
|
|
585
|
-
'{{ link .git.prUrl (printf "
|
|
586
|
-
"{{ else }}
|
|
591
|
+
'{{ link .git.prUrl (printf "⇆ #%v" .git.prNumber) }}' +
|
|
592
|
+
"{{ else }}⚠ PR{{ end }}",
|
|
587
593
|
bg: "surface-active",
|
|
588
594
|
fg: "foreground",
|
|
589
595
|
when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
|
|
@@ -596,28 +602,28 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
596
602
|
// one OSC-8 clickable region whose URL the wire codec owns end-to-end.
|
|
597
603
|
toolbar: {
|
|
598
604
|
template:
|
|
599
|
-
'
|
|
600
|
-
' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}
|
|
605
|
+
'{{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
|
|
606
|
+
' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}',
|
|
601
607
|
bg: "surface",
|
|
602
608
|
fg: "foreground",
|
|
603
609
|
},
|
|
604
610
|
session: {
|
|
605
611
|
template:
|
|
606
|
-
'
|
|
612
|
+
'§ {{ template "formatCost" .session.cost }} ({{ template "formatTokens" .session.tokens }})',
|
|
607
613
|
bg: "surface",
|
|
608
614
|
fg: "foreground",
|
|
609
615
|
},
|
|
610
616
|
today: {
|
|
611
617
|
template:
|
|
612
|
-
'
|
|
613
|
-
'{{ template "budgetStatus" (dict "cost" .today.cost "budget" .today.budget.amount "warn" .today.budget.warningThreshold) }}
|
|
618
|
+
'☉ {{ template "formatCost" .today.cost }} ({{ template "formatTokens" .today.tokens }})' +
|
|
619
|
+
'{{ template "budgetStatus" (dict "cost" .today.cost "budget" .today.budget.amount "warn" .today.budget.warningThreshold) }}',
|
|
614
620
|
bg: "surface",
|
|
615
621
|
fg: "foreground",
|
|
616
622
|
},
|
|
617
623
|
block: {
|
|
618
624
|
template:
|
|
619
|
-
"
|
|
620
|
-
'({{ template "formatLongTimeRemaining" (minutesUntilReset .block.resetsAt) }})
|
|
625
|
+
"◱ {{ round .block.nativeUtilization }}% " +
|
|
626
|
+
'({{ template "formatLongTimeRemaining" (minutesUntilReset .block.resetsAt) }})',
|
|
621
627
|
bg: blockLikeBg(
|
|
622
628
|
".block.nativeUtilization",
|
|
623
629
|
".block.budget.warningThreshold",
|
|
@@ -628,8 +634,8 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
628
634
|
},
|
|
629
635
|
weekly: {
|
|
630
636
|
template:
|
|
631
|
-
"
|
|
632
|
-
'({{ template "formatLongTimeRemaining" (minutesUntilReset .weekly.resetsAt) }})
|
|
637
|
+
"◑ {{ round .weekly.percentage }}% " +
|
|
638
|
+
'({{ template "formatLongTimeRemaining" (minutesUntilReset .weekly.resetsAt) }})',
|
|
633
639
|
bg: blockLikeBg(".weekly.percentage", ".weekly.budget.warningThreshold"),
|
|
634
640
|
fg: blockLikeFg(".weekly.percentage"),
|
|
635
641
|
when: "{{ gt .weekly.resetsAt 0 }}",
|
|
@@ -641,9 +647,9 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
641
647
|
// rate-limit window is active — the same signal block/weekly gate on.
|
|
642
648
|
burnrate: {
|
|
643
649
|
template:
|
|
644
|
-
'
|
|
650
|
+
'⚡ {{ template "formatRate" .burn.costPerHour }} · ' +
|
|
645
651
|
'{{ template "formatEta" .block.etaMinutes }} to 5h · ' +
|
|
646
|
-
'{{ template "formatEta" .weekly.etaMinutes }} to wk
|
|
652
|
+
'{{ template "formatEta" .weekly.etaMinutes }} to wk',
|
|
647
653
|
bg: etaHeatBg(
|
|
648
654
|
".block.etaMinutes",
|
|
649
655
|
".burn.eta.warnMinutes",
|
|
@@ -662,9 +668,9 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
662
668
|
// turn start, `total` is their sum.
|
|
663
669
|
speed: {
|
|
664
670
|
template:
|
|
665
|
-
'
|
|
671
|
+
'⇅ out {{ template "formatSpeed" .speed.output }} · ' +
|
|
666
672
|
'in {{ template "formatSpeed" .speed.input }} · ' +
|
|
667
|
-
'tot {{ template "formatSpeed" .speed.total }}
|
|
673
|
+
'tot {{ template "formatSpeed" .speed.total }}',
|
|
668
674
|
bg: "panel",
|
|
669
675
|
fg: "foreground",
|
|
670
676
|
when: "{{ gt .session.tokens 0 }}",
|
|
@@ -679,7 +685,7 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
679
685
|
// two samples before its first bar). [LAW:effects-at-boundaries] — all the
|
|
680
686
|
// history lives in the daemon ring, the template only draws.
|
|
681
687
|
tokenSparkline: {
|
|
682
|
-
template: "
|
|
688
|
+
template: "⚡ {{ sparkline .speed.history 24 }}",
|
|
683
689
|
bg: "panel",
|
|
684
690
|
fg: "foreground",
|
|
685
691
|
when: '{{ ne .speed.history "" }}',
|
|
@@ -693,8 +699,8 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
693
699
|
// (warm = normal, ≤20m = warning, ≤8m/cold = error).
|
|
694
700
|
cacheTimer: {
|
|
695
701
|
template:
|
|
696
|
-
"
|
|
697
|
-
"{{ else }}{{ minutesUntilReset .cache.expiresAt }}m{{ end }}
|
|
702
|
+
"◴ {{ if le (minutesUntilReset .cache.expiresAt) 0 }}cold" +
|
|
703
|
+
"{{ else }}{{ minutesUntilReset .cache.expiresAt }}m{{ end }}",
|
|
698
704
|
bg: "surface",
|
|
699
705
|
fg:
|
|
700
706
|
"{{ if le (minutesUntilReset .cache.expiresAt) 8 }}error" +
|
|
@@ -704,7 +710,7 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
704
710
|
},
|
|
705
711
|
context: {
|
|
706
712
|
template:
|
|
707
|
-
"
|
|
713
|
+
"◔ {{ formatInteger .context.totalTokens }} ({{ .context.contextLeft }}%)",
|
|
708
714
|
bg:
|
|
709
715
|
"{{ if le .context.contextLeft 20 }}error" +
|
|
710
716
|
"{{ else }}{{ if le .context.contextLeft 40 }}warning" +
|
|
@@ -724,6 +730,12 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
724
730
|
// level `when` survives as a weak any-present check so a payload
|
|
725
731
|
// with zero metrics data renders no cell at all (an empty template
|
|
726
732
|
// would otherwise produce a single-space bg-styled cell).
|
|
733
|
+
//
|
|
734
|
+
// [LAW:one-source-of-truth] exception: each arm's leading space is the
|
|
735
|
+
// SEPARATOR between present parts (only data can decide which part is
|
|
736
|
+
// first, so no static strip can remove just the first one), and the
|
|
737
|
+
// trailing space mirrors it for symmetry. At the default padding this
|
|
738
|
+
// cell therefore reads one space wider per side than its siblings.
|
|
727
739
|
template:
|
|
728
740
|
'{{ if .metrics.lastResponseTime }} Δ {{ template "formatResponseTime" .metrics.lastResponseTime }}{{ end }}' +
|
|
729
741
|
'{{ if .metrics.responseTime }} ⧖ {{ template "formatResponseTime" .metrics.responseTime }}{{ end }}' +
|
package/src/config/dsl-types.ts
CHANGED
|
@@ -189,6 +189,21 @@ export interface Globals {
|
|
|
189
189
|
// so a style click reshapes the bar live and a config can set the default
|
|
190
190
|
// shape without an edit-per-session.
|
|
191
191
|
readonly style?: StripStyle;
|
|
192
|
+
|
|
193
|
+
// The legacy display.autoWrap knob: whether FlexStrip soft-wraps a root
|
|
194
|
+
// row that exceeds the usable width. Default true (current behavior);
|
|
195
|
+
// false renders each row as one unbounded line, overflow off-screen.
|
|
196
|
+
// [config-only] Unlike palette/style there is no SessionState/click half —
|
|
197
|
+
// the daemon resolves `globals.autoWrap ?? true` into renderOpts.wrap.
|
|
198
|
+
readonly autoWrap?: boolean;
|
|
199
|
+
|
|
200
|
+
// The legacy display.padding knob: spaces synthesized INSIDE each segment
|
|
201
|
+
// cell per side (intra-cell, within the bg fill — not rich-js FlexStrip's
|
|
202
|
+
// inter-item gap). Default 1 (current behavior). Templates author content;
|
|
203
|
+
// this chrome is applied structurally at the cell-formation seam.
|
|
204
|
+
// [config-only] The daemon resolves `globals.padding ?? 1` into
|
|
205
|
+
// renderOpts.padding; no SessionState/click half.
|
|
206
|
+
readonly padding?: number;
|
|
192
207
|
}
|
|
193
208
|
|
|
194
209
|
// [LAW:one-type-per-behavior] One discriminated union covers every source
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { type Globals } from "../dsl-types.js";
|
|
7
7
|
import { STRIP_STYLES } from "../../themes/policy.js";
|
|
8
8
|
import {
|
|
9
|
+
optionalBooleanSpec,
|
|
9
10
|
optionalEnumSpec,
|
|
11
|
+
optionalIntSpec,
|
|
10
12
|
optionalStringSpec,
|
|
11
13
|
paletteSpec,
|
|
12
14
|
record,
|
|
@@ -29,6 +31,12 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
|
29
31
|
// shapes the joiner can render), unlike the open-ended palette NAME — so it
|
|
30
32
|
// validates by membership and emits a JSON-Schema `enum`.
|
|
31
33
|
style: optionalEnumSpec(STRIP_STYLES),
|
|
34
|
+
autoWrap: optionalBooleanSpec(),
|
|
35
|
+
// Intra-cell spaces per side. Bounded above so a config value can never
|
|
36
|
+
// drive an unbounded `" ".repeat` allocation in the daemon
|
|
37
|
+
// [LAW:no-silent-failure] — an absurd value is a loud load error, not a
|
|
38
|
+
// silently-huge render.
|
|
39
|
+
padding: optionalIntSpec({ min: 0, max: 16 }),
|
|
32
40
|
},
|
|
33
41
|
};
|
|
34
42
|
|
|
@@ -554,6 +554,34 @@ export function optionalBooleanSpec(): FieldSpec<boolean> {
|
|
|
554
554
|
};
|
|
555
555
|
}
|
|
556
556
|
|
|
557
|
+
// [LAW:dataflow-not-control-flow] An optional bounded-integer field: the bounds
|
|
558
|
+
// are DATA feeding both interpreters — `parse` checks them and interpolates them
|
|
559
|
+
// into the one message, `json` emits them as minimum/maximum — so the validator
|
|
560
|
+
// and the editor-facing schema cannot describe different ranges.
|
|
561
|
+
export function optionalIntSpec(bounds: {
|
|
562
|
+
readonly min: number;
|
|
563
|
+
readonly max: number;
|
|
564
|
+
}): FieldSpec<number> {
|
|
565
|
+
const { min, max } = bounds;
|
|
566
|
+
return {
|
|
567
|
+
required: false,
|
|
568
|
+
json: { type: "integer", minimum: min, maximum: max },
|
|
569
|
+
parse: (ctx, path, field, raw) => {
|
|
570
|
+
const v = raw[field];
|
|
571
|
+
if (v === undefined) return undefined;
|
|
572
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
|
|
573
|
+
ctx.issues.push({
|
|
574
|
+
path: `${path}.${field}`,
|
|
575
|
+
message: `${field} must be an integer between ${min} and ${max}, got ${describeValue(v)}`,
|
|
576
|
+
line: findKeyLine(ctx.source, [field]),
|
|
577
|
+
});
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
return v;
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
557
585
|
// [LAW:single-enforcer] The palette field defers to the one palette-name
|
|
558
586
|
// authority; the field key is conventionally "palette", which validatePaletteName
|
|
559
587
|
// reads directly.
|
package/src/daemon/server.ts
CHANGED
|
@@ -46,7 +46,9 @@ import {
|
|
|
46
46
|
} from "../themes/index.js";
|
|
47
47
|
import {
|
|
48
48
|
renderStripCells,
|
|
49
|
+
DEFAULT_PADDING,
|
|
49
50
|
DEFAULT_TERMINAL_WIDTH,
|
|
51
|
+
DEFAULT_WRAP,
|
|
50
52
|
type BuildLineOptions,
|
|
51
53
|
} from "../render/strip.js";
|
|
52
54
|
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
@@ -767,6 +769,17 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
767
769
|
sessionState.get(req.hookData.session_id, "style"),
|
|
768
770
|
entry.state.config.globals.style,
|
|
769
771
|
);
|
|
772
|
+
// [config-only] globals.autoWrap has no SessionState half (no click
|
|
773
|
+
// verb) — the config global over the base floor is the whole
|
|
774
|
+
// resolution. Width stays finite regardless: it also feeds the
|
|
775
|
+
// picker's pagination (term.cols), which no-wrap must not break.
|
|
776
|
+
renderOpts.wrap = entry.state.config.globals.autoWrap ?? DEFAULT_WRAP;
|
|
777
|
+
// [config-only] globals.padding, same shape as autoWrap: the config
|
|
778
|
+
// global over the base floor is the whole resolution — the ONE home
|
|
779
|
+
// of the value every cell builder (and the picker's pagination
|
|
780
|
+
// reserve) derives from. [LAW:one-source-of-truth]
|
|
781
|
+
renderOpts.padding =
|
|
782
|
+
entry.state.config.globals.padding ?? DEFAULT_PADDING;
|
|
770
783
|
// [LAW:single-enforcer] renderDsl internally calls
|
|
771
784
|
// `registry.applyInput(payload)` as its first step (see step 1 in
|
|
772
785
|
// src/dsl/render.ts). The daemon must not pre-apply — doing so
|
|
@@ -1057,6 +1070,8 @@ const verbCtx = { sessionState, dlog };
|
|
|
1057
1070
|
const RENDER_OPTS_BASE = {
|
|
1058
1071
|
style: "powerline" as const,
|
|
1059
1072
|
colorCompatibility: "truecolor" as const,
|
|
1073
|
+
wrap: DEFAULT_WRAP,
|
|
1074
|
+
padding: DEFAULT_PADDING,
|
|
1060
1075
|
};
|
|
1061
1076
|
const DEBUG_RENDER_OPTS: BuildLineOptions = {
|
|
1062
1077
|
...RENDER_OPTS_BASE,
|
|
@@ -81,10 +81,10 @@ export type DerivedValidatorSpec =
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
// [LAW:one-source-of-truth] listResolvablePaletteNames is THE set whose
|
|
84
|
-
// members resolve to a concrete Palette.
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
84
|
+
// members resolve to a concrete Palette. It deliberately excludes the "custom"
|
|
85
|
+
// sentinel (which needs inline colors and is not a renderable theme name):
|
|
86
|
+
// accepting "custom" here would persist an unrenderable value into SessionState
|
|
87
|
+
// and break the next render.
|
|
88
88
|
//
|
|
89
89
|
// [LAW:single-enforcer] Each validator's accepted-set is one constant
|
|
90
90
|
// lookup structure — a Set for O(1) `has` (matching the BOOLEAN_*
|
package/src/demo/dsl.ts
CHANGED
|
@@ -31,7 +31,11 @@ import { SessionState } from "../daemon/session-state.js";
|
|
|
31
31
|
import { listResolvablePaletteNames } from "../themes/policy.js";
|
|
32
32
|
import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
|
|
33
33
|
import { registerDslConfig, renderDsl } from "../dsl/render.js";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
DEFAULT_PADDING,
|
|
36
|
+
DEFAULT_TERMINAL_WIDTH,
|
|
37
|
+
DEFAULT_WRAP,
|
|
38
|
+
} from "../render/strip.js";
|
|
35
39
|
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
36
40
|
|
|
37
41
|
const FRAMES = 4;
|
|
@@ -110,6 +114,10 @@ try {
|
|
|
110
114
|
width: applyClaudeCodeReserve(
|
|
111
115
|
process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
|
|
112
116
|
),
|
|
117
|
+
// Same resolution the daemon applies: the config global over the
|
|
118
|
+
// default-on floor.
|
|
119
|
+
wrap: config.globals.autoWrap ?? DEFAULT_WRAP,
|
|
120
|
+
padding: config.globals.padding ?? DEFAULT_PADDING,
|
|
113
121
|
},
|
|
114
122
|
);
|
|
115
123
|
process.stdout.write(` ${line}\n`);
|
package/src/dsl/node-registry.ts
CHANGED
|
@@ -101,6 +101,10 @@ export interface NodeRenderCtx {
|
|
|
101
101
|
readonly scope: object;
|
|
102
102
|
readonly basePalette: PaletteResolver;
|
|
103
103
|
readonly visible: boolean;
|
|
104
|
+
// [LAW:one-source-of-truth] The render-wide intra-cell padding (resolved
|
|
105
|
+
// globals.padding), threaded by the driver from BuildLineOptions into every
|
|
106
|
+
// segment's layout — one value per render, never re-defaulted per node.
|
|
107
|
+
readonly padding: number;
|
|
104
108
|
// Advance the walk-owned hue cursor by one unit and return that unit's shift.
|
|
105
109
|
nextHueShift(): number;
|
|
106
110
|
readonly perSegmentSink?: Map<string, readonly RichText[]>;
|
|
@@ -279,6 +283,11 @@ const segmentType: NodeType<"segment"> = {
|
|
|
279
283
|
justify: seg.justify ?? "left",
|
|
280
284
|
truncate: seg.truncate ?? "right",
|
|
281
285
|
baseStyle,
|
|
286
|
+
// [LAW:dataflow-not-control-flow] Padding is uniform across every line
|
|
287
|
+
// a segment contributes — inline rows AND dropped menu bands — one
|
|
288
|
+
// value, no per-line-kind branch. The picker reserves 2×padding at its
|
|
289
|
+
// pagination seam so a padded band still fits the width budget.
|
|
290
|
+
padding: ctx.padding,
|
|
282
291
|
} as const;
|
|
283
292
|
|
|
284
293
|
// [LAW:single-enforcer] Partition the segment's authored "\n" into visual
|
package/src/dsl/render.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
type GitField,
|
|
30
30
|
} from "../var-system/sources.js";
|
|
31
31
|
import type { BuildLineOptions } from "../render/strip.js";
|
|
32
|
-
import { renderStripCells } from "../render/strip.js";
|
|
32
|
+
import { DEFAULT_PADDING, renderStripCells } from "../render/strip.js";
|
|
33
33
|
import { resolverForThemeName } from "../themes/index.js";
|
|
34
34
|
import { buildScope } from "../template-engine/scope.js";
|
|
35
35
|
import {
|
|
@@ -286,6 +286,9 @@ export function registerDslConfig(
|
|
|
286
286
|
// style each render; "powerline" is the registration-time default so a
|
|
287
287
|
// compile-only path (no render) still has a valid value.
|
|
288
288
|
stripStyle: "powerline",
|
|
289
|
+
// Same contract as stripStyle: renderDsl republishes the live resolved
|
|
290
|
+
// globals.padding each render; the constant is only the compile-only floor.
|
|
291
|
+
padding: DEFAULT_PADDING,
|
|
289
292
|
};
|
|
290
293
|
// [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
|
|
291
294
|
// stays generic. The picker shares the ACTION runtime (it resolves its
|
|
@@ -537,6 +540,10 @@ export function renderDsl(
|
|
|
537
540
|
// once per render here — the same one-owner, per-render-mutation idiom as the
|
|
538
541
|
// menu placement cursor below. [LAW:no-ambient-temporal-coupling]
|
|
539
542
|
compiled.menuRuntime.action.stripStyle = opts.style;
|
|
543
|
+
// [LAW:one-source-of-truth] Publish the render's intra-cell padding beside the
|
|
544
|
+
// style: the picker reserves 2×padding at its pagination seam, the same seam
|
|
545
|
+
// that reserves the joiner chrome — one resolved value, read where needed.
|
|
546
|
+
compiled.menuRuntime.action.padding = opts.padding;
|
|
540
547
|
|
|
541
548
|
const scope = buildScope(store);
|
|
542
549
|
// [LAW:one-source-of-truth] hueStep is a value in the store like every other
|
|
@@ -610,6 +617,7 @@ export function renderDsl(
|
|
|
610
617
|
scope,
|
|
611
618
|
basePalette,
|
|
612
619
|
visible,
|
|
620
|
+
padding: opts.padding,
|
|
613
621
|
nextHueShift,
|
|
614
622
|
perSegmentSink,
|
|
615
623
|
beginSegment,
|
package/src/render/action.ts
CHANGED
|
@@ -134,6 +134,12 @@ export interface ActionRuntime {
|
|
|
134
134
|
// and synchronous, so the per-render write never leaks across renders.
|
|
135
135
|
// [LAW:no-ambient-temporal-coupling]
|
|
136
136
|
stripStyle: StripStyle;
|
|
137
|
+
// [LAW:locality-or-seam] The current render's intra-cell padding (resolved
|
|
138
|
+
// globals.padding), published per render by renderDsl exactly like
|
|
139
|
+
// stripStyle. The picker reserves 2×padding at its pagination seam — the
|
|
140
|
+
// segment layout pads every line it emits, so a page packed to the full
|
|
141
|
+
// budget would otherwise be pushed past the width by the pad spaces.
|
|
142
|
+
padding: number;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
// ─── Compilation ───────────────────────────────────────────────────────────────
|
package/src/render/picker.ts
CHANGED
|
@@ -168,11 +168,15 @@ export function renderPicker(
|
|
|
168
168
|
// pagination seam, rather than shrinking the shared term.cols every template
|
|
169
169
|
// reads. stripChromeCols owns the per-style geometry; Infinity − chrome stays
|
|
170
170
|
// Infinity, so wrap mode is unaffected.
|
|
171
|
+
// The pad spaces the segment layout synthesizes around the picker's line
|
|
172
|
+
// (2 × the render's intra-cell padding) consume the same width budget the
|
|
173
|
+
// joiner chrome does — reserve both here, at the pagination seam.
|
|
171
174
|
const available = paged
|
|
172
175
|
? Math.max(
|
|
173
176
|
1,
|
|
174
177
|
toNumber(store.read(TERM_COLS_VAR)) -
|
|
175
|
-
stripChromeCols(runtime.stripStyle)
|
|
178
|
+
stripChromeCols(runtime.stripStyle) -
|
|
179
|
+
2 * runtime.padding,
|
|
176
180
|
)
|
|
177
181
|
: Infinity;
|
|
178
182
|
const closeReserve = cellWidth(PICKER_CLOSE) + 1;
|
package/src/render/strip.ts
CHANGED
|
@@ -32,14 +32,39 @@ export type { StripStyle };
|
|
|
32
32
|
// applyClaudeCodeReserve from src/utils/terminal-width).
|
|
33
33
|
export const DEFAULT_TERMINAL_WIDTH = 120;
|
|
34
34
|
|
|
35
|
+
// [LAW:one-source-of-truth] The one statement of the globals.autoWrap
|
|
36
|
+
// default (on — current behavior). Every resolver of the config global
|
|
37
|
+
// (`globals.autoWrap ?? DEFAULT_WRAP`) derives from this constant.
|
|
38
|
+
export const DEFAULT_WRAP = true;
|
|
39
|
+
|
|
40
|
+
// [LAW:one-source-of-truth] The one statement of the globals.padding
|
|
41
|
+
// default (one space per side inside each segment cell — current behavior,
|
|
42
|
+
// matching the legacy display.padding). Every resolver of the config global
|
|
43
|
+
// (`globals.padding ?? DEFAULT_PADDING`) derives from this constant.
|
|
44
|
+
export const DEFAULT_PADDING = 1;
|
|
45
|
+
|
|
35
46
|
export interface BuildLineOptions {
|
|
36
47
|
style: StripStyle;
|
|
37
48
|
colorCompatibility: ColorSystemSpec;
|
|
38
49
|
separator?: string;
|
|
39
|
-
// [LAW:types-are-the-program] Every render carries a width.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
50
|
+
// [LAW:types-are-the-program] Every render carries a width. Required (not
|
|
51
|
+
// optional) so callers cannot silently drop the wire's value.
|
|
52
|
+
// [LAW:one-source-of-truth] Width is a FACT (usable cells) feeding two
|
|
53
|
+
// consumers — FlexStrip's wrap limit AND the picker's pagination
|
|
54
|
+
// (`term.cols`). It stays finite even when wrapping is off; `wrap` below is
|
|
55
|
+
// the separate POLICY of whether rows may soft-break at that width.
|
|
42
56
|
width: number;
|
|
57
|
+
// [LAW:types-are-the-program] Required for the same reason as width: the
|
|
58
|
+
// wrap decision (globals.autoWrap, default on) must reach every render
|
|
59
|
+
// explicitly — encoding "no wrap" as width=Infinity would corrupt the
|
|
60
|
+
// picker's pagination, which reads the same width value.
|
|
61
|
+
wrap: boolean;
|
|
62
|
+
// [LAW:one-source-of-truth] Spaces inside each segment cell per side
|
|
63
|
+
// (globals.padding, default 1 — the legacy display.padding, intra-cell,
|
|
64
|
+
// not rich-js FlexStrip's inter-item gap). Required so every construction
|
|
65
|
+
// site states the resolved value; the cell builders derive from it and
|
|
66
|
+
// never re-default.
|
|
67
|
+
padding: number;
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
function pickJoiner(style: StripStyle, separator?: string): Joiner {
|
|
@@ -90,15 +115,15 @@ export function stripChromeCols(style: StripStyle): number {
|
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
93
|
-
function toCell(seg: RenderedSegmentLike): RichText {
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
118
|
+
function toCell(seg: RenderedSegmentLike, padding: number): RichText {
|
|
119
|
+
// [LAW:one-source-of-truth] Intra-cell padding derives from the one resolved
|
|
120
|
+
// globals.padding value on BuildLineOptions — the joiners sit between cells;
|
|
121
|
+
// padding sits inside, inheriting the cell's wrapping style (bg fill).
|
|
97
122
|
const style = new Style({
|
|
98
123
|
bgcolor: seg.bgHex || undefined,
|
|
99
124
|
color: seg.fgHex || undefined,
|
|
100
125
|
});
|
|
101
|
-
return new RichText(
|
|
126
|
+
return new RichText(seg.text, { style, end: "", noWrap: true }).pad(padding);
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
/**
|
|
@@ -106,12 +131,13 @@ function toCell(seg: RenderedSegmentLike): RichText {
|
|
|
106
131
|
* string. Every render path (DSL RichText[] via the template-engine
|
|
107
132
|
* pipeline, buildLineStrip's input-shape adapter, debug per-segment
|
|
108
133
|
* serialization) flows through here. The wrap dispatch lives here too:
|
|
109
|
-
* finite width → FlexStrip (rich-js owns the wrap
|
|
110
|
-
*
|
|
134
|
+
* wrap enabled at a finite width → FlexStrip (rich-js owns the wrap
|
|
135
|
+
* algebra); otherwise → Strip, one unbounded line.
|
|
111
136
|
*
|
|
112
|
-
* [LAW:dataflow-not-control-flow] The dispatch is on
|
|
113
|
-
*
|
|
114
|
-
*
|
|
137
|
+
* [LAW:dataflow-not-control-flow] The dispatch is on values, not on caller
|
|
138
|
+
* branches: `wrap` (the globals.autoWrap policy) gates whether the finite
|
|
139
|
+
* width acts as a break limit. An infinite width has nothing to break at,
|
|
140
|
+
* so it renders unbounded regardless of `wrap`.
|
|
115
141
|
*/
|
|
116
142
|
export function renderStripCells(
|
|
117
143
|
cells: readonly RichText[],
|
|
@@ -119,7 +145,7 @@ export function renderStripCells(
|
|
|
119
145
|
): string {
|
|
120
146
|
if (cells.length === 0) return "";
|
|
121
147
|
const joiner = pickJoiner(options.style, options.separator);
|
|
122
|
-
if (Number.isFinite(options.width)) {
|
|
148
|
+
if (options.wrap && Number.isFinite(options.width)) {
|
|
123
149
|
const flex = new FlexStrip([...cells], { joiner });
|
|
124
150
|
const out = renderToString(flex, {
|
|
125
151
|
width: options.width,
|
|
@@ -142,5 +168,8 @@ export function buildLineStrip(
|
|
|
142
168
|
segments: readonly RenderedSegmentLike[],
|
|
143
169
|
options: BuildLineOptions,
|
|
144
170
|
): string {
|
|
145
|
-
return renderStripCells(
|
|
171
|
+
return renderStripCells(
|
|
172
|
+
segments.map((seg) => toCell(seg, options.padding)),
|
|
173
|
+
options,
|
|
174
|
+
);
|
|
146
175
|
}
|