@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 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.0",
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
+ }