@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 +12 -0
- package/README.md +31 -31
- package/docs/commands.md +1 -1
- package/docs/segments.md +2 -2
- package/package.json +1 -1
- package/src/config/presets.ts +11 -1
- package/src/config/segment-options.ts +3 -0
- package/src/config/types.ts +2 -0
- package/src/extension/ui/deck/component.ts +17 -5
- package/src/extension/ui/deck/render.ts +26 -7
- package/src/render/motion-rail.ts +71 -115
- package/src/segments/system.ts +13 -6
- package/tsconfig.test.json +7 -0
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="
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:@groeponline/pi-wishcraft
|
|
22
|
+
```
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
Reload Pi, then press `alt+p` to open the Deck.
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
Try the two flows that make Wishcraft click fastest:
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
```text
|
|
29
|
+
# remember to benchmark the new provider path
|
|
30
|
+
/ideas
|
|
31
|
+
```
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
The `#` line is captured as an idea instead of being sent as a prompt. Your active run keeps going.
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
Then open the skill picker:
|
|
29
36
|
|
|
30
|
-
```
|
|
31
|
-
|
|
37
|
+
```text
|
|
38
|
+
/skills
|
|
32
39
|
```
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
Search, inspect and insert a skill without leaving the session.
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
8
|
-
- `open_ports`: count of unique **TCP** listening ports (`ss` → `netstat` → `/proc/net` fallback, dedupes IPv4/IPv6)
|
|
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.
|
|
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": [
|
package/src/config/presets.ts
CHANGED
|
@@ -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: [
|
|
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
|
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
|
172
|
-
// ↑↓
|
|
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") ||
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
10
|
-
import { frameAt,
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 (
|
|
43
|
-
return
|
|
44
|
+
if (ascii) return distance === 0 ? headFallback : trailGlyph(distance, true);
|
|
45
|
+
return def ? cellGlyph(frameAt(def, tick - distance), headFallback) : headFallback;
|
|
44
46
|
};
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
+
|
|
54
|
+
let rail: string;
|
|
56
55
|
if (!runtime.active) {
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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,
|
|
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
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/segments/system.ts
CHANGED
|
@@ -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)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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,
|
|
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
|
|
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,
|