@hank-warren/pi-statusline 0.1.1 → 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 +3 -1
  2. package/index.ts +13 -11
  3. package/package.json +1 -1
  4. package/redraw.ts +58 -17
package/README.md CHANGED
@@ -30,7 +30,9 @@ PR lookups use the `gh` CLI when available and degrade gracefully without it.
30
30
 
31
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
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.
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.
34
36
 
35
37
  ## Install
36
38
 
package/index.ts CHANGED
@@ -178,17 +178,19 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
178
178
  const cwd = basename(ctx.cwd) || ctx.cwd;
179
179
  const model = ctx.model?.id.split("/").pop() || "no-model";
180
180
 
181
- return renderStatusline(
182
- {
183
- model,
184
- cwd,
185
- cwdGit,
186
- contextTokens: usage?.tokens ?? null,
187
- contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
188
- worktrees: tracker?.getWorktrees() ?? [],
189
- sessionId: ctx.sessionManager.getSessionId(),
190
- },
191
- 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
+ ),
192
194
  );
193
195
  },
194
196
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.1.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": [
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
- * 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.
6
+ * Once terminal cells desync from Pi's row cache, static statusline rows can
7
+ * therefore retain stale text indefinitely.
12
8
  *
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.
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 so artifacts also heal without any agent activity. */
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 handle: unknown;
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 the idle sweep. */
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.handle = this.schedule(() => {
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.handle !== undefined) this.cancel(this.handle);
78
- this.handle = undefined;
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;