@hank-warren/pi-statusline 0.1.0 → 0.1.1
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 +6 -0
- package/index.ts +8 -0
- package/package.json +2 -1
- package/redraw.ts +96 -0
package/README.md
CHANGED
|
@@ -26,6 +26,12 @@ It uses a fixed true-color palette with context warning thresholds.
|
|
|
26
26
|
|
|
27
27
|
PR lookups use the `gh` CLI when available and degrade gracefully without it.
|
|
28
28
|
|
|
29
|
+
## Fullscreen TUI mode
|
|
30
|
+
|
|
31
|
+
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
|
+
|
|
33
|
+
To repair that, the extension asks Pi for a forced full redraw at turn boundaries and on a 30-second idle sweep, throttled to at most one every five seconds. Both are skipped entirely in regular TUI mode, which reprints its whole block each frame and therefore self-heals. A forced redraw drops a scrollback text selection highlight for a single frame.
|
|
34
|
+
|
|
29
35
|
## Install
|
|
30
36
|
|
|
31
37
|
```bash
|
package/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { FullRedrawScheduler } from "./redraw.ts";
|
|
4
5
|
import {
|
|
5
6
|
type GitRepositoryStatus,
|
|
6
7
|
readGitStatus,
|
|
@@ -96,6 +97,7 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
|
|
|
96
97
|
|
|
97
98
|
export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
98
99
|
let requestRender: (() => void) | undefined;
|
|
100
|
+
const fullRedraw = new FullRedrawScheduler();
|
|
99
101
|
let tracker: SessionWorktreeTracker | undefined;
|
|
100
102
|
let cwdGit: GitRepositoryStatus | null = null;
|
|
101
103
|
let cwdStatusAbort: AbortController | undefined;
|
|
@@ -156,6 +158,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
156
158
|
|
|
157
159
|
ctx.ui.setFooter((tui, _theme, footerData) => {
|
|
158
160
|
requestRender = () => tui.requestRender();
|
|
161
|
+
// Fullscreen mode never repaints unchanged rows; the session id line is
|
|
162
|
+
// static, so it needs periodic forced redraws to shed stale cells.
|
|
163
|
+
fullRedraw.attach(tui);
|
|
159
164
|
const stopBranchUpdates = footerData.onBranchChange(() => {
|
|
160
165
|
runInBackground(refreshCwdStatus(ctx));
|
|
161
166
|
tui.requestRender();
|
|
@@ -164,6 +169,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
164
169
|
return {
|
|
165
170
|
dispose(): void {
|
|
166
171
|
stopBranchUpdates();
|
|
172
|
+
fullRedraw.detach();
|
|
167
173
|
requestRender = undefined;
|
|
168
174
|
},
|
|
169
175
|
invalidate(): void {},
|
|
@@ -195,6 +201,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
195
201
|
if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
|
|
196
202
|
});
|
|
197
203
|
pi.on("turn_end", (_event, ctx) => {
|
|
204
|
+
fullRedraw.request();
|
|
198
205
|
requestRender?.();
|
|
199
206
|
runInBackground(refreshCwdStatus(ctx));
|
|
200
207
|
if (tracker) runInBackground(tracker.refresh());
|
|
@@ -202,6 +209,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
202
209
|
pi.on("model_select", () => requestRender?.());
|
|
203
210
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
|
204
211
|
pi.on("session_shutdown", () => {
|
|
212
|
+
fullRedraw.detach();
|
|
205
213
|
tracker?.dispose();
|
|
206
214
|
tracker = undefined;
|
|
207
215
|
cwdStatusAbort?.abort();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Compact Pi footer statusline: model ID, git branch/dirty/behind state, linked worktrees with PR numbers, context usage, and session ID.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"index.ts",
|
|
35
|
+
"redraw.ts",
|
|
35
36
|
"worktrees.ts",
|
|
36
37
|
"README.md",
|
|
37
38
|
"LICENSE"
|
package/redraw.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fullscreen TUI artifact repair.
|
|
3
|
+
*
|
|
4
|
+
* Pi's fullscreen (alt-screen) renderer writes rows differentially: a row whose
|
|
5
|
+
* rendered content is byte-identical to the previous frame is never re-emitted.
|
|
6
|
+
* That is normally invisible, but this statusline is the only thing on screen
|
|
7
|
+
* with permanently static rows — the worktree line barely changes and the
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* `requestRender(true)` resets the renderer's row cache and repaints every row,
|
|
14
|
+
* which repairs the damage. It is heavier than a normal frame, so this
|
|
15
|
+
* scheduler keeps forced redraws rare: fullscreen only, throttled to a minimum
|
|
16
|
+
* gap, driven by turn boundaries plus a slow idle sweep.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Minimum spacing between forced full redraws. */
|
|
20
|
+
export const DEFAULT_MIN_GAP_MS = 5_000;
|
|
21
|
+
/** Idle cadence so artifacts also heal without any agent activity. */
|
|
22
|
+
export const DEFAULT_IDLE_INTERVAL_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
/** The subset of Pi's TUI surface this scheduler needs. */
|
|
25
|
+
export interface RedrawTarget {
|
|
26
|
+
readonly mode: string;
|
|
27
|
+
requestRender(force?: boolean): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FullRedrawSchedulerOptions {
|
|
31
|
+
minGapMs?: number;
|
|
32
|
+
idleIntervalMs?: number;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
35
|
+
cancel?: (handle: unknown) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultSchedule(callback: () => void, intervalMs: number): unknown {
|
|
39
|
+
const timer = setInterval(callback, intervalMs);
|
|
40
|
+
// Never hold the process open just to repair cosmetic artifacts.
|
|
41
|
+
(timer as { unref?: () => void }).unref?.();
|
|
42
|
+
return timer;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultCancel(handle: unknown): void {
|
|
46
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FullRedrawScheduler {
|
|
50
|
+
private target: RedrawTarget | undefined;
|
|
51
|
+
private handle: unknown;
|
|
52
|
+
private lastRedrawAt = Number.NEGATIVE_INFINITY;
|
|
53
|
+
private readonly minGapMs: number;
|
|
54
|
+
private readonly idleIntervalMs: number;
|
|
55
|
+
private readonly now: () => number;
|
|
56
|
+
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
57
|
+
private readonly cancel: (handle: unknown) => void;
|
|
58
|
+
|
|
59
|
+
constructor(options: FullRedrawSchedulerOptions = {}) {
|
|
60
|
+
this.minGapMs = options.minGapMs ?? DEFAULT_MIN_GAP_MS;
|
|
61
|
+
this.idleIntervalMs = options.idleIntervalMs ?? DEFAULT_IDLE_INTERVAL_MS;
|
|
62
|
+
this.now = options.now ?? Date.now;
|
|
63
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
64
|
+
this.cancel = options.cancel ?? defaultCancel;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bind to the TUI that owns the footer and start the idle sweep. */
|
|
68
|
+
attach(target: RedrawTarget): void {
|
|
69
|
+
this.detach();
|
|
70
|
+
this.target = target;
|
|
71
|
+
this.handle = this.schedule(() => {
|
|
72
|
+
this.request();
|
|
73
|
+
}, this.idleIntervalMs);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
detach(): void {
|
|
77
|
+
if (this.handle !== undefined) this.cancel(this.handle);
|
|
78
|
+
this.handle = undefined;
|
|
79
|
+
this.target = undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ask for a forced full redraw. No-ops outside fullscreen mode and while
|
|
84
|
+
* throttled. Returns whether a redraw was actually requested.
|
|
85
|
+
*/
|
|
86
|
+
request(): boolean {
|
|
87
|
+
const target = this.target;
|
|
88
|
+
// Regular mode reprints its whole block every frame, so it self-heals.
|
|
89
|
+
if (!target || target.mode !== "fullscreen") return false;
|
|
90
|
+
const now = this.now();
|
|
91
|
+
if (now - this.lastRedrawAt < this.minGapMs) return false;
|
|
92
|
+
this.lastRedrawAt = now;
|
|
93
|
+
target.requestRender(true);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|