@groeponline/pi-wishcraft 1.4.17 → 1.8.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/CHANGELOG.md CHANGED
@@ -2,16 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.8.0] - 2026-09-12
6
+
7
+ ### Fixed
8
+ - Deck dashboard focus model: the focused pane is now marked with ◉/○ so ↓ on the skills workbench no longer looks like it "goes right" — ↑↓ moves the focused pane, ←/tab focuses NAVIGATION, → returns to the list, and the footer advertises both. List cursors clamp from the current position, so a stale cursor after filtering/refresh moves one row instead of sticking.
9
+
5
10
  ## [1.4.17] - 2026-09-07
6
11
 
7
12
  ### Added
8
13
  - 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`.
9
14
  - 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.
10
15
 
16
+ ### Changed
17
+ - 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.
18
+ - 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.
19
+
11
20
  ### Fixed
12
21
  - 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.
13
22
  - 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.
14
23
  - Segment-options spread hygiene (CodeFactor #86–89): `normalizeSegmentOptions` assigns fields imperatively instead of spreading conditional empty objects.
24
+ - Motion frames that contain multiple terminal columns no longer expand a rail cell and shift the footer layout while they animate.
25
+ - Idle rail no longer samples `Date.now()` without a corresponding repaint clock, eliminating stale or apparently random idle frames.
26
+ - 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.
15
27
 
16
28
  ## [1.4.16] - 2026-09-05
17
29
 
package/README.md CHANGED
@@ -1,43 +1,54 @@
1
- <p>
2
- <img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/banner.png" alt="pi-wishcraft" width="1100">
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/banner.png" alt="Pi Wishcraft" width="100%">
3
3
  </p>
4
4
 
5
- <p>
5
+ <h1 align="center">Pi Wishcraft</h1>
6
+
7
+ <p align="center"><strong>Your operator cockpit for Pi.</strong><br>See what the session is doing, park ideas without interrupting it, search skills, switch into Bash, and keep the important controls one keypress away.</p>
8
+
9
+ <p align="center">
6
10
  <a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/v/@groeponline/pi-wishcraft.svg" alt="npm version"></a>
7
11
  <a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/dm/@groeponline/pi-wishcraft.svg?label=downloads" alt="npm downloads"></a>
8
12
  <a href="https://pi.dev/packages/@groeponline/pi-wishcraft"><img src="https://img.shields.io/badge/Pi-package-9b59b6.svg" alt="Pi package"></a>
9
13
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555.svg" alt="MIT license"></a>
10
14
  </p>
11
15
 
12
- # pi-wishcraft
16
+ <p align="center"><img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/docs/images/wishcraft-cockpit-map.svg" alt="Wishcraft cockpit map showing Signal, Deck, idea capture, skills, managed shell and local guardrails around Pi" width="100%"><br><sub>A map of the shipped operator surfaces. Pi remains the agent runtime.</sub></p>
13
17
 
14
- Cockpit and harness for the [pi](https://github.com/badlogic/pi-mono) coding agent: a live status bar, overlay menus, skills, an idea inbox, sticky bash, hooks, and tool-input repairs. Stock pi stays the engine. This package is the operator layer.
15
- Kongming lanterns started as battlefield signals and later carried wishes. Wishcraft is that split in a coding session: telemetry on the bar, thoughts you can park without interrupting the run.
18
+ ## Start in 10 seconds
16
19
 
17
- Install `@groeponline/pi-wishcraft`. It is listed on the [Pi package catalog](https://pi.dev/packages/@groeponline/pi-wishcraft). Grew out of [`nicobailon/pi-powerline-footer`](https://github.com/nicobailon/pi-powerline-footer). Maintained by [GroepOnline](https://github.com/GroepOnline).
20
+ ```bash
21
+ pi install npm:@groeponline/pi-wishcraft
22
+ ```
18
23
 
19
- Guides live in [`docs/`](docs/index.md). This is the Pi extension contract: what ships, how to install it, and what can fail.
24
+ Reload Pi, then press `alt+p` to open the Deck.
20
25
 
21
- ## Quick start tutorial
26
+ Try the two flows that make Wishcraft click fastest:
22
27
 
23
- Install, restart Pi (or run `/reload`), then press `alt+p` for the Deck. Try `/signal menu`, `/skills`, `/ideas`, and `ctrl+shift+b`. Full setup: [docs/configuration.md](docs/configuration.md).
24
- **Portfolio boundary:** Wishcraft owns the operator cockpit and lightweight idea capture. Promote durable work to [`pi-missions`](https://github.com/GroepOnline/pi-missions), then use [`pi-agent-orchestrator`](https://github.com/GroepOnline/pi-agent-orchestrator) when parallel/isolated execution adds value: `idea -> mission -> orchestration run`.
28
+ ```text
29
+ # remember to benchmark the new provider path
30
+ /ideas
31
+ ```
25
32
 
26
- ## Install
33
+ The `#` line is captured as an idea instead of being sent as a prompt. Your active run keeps going.
27
34
 
