@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.
Files changed (4) hide show
  1. package/README.md +8 -0
  2. package/index.ts +21 -11
  3. package/package.json +2 -1
  4. 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 renderStatusline(
176
- {
177
- model,
178
- cwd,
179
- cwdGit,
180
- contextTokens: usage?.tokens ?? null,
181
- contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
182
- worktrees: tracker?.getWorktrees() ?? [],
183
- sessionId: ctx.sessionManager.getSessionId(),
184
- },
185
- width,
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.0",
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
+ }