@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.8.4",
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.8.4",
95
- "@promptctl/cc-candybar-darwin-x64": "1.8.4",
96
- "@promptctl/cc-candybar-linux-x64": "1.8.4",
97
- "@promptctl/cc-candybar-linux-arm64": "1.8.4"
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": {
@@ -32,6 +32,14 @@
32
32
  "capsule",
33
33
  "plain"
34
34
  ]
35
+ },
36
+ "autoWrap": {
37
+ "type": "boolean"
38
+ },
39
+ "padding": {
40
+ "type": "integer",
41
+ "minimum": 0,
42
+ "maximum": 16
35
43
  }
36
44
  },
37
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 }}' +
@@ -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.
@@ -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. The broader listAvailableThemes
85
- // includes the "custom" sentinel (read inline colors) which is not a
86
- // renderable theme name; accepting "custom" here would persist an
87
- // unrenderable value into SessionState and break the next render.
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 { DEFAULT_TERMINAL_WIDTH } 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;
@@ -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`);
@@ -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;
@@ -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. Finite values
40
- // wrap via FlexStrip; Number.POSITIVE_INFINITY renders one unbounded line.
41
- // Required (not optional) so callers cannot silently drop the wire's value.
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
- // Padding mirrors the legacy buildLineFromSegments: one space on each side
95
- // of the segment text. The joiners sit between cells; padding sits inside.
96
- 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).
97
122
  const style = new Style({
98
123
  bgcolor: seg.bgHex || undefined,
99
124
  color: seg.fgHex || undefined,
100
125
  });
101
- return new RichText(padded, { style, end: "", noWrap: true });
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 algebra); infinite
110
- * width → Strip.
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 the value, not on a
113
- * flag. There is no `wrap: boolean` knob; presence of a finite width IS
114
- * the decision.
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(segments.map(toCell), options);
171
+ return renderStripCells(
172
+ segments.map((seg) => toCell(seg, options.padding)),
173
+ options,
174
+ );
146
175
  }