@groeponline/pi-wishcraft 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -115,9 +115,11 @@ scripts/github-release.sh
115
115
 
116
116
  CI workflows (`.github/workflows/`):
117
117
  - `test.yml` — Node 24, typecheck, test, circular, verify:package, npm audit
118
- - `release.yml` — reuses test.yml, prepares release-candidate branch
118
+ - `release.yml` — reuses test.yml, prepares release-candidate branch, publishes on tag, attaches `npm pack` + `SHA256SUMS`
119
119
  - `promote-release-candidate.yml` — promotes verified SHA to main, tags, dispatches publish
120
120
 
121
+ Tag `vX.Y.Z` must equal `package.json` and sit on `origin/main`. Identity: `src/product-identity.ts --version` / `/signal version`. See [`docs/release.md`](docs/release.md).
122
+
121
123
  Release behavior:
122
124
  - **Every main merge with `[Unreleased]` CHANGELOG notes cuts a release automatically** (verify → candidate → promote → tag → npm). Notes-less merges skip candidate creation entirely — no more empty versions.
123
125
  - Notes live under `## [Unreleased]` in CHANGELOG.md; the release commit rolls them into the new version heading.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.13.0] - 2026-09-25
6
+
7
+ ### Added
8
+ - `budget` segment (daily token budget): renders `budget NN%` with a fill bar when `wishcraft.tokenBudget.daily.limit` is set; wired into the `chef`, `full`, and `nerd` presets.
9
+ - Resting animation has a real clock: while idle at full motion, the signal rail leases the scheduler's `ambient` channel and breathes from that tick. `reduced`/`off` motion still renders a stable dim marker with no scheduler consumer.
10
+
5
11
  ## [1.12.0] - 2026-09-25
6
12
 
7
13
  ### Added
