@hank-warren/pi-statusline 0.1.0 → 0.1.2
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 +8 -0
- package/index.ts +21 -11
- package/package.json +2 -1
- package/redraw.ts +137 -0
package/README.md
CHANGED
|
@@ -26,6 +26,14 @@ 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 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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
29
37
|
## Install
|
|
30
38
|
|
|
31
39
|
```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 {},
|
|
@@ -172,17 +178,19 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
172
178
|
const cwd = basename(ctx.cwd) || ctx.cwd;
|
|
173
179
|
const model = ctx.model?.id.split("/").pop() || "no-model";
|
|
174
180
|
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
return fullRedraw.decorate(
|
|
182
|
+
renderStatusline(
|
|
183
|
+
{
|
|
184
|
+
model,
|
|
185
|
+
cwd,
|
|
186
|
+
cwdGit,
|
|
187
|
+
contextTokens: usage?.tokens ?? null,
|
|
188
|
+
contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
189
|
+
worktrees: tracker?.getWorktrees() ?? [],
|
|
190
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
191
|
+
},
|
|
192
|
+
width,
|
|
193
|
+
),
|
|
186
194
|
);
|
|
187
195
|
},
|
|
188
196
|
};
|
|
@@ -195,6 +203,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
195
203
|
if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
|
|
196
204
|
});
|
|
197
205
|
pi.on("turn_end", (_event, ctx) => {
|
|
206
|
+
fullRedraw.request();
|
|
198
207
|
requestRender?.();
|
|
199
208
|
runInBackground(refreshCwdStatus(ctx));
|
|
200
209
|
if (tracker) runInBackground(tracker.refresh());
|
|
@@ -202,6 +211,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
202
211
|
pi.on("model_select", () => requestRender?.());
|
|
203
212
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
|
204
213
|
pi.on("session_shutdown", () => {
|
|
214
|
+
fullRedraw.detach();
|
|
205
215
|
tracker?.dispose();
|
|
206
216
|
tracker = undefined;
|
|
207
217
|
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.2",
|
|
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,137 @@
|
|
|
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
|
+
* Once terminal cells desync from Pi's row cache, static statusline rows can
|
|
7
|
+
* therefore retain stale text indefinitely.
|
|
8
|
+
*
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Minimum spacing between forced full redraws. */
|
|
22
|
+
export const DEFAULT_MIN_GAP_MS = 5_000;
|
|
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. */
|
|
26
|
+
export const DEFAULT_IDLE_INTERVAL_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
const ANSI_RESET = "\x1b[0m";
|
|
29
|
+
|
|
30
|
+
/** The subset of Pi's TUI surface this scheduler needs. */
|
|
31
|
+
export interface RedrawTarget {
|
|
32
|
+
readonly mode: string;
|
|
33
|
+
requestRender(force?: boolean): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FullRedrawSchedulerOptions {
|
|
37
|
+
minGapMs?: number;
|
|
38
|
+
rowRefreshIntervalMs?: number;
|
|
39
|
+
idleIntervalMs?: number;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
42
|
+
cancel?: (handle: unknown) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultSchedule(callback: () => void, intervalMs: number): unknown {
|
|
46
|
+
const timer = setInterval(callback, intervalMs);
|
|
47
|
+
// Never hold the process open just to repair cosmetic artifacts.
|
|
48
|
+
(timer as { unref?: () => void }).unref?.();
|
|
49
|
+
return timer;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultCancel(handle: unknown): void {
|
|
53
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class FullRedrawScheduler {
|
|
57
|
+
private target: RedrawTarget | undefined;
|
|
58
|
+
private rowRefreshHandle: unknown;
|
|
59
|
+
private fullRedrawHandle: unknown;
|
|
60
|
+
private lastRedrawAt = Number.NEGATIVE_INFINITY;
|
|
61
|
+
private decorationPhase = 0;
|
|
62
|
+
private readonly minGapMs: number;
|
|
63
|
+
private readonly rowRefreshIntervalMs: number;
|
|
64
|
+
private readonly idleIntervalMs: number;
|
|
65
|
+
private readonly now: () => number;
|
|
66
|
+
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
67
|
+
private readonly cancel: (handle: unknown) => void;
|
|
68
|
+
|
|
69
|
+
constructor(options: FullRedrawSchedulerOptions = {}) {
|
|
70
|
+
this.minGapMs = options.minGapMs ?? DEFAULT_MIN_GAP_MS;
|
|
71
|
+
this.rowRefreshIntervalMs = options.rowRefreshIntervalMs ?? DEFAULT_ROW_REFRESH_INTERVAL_MS;
|
|
72
|
+
this.idleIntervalMs = options.idleIntervalMs ?? DEFAULT_IDLE_INTERVAL_MS;
|
|
73
|
+
this.now = options.now ?? Date.now;
|
|
74
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
75
|
+
this.cancel = options.cancel ?? defaultCancel;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Bind to the TUI that owns the footer and start both repair sweeps. */
|
|
79
|
+
attach(target: RedrawTarget): void {
|
|
80
|
+
this.detach();
|
|
81
|
+
this.target = target;
|
|
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(() => {
|
|
88
|
+
this.request();
|
|
89
|
+
}, this.idleIntervalMs);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
detach(): void {
|
|
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;
|
|
97
|
+
this.target = undefined;
|
|
98
|
+
}
|
|
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
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ask for a forced full redraw. No-ops outside fullscreen mode and while
|
|
126
|
+
* throttled. Returns whether a redraw was actually requested.
|
|
127
|
+
*/
|
|
128
|
+
request(): boolean {
|
|
129
|
+
const target = this.target;
|
|
130
|
+
if (!target || target.mode !== "fullscreen") return false;
|
|
131
|
+
const now = this.now();
|
|
132
|
+
if (now - this.lastRedrawAt < this.minGapMs) return false;
|
|
133
|
+
this.lastRedrawAt = now;
|
|
134
|
+
target.requestRender(true);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|