@hank-warren/pi-statusline 0.1.1 → 0.1.3
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/README.md +22 -2
- package/cache-celebration.ts +132 -0
- package/index.ts +49 -13
- package/package.json +3 -2
- package/redraw.ts +58 -17
package/README.md
CHANGED
|
@@ -10,12 +10,30 @@ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m
|
|
|
10
10
|
|
|
11
11
|
## What it shows
|
|
12
12
|
|
|
13
|
-
- **Line 1** — active model ID, current directory basename and Git branch, and current context usage/window. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate.
|
|
13
|
+
- **Line 1** — active model ID, current directory basename and Git branch, and current context usage/window. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
|
|
14
14
|
- **Worktree lines** — when the session works in or sends tool calls into linked worktrees, one line shows the same branch/dirty/behind state for each worktree plus its associated PR number.
|
|
15
15
|
- **Final line** — the full Pi session ID.
|
|
16
16
|
|
|
17
17
|
It uses a fixed true-color palette with context warning thresholds.
|
|
18
18
|
|
|
19
|
+
## Neon cache-wave celebration
|
|
20
|
+
|
|
21
|
+
Whenever one assistant response reaches a prompt-cache hit rate of at least 90%, a temporary module is appended after context usage for about two seconds:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡ 96% CACHE
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Only the `⚡ 96% CACHE` badge animates with a cyan → blue → magenta wave every 80 ms. The existing model, repository, context, separators, worktree, and session-ID rendering do not change.
|
|
28
|
+
|
|
29
|
+
The rate is evaluated per provider response as:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
cacheRead / (input + cacheRead + cacheWrite)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Output and reasoning tokens are excluded because they are not prompt-cache candidates. A zero-token denominator does not trigger the effect, exactly 90% does, and only the displayed percentage is rounded. Another qualifying response during the animation restarts it from frame zero with the new percentage. After expiry, the original statusline is restored exactly.
|
|
36
|
+
|
|
19
37
|
## Worktree/PR tracking behavior
|
|
20
38
|
|
|
21
39
|
- Only worktrees touched on the active Pi session branch are included.
|
|
@@ -30,7 +48,9 @@ PR lookups use the `gh` CLI when available and degrade gracefully without it.
|
|
|
30
48
|
|
|
31
49
|
Pi's fullscreen renderer only re-emits terminal rows whose rendered content changed. The worktree and session-ID lines are static for the life of a session, so if their cells ever desync from Pi's row cache — stale transcript text, a process sharing the tty, a stray escape sequence — nothing repaints them and the artifact persists.
|
|
32
50
|
|
|
33
|
-
To repair that
|
|
51
|
+
To repair that promptly without repeatedly clearing the screen, a one-second sweep changes an invisible marker on the fullscreen footer and requests a targeted differential render. Only the statusline rows compare as changed, so they repaint at most once per second even while the rest of Pi is actively rendering. A 30-second forced full redraw remains as a fallback for corruption outside the footer, throttled to at most one every five seconds and also requested at turn boundaries.
|
|
52
|
+
|
|
53
|
+
Both repair layers are skipped entirely in regular TUI mode, which reprints its whole block each frame and therefore self-heals. Only the slower forced fallback can drop a scrollback text selection highlight for a single frame.
|
|
34
54
|
|
|
35
55
|
## Install
|
|
36
56
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const CACHE_HIT_THRESHOLD = 0.9;
|
|
2
|
+
export const CACHE_CELEBRATION_FRAME_INTERVAL_MS = 80;
|
|
3
|
+
export const CACHE_CELEBRATION_DURATION_MS = 2_000;
|
|
4
|
+
|
|
5
|
+
export interface CacheUsage {
|
|
6
|
+
input: number;
|
|
7
|
+
cacheRead: number;
|
|
8
|
+
cacheWrite: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CacheCelebrationSnapshot {
|
|
12
|
+
percent: number;
|
|
13
|
+
frame: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CacheCelebrationTarget {
|
|
17
|
+
start(percent: number): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CacheCelebrationControllerOptions {
|
|
21
|
+
frameIntervalMs?: number;
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
25
|
+
cancel?: (handle: unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function defaultSchedule(callback: () => void, intervalMs: number): unknown {
|
|
29
|
+
const timer = setInterval(callback, intervalMs);
|
|
30
|
+
// A cosmetic celebration must never keep Pi alive.
|
|
31
|
+
(timer as { unref?: () => void }).unref?.();
|
|
32
|
+
return timer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultCancel(handle: unknown): void {
|
|
36
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Return the prompt-cache hit ratio for one response, or null without prompt tokens. */
|
|
40
|
+
export function cacheReadHitRate(usage: CacheUsage): number | null {
|
|
41
|
+
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
42
|
+
return promptTokens > 0 ? usage.cacheRead / promptTokens : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Return the rounded badge percentage only when a response qualifies for celebration. */
|
|
46
|
+
export function qualifyingCacheHitPercent(usage: CacheUsage): number | null {
|
|
47
|
+
const rate = cacheReadHitRate(usage);
|
|
48
|
+
return rate !== null && rate >= CACHE_HIT_THRESHOLD ? Math.round(rate * 100) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply the same narrowing used by the turn_end hook and start a celebration
|
|
53
|
+
* for a qualifying assistant response.
|
|
54
|
+
*/
|
|
55
|
+
export function triggerCacheCelebrationForMessage(
|
|
56
|
+
message: unknown,
|
|
57
|
+
target: CacheCelebrationTarget,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (!message || typeof message !== "object") return false;
|
|
60
|
+
const candidate = message as { role?: unknown; usage?: Partial<CacheUsage> };
|
|
61
|
+
if (candidate.role !== "assistant" || !candidate.usage) return false;
|
|
62
|
+
const { input, cacheRead, cacheWrite } = candidate.usage;
|
|
63
|
+
if (typeof input !== "number" || typeof cacheRead !== "number" || typeof cacheWrite !== "number") {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const percent = qualifyingCacheHitPercent({ input, cacheRead, cacheWrite });
|
|
67
|
+
if (percent === null) return false;
|
|
68
|
+
target.start(percent);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Owns the short-lived animation timer and exposes an immutable render snapshot. */
|
|
73
|
+
export class CacheCelebrationController implements CacheCelebrationTarget {
|
|
74
|
+
private current: CacheCelebrationSnapshot | undefined;
|
|
75
|
+
private startedAt = 0;
|
|
76
|
+
private timerHandle: unknown;
|
|
77
|
+
private readonly frameIntervalMs: number;
|
|
78
|
+
private readonly durationMs: number;
|
|
79
|
+
private readonly now: () => number;
|
|
80
|
+
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
81
|
+
private readonly cancel: (handle: unknown) => void;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly requestRender: () => void,
|
|
85
|
+
options: CacheCelebrationControllerOptions = {},
|
|
86
|
+
) {
|
|
87
|
+
this.frameIntervalMs = options.frameIntervalMs ?? CACHE_CELEBRATION_FRAME_INTERVAL_MS;
|
|
88
|
+
this.durationMs = options.durationMs ?? CACHE_CELEBRATION_DURATION_MS;
|
|
89
|
+
this.now = options.now ?? Date.now;
|
|
90
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
91
|
+
this.cancel = options.cancel ?? defaultCancel;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
start(percent: number): void {
|
|
95
|
+
this.clearTimer();
|
|
96
|
+
this.startedAt = this.now();
|
|
97
|
+
this.current = { percent, frame: 0 };
|
|
98
|
+
this.requestRender();
|
|
99
|
+
this.timerHandle = this.schedule(() => this.tick(), this.frameIntervalMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
snapshot(): CacheCelebrationSnapshot | undefined {
|
|
103
|
+
return this.current ? { ...this.current } : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stop without requesting a render; intended for footer/session teardown. */
|
|
107
|
+
dispose(): void {
|
|
108
|
+
this.clearTimer();
|
|
109
|
+
this.current = undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private tick(): void {
|
|
113
|
+
if (!this.current) return;
|
|
114
|
+
const elapsed = this.now() - this.startedAt;
|
|
115
|
+
if (elapsed >= this.durationMs) {
|
|
116
|
+
this.clearTimer();
|
|
117
|
+
this.current = undefined;
|
|
118
|
+
this.requestRender();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.current = {
|
|
122
|
+
...this.current,
|
|
123
|
+
frame: Math.floor(elapsed / this.frameIntervalMs),
|
|
124
|
+
};
|
|
125
|
+
this.requestRender();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private clearTimer(): void {
|
|
129
|
+
if (this.timerHandle !== undefined) this.cancel(this.timerHandle);
|
|
130
|
+
this.timerHandle = undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
CacheCelebrationController,
|
|
6
|
+
type CacheCelebrationSnapshot,
|
|
7
|
+
triggerCacheCelebrationForMessage,
|
|
8
|
+
} from "./cache-celebration.ts";
|
|
4
9
|
import { FullRedrawScheduler } from "./redraw.ts";
|
|
5
10
|
import {
|
|
6
11
|
type GitRepositoryStatus,
|
|
@@ -18,6 +23,7 @@ export interface StatuslineData {
|
|
|
18
23
|
contextWindow: number;
|
|
19
24
|
worktrees: SessionWorktree[];
|
|
20
25
|
sessionId: string;
|
|
26
|
+
cacheCelebration?: CacheCelebrationSnapshot;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const BLUE = "\x1b[38;2;0;153;255m";
|
|
@@ -28,8 +34,13 @@ const RED = "\x1b[38;2;255;85;85m";
|
|
|
28
34
|
const YELLOW = "\x1b[38;2;230;200;0m";
|
|
29
35
|
const WHITE = "\x1b[38;2;220;220;220m";
|
|
30
36
|
const MAGENTA = "\x1b[38;2;190;120;255m";
|
|
37
|
+
const NEON_CYAN = "\x1b[38;2;0;255;255m";
|
|
38
|
+
const NEON_BLUE = "\x1b[38;2;0;125;255m";
|
|
39
|
+
const NEON_MAGENTA = "\x1b[38;2;255;0;255m";
|
|
31
40
|
const DIM = "\x1b[2m";
|
|
41
|
+
const BOLD = "\x1b[1m";
|
|
32
42
|
const RESET = "\x1b[0m";
|
|
43
|
+
const NEON_WAVE = [NEON_CYAN, NEON_CYAN, NEON_BLUE, NEON_BLUE, NEON_MAGENTA, NEON_MAGENTA] as const;
|
|
33
44
|
|
|
34
45
|
function styled(style: string, text: string): string {
|
|
35
46
|
return `${style}${text}${RESET}`;
|
|
@@ -62,6 +73,20 @@ function renderRepository(name: string, status: GitRepositoryStatus, nameColor =
|
|
|
62
73
|
return part;
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
export function renderCacheCelebrationLine(
|
|
77
|
+
summary: string,
|
|
78
|
+
celebration: CacheCelebrationSnapshot,
|
|
79
|
+
): string {
|
|
80
|
+
const badge = `⚡ ${celebration.percent}% CACHE`;
|
|
81
|
+
const animatedBadge = Array.from(badge)
|
|
82
|
+
.map((character, index) => {
|
|
83
|
+
const color = NEON_WAVE[(index + celebration.frame) % NEON_WAVE.length];
|
|
84
|
+
return `${BOLD}${color}${character}${RESET}`;
|
|
85
|
+
})
|
|
86
|
+
.join("");
|
|
87
|
+
return `${summary}${styled(DIM, " | ")}${animatedBadge}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
function renderWorktreeLine(worktrees: SessionWorktree[]): string {
|
|
66
91
|
const separator = styled(DIM, " | ");
|
|
67
92
|
const parts = worktrees.map((worktree) => {
|
|
@@ -88,8 +113,11 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
|
|
|
88
113
|
const context = `${used}${styled(DIM, "/")}${styled(WHITE, formatTokenCount(data.contextWindow))}`;
|
|
89
114
|
const cwd = data.cwdGit ? renderRepository(data.cwd, data.cwdGit) : styled(CYAN, data.cwd);
|
|
90
115
|
const summary = styled(BLUE, data.model) + separator + cwd + separator + context;
|
|
116
|
+
const firstLine = data.cacheCelebration
|
|
117
|
+
? renderCacheCelebrationLine(summary, data.cacheCelebration)
|
|
118
|
+
: summary;
|
|
91
119
|
|
|
92
|
-
const lines = [truncateToWidth(
|
|
120
|
+
const lines = [truncateToWidth(firstLine, width)];
|
|
93
121
|
if (data.worktrees.length > 0) lines.push(truncateToWidth(renderWorktreeLine(data.worktrees), width, "…"));
|
|
94
122
|
lines.push(truncateToWidth(styled(DIM, data.sessionId), width));
|
|
95
123
|
return lines;
|
|
@@ -98,6 +126,7 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
|
|
|
98
126
|
export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
99
127
|
let requestRender: (() => void) | undefined;
|
|
100
128
|
const fullRedraw = new FullRedrawScheduler();
|
|
129
|
+
const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
|
|
101
130
|
let tracker: SessionWorktreeTracker | undefined;
|
|
102
131
|
let cwdGit: GitRepositoryStatus | null = null;
|
|
103
132
|
let cwdStatusAbort: AbortController | undefined;
|
|
@@ -154,6 +183,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
154
183
|
};
|
|
155
184
|
|
|
156
185
|
pi.on("session_start", (_event, ctx) => {
|
|
186
|
+
cacheCelebration.dispose();
|
|
157
187
|
if (ctx.mode !== "tui") return;
|
|
158
188
|
|
|
159
189
|
ctx.ui.setFooter((tui, _theme, footerData) => {
|
|
@@ -169,6 +199,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
169
199
|
return {
|
|
170
200
|
dispose(): void {
|
|
171
201
|
stopBranchUpdates();
|
|
202
|
+
cacheCelebration.dispose();
|
|
172
203
|
fullRedraw.detach();
|
|
173
204
|
requestRender = undefined;
|
|
174
205
|
},
|
|
@@ -178,17 +209,20 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
178
209
|
const cwd = basename(ctx.cwd) || ctx.cwd;
|
|
179
210
|
const model = ctx.model?.id.split("/").pop() || "no-model";
|
|
180
211
|
|
|
181
|
-
return
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
212
|
+
return fullRedraw.decorate(
|
|
213
|
+
renderStatusline(
|
|
214
|
+
{
|
|
215
|
+
model,
|
|
216
|
+
cwd,
|
|
217
|
+
cwdGit,
|
|
218
|
+
contextTokens: usage?.tokens ?? null,
|
|
219
|
+
contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
220
|
+
worktrees: tracker?.getWorktrees() ?? [],
|
|
221
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
222
|
+
cacheCelebration: cacheCelebration.snapshot(),
|
|
223
|
+
},
|
|
224
|
+
width,
|
|
225
|
+
),
|
|
192
226
|
);
|
|
193
227
|
},
|
|
194
228
|
};
|
|
@@ -200,7 +234,8 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
200
234
|
pi.on("tool_call", (event) => {
|
|
201
235
|
if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
|
|
202
236
|
});
|
|
203
|
-
pi.on("turn_end", (
|
|
237
|
+
pi.on("turn_end", (event, ctx) => {
|
|
238
|
+
if (requestRender) triggerCacheCelebrationForMessage(event.message, cacheCelebration);
|
|
204
239
|
fullRedraw.request();
|
|
205
240
|
requestRender?.();
|
|
206
241
|
runInBackground(refreshCwdStatus(ctx));
|
|
@@ -209,6 +244,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
209
244
|
pi.on("model_select", () => requestRender?.());
|
|
210
245
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
|
211
246
|
pi.on("session_shutdown", () => {
|
|
247
|
+
cacheCelebration.dispose();
|
|
212
248
|
fullRedraw.detach();
|
|
213
249
|
tracker?.dispose();
|
|
214
250
|
tracker = undefined;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Compact Pi footer statusline
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"index.ts",
|
|
35
|
+
"cache-celebration.ts",
|
|
35
36
|
"redraw.ts",
|
|
36
37
|
"worktrees.ts",
|
|
37
38
|
"README.md",
|
package/redraw.ts
CHANGED
|
@@ -3,24 +3,30 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Pi's fullscreen (alt-screen) renderer writes rows differentially: a row whose
|
|
5
5
|
* rendered content is byte-identical to the previous frame is never re-emitted.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* session id never changes at all. Once those terminal cells desync from Pi's
|
|
9
|
-
* row cache (stale transcript text, a process sharing the tty, a stray escape
|
|
10
|
-
* sequence), nothing repaints them and the garbage persists for the whole
|
|
11
|
-
* session.
|
|
6
|
+
* Once terminal cells desync from Pi's row cache, static statusline rows can
|
|
7
|
+
* therefore retain stale text indefinitely.
|
|
12
8
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* This scheduler repairs the footer in two layers:
|
|
10
|
+
*
|
|
11
|
+
* 1. A short timer alternates between two visually equivalent ANSI reset
|
|
12
|
+
* prefixes and requests a normal render. Only the statusline rows compare as
|
|
13
|
+
* changed, even when Pi is otherwise idle.
|
|
14
|
+
* 2. `request()` keeps the slower forced full redraw fallback for corruption
|
|
15
|
+
* outside the footer.
|
|
16
|
+
*
|
|
17
|
+
* Both layers are fullscreen-only. Regular mode already reprints its block and
|
|
18
|
+
* does not need a periodic render.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
/** Minimum spacing between forced full redraws. */
|
|
20
22
|
export const DEFAULT_MIN_GAP_MS = 5_000;
|
|
21
|
-
/** Idle cadence
|
|
23
|
+
/** Idle cadence for targeted statusline-row repainting. */
|
|
24
|
+
export const DEFAULT_ROW_REFRESH_INTERVAL_MS = 1_000;
|
|
25
|
+
/** Slow fallback cadence for corruption outside the footer. */
|
|
22
26
|
export const DEFAULT_IDLE_INTERVAL_MS = 30_000;
|
|
23
27
|
|
|
28
|
+
const ANSI_RESET = "\x1b[0m";
|
|
29
|
+
|
|
24
30
|
/** The subset of Pi's TUI surface this scheduler needs. */
|
|
25
31
|
export interface RedrawTarget {
|
|
26
32
|
readonly mode: string;
|
|
@@ -29,6 +35,7 @@ export interface RedrawTarget {
|
|
|
29
35
|
|
|
30
36
|
export interface FullRedrawSchedulerOptions {
|
|
31
37
|
minGapMs?: number;
|
|
38
|
+
rowRefreshIntervalMs?: number;
|
|
32
39
|
idleIntervalMs?: number;
|
|
33
40
|
now?: () => number;
|
|
34
41
|
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
@@ -48,9 +55,12 @@ function defaultCancel(handle: unknown): void {
|
|
|
48
55
|
|
|
49
56
|
export class FullRedrawScheduler {
|
|
50
57
|
private target: RedrawTarget | undefined;
|
|
51
|
-
private
|
|
58
|
+
private rowRefreshHandle: unknown;
|
|
59
|
+
private fullRedrawHandle: unknown;
|
|
52
60
|
private lastRedrawAt = Number.NEGATIVE_INFINITY;
|
|
61
|
+
private decorationPhase = 0;
|
|
53
62
|
private readonly minGapMs: number;
|
|
63
|
+
private readonly rowRefreshIntervalMs: number;
|
|
54
64
|
private readonly idleIntervalMs: number;
|
|
55
65
|
private readonly now: () => number;
|
|
56
66
|
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
@@ -58,34 +68,65 @@ export class FullRedrawScheduler {
|
|
|
58
68
|
|
|
59
69
|
constructor(options: FullRedrawSchedulerOptions = {}) {
|
|
60
70
|
this.minGapMs = options.minGapMs ?? DEFAULT_MIN_GAP_MS;
|
|
71
|
+
this.rowRefreshIntervalMs = options.rowRefreshIntervalMs ?? DEFAULT_ROW_REFRESH_INTERVAL_MS;
|
|
61
72
|
this.idleIntervalMs = options.idleIntervalMs ?? DEFAULT_IDLE_INTERVAL_MS;
|
|
62
73
|
this.now = options.now ?? Date.now;
|
|
63
74
|
this.schedule = options.schedule ?? defaultSchedule;
|
|
64
75
|
this.cancel = options.cancel ?? defaultCancel;
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
/** Bind to the TUI that owns the footer and start
|
|
78
|
+
/** Bind to the TUI that owns the footer and start both repair sweeps. */
|
|
68
79
|
attach(target: RedrawTarget): void {
|
|
69
80
|
this.detach();
|
|
70
81
|
this.target = target;
|
|
71
|
-
this.
|
|
82
|
+
this.lastRedrawAt = Number.NEGATIVE_INFINITY;
|
|
83
|
+
this.decorationPhase = 0;
|
|
84
|
+
this.rowRefreshHandle = this.schedule(() => {
|
|
85
|
+
this.requestRowRefresh();
|
|
86
|
+
}, this.rowRefreshIntervalMs);
|
|
87
|
+
this.fullRedrawHandle = this.schedule(() => {
|
|
72
88
|
this.request();
|
|
73
89
|
}, this.idleIntervalMs);
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
detach(): void {
|
|
77
|
-
if (this.
|
|
78
|
-
this.
|
|
93
|
+
if (this.rowRefreshHandle !== undefined) this.cancel(this.rowRefreshHandle);
|
|
94
|
+
if (this.fullRedrawHandle !== undefined) this.cancel(this.fullRedrawHandle);
|
|
95
|
+
this.rowRefreshHandle = undefined;
|
|
96
|
+
this.fullRedrawHandle = undefined;
|
|
79
97
|
this.target = undefined;
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Make footer rows byte-different without changing their visible contents.
|
|
102
|
+
* Pi will then clear and repaint those rows during an ordinary differential
|
|
103
|
+
* render instead of requiring a full-screen clear.
|
|
104
|
+
*/
|
|
105
|
+
decorate(lines: string[]): string[] {
|
|
106
|
+
if (this.target?.mode !== "fullscreen") return lines;
|
|
107
|
+
const prefix = this.decorationPhase === 0 ? ANSI_RESET : `${ANSI_RESET}${ANSI_RESET}`;
|
|
108
|
+
return lines.map((line) => `${prefix}${line}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Advance the invisible footer marker and request an ordinary render. The
|
|
113
|
+
* timer calls this at most once per interval, so active Pi frames do not
|
|
114
|
+
* repeatedly repaint otherwise-static statusline rows.
|
|
115
|
+
*/
|
|
116
|
+
requestRowRefresh(): boolean {
|
|
117
|
+
const target = this.target;
|
|
118
|
+
if (!target || target.mode !== "fullscreen") return false;
|
|
119
|
+
this.decorationPhase = (this.decorationPhase + 1) % 2;
|
|
120
|
+
target.requestRender();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
82
124
|
/**
|
|
83
125
|
* Ask for a forced full redraw. No-ops outside fullscreen mode and while
|
|
84
126
|
* throttled. Returns whether a redraw was actually requested.
|
|
85
127
|
*/
|
|
86
128
|
request(): boolean {
|
|
87
129
|
const target = this.target;
|
|
88
|
-
// Regular mode reprints its whole block every frame, so it self-heals.
|
|
89
130
|
if (!target || target.mode !== "fullscreen") return false;
|
|
90
131
|
const now = this.now();
|
|
91
132
|
if (now - this.lastRedrawAt < this.minGapMs) return false;
|