@@ -38,18 +44,6 @@
38
44
  - Bash forward-mode privacy notice (issue #71): the first keystroke forwarded to a running command raises an info notice that typed input may echo into the transcript; documented in `docs/bash-mode.md`.
39
45
  - Managed PTY suite is now script-gated (issue #73): PTY-core tests skip when `script(1)` is missing instead of silently passing in degraded pipe mode, the basic run asserts a real PTY transport, and the explicit pipe-mode tests still cover the fallback.
40
46
 
41
- ### Changed
42
- - Signal rail is now a compact single-line state surface. `ready` is stable and costs no animation frames; thinking, streaming, tools, and compaction use a bounded directional trail without expanding the footer to three rows.
43
- - Chef preset now includes session age, generated-token throughput, and cache-read information when present. The port counter names its protocol (`tcp` or `tcp+udp`) instead of displaying an ambiguous bare count.
44
-
45
- ### Fixed
46
- - Bash paste-while-running (issue #72): a bracketed paste performed while a command runs is stripped of its markers and forwarded to the child stdin — including split delivery across input chunks — instead of silently queuing in the editor buffer behind the command.
47
- - PTY/transcript memory bounds (from jan `fix/runtime-pty-bounds`): a single unterminated output line is capped at a 64 KiB UTF-8 tail with a one-time notice, the partial-escape tail is bounded, and the active transcript command trims head lines/bytes within limits instead of growing unbounded.
48
- - Segment-options spread hygiene (CodeFactor #86–89): `normalizeSegmentOptions` assigns fields imperatively instead of spreading conditional empty objects.
49
- - Motion frames that contain multiple terminal columns no longer expand a rail cell and shift the footer layout while they animate.
50
- - Idle rail no longer samples `Date.now()` without a corresponding repaint clock, eliminating stale or apparently random idle frames.
51
- - TPS now reports `--` until a live two-sample rate exists, rather than falsely presenting idle telemetry as `0`; the existing `both`/`out`/`in` setting is validated and honored.
52
-
53
47
  ## [1.4.16] - 2026-09-05
54
48
 
55
49
  ### Fixed
@@ -0,0 +1 @@
1
+ {"source_sha":"394402d958799308f24ffc284a240b878588a931"}
package/docs/commands.md CHANGED
@@ -86,7 +86,7 @@ You can also set it in the agent settings file (`~/.pi/agent/settings.json` by d
86
86
  | `full` | Everything: hostname, model, path (abbreviated), full git incl commit + ↑/↓, totals, context total, elapsed + clock |
87
87
  | `nerd` | Maximum detail for Nerd Font users: qualified model, full tokens + cache, commit, totals, seconds clock |
88
88
  | `ascii` | Safe for any terminal: branch + short commit + ↑/↓, tokens, cost, context% (no Nerd glyphs) |
89
- | `chef` | Fork default: muted colors, slash separators, compact live state rail, session age, TPS in/out, protocol-labelled open ports, generated tokens, cache, subagent cost |
89
+ | `chef` | Fork default: muted colors, slash separators, breathing state rail, session age, TPS in/out, protocol-labelled open ports, generated tokens, cache, budget, subagent cost |
90
90
 
91
91
  **Environment:** `POWERLINE_NERD_FONTS=1` to force Nerd Fonts, `=0` for ASCII.
92
92
 
@@ -99,7 +99,8 @@ Pi core renders the footer as static text, so live click is not possible; action
99
99
  - `/tps`: overlay of the live 1s window (same ring as the segment). `/tps <value>` sets `POWERLINE_TPS`
100
100
  - `/usage`: session / today / week overlay from `~/.pi/agent/wishcraft-usage.json`
101
101
  - `/open-ports`: list listening ports and pick one
102
- - `/powerline doctor`: diagnostics overlay — settings file validity, unknown presets, Nerd Font detection, git polling, bash-mode status, and queue file health
102
+ - `/powerline doctor`: diagnostics overlay — settings file validity, unknown presets, Nerd Font detection, git polling, bash-mode status, queue file health, and `package.identity`
103
+ - `/powerline version`: `{ version, source_sha }` from `package.json` (SHA only when release-stamped). See [release.md](./release.md).
103
104
  - `/powerline export`: export the current preset + effective layout + labels as a JSON snippet (Enter copies it to the clipboard)
104
105
  - `alt+p`: **Wishcraft Deck** — operator overlay (Home, Signal, Skills, Ideas, Guardrails, Appearance, …). `g` then a jump key (`h` home, `s` signal, `a` appearance). Escape closes. `/signal menu` still opens Navigate / Configure / Status.
105
106
  - `alt+i`: **powerline info**: full open-ports list
package/docs/index.md CHANGED
@@ -11,6 +11,7 @@ This package is a Pi.dev extension, not a standalone website. The README is the
11
11
  - [Skill manager](./skill-manager.md) — browsing, inserting, `/skills doctor`, and `/skills new` templates.
12
12
  - [Working vibes](./working-vibes.md) — themed loading messages, modes, and configuration.
13
13
  - [Segments & theming](./segments.md) — segment reference, separators, thinking/path/git options, and theme overrides.
14
+ - [Release route](./release.md) — tag == package version, GitHub Release artifacts, and identity.
14
15
 
15
16
  ## Design System & vNext Specifications
16
17
 
@@ -0,0 +1,16 @@
1
+ # Release route
2
+
3
+ Declared version source: `package.json` `version`. Product tag `vX.Y.Z` must equal that value at the tagged commit, and the commit must be an ancestor of `origin/main`.
4
+
5
+ Existing flow is unchanged: main pushes with `[Unreleased]` notes still prepare a candidate; promotion still tags protected main and dispatches publish. `scripts/npm-publish.sh` remains the npm publish target.
6
+
7
+ This route adds GitHub Release evidence on top of that:
8
+
9
+ 1. `.github/workflows/release.yml` re-checks tag == `package.json` and ancestry on `origin/main`.
10
+ 2. It writes `build-stamp.json` / `PI_WISHCRAFT_BUILD_SHA` from the tag commit only (never a fake such as `0.0.0`).
11
+ 3. It runs `npm pack`, writes `SHA256SUMS`, and attaches those assets to the GitHub Release.
12
+ 4. Visible identity: `node --experimental-strip-types src/product-identity.ts --version` prints `{ version, source_sha }`. `/signal version` and `/signal doctor` (`package.identity`) show the same values. Missing stamp/env is `null`, never invented.
13
+
14
+ `workflow_dispatch` input `tag` backfills the same checks at an existing tag (for example `v1.10.0`) and uploads artifacts. It does not publish to npm. Do not run the backfill from a contract PR.
15
+
16
+ This document does not create a tag or GitHub Release.
package/docs/segments.md CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  - `tps`: live tokens/sec over a rolling 1-second window (EMA-free, no spikes), reporting **output and input** rates separately (`⇡out ⇣in`) so you can see generation speed and incoming prompt tokens at a glance. It shows `--` until two live samples exist, rather than pretending an idle session runs at zero. Set `segmentOptions.tps.mode` to `both`, `out`, or `in`; override with `POWERLINE_TPS` when needed.
8
8
  - `open_ports`: count of unique **TCP** listening ports (`ss` → `netstat` → `/proc/net` fallback, dedupes IPv4/IPv6) rendered as `21 tcp`. Set `segmentOptions.openPorts.includeUdp: true` to include noisy UDP (mDNS/DHCP/ephemeral), rendered as `tcp+udp`.
9
+ - `budget`: daily token-budget usage (`budget 62% ▓▓▓▓░░░░`) when `wishcraft.tokenBudget.daily.limit` is configured. Warns through the same threshold colors as the cost segment.
9
10
 
10
11
  ### Open-port process owners
11
12
 
@@ -101,7 +102,7 @@ The `minimal` and `compact` presets keep these off to stay lean; set `showCommit
101
102
 
102
103
  ## Segments
103
104
 
104
- `model` · `shell_mode` · `path` · `git` · `subagents` · `queue` · `token_in` · `token_out` · `token_total` · `cost` · `context_pct` · `context_total` · `time_spent` · `time` · `session` · `hostname` · `cache_read` · `cache_write` · `thinking` · `tps` · `open_ports` · `extension_statuses`
105
+ `model` · `shell_mode` · `path` · `git` · `subagents` · `queue` · `token_in` · `token_out` · `token_total` · `cost` · `budget` · `context_pct` · `context_total` · `time_spent` · `time` · `session` · `hostname` · `cache_read` · `cache_write` · `thinking` · `tps` · `open_ports` · `extension_statuses`
105
106
 
106
107
  ## Separators
107
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
@@ -251,6 +251,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
251
251
  "cost",
252
252
  "context_pct",
253
253
  "context_total",
254
+ "budget",
254
255
  "time_spent",
255
256
  "time",
256
257
  "extension_statuses",
@@ -299,6 +300,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
299
300
  "cost",
300
301
  "context_pct",
301
302
  "context_total",
303
+ "budget",
302
304
  "time_spent",
303
305
  "time",
304
306
  "extension_statuses",
@@ -369,6 +371,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
369
371
  "subagents",
370
372
  "cost",
371
373
  "context_pct",
374
+ "budget",
372
375
  "time",
373
376
  ],
374
377
  separator: "slash",
@@ -213,6 +213,7 @@ export const BUILTIN_STATUS_LINE_SEGMENT_IDS = [
213
213
  "cost",
214
214
  "context_pct",
215
215
  "context_total",
216
+ "budget",
216
217
  "time_spent",
217
218
  "time",
218
219
  "session",
@@ -35,6 +35,7 @@ import { config, normalizePreset } from "../core/state.ts";
35
35
  import type { RuntimeState } from "../core/types.ts";
36
36
  import { getPowerlineArgumentCompletions } from "./powerline-completions.ts";
37
37
  import { settleSignal } from "../../signal/integration.ts";
38
+ import { formatProductIdentity, readProductIdentity } from "../../product-identity.ts";
38
39
 
39
40
  export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
40
41
  registerCdCommand(pi, () => rt.currentCtx?.cwd ?? process.cwd());
@@ -110,6 +111,10 @@ export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
110
111
  await runPowerlineDoctor(rt, ctx);
111
112
  return;
112
113
  }
114
+ if (normalizedArgs === "version") {
115
+ ctx.ui.notify(formatProductIdentity(readProductIdentity()).trimEnd(), "info");
116
+ return;
117
+ }
113
118
  if (normalizedArgs === "export") {
114
119
  await runPowerlineExport(ctx);
115
120
  return;
@@ -11,6 +11,10 @@ export const POWERLINE_SUBCOMMANDS: ReadonlyArray<{
11
11
  value: "doctor",
12
12
  description: "Run environment and settings diagnostics",
13
13
  },
14
+ {
15
+ value: "version",
16
+ description: "Print package version and stamped source SHA",
17
+ },
14
18
  {
15
19
  value: "export",
16
20
  description: "Export the current status-line configuration",
@@ -13,6 +13,7 @@ import { hasNerdFonts } from "../../theme/icons.ts";
13
13
  import { config } from "../core/state.ts";
14
14
  import type { RuntimeState } from "../core/types.ts";
15
15
  import { showSelectOverlay } from "../ui/menu-views.ts";
16
+ import { readProductIdentity } from "../../product-identity.ts";
16
17
 
17
18
  export type DoctorStatus = "ok" | "warn" | "fail";
18
19
 
@@ -51,6 +52,15 @@ export function buildDoctorReport(
51
52
  const cwd = ctx.cwd ?? process.cwd();
52
53
  const checks: DoctorCheck[] = [];
53
54
 
55
+ const identity = readProductIdentity();
56
+ checks.push({
57
+ status: "ok",
58
+ name: "package.identity",
59
+ detail: identity.source_sha
60
+ ? `${identity.version} source_sha=${identity.source_sha}`
61
+ : `${identity.version} source_sha=null`,
62
+ });
63
+
54
64
  // ── Settings ─────────────────────────────────────────────────────────────
55
65
  const globalPath = getSettingsPath();
56
66
  const projectPath = getProjectSettingsPath(cwd);
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const BUILD_SHA_ENV = "PI_WISHCRAFT_BUILD_SHA";
6
+ export const BUILD_STAMP_FILE = "build-stamp.json";
7
+ const SHA_RE = /^[0-9a-f]{40}$/;
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ export type ProductIdentity = {
12
+ version: string;
13
+ source_sha: string | null;
14
+ };
15
+
16
+ function readJson(relativePath: string, rootDir = root): unknown {
17
+ return JSON.parse(readFileSync(path.join(rootDir, relativePath), "utf8"));
18
+ }
19
+
20
+ export function readPackageVersion(rootDir = root): string {
21
+ const pkg = readJson("package.json", rootDir);
22
+ if (typeof pkg !== "object" || pkg === null) {
23
+ throw new Error("package.json is not an object");
24
+ }
25
+ const version = (pkg as { version?: unknown }).version;
26
+ if (typeof version !== "string" || !version.trim()) {
27
+ throw new Error("package.json version is missing");
28
+ }
29
+ return version.trim();
30
+ }
31
+
32
+ function parseSha(raw: string, label: string): string {
33
+ const sha = raw.trim();
34
+ if (!SHA_RE.test(sha)) {
35
+ throw new Error(`${label} must be a 40-character lowercase hex SHA, or unset`);
36
+ }
37
+ return sha;
38
+ }
39
+
40
+ export function readStampSha(rootDir = root): string | null {
41
+ const stampPath = path.join(rootDir, BUILD_STAMP_FILE);
42
+ if (!existsSync(stampPath)) return null;
43
+ const stamp = JSON.parse(readFileSync(stampPath, "utf8")) as {
44
+ source_sha?: unknown;
45
+ sha?: unknown;
46
+ };
47
+ const raw = stamp.source_sha ?? stamp.sha;
48
+ if (raw == null || raw === "") return null;
49
+ if (typeof raw !== "string") {
50
+ throw new Error(`${BUILD_STAMP_FILE} source_sha must be a string or omitted`);
51
+ }
52
+ return parseSha(raw, BUILD_STAMP_FILE);
53
+ }
54
+
55
+ export function readBuildSha(
56
+ env: NodeJS.ProcessEnv = process.env,
57
+ name = BUILD_SHA_ENV,
58
+ rootDir = root,
59
+ ): string | null {
60
+ const raw = String(env[name] ?? "").trim();
61
+ if (raw) return parseSha(raw, name);
62
+ return readStampSha(rootDir);
63
+ }
64
+
65
+ export function readProductIdentity(
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ rootDir = root,
68
+ ): ProductIdentity {
69
+ return {
70
+ version: readPackageVersion(rootDir),
71
+ source_sha: readBuildSha(env, BUILD_SHA_ENV, rootDir),
72
+ };
73
+ }
74
+
75
+ export function formatProductIdentity(identity: ProductIdentity): string {
76
+ return `${JSON.stringify(identity)}\n`;
77
+ }
78
+
79
+ function isDirectEntrypoint(): boolean {
80
+ if (!process.argv[1]) return false;
81
+ return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
82
+ }
83
+
84
+ if (isDirectEntrypoint() && process.argv.includes("--version")) {
85
+ process.stdout.write(formatProductIdentity(readProductIdentity()));
86
+ }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Compact Signal rail. It is deliberately a single terminal row: the rail
3
3
  * reports agent state, it never displaces the footer while a response streams.
4
- * Idle has no scheduler consumer; active work leases the shared scheduler.
4
+ * Full-motion idle leases the scheduler's ambient channel and breathes from
5
+ * that tick. Reduced and off motion stay a static marker with no consumer.
6
+ * Active work leases the signal channel.
5
7
  */
6
8
 
7
- import { getMotion } from "../motion/catalog.ts";
8
- import { frameAt, lanternGlow, sweepPhase, sweepReturning, trailGlyph } from "../motion/frames.ts";
9
+ import { defaultMotionFor, getMotion } from "../motion/catalog.ts";
10
+ import { frameAt, framesOf, lanternGlow, sweepPhase, sweepReturning, trailGlyph } from "../motion/frames.ts";
9
11
  import { fatBand } from "./motion-candidates.ts";
10
12
  import type { SignalRuntime } from "../signal/controller.ts";
11
13
  import type { SignalSpec } from "../config/types.ts";
@@ -55,6 +57,21 @@ function railCellColor(distance: number, p: RailPalette): string {
55
57
  return fgGradientCode("accent", "sep", Math.min(distance, p.trailDepth + 1) / (p.trailDepth + 1));
56
58
  }
57
59
 
60
+ function clamp01(value: number): number {
61
+ return value < 0 ? 0 : value > 1 ? 1 : value;
62
+ }
63
+
64
+ /** Map a scheduler tick to a repeating phase in the half-open range [0, 1). */
65
+ function scene(duration: number, tick: number): number {
66
+ return (((tick % duration) + duration) % duration) / duration;
67
+ }
68
+
69
+ /**
70
+ * Render the one-line Signal rail and its activity label.
71
+ *
72
+ * `width` is the available status-line width used to size the rail. ASCII mode
73
+ * uses single-column fallback glyphs and a static marker while idle.
74
+ */
58
75
  export function renderActivity(
59
76
  runtime: SignalRuntime,
60
77
  spec: SignalSpec,
@@ -99,7 +116,9 @@ export function renderActivity(
99
116
 
100
117
  let rail: string;
101
118
  if (!runtime.active) {
102
- rail = renderIdleRail(railWidth, ascii, track, dim);
119
+ rail = runtime.idleAnimated && !ascii
120
+ ? renderBreathingRail(runtime.tick, railWidth, dim, model, hot)
121
+ : renderIdleRail(railWidth, ascii, track, dim);
103
122
  } else if (runtime.event === "compact") {
104
123
  rail = renderCompactRail(runtime.tick, railWidth, ascii, headGlyph, cellColor);
105
124
  } else if (runtime.motionId === "fat-band") {
@@ -114,10 +133,41 @@ export function renderActivity(
114
133
  return `${paint(left, edge)}${rail}${paint(right, edge)} ${paint(label, runtime.active ? hot : dim)}`;
115
134
  }
116
135
 
117
- /** Idle rail. Do not derive idle state from Date.now(): there is
118
- * intentionally no idle animation clock, so a clock-derived rail otherwise
119
- * changes only when an unrelated repaint happens. The center marker makes
120
- * ready glanceable. */
136
+ /**
137
+ * Resting rail on the ambient tick. A warm radial glow breathes and
138
+ * shimmers per cell. The same tick always paints the same rail, so tests
139
+ * can advance `runtime.tick` without sampling the wall clock.
140
+ */
141
+ function renderBreathingRail(
142
+ tick: number,
143
+ width: number,
144
+ dim: string,
145
+ model: string,
146
+ hot: string,
147
+ ): string {
148
+ const idleDef = getMotion(defaultMotionFor("idle"));
149
+ const idleFrames = idleDef ? framesOf(idleDef) : ["◌", "◎", "◈", "⬡", "◈", "◎"];
150
+ const phase = scene(96, tick);
151
+ const center = (width - 1) / 2;
152
+ const built: string[] = [];
153
+ for (let i = 0; i < width; i++) {
154
+ const radial = Math.cos(((i - center) / Math.max(1, center)) * Math.PI * 0.5);
155
+ const shimmer = 0.22 * Math.sin(phase * Math.PI * 2 + i * 0.9);
156
+ const level = clamp01(radial * 0.9 + 0.1 + shimmer);
157
+ if (level <= 0.45) {
158
+ built.push(paint("─", dim));
159
+ continue;
160
+ }
161
+ const frameIdx = Math.floor(level * (idleFrames.length - 1)) % idleFrames.length;
162
+ const glyph = cellGlyph(idleFrames[frameIdx] ?? "", "·");
163
+ const color = level > 0.72 ? hot : model;
164
+ built.push(paint(glyph, color));
165
+ }
166
+ return built.join("");
167
+ }
168
+
169
+ /** Static idle rail. Reduced and off motion never lease a consumer, so the
170
+ * marker must not change between unrelated repaints. */
121
171
  function renderIdleRail(
122
172
  width: number,
123
173
  ascii: boolean,
@@ -21,6 +21,7 @@ import {
21
21
  tokenOutSegment,
22
22
  tokenTotalSegment,
23
23
  costSegment,
24
+ budgetSegment,
24
25
  contextPctSegment,
25
26
  contextTotalSegment,
26
27
  cacheReadSegment,
@@ -152,6 +153,7 @@ export const SEGMENTS: Record<BuiltinStatusLineSegmentId, StatusLineSegment> = {
152
153
  cost: costSegment,
153
154
  context_pct: contextPctSegment,
154
155
  context_total: contextTotalSegment,
156
+ budget: budgetSegment,
155
157
  time_spent: timeSpentSegment,
156
158
  time: timeSegment,
157
159
  session: sessionSegment,
@@ -139,10 +139,6 @@ function contextFillBar(
139
139
  percent: number,
140
140
  colorFn: (semantic: "context" | "contextWarn" | "contextError") => string,
141
141
  ): string {
142
- const CELLS = 8;
143
- // Clamp to the bar's cell count: percentages above 100 must not grow the
144
- // status line, and negative percentages must not underflow the empty run.
145
- const filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));
146
142
  let semantic: "context" | "contextWarn" | "contextError";
147
143
  if (percent > 90) {
148
144
  semantic = "contextError";
@@ -151,8 +147,25 @@ function contextFillBar(
151
147
  } else {
152
148
  semantic = "context";
153
149
  }
150
+ return fillBar(percent, semantic, colorFn);
151
+ }
152
+
153
+ /**
154
+ * Draw the 8-cell bar for an already-resolved semantic color. Thresholds are
155
+ * the caller's contract: the budget segment uses its own (80/100) so the bar
156
+ * never disagrees with the label color next to it.
157
+ */
158
+ function fillBar(
159
+ percent: number,
160
+ semantic: "context" | "contextWarn" | "contextError",
161
+ colorFn: (semantic: "context" | "contextWarn" | "contextError") => string,
162
+ ): string {
163
+ const CELLS = 8;
164
+ // Clamp to the bar's cell count: percentages above 100 must not grow the
165
+ // status line, and negative percentages must not underflow the empty run.
166
+ const filled = Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)));
154
167
  // Filled cells are left-aligned, so we can emit them as one colored run,
155
- // then the empty cells as another — just two color codes total, not one per cell.
168
+ // then the empty cells as another: just two color codes total, not one per cell.
156
169
  const filledColor = colorFn(semantic);
157
170
  const emptyColor = colorFn("context");
158
171
  const reset = colorEnabled() ? ansi.reset : "";
@@ -161,6 +174,28 @@ function contextFillBar(
161
174
  return `${filledRun}${emptyRun}`;
162
175
  }
163
176
 
177
+ export const budgetSegment: StatusLineSegment = {
178
+ id: "budget",
179
+ /** Render daily token use, or hide the segment when no positive limit exists. */
180
+ render(ctx) {
181
+ const budget = ctx.tokenBudget;
182
+ if (!budget || !budget.dailyLimit || budget.dailyLimit <= 0) {
183
+ return { content: "", visible: false };
184
+ }
185
+ const percent = Math.max(0, Math.min(100, (budget.dailyUsed / budget.dailyLimit) * 100));
186
+ const level = tokenBudgetLevel(budget.dailyUsed, budget.dailyLimit);
187
+ // The bar must follow the same threshold contract as the label
188
+ // (costColorForBudget via tokenBudgetLevel: warning from 80%, error only
189
+ // at 100%): reusing the context bar's 70/90 thresholds would color the
190
+ // bar error while the label next to it still reads warning.
191
+ const semantic: "context" | "contextWarn" | "contextError" =
192
+ level.level >= 100 ? "contextError" : level.level >= 80 ? "contextWarn" : "context";
193
+ const bar = fillBar(percent, semantic, (s) => color(ctx, s, ""));
194
+ const text = `budget ${Math.round(percent)}%`;
195
+ return { content: `${color(ctx, costColorForBudget(level.level), text)} ${bar}`, visible: true };
196
+ },
197
+ };
198
+
164
199
  export const contextTotalSegment: StatusLineSegment = {
165
200
  id: "context_total",
166
201
  render(ctx) {
@@ -21,9 +21,12 @@ export interface SignalRuntime {
21
21
  startedAt: number;
22
22
  activity: string;
23
23
  active: boolean;
24
+ /** Idle leases an ambient consumer so the breathing rail has a real clock. */
25
+ idleAnimated: boolean;
24
26
  release: (() => void) | null;
25
27
  }
26
28
 
29
+ /** Create an idle Signal runtime with no active scheduler lease. */
27
30
  export function createSignalRuntime(now = Date.now()): SignalRuntime {
28
31
  return {
29
32
  event: "idle",
@@ -32,6 +35,7 @@ export function createSignalRuntime(now = Date.now()): SignalRuntime {
32
35
  startedAt: now,
33
36
  activity: "ready",
34
37
  active: false,
38
+ idleAnimated: false,
35
39
  release: null,
36
40
  };
37
41
  }
@@ -47,6 +51,11 @@ export interface SetSignalEventOptions {
47
51
  settleOnDone?: boolean;
48
52
  }
49
53
 
54
+ /**
55
+ * Update the Signal state for an event and replace its current scheduler lease.
56
+ * Active events lease the signal channel when policy permits; idle leases the
57
+ * ambient channel for its breathing rail. Otherwise, the runtime stays static.
58
+ */
50
59
  export function setSignalEvent(
51
60
  runtime: SignalRuntime,
52
61
  scheduler: MotionScheduler,
@@ -71,7 +80,41 @@ export function setSignalEvent(
71
80
  // filters both paths identically via `channelsForMotion`.
72
81
  runtime.active =
73
82
  def !== undefined && channelsForMotion(def, policy).includes("signal");
74
- if (!runtime.active) return;
83
+ runtime.idleAnimated = false;
84
+ if (!runtime.active) {
85
+ // Rest is not static: while nothing runs, lease the ambient channel so
86
+ // the rail breathes on the shared clock instead of sampling Date.now()
87
+ // with no repaint of its own. The idle motion is always the catalog's
88
+ // ambient candidate (wisp) regardless of the active lane's signature.
89
+ const ambientDef = getMotion(defaultMotionFor("idle"));
90
+ if (
91
+ event === "idle" &&
92
+ ambientDef !== undefined &&
93
+ channelsForMotion(ambientDef, policy).includes("ambient")
94
+ ) {
95
+ runtime.idleAnimated = true;
96
+ let ambientRelease: (() => void) | null = null;
97
+ try {
98
+ ambientRelease = scheduler.subscribe({
99
+ id: "signal-ambient",
100
+ channel: "ambient",
101
+ intervalMs: ambientDef.generator?.intervalMs,
102
+ onTick(tick) {
103
+ runtime.tick = tick;
104
+ },
105
+ onDone() {
106
+ runtime.release = null;
107
+ runtime.idleAnimated = false;
108
+ },
109
+ });
110
+ } catch {
111
+ runtime.idleAnimated = false;
112
+ return;
113
+ }
114
+ runtime.release = ambientRelease;
115
+ }
116
+ return;
117
+ }
75
118
  // Wrap subscribe so a throw doesn't leave runtime.active=true with
76
119
  // release=null (a leaked state that would survive stopSignal).
77
120
  let release: (() => void) | null = null;