@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,661 @@
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] `satisfies DslConfig` (not an annotation)
23
+ // preserves the literal's narrow keys for downstream consumers — every
24
+ // declared segment name shows up as a known key, every variable name shows
25
+ // up in the input set the daemon must populate.
26
+
27
+ import type { DslConfig } from "./dsl-types.js";
28
+
29
+ // ─── Shared template fragments ───────────────────────────────────────────────
30
+ //
31
+ // Factored out of the segments' `template` fields so:
32
+ // (1) the git working-tree counts and status icon can be shared by the two
33
+ // git-style segments (git, gitTaculous) without duplication, and
34
+ // (2) the block/weekly threshold cascade can be parameterized on the
35
+ // variable name without resorting to runtime string surgery.
36
+
37
+ // Directory: ~ collapse under $HOME, project-relative under workspace.project_dir,
38
+ // else raw. Inline-recomputes the project-relative path because the DSL has no
39
+ // template-level `:=` (a `kind: "template"` var would express it once, but adds
40
+ // noise for a single use).
41
+ //
42
+ // Prefix checks are boundary-safe: a path is "under" a base iff it equals the
43
+ // base OR starts with `base + "/"`. The naive `hasPrefix base path` is a
44
+ // string match — it would treat `/home/alice` as a child of `/home/al`.
45
+ // `(printf "%s/" base)` adds the separator so the prefix can only land at a
46
+ // path boundary; the `eq` arm catches the exact-match case where the trailing
47
+ // slash would over-match.
48
+ //
49
+ // Equal-paths case (current_dir === project_dir) DOES enter the project-
50
+ // relative arm: DIR_REL evaluates to "" and the ternary picks basename
51
+ // (project_dir), so the project root renders as `<repo-name>` instead of the
52
+ // full absolute path. Same logic handles equal home & current_dir → just "~".
53
+ const DIR_REL = 'trimPrefix "/" (trimPrefix .project_dir .current_dir)';
54
+ const DIR_TEMPLATE =
55
+ ' {{ if and (ne .home "") (or (eq .home .current_dir) (hasPrefix (printf "%s/" .home) .current_dir)) }}~{{ trimPrefix .home .current_dir }}' +
56
+ "{{ else }}" +
57
+ '{{ if or (eq .project_dir .current_dir) (hasPrefix (printf "%s/" .project_dir) .current_dir) }}' +
58
+ `{{ ternary (${DIR_REL}) (basename .project_dir) (ne (${DIR_REL}) "") }}` +
59
+ "{{ else }}{{ .current_dir }}{{ end }}{{ end }} ";
60
+
61
+ // Git working-tree counts — leading-space-then-trim idiom: each present count
62
+ // contributes " +N", trim drops the leading space, survivors single-spaced.
63
+ const GIT_WORKTREE =
64
+ "{{ if or (gt .git.staged 0) (gt .git.unstaged 0) (gt .git.untracked 0) (gt .git.conflicts 0) }}" +
65
+ ' ({{ printf "%s%s%s%s"' +
66
+ ' (ternary (printf " +%v" .git.staged) "" (gt .git.staged 0))' +
67
+ ' (ternary (printf " ~%v" .git.unstaged) "" (gt .git.unstaged 0))' +
68
+ ' (ternary (printf " ?%v" .git.untracked) "" (gt .git.untracked 0))' +
69
+ ' (ternary (printf " !%v" .git.conflicts) "" (gt .git.conflicts 0)) | trim }}){{ end }}';
70
+
71
+ // Status icon precedence: conflicts → ⚠, dirty → ●, else clean ✓.
72
+ const GIT_STATUS =
73
+ '{{ if eq .git.status "conflicts" }}⚠{{ else }}' +
74
+ '{{ if eq .git.status "dirty" }}●{{ else }}✓{{ end }}{{ end }}';
75
+
76
+ const GIT_TEMPLATE =
77
+ ' {{ if ne .git.repoName "" }}{{ .git.repoName }} {{ end }}⎇ {{ .git.branch }}' +
78
+ "{{ if .git.sha }} ♯ {{ .git.sha }}{{ end }}" +
79
+ "{{ if or (gt .git.ahead 0) (gt .git.behind 0) }}" +
80
+ " {{ if gt .git.ahead 0 }}↑{{ .git.ahead }}{{ end }}" +
81
+ "{{ if gt .git.behind 0 }}↓{{ .git.behind }}{{ end }}{{ end }}" +
82
+ GIT_WORKTREE +
83
+ "{{ if .git.upstream }} →{{ .git.upstream }}{{ end }}" +
84
+ "{{ if gt .git.stash 0 }} ⧇ {{ .git.stash }}{{ end }}" +
85
+ " " +
86
+ GIT_STATUS +
87
+ " ";
88
+
89
+ // [LAW:dataflow-not-control-flow] block and weekly share the same threshold
90
+ // cascade (≥warningThreshold → error, ≥50 → warning, else panel) on a numeric
91
+ // ref. The builders parameterize BOTH the percentage ref and the threshold
92
+ // ref so each segment reads its own configured threshold from the var store
93
+ // rather than the literal 80 baked into the template. User overrides flow
94
+ // through the variables-merge-by-name cascade in mergeWithDefault — no new
95
+ // override mechanism required.
96
+ function blockLikeBg(pctRef: string, thresholdRef: string): string {
97
+ return (
98
+ `{{ if ge (round ${pctRef}) ${thresholdRef} }}error` +
99
+ `{{ else }}{{ if ge (round ${pctRef}) 50 }}warning` +
100
+ `{{ else }}panel{{ end }}{{ end }}`
101
+ );
102
+ }
103
+
104
+ function blockLikeFg(pctRef: string): string {
105
+ return (
106
+ `{{ if ge (round ${pctRef}) 50 }}button-color-foreground` +
107
+ `{{ else }}foreground{{ end }}`
108
+ );
109
+ }
110
+
111
+ // ─── The default config ──────────────────────────────────────────────────────
112
+
113
+ export const DEFAULT_DSL_CONFIG = {
114
+ globals: {
115
+ // Picked by the daemon's basePalette resolution; user overrides in their
116
+ // own config. catppuccin-latte ships every spec name the default
117
+ // segments reference (surface, panel, surface-active, foreground).
118
+ palette: "catppuccin-latte",
119
+ },
120
+
121
+ // ─── Variables ─────────────────────────────────────────────────────────────
122
+ // Every value the segment templates read. Sources:
123
+ // • input — daemon's augmented payload (see src/daemon/render-payload.ts)
124
+ // • env — process environment
125
+ // • shell — subprocess; cached
126
+ // • state — per-session daemon state
127
+ variables: {
128
+ // From hookData, pass-through.
129
+ current_dir: {
130
+ kind: "input",
131
+ path: "workspace.current_dir",
132
+ default: "?",
133
+ },
134
+ project_dir: {
135
+ kind: "input",
136
+ path: "workspace.project_dir",
137
+ default: "",
138
+ },
139
+ "model.display_name": {
140
+ kind: "input",
141
+ path: "model.display_name",
142
+ default: "",
143
+ },
144
+ "session.id": { kind: "input", path: "session_id", default: "" },
145
+ version: { kind: "input", path: "version", default: "" },
146
+
147
+ // [LAW:one-source-of-truth] The usable terminal width for THIS render —
148
+ // the exact post-reserve cell count FlexStrip wraps to. renderDsl injects
149
+ // it into the payload from its own `opts.width` (the single value that
150
+ // feeds both the wrap and this variable), so a width-paginated picker and
151
+ // the wrap algebra can never disagree. Never cached: a resize is just a
152
+ // new value on the same path, re-read every render. The default only
153
+ // applies to compile-only callers that render without injecting a width.
154
+ "term.cols": {
155
+ kind: "input",
156
+ path: "term.cols",
157
+ type: "number",
158
+ default: 80,
159
+ },
160
+
161
+ // [LAW:one-source-of-truth] The per-segment hue-rotation step renderDsl
162
+ // reads (HUE_STEP_VAR) — a value in the store like every other render input,
163
+ // NOT a globals field. [LAW:types-are-the-program] In the bundled default it
164
+ // is a LITERAL: nothing here writes the "hue-step" SessionState key (the
165
+ // default declares no interactive actions), so a fixed 14° is the strongest
166
+ // TRUE theorem — a `state` var would claim a session-variability the default
167
+ // never exercises and force a SessionState on every consumer. A user makes
168
+ // hue live by overriding this one variable to `{ kind: "state", key:
169
+ // "hue-step" }` and adding a pair of bounded stepper actions — the same
170
+ // two-part pattern the theme picker uses.
171
+ // 14°: adjacent segments stay visually distinct without per-segment colors.
172
+ "hue.step": { kind: "literal", value: 14 },
173
+
174
+ // home flows through the augmented payload (buildRenderPayload reads
175
+ // HOME, falling back to USERPROFILE on Windows where HOME is often
176
+ // unset). Sourcing via `kind: "input"` rather than `kind: "env",
177
+ // name: "HOME"` makes the directory `~` collapse work on every
178
+ // platform without per-platform config edits.
179
+ home: { kind: "input", path: "home", default: "" },
180
+
181
+ // Tmux session id flows through the daemon's augmented payload
182
+ // (TmuxService caches by socket and never re-spawns for the lifetime of
183
+ // the daemon, so this stays cheap). A `kind: "shell"` declaration would
184
+ // spawn the subprocess at every cache-entry creation regardless of
185
+ // whether the tmux segment is in the active layout — buildNeededPrefixes
186
+ // gates the input variant so unused segments cost nothing.
187
+ "tmux.session": { kind: "input", path: "tmux.session", default: "" },
188
+
189
+ // Git — every field flows from the daemon's projected GitInfo payload.
190
+ // The DSL's native `kind: "git"` source covers a 6-field subset
191
+ // (branch/sha/dirty/ahead/behind/stash); using `input` here gives the
192
+ // full 12-field surface uniformly via the augmented payload.
193
+ "git.repoName": {
194
+ kind: "input",
195
+ path: "git.repoName",
196
+ default: "",
197
+ },
198
+ "git.branch": { kind: "input", path: "git.branch", default: "" },
199
+ "git.sha": { kind: "input", path: "git.sha", default: "" },
200
+ "git.ahead": {
201
+ kind: "input",
202
+ path: "git.ahead",
203
+ type: "number",
204
+ default: 0,
205
+ },
206
+ "git.behind": {
207
+ kind: "input",
208
+ path: "git.behind",
209
+ type: "number",
210
+ default: 0,
211
+ },
212
+ "git.staged": {
213
+ kind: "input",
214
+ path: "git.staged",
215
+ type: "number",
216
+ default: 0,
217
+ },
218
+ "git.unstaged": {
219
+ kind: "input",
220
+ path: "git.unstaged",
221
+ type: "number",
222
+ default: 0,
223
+ },
224
+ "git.untracked": {
225
+ kind: "input",
226
+ path: "git.untracked",
227
+ type: "number",
228
+ default: 0,
229
+ },
230
+ "git.conflicts": {
231
+ kind: "input",
232
+ path: "git.conflicts",
233
+ type: "number",
234
+ default: 0,
235
+ },
236
+ "git.upstream": { kind: "input", path: "git.upstream", default: "" },
237
+ "git.stash": {
238
+ kind: "input",
239
+ path: "git.stash",
240
+ type: "number",
241
+ default: 0,
242
+ },
243
+ "git.status": { kind: "input", path: "git.status", default: "clean" },
244
+ "git.operation": { kind: "input", path: "git.operation", default: "" },
245
+ "git.timeSinceCommit": {
246
+ kind: "input",
247
+ path: "git.timeSinceCommit",
248
+ type: "number",
249
+ default: 0,
250
+ },
251
+
252
+ // Prompt-cache expiry — epoch seconds, projected by the cache provider.
253
+ // Same unit/shape as block/weekly resetsAt so the cacheTimer segment
254
+ // composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
255
+ // activity found ⇒ segment's `when` hides it.
256
+ "cache.expiresAt": {
257
+ kind: "input",
258
+ path: "cache.expiresAt",
259
+ type: "number",
260
+ default: 0,
261
+ },
262
+
263
+ // Usage / cost — daemon folds from the SessionUsageStore; numeric.
264
+ "session.cost": {
265
+ kind: "input",
266
+ path: "session.cost",
267
+ type: "number",
268
+ default: 0,
269
+ },
270
+ "session.tokens": {
271
+ kind: "input",
272
+ path: "session.tokens",
273
+ type: "number",
274
+ default: 0,
275
+ },
276
+
277
+ // Today — daemon folds today's cross-session total from the SessionUsageStore.
278
+ "today.cost": {
279
+ kind: "input",
280
+ path: "today.cost",
281
+ type: "number",
282
+ default: 0,
283
+ },
284
+ "today.tokens": {
285
+ kind: "input",
286
+ path: "today.tokens",
287
+ type: "number",
288
+ default: 0,
289
+ },
290
+ // Budget knobs — pure config constants, user overrides in their file.
291
+ "today.budget.amount": { kind: "literal", value: 50 },
292
+ "today.budget.warningThreshold": { kind: "literal", value: 80 },
293
+
294
+ // Block — daemon projects directly from hookData.rate_limits.five_hour;
295
+ // resetsAt is raw epoch seconds
296
+ // so the template can compose `minutesUntilReset .block.resetsAt` (the
297
+ // same chain weekly uses, single composition point).
298
+ "block.nativeUtilization": {
299
+ kind: "input",
300
+ path: "block.nativeUtilization",
301
+ type: "number",
302
+ default: 0,
303
+ },
304
+ "block.resetsAt": {
305
+ kind: "input",
306
+ path: "block.resetsAt",
307
+ type: "number",
308
+ default: 0,
309
+ },
310
+ // Budget knob — overridable per-config through the variables-merge-by-
311
+ // name cascade. Matches the legacy DEFAULT_CONFIG.budget.block.warning
312
+ // Threshold.
313
+ "block.budget.warningThreshold": { kind: "literal", value: 80 },
314
+
315
+ // Weekly — direct projection of hookData.rate_limits.seven_day.
316
+ "weekly.percentage": {
317
+ kind: "input",
318
+ path: "weekly.percentage",
319
+ type: "number",
320
+ default: 0,
321
+ },
322
+ "weekly.resetsAt": {
323
+ kind: "input",
324
+ path: "weekly.resetsAt",
325
+ type: "number",
326
+ default: 0,
327
+ },
328
+ "weekly.budget.warningThreshold": { kind: "literal", value: 80 },
329
+
330
+ // Context — daemon fetches via ContextProvider; contextLeftPercentage.
331
+ "context.totalTokens": {
332
+ kind: "input",
333
+ path: "context.totalTokens",
334
+ type: "number",
335
+ default: 0,
336
+ },
337
+ "context.contextLeft": {
338
+ kind: "input",
339
+ path: "context.contextLeft",
340
+ type: "number",
341
+ default: 100,
342
+ },
343
+
344
+ // Metrics — daemon fetches via MetricsProvider; numeric.
345
+ "metrics.lastResponseTime": {
346
+ kind: "input",
347
+ path: "metrics.lastResponseTime",
348
+ type: "number",
349
+ default: 0,
350
+ },
351
+ "metrics.responseTime": {
352
+ kind: "input",
353
+ path: "metrics.responseTime",
354
+ type: "number",
355
+ default: 0,
356
+ },
357
+ "metrics.sessionDuration": {
358
+ kind: "input",
359
+ path: "metrics.sessionDuration",
360
+ type: "number",
361
+ default: 0,
362
+ },
363
+ "metrics.messageCount": {
364
+ kind: "input",
365
+ path: "metrics.messageCount",
366
+ type: "number",
367
+ default: 0,
368
+ },
369
+ "metrics.linesAdded": {
370
+ kind: "input",
371
+ path: "metrics.linesAdded",
372
+ type: "number",
373
+ default: 0,
374
+ },
375
+ "metrics.linesRemoved": {
376
+ kind: "input",
377
+ path: "metrics.linesRemoved",
378
+ type: "number",
379
+ default: 0,
380
+ },
381
+ },
382
+
383
+ // ─── Segments ──────────────────────────────────────────────────────────────
384
+ // Every built-in. Templates ported from the parity bindings; bg/fg are
385
+ // palette spec names resolved against the active theme. `when` predicates
386
+ // hide a segment when its primary signal is absent (no git repo, no version
387
+ // field, no env var, no tmux, no rate-limit window).
388
+ segments: {
389
+ directory: {
390
+ template: DIR_TEMPLATE,
391
+ bg: "surface",
392
+ fg: "foreground",
393
+ },
394
+ model: {
395
+ template: " ✱ {{ formatModelName .model.display_name }} ",
396
+ bg: "panel",
397
+ fg: "foreground",
398
+ when: '{{ ne .model.display_name "" }}',
399
+ },
400
+ sessionId: {
401
+ template: " ⌗{{ trunc 8 .session.id }} ",
402
+ bg: "surface",
403
+ fg: "foreground",
404
+ when: '{{ ne .session.id "" }}',
405
+ },
406
+ version: {
407
+ template: " ◈ v{{ .version }} ",
408
+ bg: "surface",
409
+ fg: "foreground",
410
+ when: '{{ ne .version "" }}',
411
+ },
412
+ tmux: {
413
+ template: ' tmux:{{ .tmux.session | default "none" }} ',
414
+ bg: "surface-active",
415
+ fg: "foreground",
416
+ when: '{{ ne .tmux.session "" }}',
417
+ },
418
+ git: {
419
+ template: GIT_TEMPLATE,
420
+ bg: "surface-active",
421
+ fg: "foreground",
422
+ when: '{{ ne .git.branch "" }}',
423
+ },
424
+ gitTaculous: {
425
+ template:
426
+ " (git)" +
427
+ '{{ if ne .git.repoName "" }} {{ .git.repoName }}{{ end }}' +
428
+ '{{ if ne .git.operation "" }} [{{ .git.operation }}]{{ end }}' +
429
+ '{{ if ne .git.sha "" }} {{ .git.sha }}{{ end }}' +
430
+ "{{ if or (gt .git.staged 0) (gt .git.unstaged 0) (gt .git.untracked 0) (gt .git.conflicts 0) }} " +
431
+ '{{ if gt .git.staged 0 }}{{ green "S" }}{{ end }}' +
432
+ '{{ if or (gt .git.unstaged 0) (gt .git.untracked 0) }}{{ red "U" }}{{ end }}' +
433
+ '{{ if gt .git.conflicts 0 }}{{ red (printf "!%v" .git.conflicts) }}{{ end }}' +
434
+ "{{ end }}" +
435
+ " ⎇ {{ .git.branch }}" +
436
+ '{{ if ne .git.upstream "" }} [{{ .git.upstream }}' +
437
+ "{{ if or (gt .git.ahead 0) (gt .git.behind 0) }} " +
438
+ '{{ if gt .git.ahead 0 }}{{ green (printf "+%v" .git.ahead) }}{{ end }}' +
439
+ "{{ if and (gt .git.ahead 0) (gt .git.behind 0) }}/{{ end }}" +
440
+ '{{ if gt .git.behind 0 }}{{ red (printf "-%v" .git.behind) }}{{ end }}' +
441
+ "{{ end }}]{{ end }}" +
442
+ "{{ if gt .git.stash 0 }} ({{ .git.stash }} stashed){{ end }}" +
443
+ '{{ if gt .git.timeSinceCommit 0 }} ◷ {{ template "formatTimeSince" .git.timeSinceCommit }}{{ end }}' +
444
+ " ",
445
+ bg: "surface-active",
446
+ fg: "foreground",
447
+ when: '{{ ne .git.branch "" }}',
448
+ },
449
+ toolbar: {
450
+ template:
451
+ ' {{ link (printf "cc-candybar://open-vscode/%s" (urlEncode .current_dir)) "\u{1F4C2}" }}' +
452
+ ' {{ link (printf "cc-candybar://copy/%s" (urlEncode (trunc 8 .session.id))) "⎘" }} ',
453
+ bg: "surface",
454
+ fg: "foreground",
455
+ },
456
+ session: {
457
+ template:
458
+ ' § {{ template "formatCost" .session.cost }} ({{ template "formatTokens" .session.tokens }}) ',
459
+ bg: "surface",
460
+ fg: "foreground",
461
+ },
462
+ today: {
463
+ template:
464
+ ' ☉ {{ template "formatCost" .today.cost }} ({{ template "formatTokens" .today.tokens }})' +
465
+ '{{ template "budgetStatus" (dict "cost" .today.cost "budget" .today.budget.amount "warn" .today.budget.warningThreshold) }} ',
466
+ bg: "surface",
467
+ fg: "foreground",
468
+ },
469
+ block: {
470
+ template:
471
+ " ◱ {{ round .block.nativeUtilization }}% " +
472
+ '({{ template "formatLongTimeRemaining" (minutesUntilReset .block.resetsAt) }}) ',
473
+ bg: blockLikeBg(
474
+ ".block.nativeUtilization",
475
+ ".block.budget.warningThreshold",
476
+ ),
477
+ fg: blockLikeFg(".block.nativeUtilization"),
478
+ // Hide unless we have a five-hour-window snapshot.
479
+ when: "{{ gt .block.resetsAt 0 }}",
480
+ },
481
+ weekly: {
482
+ template:
483
+ " ◑ {{ round .weekly.percentage }}% " +
484
+ '({{ template "formatLongTimeRemaining" (minutesUntilReset .weekly.resetsAt) }}) ',
485
+ bg: blockLikeBg(".weekly.percentage", ".weekly.budget.warningThreshold"),
486
+ fg: blockLikeFg(".weekly.percentage"),
487
+ when: "{{ gt .weekly.resetsAt 0 }}",
488
+ },
489
+ // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
490
+ // to 0, so an expired cache renders "cold" (and reads red via the ≤8
491
+ // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
492
+ // glyph + "cold"/"Nm" + color all derive from the one expiry value; the
493
+ // provider supplies no display state. Constant `surface` bg with a
494
+ // fg-only threshold cascade mirrors the legacy inline-colored text
495
+ // (warm = normal, ≤20m = warning, ≤8m/cold = error).
496
+ cacheTimer: {
497
+ template:
498
+ " ◴ {{ if le (minutesUntilReset .cache.expiresAt) 0 }}cold" +
499
+ "{{ else }}{{ minutesUntilReset .cache.expiresAt }}m{{ end }} ",
500
+ bg: "surface",
501
+ fg:
502
+ "{{ if le (minutesUntilReset .cache.expiresAt) 8 }}error" +
503
+ "{{ else }}{{ if le (minutesUntilReset .cache.expiresAt) 20 }}warning" +
504
+ "{{ else }}foreground{{ end }}{{ end }}",
505
+ when: "{{ gt .cache.expiresAt 0 }}",
506
+ },
507
+ context: {
508
+ template:
509
+ " ◔ {{ formatInteger .context.totalTokens }} ({{ .context.contextLeft }}%) ",
510
+ bg:
511
+ "{{ if le .context.contextLeft 20 }}error" +
512
+ "{{ else }}{{ if le .context.contextLeft 40 }}warning" +
513
+ "{{ else }}surface-active{{ end }}{{ end }}",
514
+ fg:
515
+ "{{ if le .context.contextLeft 40 }}button-color-foreground" +
516
+ "{{ else }}foreground{{ end }}",
517
+ when: "{{ gt .context.totalTokens 0 }}",
518
+ },
519
+ metrics: {
520
+ // [LAW:dataflow-not-control-flow] Each part guards on its own value
521
+ // rather than gating the whole segment on a single dimension. With
522
+ // MetricsPayload's fields independently optional and pickNonNull
523
+ // dropping nulls (see src/daemon/render-payload.ts), an absent field
524
+ // resolves through the var-system fallback chain to 0 — the same
525
+ // falsy shape the per-part `if` test treats as hidden. The segment-
526
+ // level `when` survives as a weak any-present check so a payload
527
+ // with zero metrics data renders no cell at all (an empty template
528
+ // would otherwise produce a single-space bg-styled cell).
529
+ template:
530
+ '{{ if .metrics.lastResponseTime }} Δ {{ template "formatResponseTime" .metrics.lastResponseTime }}{{ end }}' +
531
+ '{{ if .metrics.responseTime }} ⧖ {{ template "formatResponseTime" .metrics.responseTime }}{{ end }}' +
532
+ '{{ if .metrics.sessionDuration }} ⧗ {{ template "formatDuration" .metrics.sessionDuration }}{{ end }}' +
533
+ "{{ if .metrics.messageCount }} ◆ {{ .metrics.messageCount }}{{ end }}" +
534
+ "{{ if .metrics.linesAdded }} + {{ .metrics.linesAdded }}{{ end }}" +
535
+ "{{ if .metrics.linesRemoved }} - {{ .metrics.linesRemoved }}{{ end }} ",
536
+ bg: "panel",
537
+ fg: "foreground",
538
+ when:
539
+ "{{ or .metrics.lastResponseTime .metrics.responseTime" +
540
+ " .metrics.sessionDuration .metrics.messageCount" +
541
+ " .metrics.linesAdded .metrics.linesRemoved }}",
542
+ },
543
+ },
544
+
545
+ // Default layout — a single horizontal row of segment refs.
546
+ // A-grammar equivalent: { h: ["directory","git","model","session","today","context"] }
547
+ // [LAW:one-source-of-truth] The bundled default now authors the same terse
548
+ // surface every user config lowers to, so the default is the reference spelling.
549
+ // Adding rows = wrapping in { kind:"container", direction:"vertical", children:[...] };
550
+ // every segment is already declared above.
551
+ root: {
552
+ kind: "container",
553
+ direction: "horizontal",
554
+ children: [
555
+ { kind: "segment", name: "directory" },
556
+ { kind: "segment", name: "git" },
557
+ { kind: "segment", name: "model" },
558
+ { kind: "segment", name: "session" },
559
+ { kind: "segment", name: "today" },
560
+ { kind: "segment", name: "context" },
561
+ ],
562
+ },
563
+
564
+ // [LAW:locality-or-seam] No decoupled actions in the bundled default either —
565
+ // the baseline statusline binds no clickable regions. A user config declares
566
+ // named actions and binds them from a segment template via
567
+ // `{{ action "name" … }}`; the merge cascade adds them by name.
568
+ actions: {},
569
+
570
+ // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
571
+ // for the cost/token/budget family lives here as named template helpers, each
572
+ // DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`
573
+ // — so how a cost/token string looks is data a user overrides by name, not
574
+ // compiled JS. The K/M token-scale rule has a SINGLE home (`formatTokenCount`);
575
+ // `formatTokens` suffixes " tokens" onto it and `formatTokenBreakdown` calls it
576
+ // per part, so the scale policy can never drift between the three.
577
+ // [LAW:dataflow-not-control-flow] A multi-input helper (budgetStatus,
578
+ // formatTokenBreakdown) receives its inputs as one `dict` value through its
579
+ // single dot arg — variability flows as data across one boundary, not as a
580
+ // bespoke multi-arg signature.
581
+ helpers: {
582
+ // Cost: under a cent reads "<$0.01"; otherwise "$" + two decimals. (Null is
583
+ // unrepresentable through the var-system — type:number with a numeric default
584
+ // owns "missing" upstream — so no null branch is needed here.)
585
+ formatCost:
586
+ '{{ if lt . 0.01 }}<$0.01{{ else }}${{ printf "%.2f" . }}{{ end }}',
587
+ // The single home of the K/M token-scale rule. >=1e6 → "X.YM", >=1e3 → "X.YK",
588
+ // else the integer verbatim (0 and negatives fall through to this arm, exactly
589
+ // as the retired JS did). No " tokens" suffix — that is formatTokens' job.
590
+ formatTokenCount:
591
+ '{{ if ge . 1000000 }}{{ printf "%.1f" (divf . 1000000) }}M' +
592
+ '{{ else if ge . 1000 }}{{ printf "%.1f" (divf . 1000) }}K' +
593
+ "{{ else }}{{ . }}{{ end }}",
594
+ formatTokens: '{{ template "formatTokenCount" . }} tokens',
595
+ // Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
596
+ // part is formatted by the shared formatTokenCount and joined with " + ". A
597
+ // `$first` flag (reassigned across if-frames) inserts the separator before all
598
+ // but the first present part; all-zero collapses to "0 tokens".
599
+ formatTokenBreakdown:
600
+ "{{ $first := true }}" +
601
+ '{{ if gt .input 0 }}{{ template "formatTokenCount" .input }} in{{ $first = false }}{{ end }}' +
602
+ '{{ if gt .output 0 }}{{ if not $first }} + {{ end }}{{ template "formatTokenCount" .output }} out{{ $first = false }}{{ end }}' +
603
+ '{{ if or (gt .cacheCreation 0) (gt .cacheRead 0) }}{{ if not $first }} + {{ end }}{{ template "formatTokenCount" (add .cacheCreation .cacheRead) }} cached{{ $first = false }}{{ end }}' +
604
+ "{{ if $first }}0 tokens{{ end }}",
605
+ // Budget suffix over a dict {cost, budget, warn}. Non-displayable (budget<=0 or
606
+ // cost<0) → "". Otherwise pct = min(100, cost/budget*100), rendered " !N%" at/above
607
+ // warn, " +N%" at/above 50, " N%" below.
608
+ budgetStatus:
609
+ "{{ if or (le .budget 0) (lt .cost 0) }}{{ else }}" +
610
+ "{{ $pct := minf 100 (mulf (divf .cost .budget) 100) }}" +
611
+ '{{ $p := printf "%.0f%%" $pct }}' +
612
+ "{{ if ge $pct .warn }} !{{ $p }}" +
613
+ "{{ else }}{{ if ge $pct 50 }} +{{ $p }}{{ else }} {{ $p }}{{ end }}{{ end }}" +
614
+ "{{ end }}",
615
+
616
+ // ─── Duration / time-remaining family (bdi.4) ──────────────────────────
617
+ // Display policy for elapsed/remaining times, each DEFINED ONCE and called
618
+ // from every segment via `{{ template "name" .x }}`. Input domain is a
619
+ // non-negative number (seconds, or minutes for formatLongTimeRemaining); the
620
+ // var-system owns "missing" as a numeric default upstream, so no null arm.
621
+ //
622
+ // The cascades branch on the VALUE (which unit threshold it falls in), never
623
+ // on control flow [LAW:dataflow-not-control-flow]. `div`/`mod` are Go int64
624
+ // (truncate toward zero == Math.floor for the non-negative domain); `printf
625
+ // "%.Nf"` is the toFixed(N) stand-in (rounds, matching JS toFixed).
626
+
627
+ // Compact "since" stamp: <1m → "Ns"; then floored m/h/d/w. `div` truncates
628
+ // exactly like Math.floor here (seconds ≥ 0). Used by the git segment's
629
+ // time-since-commit affordance — verbatim seconds under a minute.
630
+ formatTimeSince:
631
+ "{{ if lt . 60 }}{{ . }}s" +
632
+ "{{ else if lt . 3600 }}{{ div . 60 }}m" +
633
+ "{{ else if lt . 86400 }}{{ div . 3600 }}h" +
634
+ "{{ else if lt . 604800 }}{{ div . 86400 }}d" +
635
+ "{{ else }}{{ div . 604800 }}w{{ end }}",
636
+ // Elapsed duration: <1m toFixed(0)+s; <1h (/60).toFixed(0)+m; <1d
637
+ // (/3600).toFixed(1)+h; else (/86400).toFixed(1)+d. printf rounds (not
638
+ // truncates), reproducing toFixed.
639
+ formatDuration:
640
+ '{{ if lt . 60 }}{{ printf "%.0f" . }}s' +
641
+ '{{ else if lt . 3600 }}{{ printf "%.0f" (divf . 60) }}m' +
642
+ '{{ else if lt . 86400 }}{{ printf "%.1f" (divf . 3600) }}h' +
643
+ '{{ else }}{{ printf "%.1f" (divf . 86400) }}d{{ end }}',
644
+ // Response time: one-decimal seconds under a minute, else one-decimal
645
+ // minutes.
646
+ formatResponseTime:
647
+ '{{ if lt . 60 }}{{ printf "%.1f" . }}s' +
648
+ '{{ else }}{{ printf "%.1f" (divf . 60) }}m{{ end }}',
649
+ // Long remaining (input = whole minutes): ≥1day → "Nd"/"Nd Nh"; ≥1hour →
650
+ // "Nh"/"Nh Nm"; else "Nm". The lower unit is appended only when non-zero,
651
+ // matching the JS hours>0/minutes>0 guards. `$d`/`$h`/`$m` declared in the
652
+ // branch frame and read by the inner if (lexical scope reads enclosing
653
+ // frames) — go-template-js cannot capture a value any other way.
654
+ formatLongTimeRemaining:
655
+ "{{ if ge . 1440 }}{{ $d := div . 1440 }}{{ $h := div (mod . 1440) 60 }}" +
656
+ "{{ if gt $h 0 }}{{ $d }}d {{ $h }}h{{ else }}{{ $d }}d{{ end }}" +
657
+ "{{ else if ge . 60 }}{{ $h := div . 60 }}{{ $m := mod . 60 }}" +
658
+ "{{ if gt $m 0 }}{{ $h }}h {{ $m }}m{{ else }}{{ $h }}h{{ end }}" +
659
+ "{{ else }}{{ . }}m{{ end }}",
660
+ },
661
+ } satisfies DslConfig;