28
- Pi package manager (usual path):
35
+ Then open the skill picker:
29
36
 
30
- ```bash
31
- pi install npm:@groeponline/pi-wishcraft
37
+ ```text
38
+ /skills
32
39
  ```
33
40
 
34
- For a local checkout or CI environment, run the repository bootstrap script with Bash:
41
+ Search, inspect and insert a skill without leaving the session.
35
42
 
36
- ```bash
37
- bash ./scripts/install.sh
38
- ```
43
+ ## Why Wishcraft
44
+
45
+ Pi stays the engine. Wishcraft adds the operator layer around it: live status, overlays, idea capture, skill search, sticky Bash, hooks, policy controls and session UX.
46
+
47
+ Kongming lanterns started as battlefield signals and later carried wishes. Wishcraft keeps both ideas in one place: telemetry while the work is moving, and thoughts you can park until you are ready for them.
48
+
49
+ **Portfolio boundary:** Wishcraft owns the operator cockpit and lightweight idea capture. Promote durable work to [`pi-missions`](https://github.com/GroepOnline/pi-missions), then use [`pi-agent-orchestrator`](https://github.com/GroepOnline/pi-agent-orchestrator) when parallel or isolated execution adds value: `idea -> mission -> orchestration run`.
39
50
 
40
- Then restart Pi or `/reload`. Pi host packages are declared as `peerDependencies: "*"`, matching the Pi package contract.
51
+ Guides live in [`docs/`](docs/index.md).
41
52
 
42
53
  ## What you get
43
54
 
@@ -80,12 +91,6 @@ Activates on load. `/signal` toggles it. `/signal <preset>` switches the informa
80
91
  /vibe star trek themed working messages
81
92
  ```
82
93
 
83
- Queue:
84
-
85
- - `# <text>` current project; `# @global`, `# @current`, `# @alias`
86
- - `/idea`, `/ideas`, `/queue` for capture, send, retry, clear, archive
87
- - `/ideas` overlay: `reviewStatus` (`idea` / `in-progress` / `done`), tags, Run with skill X
88
-
89
94
  Keybinds (`powerlineShortcuts`, applied after `/reload`; `null` disables):
90
95
 
91
96
  ```json
@@ -114,10 +119,6 @@ Keybinds (`powerlineShortcuts`, applied after `/reload`; `null` disables):
114
119
 
115
120
  `chef` is muted colors, slash separators, live TPS in/out, and TCP port count. Built-in presets: `default`, `minimal`, `compact`, `full`, `nerd`, `ascii`, `chef`. Custom segments, labels, layout, and presets are documented in [docs/configuration.md](docs/configuration.md). For every setting at its default, see [`examples/settings.example.json`](examples/settings.example.json).
116
121
 
117
- Nerd Fonts auto-detect for iTerm, WezTerm, Kitty, Ghostty, and Alacritty; ASCII otherwise. `POWERLINE_NERD_FONTS=0` forces ASCII.
118
-
119
- Context turns warning above 70% and error above 90%. TPS is tokens in the last ~1s, not a session average. `/tps` reads that ring; it does not start a second sampler.
120
-
121
122
  Daily token budget (never blocks a turn):
122
123
 
123
124
  ```json
