@promptctl/cc-candybar 1.42.1 → 1.43.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.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,1645 +0,0 @@
1
- // [LAW:one-source-of-truth] The bundled default DslConfig — the statusline
2
- // rendered when no `.cc-candybar.json5` (or `.cc-candybar.json`) is present
3
- // at any resolution layer. This is the canonical port of every built-in
4
- // segment as a DSL declaration, covering the surface previously expressed
5
- // by the legacy renderer that was retired in bzh.2.
6
- //
7
- // [LAW:single-enforcer] One default. User configs merge on top via
8
- // `mergeWithDefault`: globals shallow-merge per field, variables/segments/
9
- // helpers/actions/helpers merge by name (user wins per name), root replaces
10
- // wholesale when present. A user file only needs to declare what differs —
11
- // overriding one segment or variable takes a few lines. JSON5 supports
12
- // inline comments so users can declare only the delta. The `.json` extension
13
- // is also accepted (JSON ⊂ JSON5, same parser); `.json5` is preferred when
14
- // both exist at the same location.
15
- //
16
- // [LAW:dataflow-not-control-flow] Every segment is declared regardless of
17
- // whether the default `root` includes it — `root` is the tree that
18
- // chooses what renders. Switching a disabled segment on is a root edit
19
- // (add its name to the children), not new code. The same data flows through
20
- // the same render path whether the root has 1 leaf or 16.
21
- //
22
- // [LAW:types-are-the-program] The authored literal (RAW_DEFAULT_DSL_CONFIG)
23
- // uses `satisfies DslConfig` (not an annotation) so every declared segment
24
- // and variable name is checked against the real shape at the point of
25
- // authoring. The exported DEFAULT_DSL_CONFIG is that literal run through the
26
- // loader's own synthesis pass (see the bottom of this file) — a `DslConfig`,
27
- // the same effective shape every user config resolves to.
28
-
29
- import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
30
- import { parseDslConfig } from "./dsl-loader.js";
31
- // [LAW:one-source-of-truth] The bundled drawer's menus author their own
32
- // disclosure glyphs now that `{{ menu }}` appends none. Interpolating the
33
- // shared constants keeps the stdlib bar reading like every other disclosure
34
- // without restating the vocabulary in three string literals.
35
- import {
36
- DISCLOSURE_GLYPH_CLOSED,
37
- DISCLOSURE_GLYPH_OPEN,
38
- } from "./disclosure.js";
39
- import { mergeWithDefault } from "./loader/merge.js";
40
-
41
- // ─── Shared template fragments ───────────────────────────────────────────────
42
- //
43
- // Factored out of the segments' `template` fields so:
44
- // (1) the git working-tree counts and status icon can be shared by the two
45
- // git-style segments (git, gitaculous) without duplication, and
46
- // (2) the block/weekly threshold cascade can be parameterized on the
47
- // variable name without resorting to runtime string surgery.
48
-
49
- // Directory: ~ collapse under $HOME, project-relative under workspace.project_dir,
50
- // else raw. Inline-recomputes the project-relative path because the DSL has no
51
- // template-level `:=` (a `kind: "template"` var would express it once, but adds
52
- // noise for a single use).
53
- //
54
- // Prefix checks are boundary-safe: a path is "under" a base iff it equals the
55
- // base OR starts with `base + "/"`. The naive `hasPrefix base path` is a
56
- // string match — it would treat `/home/alice` as a child of `/home/al`.
57
- // `(printf "%s/" base)` adds the separator so the prefix can only land at a
58
- // path boundary; the `eq` arm catches the exact-match case where the trailing
59
- // slash would over-match.
60
- //
61
- // Equal-paths case (current_dir === project_dir) DOES enter the project-
62
- // relative arm: DIR_REL evaluates to "" and the ternary picks basename
63
- // (project_dir), so the project root renders as `<repo-name>` instead of the
64
- // full absolute path. Same logic handles equal home & current_dir → just "~".
65
- const DIR_REL = 'trimPrefix "/" (trimPrefix .project_dir .current_dir)';
66
- // [LAW:decomposition] Two separable concerns: (1) COLLAPSE the absolute cwd to a
67
- // short display form (`~`-relative, else project-relative, else absolute) and
68
- // (2) ABBREVIATE fish-style. Collapse stays in the template (its inputs are the
69
- // payload's home/project_dir/current_dir); abbreviation is a single helper
70
- // applied to the collapsed result. `$dir` carries the collapsed path between the
71
- // two — default is the absolute cwd, overridden only when it lives under home or
72
- // the project root. brandon-directory-781 makes fish-abbreviation the DEFAULT;
73
- // a user restores the full path by overriding `segments.directory.template`
74
- // (drop the `abbreviatePath` wrapper) — the existing merge-by-name seam.
75
- const DIR_TEMPLATE =
76
- "{{ $dir := .current_dir }}" +
77
- '{{ if and (ne .home "") (or (eq .home .current_dir) (hasPrefix (printf "%s/" .home) .current_dir)) }}' +
78
- `{{ $dir = printf "~%s" (trimPrefix .home .current_dir) }}` +
79
- "{{ else }}" +
80
- '{{ if or (eq .project_dir .current_dir) (hasPrefix (printf "%s/" .project_dir) .current_dir) }}' +
81
- `{{ $dir = ternary (${DIR_REL}) (basename .project_dir) (ne (${DIR_REL}) "") }}` +
82
- "{{ end }}{{ end }}" +
83
- "{{ abbreviatePath $dir }}";
84
-
85
- // Git-fact → semantic palette color, ONE table both the `git` and
86
- // `gitaculous` segment templates below read from. [LAW:one-source-of-truth]
87
- // brandon-segments-3eo.1 (the recoloring itself) typed this choice
88
- // independently into each template's literal string and the two drifted —
89
- // gitaculous colored branch `accent` where git used `primary`, and left
90
- // stash uncolored where git used `accent` — caught by live testing
91
- // (brandon-segments-3eo.1.1). A shared table makes "what color is this
92
- // fact" a value read twice, not a decision re-typed twice.
93
- const GIT_COLOR = {
94
- branch: "primary",
95
- staged: "success",
96
- unstaged: "warning",
97
- untracked: "accent",
98
- conflicts: "error",
99
- ahead: "success",
100
- behind: "warning",
101
- stash: "accent",
102
- } as const;
103
-
104
- // Paint one git fact in its semantic color. The table above holds palette
105
- // *variable names* (data), and this is the one place a name becomes a template
106
- // call, so the fact→color decision and its spelling stay separate concerns.
107
- // [LAW:one-source-of-truth]
108
- //
109
- // Note the shape: `fg (color "…")`, which is also what a composed color looks
110
- // like — `fg (darken (color "…") 1)`. A template that wants to adjust one of
111
- // these later wraps the color expression instead of rewriting the call.
112
- const paint = (fact: keyof typeof GIT_COLOR, content: string): string =>
113
- `{{ fg (color "${GIT_COLOR[fact]}") ${content} }}`;
114
-
115
- // How far the two git segments' *structural* text — labels, punctuation,
116
- // brackets, the sha, the upstream name, the elapsed-time annotation — sits
117
- // toward their own background, as a percentage. It is applied as the segments'
118
- // `fg:`, so structural text is simply what a token renders as when the
119
- // template does NOT paint it; only the operative facts (branch, counts,
120
- // status) name a color. [LAW:dataflow-not-control-flow] — the quiet case is
121
- // the default that always applies, not a wrapper each token opts into. That
122
- // inversion is why these templates carry no de-emphasis markup at all.
123
- //
124
- // The blend target is the segment's OWN background (`bgOf`), not the theme's
125
- // `background`. The git segments render on `surface-active`, so a palette
126
- // `foreground-muted` — which blends toward `background` — would be muted
127
- // toward a color that is not behind them, and would read either too loud or
128
- // too faint depending on how far the surface sits from the base.
129
- //
130
- // The `readableOn` floor is what makes the result theme-independent, and it is
131
- // not decoration: the bare 60% blend measures 1.93–3.10 contrast across the
132
- // bundled themes (catppuccin-latte at 1.93 is genuinely hard to read), because
133
- // a fixed *percentage* of a distance is not a fixed amount of legibility when
134
- // themes disagree about how far apart their own foreground and surface sit.
135
- // Flooring at WCAG's 3:1 large-text threshold lands every theme at 3.00–3.10
136
- // against full-strength foregrounds of 5.8–10.9 — as quiet as each theme can
137
- // afford, and never quieter. Verified across the bundled themes in
138
- // test/default-dsl-config.test.ts rather than eyeballed on one.
139
- // [LAW:verifiable-goals]
140
- const GIT_QUIET_PCT = 60;
141
- const GIT_QUIET_MIN_CONTRAST = 3;
142
- const GIT_QUIET_FG =
143
- `{{ readableOn (mix (color "foreground") (bgOf) ${GIT_QUIET_PCT}) (bgOf) ` +
144
- `${GIT_QUIET_MIN_CONTRAST} }}`;
145
-
146
- // Git working-tree counts — each present count renders in its own semantic
147
- // palette color (GIT_COLOR above) so a dirty tree reads at a glance,
148
- // p10k/gitaculous-prompt style, instead of one uniform segment fg. `$first`
149
- // tracks whether a separator space is still owed before the next present
150
- // count.
151
- // [LAW:dataflow-not-control-flow]: one variable carries the "have we emitted
152
- // yet" state rather than four copies of positional space logic. `$first` is
153
- // declared inside the outer `{{ if or ... }}` gate (unlike DIR_TEMPLATE's
154
- // `$dir`, declared at the template's top level) and reassigned via `=` in
155
- // nested `{{ if }}` blocks below — Go template variable scoping walks up to
156
- // the declaring frame on `=` regardless of nesting depth, so this still
157
- // works, just at one more scope level than DIR_TEMPLATE's `$dir`. Verified
158
- // by test/default-dsl-config.test.ts's "worktree counts render
159
- // single-space-separated" test, not merely asserted here.
160
- const GIT_WORKTREE =
161
- "{{ if or (gt .git.staged 0) (gt .git.unstaged 0) (gt .git.untracked 0) (gt .git.conflicts 0) }}" +
162
- ` ({{ $first := true }}` +
163
- `{{ if gt .git.staged 0 }}${paint("staged", '(printf "+%v" .git.staged)')}{{ $first = false }}{{ end }}` +
164
- `{{ if gt .git.unstaged 0 }}{{ if not $first }} {{ end }}${paint("unstaged", '(printf "~%v" .git.unstaged)')}{{ $first = false }}{{ end }}` +
165
- `{{ if gt .git.untracked 0 }}{{ if not $first }} {{ end }}${paint("untracked", '(printf "?%v" .git.untracked)')}{{ $first = false }}{{ end }}` +
166
- `{{ if gt .git.conflicts 0 }}{{ if not $first }} {{ end }}${paint("conflicts", '(printf "!%v" .git.conflicts)')}{{ $first = false }}{{ end }}` +
167
- "){{ end }}";
168
-
169
- // Status icon precedence: conflicts → ⚠ (error), dirty → ● (warning), else
170
- // clean ✓ (success) — colored to match the state it reports.
171
- const GIT_STATUS =
172
- '{{ if eq .git.status "conflicts" }}{{ fg (color "error") "⚠" }}{{ else }}' +
173
- '{{ if eq .git.status "dirty" }}{{ fg (color "warning") "●" }}' +
174
- '{{ else }}{{ fg (color "success") "✓" }}{{ end }}{{ end }}';
175
-
176
- // Every unpainted token here — the repo name, `⎇`, `♯`, the sha, the worktree
177
- // parentheses, `→`, the upstream name — renders in the segment's quiet `fg:`
178
- // (GIT_QUIET_FG). Only the operative facts name a color, so the template reads
179
- // as the line it draws rather than as de-emphasis markup wrapped around it.
180
- const GIT_TEMPLATE =
181
- '{{ if ne .git.repoName "" }}{{ .git.repoName }} {{ end }}' +
182
- `⎇ ${paint("branch", ".git.branch")}` +
183
- "{{ if .git.sha }} ♯ {{ .git.sha }}{{ end }}" +
184
- "{{ if or (gt .git.ahead 0) (gt .git.behind 0) }}" +
185
- ` {{ if gt .git.ahead 0 }}${paint("ahead", '(printf "↑%v" .git.ahead)')}{{ end }}` +
186
- `{{ if gt .git.behind 0 }}${paint("behind", '(printf "↓%v" .git.behind)')}{{ end }}{{ end }}` +
187
- GIT_WORKTREE +
188
- "{{ if .git.upstream }} →{{ .git.upstream }}{{ end }}" +
189
- `{{ if gt .git.stash 0 }} ${paint("stash", '(printf "⧇ %v" .git.stash)')}{{ end }}` +
190
- " " +
191
- GIT_STATUS;
192
-
193
- // [LAW:dataflow-not-control-flow] block and weekly share the same threshold
194
- // cascade (≥warningThreshold → error, ≥50 → warning, else panel) on a numeric
195
- // ref. The builders parameterize BOTH the percentage ref and the threshold
196
- // ref so each segment reads its own configured threshold from the var store
197
- // rather than the literal 80 baked into the template. User overrides flow
198
- // through the variables-merge-by-name cascade in mergeWithDefault — no new
199
- // override mechanism required.
200
- function blockLikeBg(pctRef: string, thresholdRef: string): string {
201
- return (
202
- `{{ if ge (round ${pctRef}) ${thresholdRef} }}error` +
203
- `{{ else }}{{ if ge (round ${pctRef}) 50 }}warning` +
204
- `{{ else }}panel{{ end }}{{ end }}`
205
- );
206
- }
207
-
208
- function blockLikeFg(pctRef: string): string {
209
- return (
210
- `{{ if ge (round ${pctRef}) 50 }}button-color-foreground` +
211
- `{{ else }}foreground{{ end }}`
212
- );
213
- }
214
-
215
- // [LAW:dataflow-not-control-flow] The burn segment heats as the cap NEARS, so
216
- // the cascade reads the projected minutes-to-cap (smaller = hotter), the
217
- // inverse direction of blockLikeBg's fuller-is-hotter. The not-projectable
218
- // sentinel (-1, sorts below every threshold) is caught first so "we cannot
219
- // project" colors calm, never error. Thresholds are var refs so a user
220
- // overrides them through the same by-name variables cascade.
221
- function etaHeatBg(etaRef: string, warnRef: string, errRef: string): string {
222
- return (
223
- `{{ if lt ${etaRef} 0 }}panel` +
224
- `{{ else }}{{ if lt ${etaRef} ${errRef} }}error` +
225
- `{{ else }}{{ if lt ${etaRef} ${warnRef} }}warning` +
226
- `{{ else }}panel{{ end }}{{ end }}{{ end }}`
227
- );
228
- }
229
-
230
- function etaHeatFg(etaRef: string, warnRef: string): string {
231
- return (
232
- `{{ if lt ${etaRef} 0 }}foreground` +
233
- `{{ else }}{{ if lt ${etaRef} ${warnRef} }}button-color-foreground` +
234
- `{{ else }}foreground{{ end }}{{ end }}`
235
- );
236
- }
237
-
238
- // ─── The settings drawer (candybar-config-engine-71o.4) ──────────────────────
239
-
240
- // [LAW:one-source-of-truth] exception: `kind: "group"` is authoring-grammar
241
- // sugar the loader lowers at parse time (src/config/loader/layout.ts) —
242
- // deliberately NOT a member of the canonical LayoutNode union DslConfig.root
243
- // requires (arranging + gating are behaviors `container` already has; "group"
244
- // is only a spelling), so a plain `satisfies DslConfig` cannot type-check it
245
- // inline below. Hand-lowering it here instead (writing the toggle segment +
246
- // gated body container by hand, under the reserved `groups.` namespace) is
247
- // NOT an option: reservedNamespaceCollisions rejects any USER-authored
248
- // variables/actions/segments name starting with `groups.` before synthesis
249
- // ever runs, so a hand-authored `groups.settings` segment would be rejected
250
- // as squatting the very namespace it's trying to populate — the sugar node is
251
- // the only legal way to populate it. This literal is unconditionally
252
- // round-tripped through the real parseDslConfig pipeline below (see the
253
- // module-load parse near the bottom of this file) exactly like a user's
254
- // hand-authored JSON5, so a malformed group is still caught loudly at import
255
- // time — the type safety net just moves from tsc to that parse, never lost.
256
- //
257
- // One collapsed-by-default drawer holding what the SESSION-scoped settings
258
- // menu deliberately does not: `charset` and `colorCompatibility` — terminal
259
- // capability facts (glyph coverage, colour depth) rather than tastes that
260
- // vary session to session, so they have no session half to choose between —
261
- // and one segment-scoped persist control (directoryPaletteControl, a
262
- // per-segment palette pin rather than a whole-bar default).
263
- //
264
- // [LAW:one-source-of-truth] candybar-settings-ui-aok.3 moved every setting
265
- // with BOTH halves — theme/style/look/preset/autoWrap/padding — out of this
266
- // drawer and into the synthesized settings menu, where each is ONE control
267
- // whose destination the `persist?` selector chooses. They used to be spelled
268
- // twice here (`{{ menu "applyTheme" }}` beside `📌{{ menu
269
- // "applyThemeForever" }}`), which is exactly the second representation that
270
- // collapse removed. What is left in this drawer is durable-only by nature,
271
- // not by omission — there is nothing for a persist? selector to choose.
272
- //
273
- // Placed as a sibling in row 1's horizontal container, toggled from beside the
274
- // quick-action tray — see `root` below.
275
- //
276
- // [LAW:one-source-of-truth] exception: this `kind: "group"` sugar node may
277
- // appear EXACTLY ONCE in the whole config — group names are a synthesis-wide
278
- // namespace (synthesizeGroupDecls collects every group across every preset's
279
- // root too), so a second `settingsDrawer` reference embedded in a preset's
280
- // own root would be a SECOND declaration of "settings" and collide with
281
- // itself, not a reuse of the first. It stays only in the default `root`
282
- // below; the library presets under `presets:` reach it by switching back to
283
- // the "default" preset from the GLOBAL settings menu, which
284
- // synthesizeSettingsMenu splices into every preset root
285
- // (src/config/settings-menu.ts) — never by re-embedding this group.
286
- const settingsDrawer = {
287
- kind: "group",
288
- name: "settings",
289
- label: "⚙ terminal",
290
- direction: "horizontal",
291
- children: ["charsetControl", "colorCompatControl", "directoryPaletteControl"],
292
- } as unknown as LayoutNode;
293
-
294
- // ─── The default config ──────────────────────────────────────────────────────
295
-
296
- // [LAW:one-source-of-truth] The AUTHORED literal, pre-synthesis. Production
297
- // code wants the synthesized DEFAULT_DSL_CONFIG below; this is exported only
298
- // for tests that round-trip "what a user would get by copy-pasting the
299
- // bundled default into their own file" through the real per-file parse —
300
- // that round-trip must start from the AUTHORED declarations, never from
301
- // DEFAULT_DSL_CONFIG's own already-synthesized `menus.*` entries (reparsing
302
- // those would trip the reserved-namespace guard, which exists to catch a
303
- // user hand-declaring a name only synthesis may write).
304
- export const RAW_DEFAULT_DSL_CONFIG = {
305
- globals: {
306
- // Picked by the daemon's basePalette resolution; user overrides in their
307
- // own config. Every registry theme ships the same derived spec set
308
- // (surface, panel, surface-active, foreground — see rich-js
309
- // buildPalette), so this is a pure taste call, not a compatibility one.
310
- // tokyo-night chosen (brandon-theming-8uj.2) over the prior
311
- // catppuccin-latte — a light palette that landed as a drive-by in an
312
- // unrelated formatting-cleanup commit and read poorly on the dark
313
- // terminals most users run — after live-clicking every registry theme
314
- // through the settings menu's theme picker: it stays legible as the
315
- // per-row hue-step shifts each
316
- // row's hue, where warmer bases (gruvbox, dracula) drifted toward mud
317
- // and the pastel ones (rose-pine, atom-one) washed out at this
318
- // contrast.
319
- palette: "tokyo-night",
320
- },
321
-
322
- // ─── Variables ─────────────────────────────────────────────────────────────
323
- // Every value the segment templates read. Sources:
324
- // • input — daemon's augmented payload (see src/daemon/render-payload.ts)
325
- // • env — process environment
326
- // • shell — subprocess; cached
327
- // • state — per-session daemon state
328
- variables: {
329
- // From hookData, pass-through.
330
- current_dir: {
331
- kind: "input",
332
- path: "workspace.current_dir",
333
- default: "?",
334
- },
335
- project_dir: {
336
- kind: "input",
337
- path: "workspace.project_dir",
338
- default: "",
339
- },
340
- // Transcript path (a top-level hookData field, spread onto the payload
341
- // root by buildRenderPayload). Read by the quick-action tray's
342
- // openTranscript action — pass-through, no projection.
343
- transcript_path: {
344
- kind: "input",
345
- path: "transcript_path",
346
- default: "",
347
- },
348
- "model.display_name": {
349
- kind: "input",
350
- path: "model.display_name",
351
- default: "",
352
- },
353
- "session.id": { kind: "input", path: "session_id", default: "" },
354
- version: { kind: "input", path: "version", default: "" },
355
- // [LAW:one-source-of-truth] The daemon-resolved effective theme name —
356
- // effectiveThemeName(sessionState.theme, globals.palette), the SAME name the
357
- // rendered basePalette is built from. A theme-picker config's trigger reads
358
- // `{{ .theme.effective }}` to show the active theme, so the label and the
359
- // colors trace to one resolution and cannot drift — no per-config restating
360
- // of the initial theme (which JSON5, being inert data, cannot derive). The
361
- // daemon always provides it; the "" default is the unreachable-absence floor.
362
- "theme.effective": {
363
- kind: "input",
364
- path: "theme.effective",
365
- default: "",
366
- },
367
- // [LAW:one-type-per-behavior] The effective LOOK name, the exact twin of
368
- // theme.effective one dimension over — effectiveLookName(sessionState.look,
369
- // globals.look, looks), the SAME name whose ThemeKey adapts the rendered
370
- // palette. A look-picker trigger reads `{{ .look.effective }}` for its
371
- // label; the label and the colors trace to one resolution.
372
- "look.effective": {
373
- kind: "input",
374
- path: "look.effective",
375
- default: "",
376
- },
377
- // [LAW:one-type-per-behavior] The effective PRESET name, theme/look's twin
378
- // one level up — effectivePresetName(sessionState.preset, globals.preset,
379
- // presets), the SAME name that selected the layout this render walked and
380
- // the globals it rendered with. A preset-picker trigger reads
381
- // `{{ .preset.effective }}` for its label, so the label and the arrangement
382
- // trace to one resolution.
383
- "preset.effective": {
384
- kind: "input",
385
- path: "preset.effective",
386
- default: "",
387
- },
388
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — presetIsCustomized
389
- // over the SAME reload's presetRootOps, resolved alongside preset.effective
390
- // (RenderPayload.preset.customized). edit-chrome.ts's synthesized "↺ …
391
- // customized" segment gates on this directly; a hand-authored config can
392
- // read it too for its own reset affordance.
393
- "preset.customized": {
394
- kind: "input",
395
- path: "preset.customized",
396
- type: "boolean",
397
- default: false,
398
- },
399
- // [LAW:one-type-per-behavior] style/charset/colorCompatibility/autoWrap/
400
- // padding are theme/look's twins over the remaining persistable globals
401
- // (candybar-config-engine-71o.3) — the SAME values BuildLineOptions
402
- // renders with, each read back through this projection so a `persist`
403
- // action over the field shows a "current selection" highlight and a
404
- // trigger label can display the active value without restating it.
405
- "style.effective": {
406
- kind: "input",
407
- path: "style.effective",
408
- default: "",
409
- },
410
- "charset.effective": {
411
- kind: "input",
412
- path: "charset.effective",
413
- default: "",
414
- },
415
- "colorCompatibility.effective": {
416
- kind: "input",
417
- path: "colorCompatibility.effective",
418
- default: "",
419
- },
420
- "autoWrap.effective": {
421
- kind: "input",
422
- path: "autoWrap.effective",
423
- type: "boolean",
424
- default: true,
425
- },
426
- "padding.effective": {
427
- kind: "input",
428
- path: "padding.effective",
429
- type: "number",
430
- default: 1,
431
- },
432
-
433
- // [LAW:one-source-of-truth] The usable terminal width for THIS render —
434
- // the exact post-reserve cell count FlexStrip wraps to. renderDsl injects
435
- // it into the payload from its own `opts.width` (the single value that
436
- // feeds both the wrap and this variable), so a width-paginated picker and
437
- // the wrap algebra can never disagree. Never cached: a resize is just a
438
- // new value on the same path, re-read every render. The default only
439
- // applies to compile-only callers that render without injecting a width.
440
- "term.cols": {
441
- kind: "input",
442
- path: "term.cols",
443
- type: "number",
444
- default: 80,
445
- },
446
-
447
- // [LAW:one-source-of-truth] The per-segment hue-rotation step renderDsl
448
- // reads (HUE_STEP_VAR) — a value in the store like every other render input,
449
- // NOT a globals field. [LAW:types-are-the-program] In the bundled default it
450
- // is a LITERAL: nothing here writes the "hue-step" SessionState key (the
451
- // default declares no interactive actions), so a fixed 14° is the strongest
452
- // TRUE theorem — a `state` var would claim a session-variability the default
453
- // never exercises and force a SessionState on every consumer. A user makes
454
- // hue live by overriding this one variable to `{ kind: "state", key:
455
- // "hue-step" }` and adding a pair of bounded stepper actions — the same
456
- // two-part pattern the theme picker uses.
457
- // 14°: adjacent segments stay visually distinct without per-segment colors.
458
- "hue.step": { kind: "literal", value: 14 },
459
-
460
- // home flows through the augmented payload (buildRenderPayload reads
461
- // HOME, falling back to USERPROFILE on Windows where HOME is often
462
- // unset). Sourcing via `kind: "input"` rather than `kind: "env",
463
- // name: "HOME"` makes the directory `~` collapse work on every
464
- // platform without per-platform config edits.
465
- home: { kind: "input", path: "home", default: "" },
466
-
467
- // Tmux session id flows through the daemon's augmented payload
468
- // (TmuxService caches by socket and never re-spawns for the lifetime of
469
- // the daemon, so this stays cheap). A `kind: "shell"` declaration would
470
- // spawn the subprocess at every cache-entry creation regardless of
471
- // whether the tmux segment is in the active layout — buildNeededPrefixes
472
- // gates the input variant so unused segments cost nothing.
473
- "tmux.session": { kind: "input", path: "tmux.session", default: "" },
474
-
475
- // Host identity — which machine this session is on, and whether the user
476
- // arrived over SSH. All three come through the augmented payload rather
477
- // than `kind: "env"` / `kind: "shell"`, and that is not a style choice:
478
- //
479
- // • `host.ssh` CANNOT be an env var here. Variables are evaluated in the
480
- // DAEMON, which is detached and serves every session for this user at
481
- // once — its `SSH_*` env describes whichever shell happened to spawn
482
- // it. The fact is captured by the live client and carried as a wire
483
- // hint (the `termCols` pattern); the payload is the only honest source.
484
- // • `host.name`/`host.user` are machine facts the daemon reads directly,
485
- // so they cost two syscalls instead of a per-render subprocess.
486
- //
487
- // Defaults are the "unknown" values, and for `ssh` that is `false`: an
488
- // absent field (a client too old to send the hint) renders as local, which
489
- // is the pre-feature behavior, while the input-fallback chain records a
490
- // `last_error` so `cc-candybar debug vars` can still tell the two apart.
491
- "host.name": { kind: "input", path: "host.name", default: "" },
492
- "host.user": { kind: "input", path: "host.user", default: "" },
493
- "host.ssh": {
494
- kind: "input",
495
- path: "host.ssh",
496
- type: "boolean",
497
- default: false,
498
- },
499
-
500
- // Git — every field flows from the daemon's projected GitInfo payload.
501
- // The DSL's native `kind: "git"` source covers a 6-field subset
502
- // (branch/sha/dirty/ahead/behind/stash); using `input` here gives the
503
- // full 12-field surface uniformly via the augmented payload.
504
- "git.repoName": {
505
- kind: "input",
506
- path: "git.repoName",
507
- default: "",
508
- },
509
- // The repo's browsable web page, transposed from its remote by the daemon.
510
- // "" is the genuine "no remote a browser can open" (local-only repo, bare
511
- // path remote) — the toolbar's link reads that value, not a flag.
512
- "git.repoUrl": { kind: "input", path: "git.repoUrl", default: "" },
513
- "git.branch": { kind: "input", path: "git.branch", default: "" },
514
- "git.sha": { kind: "input", path: "git.sha", default: "" },
515
- "git.ahead": {
516
- kind: "input",
517
- path: "git.ahead",
518
- type: "number",
519
- default: 0,
520
- },
521
- "git.behind": {
522
- kind: "input",
523
- path: "git.behind",
524
- type: "number",
525
- default: 0,
526
- },
527
- "git.staged": {
528
- kind: "input",
529
- path: "git.staged",
530
- type: "number",
531
- default: 0,
532
- },
533
- "git.unstaged": {
534
- kind: "input",
535
- path: "git.unstaged",
536
- type: "number",
537
- default: 0,
538
- },
539
- "git.untracked": {
540
- kind: "input",
541
- path: "git.untracked",
542
- type: "number",
543
- default: 0,
544
- },
545
- "git.conflicts": {
546
- kind: "input",
547
- path: "git.conflicts",
548
- type: "number",
549
- default: 0,
550
- },
551
- "git.upstream": { kind: "input", path: "git.upstream", default: "" },
552
- "git.stash": {
553
- kind: "input",
554
- path: "git.stash",
555
- type: "number",
556
- default: 0,
557
- },
558
- "git.status": { kind: "input", path: "git.status", default: "clean" },
559
- "git.operation": { kind: "input", path: "git.operation", default: "" },
560
- "git.timeSinceCommit": {
561
- kind: "input",
562
- path: "git.timeSinceCommit",
563
- type: "number",
564
- default: 0,
565
- },
566
-
567
- // Forge PR/MR — the daemon's git provider resolves the branch's open PR via
568
- // gh/glab and projects it here. Declaring any of these turns on the network
569
- // lookup. [LAW:no-silent-failure] prError is non-empty ONLY when the forge
570
- // was asked but couldn't answer (auth/network) — distinct from "no PR"
571
- // (every field empty). prNumber 0 (default) ⇒ no open PR.
572
- "git.prNumber": {
573
- kind: "input",
574
- path: "git.prNumber",
575
- type: "number",
576
- default: 0,
577
- },
578
- "git.prState": { kind: "input", path: "git.prState", default: "" },
579
- "git.prUrl": { kind: "input", path: "git.prUrl", default: "" },
580
- "git.prError": { kind: "input", path: "git.prError", default: "" },
581
-
582
- // Prompt-cache expiry — epoch seconds, projected by the cache provider.
583
- // Same unit/shape as block/weekly resetsAt so the cacheTimer segment
584
- // composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
585
- // activity found ⇒ segment's `when` hides it.
586
- "cache.expiresAt": {
587
- kind: "input",
588
- path: "cache.expiresAt",
589
- type: "number",
590
- default: 0,
591
- },
592
-
593
- // Usage / cost — daemon folds from the SessionUsageStore; numeric.
594
- "session.cost": {
595
- kind: "input",
596
- path: "session.cost",
597
- type: "number",
598
- default: 0,
599
- },
600
- "session.tokens": {
601
- kind: "input",
602
- path: "session.tokens",
603
- type: "number",
604
- default: 0,
605
- },
606
- // Budget knobs — pure config constants, user overrides in their file.
607
- // [LAW:dataflow-not-control-flow] amount defaults 0, the budgetStatus
608
- // helper's non-displayable value, so with no user override the session
609
- // segment renders byte-identically to its pre-budget form through the
610
- // same unconditional template — opt-in is a value, not a config mode.
611
- // Matches the legacy shipped default (budget.session had a threshold but
612
- // no amount ⇒ suffix off until the user sets an amount).
613
- "session.budget.amount": { kind: "literal", value: 0 },
614
- "session.budget.warningThreshold": { kind: "literal", value: 80 },
615
-
616
- // Today — daemon folds today's cross-session total from the SessionUsageStore.
617
- "today.cost": {
618
- kind: "input",
619
- path: "today.cost",
620
- type: "number",
621
- default: 0,
622
- },
623
- "today.tokens": {
624
- kind: "input",
625
- path: "today.tokens",
626
- type: "number",
627
- default: 0,
628
- },
629
- // Budget knobs — pure config constants, user overrides in their file.
630
- "today.budget.amount": { kind: "literal", value: 50 },
631
- "today.budget.warningThreshold": { kind: "literal", value: 80 },
632
-
633
- // Block — daemon projects directly from hookData.rate_limits.five_hour;
634
- // resetsAt is raw epoch seconds
635
- // so the template can compose `minutesUntilReset .block.resetsAt` (the
636
- // same chain weekly uses, single composition point).
637
- "block.nativeUtilization": {
638
- kind: "input",
639
- path: "block.nativeUtilization",
640
- type: "number",
641
- default: 0,
642
- },
643
- "block.resetsAt": {
644
- kind: "input",
645
- path: "block.resetsAt",
646
- type: "number",
647
- default: 0,
648
- },
649
- // Budget knob — overridable per-config through the variables-merge-by-
650
- // name cascade. Matches the legacy DEFAULT_CONFIG.budget.block.warning
651
- // Threshold.
652
- "block.budget.warningThreshold": { kind: "literal", value: 80 },
653
-
654
- // Weekly — direct projection of hookData.rate_limits.seven_day.
655
- "weekly.percentage": {
656
- kind: "input",
657
- path: "weekly.percentage",
658
- type: "number",
659
- default: 0,
660
- },
661
- "weekly.resetsAt": {
662
- kind: "input",
663
- path: "weekly.resetsAt",
664
- type: "number",
665
- default: 0,
666
- },
667
- "weekly.budget.warningThreshold": { kind: "literal", value: 80 },
668
-
669
- // Burn rate + cap projection — daemon-derived (see render-payload.ts).
670
- // Each projection is ABSENT when not projectable; the var-system fills the
671
- // -1 default, a structurally-impossible value the burnrate helpers read as
672
- // "—" [LAW:no-silent-failure] (0 minutes / $0-per-hr are real, displayable
673
- // values, so they cannot double as the absence marker).
674
- "burn.costPerHour": {
675
- kind: "input",
676
- path: "burn.costPerHour",
677
- type: "number",
678
- default: -1,
679
- },
680
- "block.etaMinutes": {
681
- kind: "input",
682
- path: "block.etaMinutes",
683
- type: "number",
684
- default: -1,
685
- },
686
- "weekly.etaMinutes": {
687
- kind: "input",
688
- path: "weekly.etaMinutes",
689
- type: "number",
690
- default: -1,
691
- },
692
- // ETA-heat thresholds (minutes-to-cap) — overridable per-config through the
693
- // variables-merge-by-name cascade, like the *.budget.warningThreshold knobs.
694
- "burn.eta.warnMinutes": { kind: "literal", value: 60 },
695
- "burn.eta.errorMinutes": { kind: "literal", value: 30 },
696
-
697
- // Token throughput for the active turn — daemon-derived tok/s on three lanes
698
- // (render-payload.ts: successive-render delta over the SessionUsageStore).
699
- // Same absence idiom as burn: -1 is the structurally-impossible default the
700
- // `formatSpeed` helper reads as "—" [LAW:no-silent-failure] (0 tok/s is a
701
- // real reading, so it cannot double as the absence marker). Each lane is
702
- // independently absent — `input` reads "—" mid-stream while `output` flows.
703
- "speed.input": {
704
- kind: "input",
705
- path: "speed.input",
706
- type: "number",
707
- default: -1,
708
- },
709
- "speed.output": {
710
- kind: "input",
711
- path: "speed.output",
712
- type: "number",
713
- default: -1,
714
- },
715
- "speed.total": {
716
- kind: "input",
717
- path: "speed.total",
718
- type: "number",
719
- default: -1,
720
- },
721
- // Recent burn-rate trend: a comma-delimited series of total-lane tok/s the
722
- // daemon folds from its sample ring (render-payload.ts). A series cannot
723
- // cross the scalar var-system seam, so it travels as a string the
724
- // `sparkline` helper decodes. Default "" is the genuine "no history yet"
725
- // form (the helper renders nothing); the segment gates on it being present.
726
- "speed.history": {
727
- kind: "input",
728
- path: "speed.history",
729
- type: "string",
730
- default: "",
731
- },
732
-
733
- // Context — daemon fetches via ContextProvider; contextLeftPercentage.
734
- "context.totalTokens": {
735
- kind: "input",
736
- path: "context.totalTokens",
737
- type: "number",
738
- default: 0,
739
- },
740
- "context.contextLeft": {
741
- kind: "input",
742
- path: "context.contextLeft",
743
- type: "number",
744
- default: 100,
745
- },
746
-
747
- // Metrics — daemon fetches via MetricsProvider; numeric.
748
- "metrics.lastResponseTime": {
749
- kind: "input",
750
- path: "metrics.lastResponseTime",
751
- type: "number",
752
- default: 0,
753
- },
754
- "metrics.responseTime": {
755
- kind: "input",
756
- path: "metrics.responseTime",
757
- type: "number",
758
- default: 0,
759
- },
760
- "metrics.sessionDuration": {
761
- kind: "input",
762
- path: "metrics.sessionDuration",
763
- type: "number",
764
- default: 0,
765
- },
766
- "metrics.messageCount": {
767
- kind: "input",
768
- path: "metrics.messageCount",
769
- type: "number",
770
- default: 0,
771
- },
772
- "metrics.linesAdded": {
773
- kind: "input",
774
- path: "metrics.linesAdded",
775
- type: "number",
776
- default: 0,
777
- },
778
- "metrics.linesRemoved": {
779
- kind: "input",
780
- path: "metrics.linesRemoved",
781
- type: "number",
782
- default: 0,
783
- },
784
-
785
- // No page-cursor var: a {{ menu }} synthesizes its own page
786
- // cursor (state var + int action, named by menuPageKey) under the reserved
787
- // menus.* namespace, alongside its open-state.
788
- },
789
-
790
- // ─── Segments ──────────────────────────────────────────────────────────────
791
- // Every built-in. Templates ported from the parity bindings; bg/fg are
792
- // palette spec names resolved against the active theme. `when` predicates
793
- // hide a segment when its primary signal is absent (no git repo, no version
794
- // field, no env var, no tmux, no rate-limit window).
795
- //
796
- // [LAW:one-source-of-truth] Templates author CONTENT only — the intra-cell
797
- // padding (the space each side of a cell) is render chrome synthesized
798
- // structurally from the one resolved globals.padding (default 1), never
799
- // authored here. A template with leading/trailing spaces would render them
800
- // IN ADDITION to the structural padding.
801
- segments: {
802
- directory: {
803
- template: DIR_TEMPLATE,
804
- bg: "surface",
805
- fg: "foreground",
806
- },
807
- model: {
808
- template: "✱ {{ formatModelName .model.display_name }}",
809
- bg: "panel",
810
- fg: "foreground",
811
- when: '{{ ne .model.display_name "" }}',
812
- },
813
- sessionId: {
814
- template: "⌗{{ trunc 8 .session.id }}",
815
- bg: "surface",
816
- fg: "foreground",
817
- when: '{{ ne .session.id "" }}',
818
- },
819
- version: {
820
- template: "◈ v{{ .version }}",
821
- bg: "surface",
822
- fg: "foreground",
823
- when: '{{ ne .version "" }}',
824
- },
825
- tmux: {
826
- template: 'tmux:{{ .tmux.session | default "none" }}',
827
- bg: "surface-active",
828
- fg: "foreground",
829
- when: '{{ ne .tmux.session "" }}',
830
- },
831
- // "You are not on your own machine." Modelled on the git-taculous zsh
832
- // theme, which prepends `(%n@%m)` to the prompt under SSH and shows
833
- // nothing locally — you already know your own hostname.
834
- //
835
- // [LAW:dataflow-not-control-flow] Presence IS the signal. There is no SSH
836
- // "mode" and no force-on flag (git-taculous's GITTACULOUS_ENABLE_SSH_THEME
837
- // would be a flag with no deletion date, [LAW:no-mode-explosion]); the cell
838
- // exists exactly when the value says so, like tmux/block/weekly. A user who
839
- // wants it always-on overrides this one segment's `when` to `"true"`.
840
- //
841
- // `bg: "warning"` is load-bearing, not decoration: warning is one of the
842
- // hue-ANCHORED palette roots, so it survives every theme, look, and
843
- // per-segment hue transposition still reading as an alert. Any other slot
844
- // would drift with the hue stepper and could land camouflaged against its
845
- // neighbours — exactly what a "wrong machine" warning must never do.
846
- // `contrastOn (bgOf)` then derives a readable foreground from whatever that
847
- // resolves to, rather than betting a fixed `foreground` stays legible.
848
- //
849
- // Each half falls back to "?" so a failed hostname/username read renders
850
- // `⇄ ?@?` — still unmistakably "remote", and legibly missing its identity
851
- // rather than a blank that reads as a rendering bug ([LAW:no-silent-failure]).
852
- host: {
853
- template:
854
- '⇄ {{ .host.user | default "?" }}@{{ .host.name | default "?" }}',
855
- bg: "warning",
856
- fg: "{{ contrastOn (bgOf) }}",
857
- when: "{{ .host.ssh }}",
858
- },
859
- git: {
860
- template: GIT_TEMPLATE,
861
- bg: "surface-active",
862
- // A computed `fg:` — the field is a template evaluating to a color
863
- // reference, and `bgOf` is available here because a segment's background
864
- // is resolved before its foreground. Structural text therefore sits a
865
- // fixed distance from THIS segment's background whatever theme, look, or
866
- // hue shift is in effect.
867
- fg: GIT_QUIET_FG,
868
- when: '{{ ne .git.branch "" }}',
869
- },
870
- gitaculous: {
871
- // Recolored from raw green/red (render-bugs-pdu.3's era) to semantic
872
- // palette names (brandon-segments-3eo.1), then unified against `git`'s
873
- // choice of color per fact via the shared GIT_COLOR table above
874
- // (brandon-segments-3eo.1.1 — the two had drifted: branch, untracked,
875
- // and stash each disagreed with `git`'s coloring of the same fact).
876
- // staged/ahead share `success` (positive — ready to commit / unpushed
877
- // additions), unstaged/behind share `warning` (needs attention),
878
- // untracked/stash share `accent` (their own GIT_COLOR entries, kept
879
- // visually distinct from unstaged by using a different glyph, not a
880
- // different color, so "U" vs "?" reads apart at a glance), conflicts
881
- // gets its own `error`, branch gets `primary`.
882
- //
883
- // Everything the template does NOT paint — the "(git)" label, repo name,
884
- // the operation and upstream brackets, the sha, the elapsed-time
885
- // annotation — renders in the segment's quiet `fg:` and recedes, so the
886
- // eye lands on the operative colored facts first (brandon-segments-
887
- // 3eo.1.1.1: live feedback that "most of it" read as one flat color when
888
- // only two facts happened to be present). Note that this needs no markup
889
- // in the template — including around `{{ template "formatTimeSince" }}`,
890
- // which as a top-level Go-template action could never have been wrapped
891
- // in a styling call at all. Making quiet the default rather than a
892
- // wrapper is what put that token in reach.
893
- template:
894
- "(git)" +
895
- '{{ if ne .git.repoName "" }} {{ .git.repoName }}{{ end }}' +
896
- '{{ if ne .git.operation "" }} [{{ .git.operation }}]{{ end }}' +
897
- '{{ if ne .git.sha "" }} {{ .git.sha }}{{ end }}' +
898
- "{{ if or (gt .git.staged 0) (gt .git.unstaged 0) (gt .git.untracked 0) (gt .git.conflicts 0) }} " +
899
- `{{ if gt .git.staged 0 }}${paint("staged", '"S"')}{{ end }}` +
900
- `{{ if gt .git.unstaged 0 }}${paint("unstaged", '"U"')}{{ end }}` +
901
- `{{ if gt .git.untracked 0 }}${paint("untracked", '"?"')}{{ end }}` +
902
- `{{ if gt .git.conflicts 0 }}${paint("conflicts", '(printf "!%v" .git.conflicts)')}{{ end }}` +
903
- "{{ end }}" +
904
- ` ⎇ ${paint("branch", ".git.branch")}` +
905
- '{{ if ne .git.upstream "" }} [{{ .git.upstream }}' +
906
- "{{ if or (gt .git.ahead 0) (gt .git.behind 0) }} " +
907
- `{{ if gt .git.ahead 0 }}${paint("ahead", '(printf "+%v" .git.ahead)')}{{ end }}` +
908
- "{{ if and (gt .git.ahead 0) (gt .git.behind 0) }}/{{ end }}" +
909
- `{{ if gt .git.behind 0 }}${paint("behind", '(printf "-%v" .git.behind)')}{{ end }}` +
910
- "{{ end }}]{{ end }}" +
911
- `{{ if gt .git.stash 0 }} ${paint("stash", '(printf "(%v stashed)" .git.stash)')}{{ end }}` +
912
- '{{ if gt .git.timeSinceCommit 0 }} ◷ {{ template "formatTimeSince" .git.timeSinceCommit }}{{ end }}',
913
- bg: "surface-active",
914
- fg: GIT_QUIET_FG,
915
- when: '{{ ne .git.branch "" }}',
916
- },
917
- // Git PR/MR — the branch's open pull/merge request as a clickable link.
918
- // OPT-IN: declared but NOT in the default root (it adds a network gh/glab
919
- // call). Add "gitPr" to a container's children to enable it. The `{{ link
920
- // url text }}` emits ONE OSC-8 region carrying the https PR url, so the
921
- // CLICK is handled by the terminal/OS (opens the browser) — no daemon verb.
922
- // [LAW:no-silent-failure] Three render states from the data: an open PR
923
- // (prUrl set) renders the link; a lookup FAILURE (prError set, prUrl empty)
924
- // renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
925
- // no PR (both empty) leaves the `when` gate false and the segment absent.
926
- gitPr: {
927
- // The pad spaces are structural chrome now, OUTSIDE the OSC-8 link
928
- // region — the clickable area is the glyph text itself.
929
- template:
930
- '{{ if ne .git.prUrl "" }}' +
931
- '{{ link .git.prUrl (printf "⇆ #%v" .git.prNumber) }}' +
932
- "{{ else }}⚠ PR{{ end }}",
933
- bg: "surface-active",
934
- fg: "foreground",
935
- when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
936
- },
937
- // Quick-action tray — the default bar's interactivity: copy the session id,
938
- // open the project dir / transcript (this session's jsonl) in the editor,
939
- // open the repo's web page in the browser, and toggle layout edit mode.
940
- // (copyDir — copy the cwd — stays declared as an action below for users who
941
- // want a fifth glyph; it is simply not in the default tray.)
942
- // [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
943
- // (below) is the BEHAVIOR; the action name is the seam between them. Re-glyph
944
- // without touching behavior; re-target without touching this template. Each
945
- // `{{ action … }}` emits one OSC-8 clickable region whose URL the wire codec
946
- // owns end-to-end.
947
- //
948
- // `↗ repo` is the one glyph here that is NOT an action: the daemon already
949
- // resolved the remote to an https page, so `{{ link }}` hands that URL
950
- // straight to the terminal/OS (same seam the gitPr segment uses) — routing a
951
- // public web URL through a cc-candybar:// verb would buy nothing. It is
952
- // gated on the VALUE (`ne … ""`), not on a flag: a local-only repo simply
953
- // supplies no page and the glyph is absent. [LAW:dataflow-not-control-flow]
954
- //
955
- // `✎ edit`/`✎ done` (brandon-layout-edit-2gc.4) is the bundled default's
956
- // ONLY reference to the reserved `edit.toggle` action — referencing it
957
- // anywhere is what opts this config into edit mode (see
958
- // docs/interaction-authoring.md's "Edit mode" section), and this tray
959
- // segment is where it lives.
960
- //
961
- // [LAW:carrying-cost] Placement resolves a self-lockout tension .3 flagged
962
- // (see the epic's tickets): once edit mode is open, EVERY ordinary segment
963
- // gets its own removable `-`, including whichever one hosts the trigger —
964
- // a config can, in principle, remove its own way back into edit mode.
965
- // Giving the trigger its own standalone segment would make that a one-click
966
- // accident. Folding it into `toolbar` instead means removing the trigger
967
- // requires removing the WHOLE quick-action tray — the same deliberate,
968
- // symmetric risk every other multi-purpose segment already carries, not a
969
- // bespoke edit-mode hazard — and it costs the default bar one glyph of
970
- // width instead of a whole new segment's cell+padding+joiner overhead. The
971
- // risk is bounded either way: `-` only removes the segment from this
972
- // preset's tree (`edit.mode` itself is untouched SessionState), so the
973
- // rest of the chrome — every remaining `+`/`-` in the bar — stays visible,
974
- // and any of them can `+` `toolbar` straight back
975
- // (test/dsl-layout-edit.test.ts covers the full round trip through a
976
- // real RenderCache reload; test/dsl-edit-mode.test.ts covers the click
977
- // itself and that edit.mode survives it).
978
- toolbar: {
979
- template:
980
- '{{ action "copySession" "⎘ id" }}' +
981
- ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}' +
982
- '{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}' +
983
- ' {{ action "edit.toggle" "✎ edit" "✎ done" }}',
984
- bg: "surface",
985
- fg: "foreground",
986
- },
987
- session: {
988
- template:
989
- '§ {{ template "formatCost" .session.cost }} ({{ template "formatTokens" .session.tokens }})' +
990
- '{{ template "budgetStatus" (dict "cost" .session.cost "budget" .session.budget.amount "warn" .session.budget.warningThreshold) }}',
991
- bg: "surface",
992
- fg: "foreground",
993
- },
994
- today: {
995
- template:
996
- '☉ {{ template "formatCost" .today.cost }} ({{ template "formatTokens" .today.tokens }})' +
997
- '{{ template "budgetStatus" (dict "cost" .today.cost "budget" .today.budget.amount "warn" .today.budget.warningThreshold) }}',
998
- bg: "surface",
999
- fg: "foreground",
1000
- },
1001
- block: {
1002
- template:
1003
- "◱ {{ round .block.nativeUtilization }}% " +
1004
- '({{ template "formatLongTimeRemaining" (minutesUntilReset .block.resetsAt) }})',
1005
- bg: blockLikeBg(
1006
- ".block.nativeUtilization",
1007
- ".block.budget.warningThreshold",
1008
- ),
1009
- fg: blockLikeFg(".block.nativeUtilization"),
1010
- // Hide unless we have a five-hour-window snapshot.
1011
- when: "{{ gt .block.resetsAt 0 }}",
1012
- },
1013
- weekly: {
1014
- template:
1015
- "◑ {{ round .weekly.percentage }}% " +
1016
- '({{ template "formatLongTimeRemaining" (minutesUntilReset .weekly.resetsAt) }})',
1017
- bg: blockLikeBg(".weekly.percentage", ".weekly.budget.warningThreshold"),
1018
- fg: blockLikeFg(".weekly.percentage"),
1019
- when: "{{ gt .weekly.resetsAt 0 }}",
1020
- },
1021
- // Burn rate + cap projection: "$X/hr · Nm to 5h · Nd to wk". The headline
1022
- // number of a usage monitor — how fast you are spending and when you hit
1023
- // the wall. All math is daemon-side (render-payload.ts); the template only
1024
- // formats. Heats as the 5h cap nears (etaHeat*). Shown when either
1025
- // rate-limit window is active — the same signal block/weekly gate on.
1026
- burnrate: {
1027
- template:
1028
- '⚡ {{ template "formatRate" .burn.costPerHour }} · ' +
1029
- '{{ template "formatEta" .block.etaMinutes }} to 5h · ' +
1030
- '{{ template "formatEta" .weekly.etaMinutes }} to wk',
1031
- bg: etaHeatBg(
1032
- ".block.etaMinutes",
1033
- ".burn.eta.warnMinutes",
1034
- ".burn.eta.errorMinutes",
1035
- ),
1036
- fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
1037
- when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
1038
- },
1039
- // Token throughput for the active turn — output / input / total tok/s, each a
1040
- // successive-render delta computed daemon-side (render-payload.ts); the
1041
- // template only formats. Declared-but-opt-in (NOT in the default root, like
1042
- // block/weekly/burnrate): a user adds `speed` to their layout. Each lane reads
1043
- // "—" when idle/between turns ([LAW:no-silent-failure] — never a stale or
1044
- // divide-by-zero number). Visible once the session has done any work (stable,
1045
- // no layout flicker); `output` is the live generation rate, `input` spikes at
1046
- // turn start, `total` is their sum.
1047
- speed: {
1048
- template:
1049
- '⇅ out {{ template "formatSpeed" .speed.output }} · ' +
1050
- 'in {{ template "formatSpeed" .speed.input }} · ' +
1051
- 'tot {{ template "formatSpeed" .speed.total }}',
1052
- bg: "panel",
1053
- fg: "foreground",
1054
- when: "{{ gt .session.tokens 0 }}",
1055
- },
1056
- // Burn-rate sparkline: the recent total-lane tok/s trend as a unicode
1057
- // mini-graph. Declared-but-opt-in (NOT in the default root, like speed /
1058
- // block / weekly): a user adds `tokenSparkline` to their layout. The
1059
- // `sparkline` helper decodes the daemon-owned series and draws it; `24`
1060
- // caps the glyph count to the cell, showing the live tail of the ring. The
1061
- // segment's fg colors the whole graph (no per-glyph color). Gated on the
1062
- // history being present so the cell never renders empty (the series needs
1063
- // two samples before its first bar). [LAW:effects-at-boundaries] — all the
1064
- // history lives in the daemon ring, the template only draws.
1065
- tokenSparkline: {
1066
- template: "⚡ {{ sparkline .speed.history 24 }}",
1067
- bg: "panel",
1068
- fg: "foreground",
1069
- when: '{{ ne .speed.history "" }}',
1070
- },
1071
- // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
1072
- // to 0, so an expired cache renders "cold" (and reads red via the ≤8
1073
- // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
1074
- // glyph + "cold"/"Nm" + color all derive from the one expiry value; the
1075
- // provider supplies no display state. Constant `surface` bg with a
1076
- // fg-only threshold cascade mirrors the legacy inline-colored text
1077
- // (warm = normal, ≤20m = warning, ≤8m/cold = error).
1078
- cacheTimer: {
1079
- template:
1080
- "◴ {{ if le (minutesUntilReset .cache.expiresAt) 0 }}cold" +
1081
- "{{ else }}{{ minutesUntilReset .cache.expiresAt }}m{{ end }}",
1082
- bg: "surface",
1083
- fg:
1084
- "{{ if le (minutesUntilReset .cache.expiresAt) 8 }}error" +
1085
- "{{ else }}{{ if le (minutesUntilReset .cache.expiresAt) 20 }}warning" +
1086
- "{{ else }}foreground{{ end }}{{ end }}",
1087
- when: "{{ gt .cache.expiresAt 0 }}",
1088
- },
1089
- context: {
1090
- template:
1091
- "◔ {{ formatInteger .context.totalTokens }} ({{ .context.contextLeft }}%)",
1092
- bg:
1093
- "{{ if le .context.contextLeft 20 }}error" +
1094
- "{{ else }}{{ if le .context.contextLeft 40 }}warning" +
1095
- "{{ else }}surface-active{{ end }}{{ end }}",
1096
- fg:
1097
- "{{ if le .context.contextLeft 40 }}button-color-foreground" +
1098
- "{{ else }}foreground{{ end }}",
1099
- when: "{{ gt .context.totalTokens 0 }}",
1100
- },
1101
- metrics: {
1102
- // [LAW:dataflow-not-control-flow] Each part guards on its own value
1103
- // rather than gating the whole segment on a single dimension. With
1104
- // MetricsPayload's fields independently optional and pickNonNull
1105
- // dropping nulls (see src/daemon/render-payload.ts), an absent field
1106
- // resolves through the var-system fallback chain to 0 — the same
1107
- // falsy shape the per-part `if` test treats as hidden. The segment-
1108
- // level `when` survives as a weak any-present check so a payload
1109
- // with zero metrics data renders no cell at all (an empty template
1110
- // would otherwise produce a single-space bg-styled cell).
1111
- //
1112
- // [LAW:one-source-of-truth] exception: each arm's leading space is the
1113
- // SEPARATOR between present parts (only data can decide which part is
1114
- // first, so no static strip can remove just the first one), and the
1115
- // trailing space mirrors it for symmetry. At the default padding this
1116
- // cell therefore reads one space wider per side than its siblings.
1117
- template:
1118
- '{{ if .metrics.lastResponseTime }} Δ {{ template "formatResponseTime" .metrics.lastResponseTime }}{{ end }}' +
1119
- '{{ if .metrics.responseTime }} ⧖ {{ template "formatResponseTime" .metrics.responseTime }}{{ end }}' +
1120
- '{{ if .metrics.sessionDuration }} ⧗ {{ template "formatDuration" .metrics.sessionDuration }}{{ end }}' +
1121
- "{{ if .metrics.messageCount }} ◆ {{ .metrics.messageCount }}{{ end }}" +
1122
- "{{ if .metrics.linesAdded }} + {{ .metrics.linesAdded }}{{ end }}" +
1123
- "{{ if .metrics.linesRemoved }} - {{ .metrics.linesRemoved }}{{ end }} ",
1124
- bg: "panel",
1125
- fg: "foreground",
1126
- when:
1127
- "{{ or .metrics.lastResponseTime .metrics.responseTime" +
1128
- " .metrics.sessionDuration .metrics.messageCount" +
1129
- " .metrics.linesAdded .metrics.linesRemoved }}",
1130
- },
1131
- // ── The TWO globals steppers left in this drawer. `charset` and
1132
- // `colorCompatibility` have no SessionState half at all — they describe
1133
- // the terminal, not a taste that varies per session — so `persist` is
1134
- // genuinely their only seam and there is no destination for a `persist?`
1135
- // selector to choose between. Everything with both halves
1136
- // (theme/style/look/preset/autoWrap/padding) moved to the synthesized
1137
- // settings menu as ONE dual control each (candybar-settings-ui-aok.3).
1138
- // Each pairs a `persist` control with a `↺` reset (docs' persist/reset
1139
- // convention); labels read `.field.effective` (the daemon-resolved value
1140
- // BuildLineOptions actually rendered with), never a restated literal.
1141
- charsetControl: {
1142
- template:
1143
- "{{ .charset.effective }} " +
1144
- `{{ menu "applyCharsetForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1145
- '{{ action "resetCharset" "↺" }}',
1146
- bg: "surface",
1147
- fg: "foreground",
1148
- },
1149
- colorCompatControl: {
1150
- template:
1151
- "{{ .colorCompatibility.effective }} " +
1152
- `{{ menu "applyColorCompatForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1153
- '{{ action "resetColorCompat" "↺" }}',
1154
- bg: "surface",
1155
- fg: "foreground",
1156
- },
1157
- // [LAW:verifiable-goals] candybar-config-engine-71o.6's own acceptance
1158
- // bar, mirrored from .3/.5: at least ONE segment-scoped field must be
1159
- // menu-able from the BUNDLED default with no hand-authored actions.
1160
- // `directory` is the demo target — always visible, palette-driven
1161
- // bg/fg, so an override is immediately legible. The persist/reset pair
1162
- // below targets `segments.directory.palette` (not a Globals field),
1163
- // proving the option-domain-as-data seam generalizes to segment-scoped
1164
- // keys with zero engine edits beyond opening the key namespace itself
1165
- // (loader/persist-target.ts) — the SAME `from: "themes"` domain
1166
- // applyThemeForever already uses.
1167
- // [LAW:one-source-of-truth] exception: unlike charsetControl/
1168
- // the other controls' `.field.effective` labels, there is no
1169
- // `segments.directory.palette.effective` payload projection — adding one
1170
- // would require threading the full DslConfig through
1171
- // buildRenderPayload's signature (today built from EffectiveGlobals
1172
- // alone), a change with no other motivation than this one label. The
1173
- // control still writes/persists/resets correctly without it: per
1174
- // render/action.ts's CONFIG_KEY_TO_EFFECTIVE_VAR, a persist key with no
1175
- // effective-var entry writes fine and only loses the picker's "current
1176
- // selection" highlight — a documented, already-accepted degrade path,
1177
- // not a bug.
1178
- directoryPaletteControl: {
1179
- template:
1180
- "🎨 directory " +
1181
- `{{ menu "applyDirectoryPaletteForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1182
- '{{ action "resetDirectoryPalette" "↺" }}',
1183
- bg: "surface",
1184
- fg: "foreground",
1185
- },
1186
- },
1187
-
1188
- // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
1189
- // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
1190
- // the loader's authoring surface for user JSON, not this typed literal — the
1191
- // one exception being `settingsDrawer` above, whose `kind: "group"` sugar has
1192
- // no canonical-form equivalent it could be hand-lowered to; see its own
1193
- // comment).
1194
- //
1195
- // Two always-visible rows stacked by the vertical container: an IDENTITY +
1196
- // ACTIONS row (where am I / what can I do here — the directory, the verbose
1197
- // `gitaculous` line, the quick-action tray: copy session id, open project /
1198
- // transcript in the editor, and the settingsDrawer toggle) over a STATUS row
1199
- // (what's happening now — model, context-window fill, prompt-cache warmth,
1200
- // and the 5h / 7d rate-limit quotas). The settingsDrawer (candybar-config-
1201
- // engine-71o.4) sits on the identity row beside the tray — collapsed by
1202
- // default and visually silent (a single "⚙ settings ▸" cell) — and reveals a
1203
- // third row of every bar-mutable display default (theme, style, look,
1204
- // charset, colorCompatibility, autoWrap, padding) on the line immediately
1205
- // below row 1 when opened, exactly where a `{{ menu }}`'s own picker body
1206
- // would drop. Each row zips its segments through the powerline joiner; `\n`
1207
- // separates the rows.
1208
- //
1209
- // [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
1210
- // own signal (no repo → the identity row is just the directory + tray; no
1211
- // active rate-limit window → block/weekly drop; no cache activity → cacheTimer
1212
- // drops). A row therefore only ever shows the segments that have real data —
1213
- // the layout is chosen by the data, not by branches — so the default never
1214
- // paints an empty or placeholder cell. The directory and the tray have no
1215
- // `when`, so row 1 always anchors the bar.
1216
- root: {
1217
- kind: "container",
1218
- direction: "vertical",
1219
- children: [
1220
- {
1221
- kind: "container",
1222
- direction: "horizontal",
1223
- children: [
1224
- // Leads the identity row: the first thing to read is WHICH MACHINE,
1225
- // because it reframes every path and branch to its right. Same
1226
- // placement git-taculous gives `(%n@%m)` — ahead of the directory.
1227
- // Gated off entirely on a local session, so the row still opens with
1228
- // `directory` where it always has.
1229
- { kind: "segment", name: "host" },
1230
- { kind: "segment", name: "directory" },
1231
- { kind: "segment", name: "gitaculous" },
1232
- { kind: "segment", name: "toolbar" },
1233
- settingsDrawer,
1234
- ],
1235
- },
1236
- {
1237
- kind: "container",
1238
- direction: "horizontal",
1239
- children: [
1240
- { kind: "segment", name: "model" },
1241
- { kind: "segment", name: "context" },
1242
- { kind: "segment", name: "cacheTimer" },
1243
- { kind: "segment", name: "block" },
1244
- { kind: "segment", name: "weekly" },
1245
- ],
1246
- },
1247
- ],
1248
- },
1249
-
1250
- // [LAW:locality-or-seam] The quick-action tray's behaviors, decoupled by NAME
1251
- // from the `toolbar` segment's glyphs above. copy/open evaluate a Go-template
1252
- // against the live render scope at click time and write NO SessionState, so
1253
- // they derive no state validator (no gate) — they are pure click effects.
1254
- //
1255
- // [LAW:single-enforcer] Each template emits a RAW value; the click-wire codec
1256
- // (effectsUrl → encodeSegments) owns ALL percent-encoding and the verb's
1257
- // `oneArg` owns the single matching decode — so the template never hand-rolls
1258
- // a `urlEncode`, and the path round-trips untouched through one codec.
1259
- //
1260
- // open* route through the open-vscode verb (`open -a "Visual Studio Code"
1261
- // <path>`), so they pass a bare filesystem path — a directory or a file the
1262
- // editor opens directly — NOT a `vscode://` URL (which `open -a` would treat
1263
- // as a literal filename, not a deep link).
1264
- actions: {
1265
- copySession: { copy: "{{ .session.id }}" },
1266
- copyDir: { copy: "{{ .current_dir }}" },
1267
- openProject: { open: "{{ .project_dir }}" },
1268
- openTranscript: { open: "{{ .transcript_path }}" },
1269
-
1270
- // [LAW:locality-or-seam] The settings-drawer controls' behaviors
1271
- // (candybar-config-engine-71o.4), decoupled by NAME from
1272
- // charsetControl/colorCompatControl below. These are durable-only by
1273
- // NATURE, not by omission: `charset` and `colorCompatibility` describe the
1274
- // terminal (glyph coverage, colour depth) rather than a taste that varies
1275
- // between sessions, so they have no SessionState half for a `persist?`
1276
- // selector to choose between — which is exactly why
1277
- // candybar-settings-ui-aok.3 left them here while moving every
1278
- // both-halves setting into the settings menu as one dual control. Each
1279
- // writes the config DEFAULT through the daemon-owned overrides layer
1280
- // (never the hand-authored file itself), gated by the SAME
1281
- // deriveConfigActionValidators pass, and is paired with a `reset` so a
1282
- // drawer choice is always undoable from the bar.
1283
- applyCharsetForever: { persist: "charset", from: "charsets" },
1284
- resetCharset: { reset: "charset" },
1285
- applyColorCompatForever: {
1286
- persist: "colorCompatibility",
1287
- from: "colorCompatibilities",
1288
- },
1289
- resetColorCompat: { reset: "colorCompatibility" },
1290
-
1291
- // [LAW:locality-or-seam] The segment-palette control's behavior
1292
- // (candybar-config-engine-71o.6), decoupled by NAME from
1293
- // directoryPaletteControl below. The target key is `segments.directory.
1294
- // palette` — NOT a Globals field — so it rides the SAME generic
1295
- // `from`/`reset` machinery every other persist pair here uses, over a
1296
- // key namespace loader/persist-target.ts opened alongside the pre-
1297
- // existing Globals-field one. Like charset/colorCompatibility, this field has
1298
- // no SessionState half at all: a per-segment `palette:` is a static pin
1299
- // that ignores the session theme by design (src/dsl/render.ts), so
1300
- // `persist` is its only seam.
1301
- applyDirectoryPaletteForever: {
1302
- persist: "segments.directory.palette",
1303
- from: "themes",
1304
- },
1305
- resetDirectoryPalette: { reset: "segments.directory.palette" },
1306
- },
1307
-
1308
- // ─── Looks ───────────────────────────────────────────────────────────────
1309
- // Named theme ADAPTATIONS — each is a full rich-js ThemeKey applied on top
1310
- // of whatever base theme is active (a transform, not a palette), so every
1311
- // look composes with every theme: pick theme, then pick look. Selected per
1312
- // session via the `look` SessionState key (an action `{ set: "look", from:
1313
- // "looks" }` + a `{{ menu }}`), exactly the theme/style selection seam.
1314
- // [LAW:one-source-of-truth] Merges by name (user wins per name), so this
1315
- // stdlib — including the "none" identity floor effectiveLookName collapses
1316
- // to — is present in every merged config by construction.
1317
- looks: {
1318
- // [LAW:dataflow-not-control-flow] "none" is just the identity look — the
1319
- // resolution floor as a value, not a special case (rich-js's isIdentityKey
1320
- // fast-path makes it free). Spelled literally (not rich-js IDENTITY /
1321
- // INVERT_LIGHTNESS) so the bundled default remains inert JSON-shaped data
1322
- // a user file can mirror axis-for-axis; the loader normalizes user specs
1323
- // onto the same identity axes.
1324
- none: { hueShift: 0, chromaScale: 1, lightnessScale: 1, lightnessShift: 0 },
1325
- // Saturation up/down — chroma is multiplicative, hue and lightness held.
1326
- vivid: {
1327
- hueShift: 0,
1328
- chromaScale: 1.35,
1329
- lightnessScale: 1,
1330
- lightnessShift: 0,
1331
- },
1332
- muted: {
1333
- hueShift: 0,
1334
- chromaScale: 0.55,
1335
- lightnessScale: 1,
1336
- lightnessShift: 0,
1337
- },
1338
- // Lightness down (scale) / up (shift) — dim compresses toward black,
1339
- // bright lifts everything a step; anchors stay hue-locked by rich-js.
1340
- dim: {
1341
- hueShift: 0,
1342
- chromaScale: 1,
1343
- lightnessScale: 0.85,
1344
- lightnessShift: 0,
1345
- },
1346
- bright: {
1347
- hueShift: 0,
1348
- chromaScale: 1,
1349
- lightnessScale: 1,
1350
- lightnessShift: 0.08,
1351
- },
1352
- // The dark↔light "octave" flip (rich-js INVERT_LIGHTNESS: L' = 1 - L) —
1353
- // errors stay red, dark-on-light becomes light-on-dark.
1354
- inverted: {
1355
- hueShift: 0,
1356
- chromaScale: 1,
1357
- lightnessScale: -1,
1358
- lightnessShift: 1,
1359
- },
1360
- },
1361
-
1362
- // ─── Presets ─────────────────────────────────────────────────────────────
1363
- // Named config FRAGMENTS — each an alternative `root` + display `globals`,
1364
- // i.e. a whole arrangement of the bar rather than one knob. A preset is to
1365
- // configuration what a look is to a theme, and rides the identical seam:
1366
- // selected per session via the `preset` SessionState key — or pinned as the
1367
- // durable default via `globals.preset` — through the settings menu's ONE
1368
- // dual preset control (src/config/settings-menu.ts), resolved as session
1369
- // pick over globals.preset over this floor.
1370
- // [LAW:one-source-of-truth] Merges by name (user wins per name), so this
1371
- // stdlib is present in every merged config by construction, exactly as
1372
- // looks' "none"/"vivid"/"muted"/… is — a user redefining "compact" or
1373
- // "verbose" wins per name; the floor cannot be shadowed by anything but an
1374
- // empty fragment, because that IS what it already is.
1375
- //
1376
- // [LAW:carrying-cost] Each library preset stages only real deltas from the
1377
- // bundled default's own root (declared above) — no preset here restates a
1378
- // segment's template, bg, or fg, only which of the ALREADY-declared
1379
- // segments appear and in what arrangement, per the field cap
1380
- // (brandon-presets-0yk.1's premise note): a bundled preset can only STAGE
1381
- // segments DEFAULT_DSL_CONFIG.segments already declares, never introduce
1382
- // one.
1383
- //
1384
- // [LAW:verifiable-goals] Every entry here is asserted error-cell-free at
1385
- // 80/120/200 columns against a RICH payload in
1386
- // test/default-dsl-config.test.ts (the same checkPayload fixture check.ts
1387
- // uses) — curated by actually rendering each arrangement, not by reading
1388
- // the segment names off the page.
1389
- presets: {
1390
- // [LAW:dataflow-not-control-flow] "default" is just the identity fragment
1391
- // — the resolution floor as a value, not a special case. An empty fragment
1392
- // declares no `root` and no `globals`, so it stages the config's own, which
1393
- // is precisely what "no preset chosen" means. This is the bundled default's
1394
- // OWN two-row (identity / status) arrangement declared as `root` above —
1395
- // it needs no entry of its own here beyond the floor, because staging it
1396
- // AS a named fragment would be a byte-for-byte copy of `root` that could
1397
- // silently drift from it the moment either changed.
1398
- default: {},
1399
-
1400
- // Single-row arrangement for narrow terminals and split panes — the
1401
- // situation where a user most wants a different arrangement and least
1402
- // wants to hand-write one. Keeps only the three facts a split pane most
1403
- // needs at a glance (where am I / what's the git state / how much context
1404
- // is left), the quiet one-line `git` segment rather than the multi-fact
1405
- // `gitaculous`, and `padding: 0` to buy back the chrome a narrow column
1406
- // can't spare.
1407
- //
1408
- // [LAW:no-silent-failure] A session that switches TO compact is never
1409
- // stranded: synthesizeSettingsMenu splices the global settings menu — and
1410
- // with it the preset switcher — into EVERY preset root, so one click back
1411
- // to "default" restores everything compact traded away for width. That is
1412
- // why this root carries no preset control of its own: one guaranteed door
1413
- // per root, minted once and referenced, not a segment each preset must
1414
- // remember to carry [LAW:one-source-of-truth].
1415
- compact: {
1416
- root: {
1417
- kind: "container",
1418
- direction: "horizontal",
1419
- children: [
1420
- { kind: "segment", name: "directory" },
1421
- { kind: "segment", name: "git" },
1422
- { kind: "segment", name: "context" },
1423
- ],
1424
- },
1425
- globals: { padding: 0 },
1426
- },
1427
-
1428
- // Verbose arrangement surfacing every segment that is declared but NOT in
1429
- // the default root (gitPr, burnrate, speed, tokenSparkline — see each
1430
- // segment's own "declared-but-opt-in" comment above) alongside the
1431
- // default's own two rows, for a user who wants the full usage-monitor
1432
- // picture rather than the quiet default. A third row carries the two
1433
- // per-turn throughput segments, which read "—" between turns
1434
- // ([LAW:no-silent-failure] on speed/tokenSparkline) rather than an empty
1435
- // or stale row. Like `compact`, it carries no preset control of its own —
1436
- // the global settings menu is spliced into every preset root and is the
1437
- // one door back.
1438
- verbose: {
1439
- root: {
1440
- kind: "container",
1441
- direction: "vertical",
1442
- children: [
1443
- {
1444
- kind: "container",
1445
- direction: "horizontal",
1446
- children: [
1447
- { kind: "segment", name: "directory" },
1448
- { kind: "segment", name: "gitaculous" },
1449
- { kind: "segment", name: "gitPr" },
1450
- { kind: "segment", name: "toolbar" },
1451
- ],
1452
- },
1453
- {
1454
- kind: "container",
1455
- direction: "horizontal",
1456
- children: [
1457
- { kind: "segment", name: "model" },
1458
- { kind: "segment", name: "context" },
1459
- { kind: "segment", name: "cacheTimer" },
1460
- { kind: "segment", name: "block" },
1461
- { kind: "segment", name: "weekly" },
1462
- { kind: "segment", name: "burnrate" },
1463
- ],
1464
- },
1465
- {
1466
- kind: "container",
1467
- direction: "horizontal",
1468
- children: [
1469
- { kind: "segment", name: "speed" },
1470
- { kind: "segment", name: "tokenSparkline" },
1471
- ],
1472
- },
1473
- ],
1474
- },
1475
- },
1476
- },
1477
-
1478
- // [LAW:one-source-of-truth] What edit mode LOOKS like, as config a user can
1479
- // retune — the whole point of candybar-settings-ui-aok.5, whose predecessor
1480
- // was renderer constants. Powerline chrome exists to make adjacent segments
1481
- // read as one continuous strip, which is precisely the wrong signal while a
1482
- // user is trying to see where one segment ends and the next begins; `plain`
1483
- // trades the caps for a visible separator between every cell.
1484
- //
1485
- // The separator is stated rather than left to PlainJoiner's own default: this
1486
- // fragment layers over the user's globals, so a config that set
1487
- // `default_separator` for its own powerline bar would otherwise carry that
1488
- // choice into edit mode, where the separator is the entire affordance. " | "
1489
- // (not "│") because it must survive `charset: "ascii"` — the fragment does
1490
- // not, and should not, know the terminal's glyph coverage.
1491
- editGlobals: {
1492
- style: "plain",
1493
- default_separator: " | ",
1494
- },
1495
-
1496
- // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
1497
- // for the cost/token/budget family lives here as named template helpers, each
1498
- // DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`
1499
- // — so how a cost/token string looks is data a user overrides by name, not
1500
- // compiled JS. The K/M token-scale rule has a SINGLE home (`formatTokenCount`);
1501
- // `formatTokens` suffixes " tokens" onto it and `formatTokenBreakdown` calls it
1502
- // per part, so the scale policy can never drift between the three.
1503
- // [LAW:dataflow-not-control-flow] A multi-input helper (budgetStatus,
1504
- // formatTokenBreakdown) receives its inputs as one `dict` value through its
1505
- // single dot arg — variability flows as data across one boundary, not as a
1506
- // bespoke multi-arg signature.
1507
- helpers: {
1508
- // Cost: under a cent reads "<$0.01"; otherwise "$" + two decimals. (Null is
1509
- // unrepresentable through the var-system — type:number with a numeric default
1510
- // owns "missing" upstream — so no null branch is needed here.)
1511
- formatCost:
1512
- '{{ if lt . 0.01 }}<$0.01{{ else }}${{ printf "%.2f" . }}{{ end }}',
1513
- // The single home of the K/M token-scale rule. >=1e6 → "X.YM", >=1e3 → "X.YK",
1514
- // else the integer verbatim (0 and negatives fall through to this arm, exactly
1515
- // as the retired JS did). No " tokens" suffix — that is formatTokens' job.
1516
- formatTokenCount:
1517
- '{{ if ge . 1000000 }}{{ printf "%.1f" (divf . 1000000) }}M' +
1518
- '{{ else if ge . 1000 }}{{ printf "%.1f" (divf . 1000) }}K' +
1519
- "{{ else }}{{ . }}{{ end }}",
1520
- formatTokens: '{{ template "formatTokenCount" . }} tokens',
1521
- // Burn rate: "$X.XX/hr" when projectable, "—/hr" otherwise. The daemon
1522
- // emits -1 (a structurally-impossible rate) for not-projectable, so the
1523
- // branch reads a VALUE, never a hidden control-flow flag. Reuses formatCost
1524
- // so the dollar policy has one home.
1525
- formatRate:
1526
- '{{ if lt . 0 }}—/hr{{ else }}{{ template "formatCost" . }}/hr{{ end }}',
1527
- // ETA to a rate-limit cap: humanized minutes when projectable, "—" when the
1528
- // daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
1529
- formatEta:
1530
- '{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
1531
- // Token throughput: "N/s" (K/M-scaled, rounded) when measured (>= 0), "—"
1532
- // when the daemon had no projectable sample (-1). Branches on the VALUE, like
1533
- // formatRate; reuses formatTokenCount so the K/M scale policy has one home.
1534
- formatSpeed:
1535
- '{{ if lt . 0 }}—{{ else }}{{ template "formatTokenCount" (round .) }}/s{{ end }}',
1536
- // Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
1537
- // part is formatted by the shared formatTokenCount and joined with " + ". A
1538
- // `$first` flag (reassigned across if-frames) inserts the separator before all
1539
- // but the first present part; all-zero collapses to "0 tokens".
1540
- formatTokenBreakdown:
1541
- "{{ $first := true }}" +
1542
- '{{ if gt .input 0 }}{{ template "formatTokenCount" .input }} in{{ $first = false }}{{ end }}' +
1543
- '{{ if gt .output 0 }}{{ if not $first }} + {{ end }}{{ template "formatTokenCount" .output }} out{{ $first = false }}{{ end }}' +
1544
- '{{ if or (gt .cacheCreation 0) (gt .cacheRead 0) }}{{ if not $first }} + {{ end }}{{ template "formatTokenCount" (add .cacheCreation .cacheRead) }} cached{{ $first = false }}{{ end }}' +
1545
- "{{ if $first }}0 tokens{{ end }}",
1546
- // Budget suffix over a dict {cost, budget, warn}. Non-displayable (budget<=0 or
1547
- // cost<0) → "". Otherwise pct = min(100, cost/budget*100), rendered " !N%" at/above
1548
- // warn, " +N%" at/above 50, " N%" below.
1549
- budgetStatus:
1550
- "{{ if or (le .budget 0) (lt .cost 0) }}{{ else }}" +
1551
- "{{ $pct := minf 100 (mulf (divf .cost .budget) 100) }}" +
1552
- '{{ $p := printf "%.0f%%" $pct }}' +
1553
- "{{ if ge $pct .warn }} !{{ $p }}" +
1554
- "{{ else }}{{ if ge $pct 50 }} +{{ $p }}{{ else }} {{ $p }}{{ end }}{{ end }}" +
1555
- "{{ end }}",
1556
-
1557
- // ─── Duration / time-remaining family (bdi.4) ──────────────────────────
1558
- // Display policy for elapsed/remaining times, each DEFINED ONCE and called
1559
- // from every segment via `{{ template "name" .x }}`. Input domain is a
1560
- // non-negative number (seconds, or minutes for formatLongTimeRemaining); the
1561
- // var-system owns "missing" as a numeric default upstream, so no null arm.
1562
- //
1563
- // The cascades branch on the VALUE (which unit threshold it falls in), never
1564
- // on control flow [LAW:dataflow-not-control-flow]. `div`/`mod` are Go int64
1565
- // (truncate toward zero == Math.floor for the non-negative domain); `printf
1566
- // "%.Nf"` is the toFixed(N) stand-in (rounds, matching JS toFixed).
1567
-
1568
- // Compact "since" stamp: <1m → "Ns"; then floored m/h/d/w. `div` truncates
1569
- // exactly like Math.floor here (seconds ≥ 0). Used by the git segment's
1570
- // time-since-commit affordance — verbatim seconds under a minute.
1571
- formatTimeSince:
1572
- "{{ if lt . 60 }}{{ . }}s" +
1573
- "{{ else if lt . 3600 }}{{ div . 60 }}m" +
1574
- "{{ else if lt . 86400 }}{{ div . 3600 }}h" +
1575
- "{{ else if lt . 604800 }}{{ div . 86400 }}d" +
1576
- "{{ else }}{{ div . 604800 }}w{{ end }}",
1577
- // Elapsed duration: <1m toFixed(0)+s; <1h (/60).toFixed(0)+m; <1d
1578
- // (/3600).toFixed(1)+h; else (/86400).toFixed(1)+d. printf rounds (not
1579
- // truncates), reproducing toFixed.
1580
- formatDuration:
1581
- '{{ if lt . 60 }}{{ printf "%.0f" . }}s' +
1582
- '{{ else if lt . 3600 }}{{ printf "%.0f" (divf . 60) }}m' +
1583
- '{{ else if lt . 86400 }}{{ printf "%.1f" (divf . 3600) }}h' +
1584
- '{{ else }}{{ printf "%.1f" (divf . 86400) }}d{{ end }}',
1585
- // Response time: one-decimal seconds under a minute, else one-decimal
1586
- // minutes.
1587
- formatResponseTime:
1588
- '{{ if lt . 60 }}{{ printf "%.1f" . }}s' +
1589
- '{{ else }}{{ printf "%.1f" (divf . 60) }}m{{ end }}',
1590
- // Long remaining (input = whole minutes): ≥1day → "Nd"/"Nd Nh"; ≥1hour →
1591
- // "Nh"/"Nh Nm"; else "Nm". The lower unit is appended only when non-zero,
1592
- // matching the JS hours>0/minutes>0 guards. `$d`/`$h`/`$m` declared in the
1593
- // branch frame and read by the inner if (lexical scope reads enclosing
1594
- // frames) — go-template-js cannot capture a value any other way.
1595
- formatLongTimeRemaining:
1596
- "{{ if ge . 1440 }}{{ $d := div . 1440 }}{{ $h := div (mod . 1440) 60 }}" +
1597
- "{{ if gt $h 0 }}{{ $d }}d {{ $h }}h{{ else }}{{ $d }}d{{ end }}" +
1598
- "{{ else if ge . 60 }}{{ $h := div . 60 }}{{ $m := mod . 60 }}" +
1599
- "{{ if gt $m 0 }}{{ $h }}h {{ $m }}m{{ else }}{{ $h }}h{{ end }}" +
1600
- "{{ else }}{{ . }}m{{ end }}",
1601
- },
1602
- } satisfies DslConfig;
1603
-
1604
- // [LAW:locality-or-seam] The palette names this module-level parse is allowed
1605
- // to accept — DERIVED from RAW_DEFAULT_DSL_CONFIG itself (globals.palette +
1606
- // every per-segment palette: pin), never from the live theme registry
1607
- // (listResolvablePaletteNames()). A real user file must validate against the
1608
- // live registry (an author can type any name); this file validates against
1609
- // ITSELF (every name here is a literal we wrote and every render test below
1610
- // exercises against the real registry already). This is what keeps the
1611
- // module-load parse below from ever depending on the registry being healthy
1612
- // at import time — a registry-loading bug elsewhere would surface where it
1613
- // actually matters (a real render failing), never as an uncatchable crash on
1614
- // every importer of this file before any daemon/CLI error handling runs.
1615
- const AUTHORED_PALETTE_NAMES = new Set(
1616
- [
1617
- RAW_DEFAULT_DSL_CONFIG.globals.palette,
1618
- ...(Object.values(RAW_DEFAULT_DSL_CONFIG.segments) as SegmentDecl[]).map(
1619
- (s) => s.palette,
1620
- ),
1621
- ].filter((name): name is string => name !== undefined),
1622
- );
1623
-
1624
- // [LAW:single-enforcer] Run the authored literal through the SAME
1625
- // parse → synthesize pipeline every user config goes through (JSON5 stage +
1626
- // synthesizeMenuDecls' `menus.*` synthesis, and any future group/menu
1627
- // synthesis pass) instead of hand-duplicating that logic here. Without this,
1628
- // the zero-config daemon path (loadConfig: no config file found ⇒ raw={},
1629
- // merged directly against this constant — see src/config/dsl-loader.ts and
1630
- // src/config/loader/merge.ts) would ship an UNSYNTHESIZED default: a
1631
- // `{{ menu (dict "key" …) }}` accordion pairing (the settings menu's pickers,
1632
- // brandon-theming-8uj.1) would render its glyph, but clicking it would reject
1633
- // with "unknown state key" — the synthesis that derives a menu's `menus.*`
1634
- // state var + cycle action only ever ran over TEXT a user typed, never over
1635
- // this TS literal. Round-tripping through JSON is exactly what
1636
- // test/default-dsl-config.test.ts's SERIALIZED-based tests already exercise,
1637
- // so this is the same well-tested path, run once here instead of skipped.
1638
- export const DEFAULT_DSL_CONFIG: DslConfig = mergeWithDefault(
1639
- parseDslConfig(
1640
- "<default>",
1641
- JSON.stringify(RAW_DEFAULT_DSL_CONFIG),
1642
- AUTHORED_PALETTE_NAMES,
1643
- ),
1644
- RAW_DEFAULT_DSL_CONFIG,
1645
- );