@promptctl/cc-candybar 1.9.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.9.0",
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.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.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": {
@@ -35,6 +35,11 @@
35
35
  },
36
36
  "autoWrap": {
37
37
  "type": "boolean"
38
+ },
39
+ "padding": {
40
+ "type": "integer",
41
+ "minimum": 0,
42
+ "maximum": 16
38
43
  }
39
44
  },
40
45
  "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 }}' +
@@ -196,6 +196,14 @@ 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;
199
207
  }
200
208
 
201
209
  // [LAW:one-type-per-behavior] One discriminated union covers every source
@@ -8,6 +8,7 @@ import { 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,11 @@ 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 }),
34
40
  },
35
41
  };
36
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.
@@ -46,6 +46,7 @@ import {
46
46
  } from "../themes/index.js";
47
47
  import {
48
48
  renderStripCells,
49
+ DEFAULT_PADDING,
49
50
  DEFAULT_TERMINAL_WIDTH,
50
51
  DEFAULT_WRAP,
51
52
  type BuildLineOptions,
@@ -773,6 +774,12 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
773
774
  // resolution. Width stays finite regardless: it also feeds the
774
775
  // picker's pagination (term.cols), which no-wrap must not break.
775
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;
776
783
  // [LAW:single-enforcer] renderDsl internally calls
777
784
  // `registry.applyInput(payload)` as its first step (see step 1 in
778
785
  // src/dsl/render.ts). The daemon must not pre-apply — doing so
@@ -1064,6 +1071,7 @@ const RENDER_OPTS_BASE = {
1064
1071
  style: "powerline" as const,
1065
1072
  colorCompatibility: "truecolor" as const,
1066
1073
  wrap: DEFAULT_WRAP,
1074
+ padding: DEFAULT_PADDING,
1067
1075
  };
1068
1076
  const DEBUG_RENDER_OPTS: BuildLineOptions = {
1069
1077
  ...RENDER_OPTS_BASE,
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 { DEFAULT_TERMINAL_WIDTH, DEFAULT_WRAP } from "../render/strip.js";
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;
@@ -113,6 +117,7 @@ try {
113
117
  // Same resolution the daemon applies: the config global over the
114
118
  // default-on floor.
115
119
  wrap: config.globals.autoWrap ?? DEFAULT_WRAP,
120
+ padding: config.globals.padding ?? DEFAULT_PADDING,
116
121
  },
117
122
  );
118
123
  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;
@@ -37,6 +37,12 @@ export const DEFAULT_TERMINAL_WIDTH = 120;
37
37
  // (`globals.autoWrap ?? DEFAULT_WRAP`) derives from this constant.
38
38
  export const DEFAULT_WRAP = true;
39
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
+
40
46
  export interface BuildLineOptions {
41
47
  style: StripStyle;
42
48
  colorCompatibility: ColorSystemSpec;
@@ -53,6 +59,12 @@ export interface BuildLineOptions {
53
59
  // explicitly — encoding "no wrap" as width=Infinity would corrupt the
54
60
  // picker's pagination, which reads the same width value.
55
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;
56
68
  }
57
69
 
58
70
  function pickJoiner(style: StripStyle, separator?: string): Joiner {
@@ -103,15 +115,15 @@ export function stripChromeCols(style: StripStyle): number {
103
115
  }
104
116
  }
105
117
 
106
- function toCell(seg: RenderedSegmentLike): RichText {
107
- // Padding mirrors the legacy buildLineFromSegments: one space on each side
108
- // of the segment text. The joiners sit between cells; padding sits inside.
109
- const padded = ` ${seg.text} `;
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).
110
122
  const style = new Style({
111
123
  bgcolor: seg.bgHex || undefined,
112
124
  color: seg.fgHex || undefined,
113
125
  });
114
- return new RichText(padded, { style, end: "", noWrap: true });
126
+ return new RichText(seg.text, { style, end: "", noWrap: true }).pad(padding);
115
127
  }
116
128
 
117
129
  /**
@@ -156,5 +168,8 @@ export function buildLineStrip(
156
168
  segments: readonly RenderedSegmentLike[],
157
169
  options: BuildLineOptions,
158
170
  ): string {
159
- return renderStripCells(segments.map(toCell), options);
171
+ return renderStripCells(
172
+ segments.map((seg) => toCell(seg, options.padding)),
173
+ options,
174
+ );
160
175
  }
@@ -30,6 +30,15 @@ export interface SegmentLayoutOptions {
30
30
  justify: JustifyMode;
31
31
  /** Overflow strategy when content exceeds a fixed width. Ignored when "auto". */
32
32
  truncate: TruncateMode;
33
+ /**
34
+ * Spaces synthesized inside the collapsed cell on each side (the resolved
35
+ * globals.padding). [LAW:types-are-the-program] Required so every layout
36
+ * site states the value — the walk threads the one render-wide resolution;
37
+ * there is no per-site re-default. Applied BEFORE width sizing, so padding
38
+ * sits inside a fixed `width` (the legacy intra-cell semantics: the joiners
39
+ * sit between cells; padding sits inside the bg fill).
40
+ */
41
+ padding: number;
33
42
  /** Glyph inserted at the overflow cut point. Default "…". */
34
43
  truncateMarker?: string;
35
44
  /**
@@ -95,11 +104,23 @@ export function applySegmentLayout(
95
104
  cells: readonly RichText[],
96
105
  options: SegmentLayoutOptions,
97
106
  ): RichText[] {
98
- const { width, justify, truncate, truncateMarker = "…", baseStyle } = options;
107
+ const {
108
+ width,
109
+ justify,
110
+ truncate,
111
+ truncateMarker = "…",
112
+ baseStyle,
113
+ padding,
114
+ } = options;
99
115
 
100
116
  if (cells.length === 0) return [];
101
117
 
102
- const cell = collapseToCell(cells, baseStyle);
118
+ // [LAW:one-source-of-truth] The DSL path's ONE intra-cell padding
119
+ // application — the same rich-js pad primitive toCell (render/strip.ts)
120
+ // uses on the legacy shape, threading the same resolved globals.padding.
121
+ // pad() shifts spans, so OSC-8 link regions survive; the spaces inherit
122
+ // the cell's wrapping style, so the segment bg is continuous.
123
+ const cell = collapseToCell(cells, baseStyle).pad(padding);
103
124
  if (width === "auto") return [cell];
104
125
 
105
126
  if (cell.cellLength > width) {