@promptctl/cc-candybar 1.9.0 → 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.9.0",
3
+ "version": "1.11.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.9.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.9.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.9.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.9.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.11.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.11.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.11.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.11.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -35,6 +35,17 @@
35
35
  },
36
36
  "autoWrap": {
37
37
  "type": "boolean"
38
+ },
39
+ "padding": {
40
+ "type": "integer",
41
+ "minimum": 0,
42
+ "maximum": 16
43
+ },
44
+ "charset": {
45
+ "enum": [
46
+ "unicode",
47
+ "ascii"
48
+ ]
38
49
  }
39
50
  },
40
51
  "additionalProperties": false
@@ -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
- ' {{ if and (ne .home "") (or (eq .home .current_dir) (hasPrefix (printf "%s/" .home) .current_dir)) }}~{{ trimPrefix .home .current_dir }}' +
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
- ' {{ if ne .git.repoName "" }}{{ .git.repoName }} {{ end }}⎇ {{ .git.branch }}' +
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: " ✱ {{ formatModelName .model.display_name }} ",
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: " ⌗{{ trunc 8 .session.id }} ",
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: " ◈ v{{ .version }} ",
536
+ template: "◈ v{{ .version }}",
532
537
  bg: "surface",
533
538
  fg: "foreground",
534
539
  when: '{{ ne .version "" }}',
535
540
  },
536
541
  tmux: {
537
- template: ' tmux:{{ .tmux.session | default "none" }} ',
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
- " (git)" +
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 " ⇆ #%v " .git.prNumber) }}' +
586
- "{{ else }} ⚠ PR {{ end }}",
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
- ' {{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
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
- ' § {{ template "formatCost" .session.cost }} ({{ template "formatTokens" .session.tokens }}) ',
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
- ' ☉ {{ template "formatCost" .today.cost }} ({{ template "formatTokens" .today.tokens }})' +
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
- " ◱ {{ round .block.nativeUtilization }}% " +
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
- " ◑ {{ round .weekly.percentage }}% " +
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
- ' ⚡ {{ template "formatRate" .burn.costPerHour }} · ' +
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
- ' ⇅ out {{ template "formatSpeed" .speed.output }} · ' +
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: " ⚡ {{ sparkline .speed.history 24 }} ",
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
- " ◴ {{ if le (minutesUntilReset .cache.expiresAt) 0 }}cold" +
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
- " ◔ {{ formatInteger .context.totalTokens }} ({{ .context.contextLeft }}%) ",
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 }}' +
@@ -14,7 +14,7 @@
14
14
  // references it here. The dependency is one-way (this file → action.ts), never
15
15
  // the reverse, so that shape can be lifted out without a cycle.
16
16
  import type { ActionDecl } from "./action.js";
17
- import type { StripStyle } from "../themes/policy.js";
17
+ import type { Charset, StripStyle } from "../themes/policy.js";
18
18
 
19
19
  // [LAW:types-are-the-program] Three stages, three names.
20
20
  //
@@ -196,6 +196,23 @@ export interface Globals {
196
196
  // [config-only] Unlike palette/style there is no SessionState/click half —
197
197
  // the daemon resolves `globals.autoWrap ?? true` into renderOpts.wrap.
198
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;
207
+
208
+ // The legacy display.charset knob: which glyph vocabulary the strip joiners
209
+ // render with. Default "unicode" (current behavior — rich-js's powerline
210
+ // caps, U+E0Bx). "ascii" swaps the caps for single-column ASCII glyphs so
211
+ // terminals/fonts without powerline glyphs render cleanly instead of tofu.
212
+ // Orthogonal to `style`: style picks the joiner shape, charset the glyphs.
213
+ // [config-only] The daemon resolves `globals.charset ?? "unicode"` into
214
+ // renderOpts.charset; no SessionState/click half.
215
+ readonly charset?: Charset;
199
216
  }
200
217
 
201
218
  // [LAW:one-type-per-behavior] One discriminated union covers every source
@@ -4,10 +4,11 @@
4
4
  // add a key to GLOBALS_SCHEMA and Globals; the engine does the rest.
5
5
 
6
6
  import { type Globals } from "../dsl-types.js";
7
- import { STRIP_STYLES } from "../../themes/policy.js";
7
+ import { CHARSETS, STRIP_STYLES } from "../../themes/policy.js";
8
8
  import {
9
9
  optionalBooleanSpec,
10
10
  optionalEnumSpec,
11
+ optionalIntSpec,
11
12
  optionalStringSpec,
12
13
  paletteSpec,
13
14
  record,
@@ -31,6 +32,15 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
31
32
  // validates by membership and emits a JSON-Schema `enum`.
32
33
  style: optionalEnumSpec(STRIP_STYLES),
33
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 }),
40
+ // [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
41
+ // vocabularies pickJoiner can render — validates by membership, emits a
42
+ // JSON-Schema `enum` from the same CHARSETS literal.
43
+ charset: optionalEnumSpec(CHARSETS),
34
44
  },
35
45
  };
36
46
 
@@ -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.
@@ -46,9 +46,12 @@ import {
46
46
  } from "../themes/index.js";
