diff --git a/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts index 5a023afd3..61daf1bf4 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -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]) + " " : ""; diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index fe0b69116..1cdf13efc 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -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. } /** diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 162c7756e..0b79a3873 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -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 diff --git a/src/resources/extensions/gsd/notification-widget.ts b/src/resources/extensions/gsd/notification-widget.ts index a4ad968a6..ce62e9eca 100644 --- a/src/resources/extensions/gsd/notification-widget.ts +++ b/src/resources/extensions/gsd/notification-widget.ts @@ -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).