fix(tui): eliminate pinned output duplication and reduce render overhead

rebuildChatFromMessages() called populatePinnedFromMessages() which
re-populated the pinned zone with text already present in the chat
history, causing visible duplication during session state changes.
Additionally, the spinner interval at 80ms generated ~12.5 renders/s
for a purely cosmetic animation, and clearOnShrink triggered
unnecessary full redraws during pinned-zone transitions.

- Remove populatePinnedFromMessages() from rebuildChatFromMessages()
  and add pinnedMessageContainer.clear() instead — the streaming
  lifecycle in chat-controller manages pinned content during active work
- Reduce spinner interval 80ms→200ms with render-batching that skips
  redundant renders when streaming already triggers requestRender()
- Debounce clearOnShrink: defer full redraw by one render tick so
  pinned-clear→new-streaming transitions avoid a wasted full redraw
- Increase notification widget safety-net timer 5s→30s since the
  store subscription already handles push-based updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
deseltrus 2026-04-14 06:17:18 +02:00
parent 13be3c58fe
commit 73f9434d11
4 changed files with 36 additions and 9 deletions

View file

@ -17,6 +17,7 @@ export class DynamicBorder implements Component {
private spinnerIndex = 0;
private spinnerInterval: NodeJS.Timeout | null = null;
private spinnerColorFn?: (str: string) => string;
private lastExternalRender = 0;
constructor(color: (str: string) => string = (str) => {
try { return theme.fg("border", str); } catch { return str; }
@ -31,7 +32,7 @@ export class DynamicBorder implements Component {
/**
* Start an animated spinner that prepends to the label.
* The spinner rotates every 80ms and triggers a re-render via the TUI.
* The spinner rotates every 200ms and triggers a re-render via the TUI.
*/
startSpinner(ui: TUI, colorFn: (str: string) => string): void {
this.stopSpinner();
@ -39,8 +40,12 @@ export class DynamicBorder implements Component {
this.spinnerIndex = 0;
this.spinnerInterval = setInterval(() => {
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
ui.requestRender();
}, 80);
// Only trigger standalone render if no other source rendered recently.
// During active streaming, message_update already calls requestRender().
if (Date.now() - this.lastExternalRender > 200) {
ui.requestRender();
}
}, 200);
ui.requestRender();
}
@ -64,6 +69,7 @@ export class DynamicBorder implements Component {
}
render(width: number): string[] {
this.lastExternalRender = Date.now();
const spinnerPrefix = this.spinnerInterval && this.spinnerColorFn
? this.spinnerColorFn(this.spinnerFrames[this.spinnerIndex]) + " "
: "";

View file

@ -2306,9 +2306,13 @@ export class InteractiveMode {
private rebuildChatFromMessages(): void {
this.chatContainer.clear();
this.pinnedMessageContainer.clear();
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context);
this.populatePinnedFromMessages(context.messages);
// Pinned content NOT re-populated here — the streaming lifecycle in
// chat-controller.ts manages the pinned zone during active work.
// populatePinnedFromMessages() remains in renderInitialMessages()
// for the session-resume case at startup.
}
/**

View file

@ -255,6 +255,7 @@ export class TUI extends Container {
private cellSizeQueryPending = false;
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1" || process.env.TERM_PROGRAM === "WarpTerminal";
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
private _shrinkDebounceActive = false;
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
private fullRedrawCount = 0;
@ -723,9 +724,25 @@ export class TUI extends Container {
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
fullRender(true);
return;
if (!this._shrinkDebounceActive) {
// First shrink detection: defer the full redraw by one tick.
// If content grows back immediately (pinned clear → new streaming),
// the full redraw is avoided.
this._shrinkDebounceActive = true;
// Do NOT update maxLinesRendered here — keep the old value so the
// condition `newLines.length < maxLinesRendered` still triggers on
// the next render if content stays shrunk.
logRedraw(`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`);
// Fall through to differential render for this frame
} else {
// Still shrunk on second render — commit the full redraw
this._shrinkDebounceActive = false;
logRedraw(`clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`);
fullRender(true);
return;
}
} else {
this._shrinkDebounceActive = false;
}
// Find first and last changed lines

View file

@ -1,6 +1,6 @@
// GSD Extension — Notification Widget
// Always-on ambient widget rendered belowEditor showing unread count and
// the most recent notification message. Refreshes every 5 seconds.
// the most recent notification message. Refreshes every 30 seconds.
// Widget key: "gsd-notifications", placement: "belowEditor"
import type { ExtensionContext } from "@gsd/pi-coding-agent";
@ -19,7 +19,7 @@ export function buildNotificationWidgetLines(): string[] {
// ─── Widget init ────────────────────────────────────────────────────────
const REFRESH_INTERVAL_MS = 5_000;
const REFRESH_INTERVAL_MS = 30_000;
/**
* Initialize the always-on notification widget (belowEditor).