@@ -233,7 +234,6 @@ Privacy/network boundary: ideas, settings, usage ledgers, and normal cockpit sta
233
234
  ## vNext Direction
234
235
 
235
236
  Wishcraft is Pi's animated operator layer. See the [release plan](docs/design/vnext-release-plan.md) and [design corpus](docs/index.md#design-system--vnext-specifications).
236
-
237
237
  ## Docs
238
238
 
239
239
  - [Commands](docs/commands.md)
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, live TPS in/out + open-ports + subagent-cost segments |
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 |
90
90
 
91
91
  **Environment:** `POWERLINE_NERD_FONTS=1` to force Nerd Fonts, `=0` for ASCII.
92
92
 
package/docs/segments.md CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  `preset: "chef"` is the GroepOnline fork's default look: muted colors (no rainbow), slash separators, and two extra right-side segments:
6
6
 
7
- - `tps`: live tokens/sec over a rolling 1-second window (EMA-free, no spikes), now reporting **output and input** rates separately (`⇡out ⇣in`) so you can see generation speed and incoming prompt tokens at a glance. A rocket/bolt icon lights up while streaming (override with env `POWERLINE_TPS`).
8
- - `open_ports`: count of unique **TCP** listening ports (`ss` → `netstat` → `/proc/net` fallback, dedupes IPv4/IPv6). Set `segmentOptions.openPorts.includeUdp: true` to include noisy UDP (mDNS/DHCP/ephemeral).
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
+ - `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
9
 
10
10
  ### Open-port process owners
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.4.17",
3
+ "version": "1.8.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": [
@@ -358,9 +358,19 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
358
358
  "shell_mode",
359
359
  "path",
360
360
  "git",
361
+ "session",
361
362
  "queue",
362
363
  ],
363
- rightSegments: ["tps", "open_ports", "subagents", "cost", "context_pct", "time"],
364
+ rightSegments: [
365
+ "tps",
366
+ "open_ports",
367
+ "token_out",
368
+ "cache_read",
369
+ "subagents",
370
+ "cost",
371
+ "context_pct",
372
+ "time",
373
+ ],
364
374
  separator: "slash",
365
375
  colors: CHEF_COLORS,
366
376
  segmentOptions: {
@@ -139,6 +139,9 @@ export function normalizeSegmentOptions(
139
139
  if (typeof raw.tps.windowMs === "number" && Number.isFinite(raw.tps.windowMs)) {
140
140
  tps.windowMs = Math.min(5000, Math.max(500, Math.floor(raw.tps.windowMs)));
141
141
  }
142
+ if (raw.tps.mode === "both" || raw.tps.mode === "out" || raw.tps.mode === "in") {
143
+ tps.mode = raw.tps.mode;
144
+ }
142
145
  options.tps = tps;
143
146
  }
144
147
 
@@ -325,6 +325,8 @@ export interface StatusLineSegmentOptions {
325
325
  tps?: SegmentFormatOption & {
326
326
  /** Sliding rate window length in ms (default 1000; wider = smoother, e.g. 2000 for fast models). */
327
327
  windowMs?: number;
328
+ /** Show both rates, generated tokens only, or prompt/input throughput only. */
329
+ mode?: "both" | "out" | "in";
328
330
  };
329
331
  }
330
332
 
@@ -168,16 +168,24 @@ export function createDeckComponent(
168
168
  return;
169
169
  }
170
170
 
171
- // ←/tab returns focus to the NAVIGATION column in one press; from there
172
- // ↑↓ moves the nav selection and any other key drops back into the list.
171
+ // ←/tab moves focus to the NAVIGATION column in one press; from there
172
+ // ↑↓ walks the routes and → (or any other key) drops back into the
173
+ // list. The focused pane is highlighted in the frame (◉/○ markers)
174
+ // so ↑↓ never appears to "go right" while the list has focus.
173
175
  if (
174
176
  !state.composerOpen &&
175
177
  !state.skillCreate &&
176
- (matchesKey(data, "left") || matchesKey(data, "tab"))
178
+ (matchesKey(data, "left") ||
179
+ matchesKey(data, "tab") ||
180
+ matchesKey(data, "shift+tab"))
177
181
  ) {
178
182
  state = { ...state, navMode: true };
179
183
  return;
180
184
  }
185
+ if (state.navMode && matchesKey(data, "right")) {
186
+ state = { ...state, navMode: false };
187
+ return;
188
+ }
181
189
  if (state.navMode) {
182
190
  if (matchesKey(data, "up") || matchesKey(data, "down")) {
183
191
  // fall through to the nav handlers at the bottom
@@ -313,12 +321,16 @@ export function createDeckComponent(
313
321
  length: number,
314
322
  ): boolean {
315
323
  if (length <= 0) return false;
324
+ // Clamp first: the catalog can shrink (filter/refresh) while the stored
325
+ // cursor still points past the end. Without this, ↑ from a stale cursor
326
+ // lands out of range and the highlight appears stuck.
327
+ const cursor = Math.min(Math.max(0, state[key]), length - 1);
316
328
  if (matchesKey(data, "up")) {
317
- state = { ...state, [key]: Math.max(0, state[key] - 1) };
329
+ state = { ...state, [key]: Math.max(0, cursor - 1) };
318
330
  return true;
319
331
  }
320
332
  if (matchesKey(data, "down")) {
321
- state = { ...state, [key]: Math.min(length - 1, state[key] + 1) };
333
+ state = { ...state, [key]: Math.min(length - 1, cursor + 1) };
322
334
  return true;
323
335
  }
324
336
  return false;
@@ -29,15 +29,18 @@ export function deckFooter(state: DeckNavState): string {
29
29
  if (state.searchOpen) return `/ ${state.searchQuery}_`;
30
30
  if (state.composerOpen) return "←→ nudge · ↑↓ field · enter apply · esc back";
31
31
  if (state.skillCreate) return "type a name · enter create · esc cancel";
32
+ // Nav column has focus: ↑↓ walks routes, → jumps back into the list.
33
+ if (state.navMode)
34
+ return "↑↓ route · → list · / Search · g h Home · Esc Close";
32
35
  switch (state.route) {
33
36
  case "appearance":
34
- return "↑↓ select base · enter apply · ←/tab nav · / Search · g h Home · Esc Close";
37
+ return "↑↓ select base · enter apply · ←/tab nav · → list · / Search · g h Home · Esc Close";
35
38
  case "motion":
36
- return "↑↓ motion · t event · e composer · enter apply · ←/tab nav · Esc Close";
39
+ return "↑↓ motion · t event · e composer · enter apply · ←/tab nav · → list · Esc Close";
37
40
  case "skills":
38
- return "↑↓ skill · enter insert · n new · / filter · ←/tab nav · Esc Close";
41
+ return "↑↓ skill · enter insert · n new · / filter · ←/tab nav · → list · Esc Close";
39
42
  case "ideas":
40
- return "↑↓ idea · / Search · ←/tab nav · g h Home · Esc Close";
43
+ return "↑↓ idea · / Search · ←/tab nav · → list · g h Home · Esc Close";
41
44
  default:
42
45
  return "/ Search g h Home g s Signal g i Ideas ? Help Esc Close";
43
46
  }
@@ -67,7 +70,7 @@ export function renderDeckFrame(
67
70
  lines.push(wrap(theme.fg("accent", theme.bold(truncateToWidth(header, inner, "…", true)))));
68
71
  lines.push(border(`├${"─".repeat(inner)}┤`));
69
72
 
70
- const nav = navLines(snapshot, state, theme, leftW);
73
+ const nav = navLines(snapshot, state, theme, leftW, state.navMode);
71
74
  const center = centerRouteBody(snapshot, state, theme, centerW, shortcuts, composer, tick);
72
75
  const right = rightRail(snapshot, theme, rightW);
73
76
 
@@ -91,8 +94,14 @@ function navLines(
91
94
  state: DeckNavState,
92
95
  theme: Theme,
93
96
  width: number,
97
+ focused: boolean,
94
98
  ): string[] {
95
- const lines: string[] = [theme.fg("accent", "NAVIGATION")];
99
+ // Focus marker: = ↑↓ walks this column, ○ = ↑↓ moves the center list.
100
+ // Without this the cursor appears to "go right" on ↓ while list-focused.
101
+ const header = focused
102
+ ? theme.fg("accent", theme.bold("◉ NAVIGATION"))
103
+ : theme.fg("muted", "○ NAVIGATION");
104
+ const lines: string[] = [header];
96
105
  for (const route of DECK_ROUTE_DEFS) {
97
106
  const active = route.id === state.route;
98
107
  const marker = active ? "◉" : "◇";
@@ -118,7 +127,17 @@ function centerRouteBody(
118
127
  composer: ComposerDraft | null = null,
119
128
  tick = Date.now(),
120
129
  ): string[] {
121
- const title = theme.fg("accent", `ACTIVE ROUTE: ${routeTitle(state.route).toUpperCase()}`);
130
+ const listFocused =
131
+ !state.navMode && !state.searchOpen && !state.composerOpen;
132
+ const title = listFocused
133
+ ? theme.fg(
134
+ "accent",
135
+ theme.bold(`◉ ACTIVE ROUTE: ${routeTitle(state.route).toUpperCase()}`),
136
+ )
137
+ : theme.fg(
138
+ "muted",
139
+ `○ ACTIVE ROUTE: ${routeTitle(state.route).toUpperCase()}`,
140
+ );
122
141
  const body: string[] = [title, ""];
123
142
  switch (state.route) {
124
143
  case "home":
@@ -1,17 +1,25 @@
1
1
  /**
2
- * Motion rail (v2). Renders the Signal activity rail the animated
3
- * sweep/compact rail plus activity label as a first-class layout segment.
4
- * Extracted from the legacy v1 Signal renderer (src/signal/render.ts,
5
- * deleted in the U12 cutover); rail semantics are unchanged: idle = flat
6
- * rail, streaming = travelling head + trail, compacting = inward heads.
2
+ * Compact Signal rail. It is deliberately a single terminal row: the rail
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.
7
5
  */
8
6
 
9
- import { defaultMotionFor, getMotion } from "../motion/catalog.ts";
10
- import { frameAt, framesOf, sweepPosition, trailGlyph } from "../motion/frames.ts";
7
+ import { getMotion } from "../motion/catalog.ts";
8
+ import { frameAt, sweepPosition, trailGlyph } from "../motion/frames.ts";
11
9
  import type { SignalRuntime } from "../signal/controller.ts";
12
10
  import type { SignalSpec } from "../config/types.ts";
13
11
  import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
14
- import { lanternSigil } from "./motion-candidates.ts";
12
+
13
+ function paint(text: string, color: string): string {
14
+ if (!color || !colorEnabled()) return text;
15
+ return `${color}${text}${ansi.reset}`;
16
+ }
17
+
18
+ /** Generators such as writing-reveal may contain several columns. A rail cell
19
+ * must always occupy exactly one column or its layout drifts under animation. */
20
+ function cellGlyph(value: string, fallback: string): string {
21
+ return Array.from(value)[0] ?? fallback;
22
+ }
15
23
 
16
24
  export function renderActivity(
17
25
  runtime: SignalRuntime,
@@ -20,134 +28,82 @@ export function renderActivity(
20
28
  width = 80,
21
29
  ): string {
22
30
  const label = runtime.activity || "ready";
23
- const open = spec.caps.leftOpen ?? "";
24
- const close = spec.caps.leftClose ?? "";
25
31
  const dim = getFgAnsiCode("sep");
26
32
  const hot = getFgAnsiCode("accent");
27
- const reset = colorEnabled() ? ansi.reset : "";
28
- // Adaptive rail width: ~20% of terminal, clamped [16, 40]. Wider
29
- // terminals get a longer sweep so the motion reads at a glance.
30
- const RAIL_WIDTH = Math.max(16, Math.min(40, Math.round(width * 0.2)));
33
+ const model = getFgAnsiCode("model");
34
+ const path = getFgAnsiCode("path");
31
35
  const track = ascii ? "-" : "─";
32
- const head = ascii ? "o" : "●";
33
- const railColor = runtime.active ? hot : dim;
36
+ // A footer rail is state indication, not a hero animation. Keep it readable
37
+ // on wide terminals and leave enough room for actual operational segments.
38
+ const railWidth = Math.max(12, Math.min(22, Math.round(width * 0.1)));
34
39
  const def = getMotion(runtime.motionId);
35
- // The head glyph is the chosen motion's own frame — so ember-relay
36
- // sweeps a ◇→◈→◆ sequence and hex-relay carries #-density, instead of
37
- // every motion wearing the same generic comet. The trail reuses the
38
- // motion's own past frames too, so each motion leaves its own wake.
39
- // ASCII terminals fall back to the clean box-drawing comet — motion
40
- // frames are a color-font feature.
40
+ const direction = def?.generator?.direction === "reverse" ? "reverse" : "forward";
41
+ const trailDepth = Math.max(2, Math.min(6, def?.generator?.trail ?? 4));
42
+ const headFallback = ascii ? "o" : "●";
41
43
  const headGlyph = (tick: number, distance: number) => {
42
- if (def && !ascii) return frameAt(def, Math.max(0, tick - distance), false);
43
- return distance === 0 ? head : trailGlyph(distance, ascii);
44
+ if (ascii) return distance === 0 ? headFallback : trailGlyph(distance, true);
45
+ return def ? cellGlyph(frameAt(def, tick - distance), headFallback) : headFallback;
44
46
  };
45
- const trailDepth = def?.generator?.trail ?? 4;
46
- // Per-cell color gradient: hot head fading to dim through the palette.
47
- // Distance 0 = accent, then model → path → sep so the wake cools off.
48
- const cellColor = (distance: number): string => {
49
- if (!colorEnabled()) return "";
50
- if (distance <= 0) return hot;
51
- if (distance <= 1) return getFgAnsiCode("model");
52
- if (distance <= 2) return getFgAnsiCode("path");
47
+ const cellColor = (distance: number) => {
48
+ if (distance === 0) return hot;
49
+ if (distance === 1) return model;
50
+ if (distance === 2) return path;
53
51
  return dim;
54
52
  };
55
- let railBlock: string;
53
+
54
+ let rail: string;
56
55
  if (!runtime.active) {
57
- // Idle: a frozen breathing wave of the ambient motion (wisp) frames.
58
- // No animation consumer at idle, but a sine-sampled wave reads as
59
- // "resting, not dead" calmer than a full sweep, warmer than a flat track.
60
- const ambient = getMotion(defaultMotionFor("idle"));
61
- const ambientFrames = ambient ? framesOf(ambient) : null;
62
- if (ambientFrames && colorEnabled()) {
63
- const phase = Date.now() % 4000 / 4000;
64
- const built: string[] = [];
65
- for (let i = 0; i < RAIL_WIDTH; i++) {
66
- const wave = Math.sin((i / RAIL_WIDTH) * Math.PI * 2 + phase * Math.PI * 2);
67
- const frameIdx = Math.floor(((wave + 1) / 2) * ambientFrames.length) % ambientFrames.length;
68
- const color = wave > 0.3 ? hot : dim;
69
- built.push(`${color}${ambientFrames[frameIdx]}${reset}`);
70
- }
71
- railBlock = built.join("");
72
- } else {
73
- railBlock = track.repeat(RAIL_WIDTH);
74
- }
75
- } else if (runtime.activity === "compacting") {
76
- railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii, headGlyph, cellColor);
56
+ // Do not derive idle state from Date.now(): there is intentionally no idle
57
+ // animation clock, so a clock-derived rail otherwise changes only when an
58
+ // unrelated repaint happens. The center marker makes ready glanceable.
59
+ const center = Math.floor(railWidth / 2);
60
+ rail = Array.from({ length: railWidth }, (_, index) =>
61
+ paint(index === center ? (ascii ? "." : "⋄") : track, dim),
62
+ ).join("");
63
+ } else if (runtime.event === "compact") {
64
+ rail = renderCompactRail(runtime.tick, railWidth, ascii, headGlyph, cellColor);
77
65
  } else {
78
- const pos = sweepPosition(runtime.tick, RAIL_WIDTH, true);
79
- const built: string[] = [];
80
- for (let i = 0; i < RAIL_WIDTH; i++) {
81
- if (i === pos) built.push(`${cellColor(0)}${headGlyph(runtime.tick, 0)}${reset}`);
82
- else if (i < pos) {
83
- const distance = pos - i;
84
- if (distance <= trailDepth) {
85
- built.push(`${cellColor(distance)}${headGlyph(runtime.tick, distance)}${reset}`);
86
- } else {
87
- built.push(track);
88
- }
89
- } else {
90
- built.push(track);
66
+ const pos = sweepPosition(runtime.tick, railWidth, true, direction);
67
+ rail = Array.from({ length: railWidth }, (_, index) => {
68
+ const distance = direction === "forward" ? pos - index : index - pos;
69
+ if (distance === 0) return paint(headGlyph(runtime.tick, 0), cellColor(0));
70
+ if (distance > 0 && distance <= trailDepth) {
71
+ return paint(headGlyph(runtime.tick, distance), cellColor(distance));
91
72
  }
92
- }
93
- railBlock = built.join("");
94
- }
95
- // Active state: 3-row lantern sigil ONLY for the streaming/working
96
- // activity. Each row is wrapped individually with the lane color
97
- // so rows 1+ don't inherit an empty ANSI state from row 0.
98
- if (runtime.active && !ascii && runtime.activity === "streaming") {
99
- const sigilRows = lanternSigil(runtime.tick, false);
100
- const [r0, ...rest] = sigilRows;
101
- const framed = `${spec.separators.left}${r0} ${label}${spec.separators.right}`;
102
- const allRows = [framed, ...rest];
103
- const colored = (row: string) => `${railColor}${row}${reset}`;
104
- // open brackets on the first row, close on the last row, color on
105
- // every row so the whole sigil reads as one amber block.
106
- const rows = allRows.map((row, i) => {
107
- const prefix = i === 0 ? open : "";
108
- const suffix = i === allRows.length - 1 ? close : "";
109
- return `${prefix}${colored(row)}${suffix}`;
110
- });
111
- railBlock = rows.join("\n");
112
- return railBlock;
73
+ return paint(track, dim);
74
+ }).join("");
113
75
  }
114
- const rail = `${spec.separators.left}${railBlock}${spec.separators.right}`;
115
- return `${railColor}${open}${rail}${close}${reset} ${label}`;
76
+
77
+ const edge = runtime.active ? hot : dim;
78
+ const left = `${spec.caps.leftOpen ?? ""}${spec.separators.left}`;
79
+ const right = `${spec.separators.right}${spec.caps.leftClose ?? ""}`;
80
+ return `${paint(left, edge)}${rail}${paint(right, edge)} ${paint(label, runtime.active ? hot : dim)}`;
116
81
  }
117
82
 
118
- /**
119
- * Compact rail: two heads travel inward toward center, compressing the rail.
120
- * Gives compaction a distinct visual signature vs the generic sweep.
121
- */
122
83
  function renderCompactRail(
123
84
  tick: number,
124
85
  width: number,
125
86
  ascii: boolean,
126
- headGlyph: (tick: number, i: number) => string,
87
+ headGlyph: (tick: number, distance: number) => string,
127
88
  cellColor: (distance: number) => string,
128
89
  ): string {
129
90
  const half = Math.floor(width / 2);
130
- // Heads oscillate from the edges toward center and back.
131
- const span = half;
132
- const phase = tick % (span * 2);
133
- const inward = phase < span ? phase : span * 2 - phase;
134
- const leftPos = inward;
135
- const rightPos = width - 1 - inward;
91
+ const phase = tick % (half * 2);
92
+ const inward = phase < half ? phase : half * 2 - phase;
93
+ const left = inward;
94
+ const right = width - 1 - inward;
136
95
  const core = ascii ? "=" : "━";
137
96
  const track = ascii ? "-" : "─";
138
- const reset = colorEnabled() ? ansi.reset : "";
139
- // Color the core by distance from the nearest head — hottest at the
140
- // heads, cooling toward center, so the compression reads thermally.
141
- let built = "";
142
- for (let i = 0; i < width; i++) {
143
- if (i === leftPos || i === rightPos) {
144
- built += `${cellColor(0)}${headGlyph(tick, 0)}${reset}`;
145
- } else if (i > leftPos && i < rightPos) {
146
- const nearestHead = Math.min(Math.abs(i - leftPos), Math.abs(i - rightPos));
147
- built += `${cellColor(nearestHead)}${core}${reset}`;
148
- } else {
149
- built += track;
97
+ const dim = getFgAnsiCode("sep");
98
+
99
+ return Array.from({ length: width }, (_, index) => {
100
+ if (index === left || index === right) {
101
+ return paint(headGlyph(tick, 0), cellColor(0));
150
102
  }
151
- }
152
- return built;
153
- }
103
+ if (index > left && index < right) {
104
+ const distance = Math.min(index - left, right - index);
105
+ return paint(core, cellColor(distance));
106
+ }
107
+ return paint(track, dim);
108
+ }).join("");
109
+ }
@@ -383,15 +383,21 @@ export const tpsSegment: StatusLineSegment = {
383
383
  windowMs,
384
384
  );
385
385
  const icons = getIcons();
386
+ const mode = ctx.options.tps?.mode ?? "both";
386
387
  const parts: string[] = [];
387
- if (outRate > 0) parts.push(`${icons.output}${formatTpsRate(outRate)}`);
388
- if (inRate > 0) parts.push(`${icons.input}${formatTpsRate(inRate)}`);
389
- const valueText = parts.length > 0 ? parts.join(" ") : "0";
390
- const active = outRate > 0 || inRate > 0;
388
+ if (mode !== "in" && outRate > 0) {
389
+ parts.push(`${icons.output}${formatTpsRate(outRate)}`);
390
+ }
391
+ if (mode !== "out" && inRate > 0) {
392
+ parts.push(`${icons.input}${formatTpsRate(inRate)}`);
393
+ }
394
+ // `0` looks like a measured rate and implies broken telemetry when the
395
+ // agent is simply idle. `--` means no live generation sample yet.
396
+ const valueText = parts.length > 0 ? parts.join(" ") : "--";
391
397
  return {
392
398
  content: withIcon(
393
399
  icons.tps,
394
- color(ctx, active ? "tokens" : "queue", valueText),
400
+ color(ctx, parts.length > 0 ? "tokens" : "queue", valueText),
395
401
  ),
396
402
  visible: true,
397
403
  };
@@ -415,7 +421,8 @@ export const openPortsSegment: StatusLineSegment = {
415
421
  entry = { at: now, count: countListeningPorts(includeUdp, host) };
416
422
  openPortsCache.set(key, entry);
417
423
  }
418
- const text = entry.count < 0 ? "?" : String(entry.count);
424
+ const protocol = includeUdp ? "tcp+udp" : "tcp";
425
+ const text = entry.count < 0 ? `? ${protocol}` : `${entry.count} ${protocol}`;
419
426
  return {
420
427
  content: withIcon(getIcons().ports, color(ctx, "queue", text)),
421
428
  visible: true,
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noUnusedLocals": false,
5
+ "noUnusedParameters": false
6
+ }
7
+ }