47
47
  import {
48
48
  renderStripCells,
49
+ DEFAULT_CHARSET,
50
+ DEFAULT_PADDING,
49
51
  DEFAULT_TERMINAL_WIDTH,
50
52
  DEFAULT_WRAP,
51
53
  type BuildLineOptions,
54
+ type Charset,
52
55
  } from "../render/strip.js";
53
56
  import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
54
57
  import type { RichText } from "@promptctl/rich-js";
@@ -773,6 +776,18 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
773
776
  // resolution. Width stays finite regardless: it also feeds the
774
777
  // picker's pagination (term.cols), which no-wrap must not break.
775
778
  renderOpts.wrap = entry.state.config.globals.autoWrap ?? DEFAULT_WRAP;
779
+ // [config-only] globals.padding, same shape as autoWrap: the config
780
+ // global over the base floor is the whole resolution — the ONE home
781
+ // of the value every cell builder (and the picker's pagination
782
+ // reserve) derives from. [LAW:one-source-of-truth]
783
+ renderOpts.padding =
784
+ entry.state.config.globals.padding ?? DEFAULT_PADDING;
785
+ // [config-only] globals.charset, same shape as autoWrap/padding: the
786
+ // config global over the base floor is the whole resolution — the ONE
787
+ // home of the glyph-vocabulary choice pickJoiner derives from.
788
+ // [LAW:one-source-of-truth]
789
+ renderOpts.charset =
790
+ entry.state.config.globals.charset ?? DEFAULT_CHARSET;
776
791
  // [LAW:single-enforcer] renderDsl internally calls
777
792
  // `registry.applyInput(payload)` as its first step (see step 1 in
778
793
  // src/dsl/render.ts). The daemon must not pre-apply — doing so
@@ -877,7 +892,10 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
877
892
  compiled: dbgEntry.compiled,
878
893
  lastRenderBySegment:
879
894
  req.what === "segments"
880
- ? serializeSegmentCells(dbgEntry.lastRenderCellsBySegment)
895
+ ? serializeSegmentCells(
896
+ dbgEntry.lastRenderCellsBySegment,
897
+ dbgEntry.config.globals.charset ?? DEFAULT_CHARSET,
898
+ )
881
899
  : EMPTY_RENDER_MAP,
882
900
  };
883
901
  return stay({ ok: true, debug: buildDebugSnapshot(req.what, dbgState) });
@@ -1064,6 +1082,8 @@ const RENDER_OPTS_BASE = {
1064
1082
  style: "powerline" as const,
1065
1083
  colorCompatibility: "truecolor" as const,
1066
1084
  wrap: DEFAULT_WRAP,
1085
+ padding: DEFAULT_PADDING,
1086
+ charset: DEFAULT_CHARSET,
1067
1087
  };
1068
1088
  const DEBUG_RENDER_OPTS: BuildLineOptions = {
1069
1089
  ...RENDER_OPTS_BASE,
@@ -1075,12 +1095,22 @@ const DEBUG_RENDER_OPTS: BuildLineOptions = {
1075
1095
  // the DaemonDslState type requires the field.
1076
1096
  const EMPTY_RENDER_MAP = new Map<string, string>();
1077
1097
 
1098
+ // [LAW:one-source-of-truth] The joiner glyph vocabulary is a serialization-time
1099
+ // choice, and charset is config-only (no SessionState half) — so the faithful
1100
+ // value is fully derivable from the sampled entry's config, unlike style, whose
1101
+ // live session-over-config resolution needs a session a debug request doesn't
1102
+ // carry. The caller threads the entry-resolved charset; this serializer never
1103
+ // re-defaults it.
1078
1104
  function serializeSegmentCells(
1079
1105
  cells: ReadonlyMap<string, readonly RichText[]>,
1106
+ charset: Charset,
1080
1107
  ): Map<string, string> {
1081
1108
  const out = new Map<string, string>();
1082
1109
  for (const [name, segCells] of cells) {
1083
- out.set(name, renderStripCells(segCells, DEBUG_RENDER_OPTS));
1110
+ out.set(
1111
+ name,
1112
+ renderStripCells(segCells, { ...DEBUG_RENDER_OPTS, charset }),
1113
+ );
1084
1114
  }
1085
1115
  return out;
1086
1116
  }
package/src/demo/dsl.ts CHANGED
@@ -31,7 +31,12 @@ 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 { DEFAULT_TERMINAL_WIDTH, DEFAULT_WRAP } from "../render/strip.js";
34
+ import {
35
+ DEFAULT_CHARSET,
36
+ DEFAULT_PADDING,
37
+ DEFAULT_TERMINAL_WIDTH,
38
+ DEFAULT_WRAP,
39
+ } from "../render/strip.js";
35
40
  import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
36
41
 
37
42
  const FRAMES = 4;
@@ -113,6 +118,8 @@ try {
113
118
  // Same resolution the daemon applies: the config global over the
114
119
  // default-on floor.
115
120
  wrap: config.globals.autoWrap ?? DEFAULT_WRAP,
121
+ padding: config.globals.padding ?? DEFAULT_PADDING,
122
+ charset: config.globals.charset ?? DEFAULT_CHARSET,
116
123
  },
117
124
  );
118
125
  process.stdout.write(` ${line}\n`);
@@ -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,
@@ -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 ───────────────────────────────────────────────────────────────
@@ -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;