From aa6bdd778a561b8641a5451093135445e4c855e1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 22 Jun 2026 17:18:43 +0200 Subject: [PATCH 1/6] fix(coding-agent): load resume startup themes --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/cli/session-picker.ts | 9 +- packages/coding-agent/src/cli/startup-ui.ts | 90 +++++++++++++++---- .../coding-agent/src/core/settings-manager.ts | 4 +- packages/coding-agent/src/main.ts | 2 +- .../interactive/theme/theme-controller.ts | 13 +-- .../src/modes/interactive/theme/theme.ts | 23 +++++ 7 files changed, 112 insertions(+), 33 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6795f662..e2650200 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,10 @@ - Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`. +### Fixed + +- Fixed `pi --resume` to load user package themes and resolve automatic light/dark theme settings. + ## [0.79.10] - 2026-06-22 ### New Features diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 42dcb808..793f1d0a 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -2,10 +2,12 @@ * TUI session selector for --resume flag */ -import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { setKeybindings } from "@earendil-works/pi-tui"; import { KeybindingsManager } from "../core/keybindings.ts"; import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts"; +import { createStartupTui, startStartupTui } from "./startup-ui.ts"; type SessionsLoader = (onProgress?: SessionListProgress) => Promise; @@ -13,9 +15,10 @@ type SessionsLoader = (onProgress?: SessionListProgress) => Promise { + const ui = await createStartupTui(settingsManager); return new Promise((resolve) => { - const ui = new TUI(new ProcessTerminal()); const keybindings = KeybindingsManager.create(); setKeybindings(keybindings); let resolved = false; @@ -47,6 +50,6 @@ export async function selectSession( ui.addChild(selector); ui.setFocus(selector.getSessionList()); - ui.start(); + startStartupTui(ui, settingsManager); }); } diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 93841304..73c0271c 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -1,16 +1,27 @@ import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import { existsSync } from "fs"; -import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getSettingsPath, PACKAGE_NAME } from "../config.ts"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts"; import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; import { KeybindingsManager } from "../core/keybindings.ts"; -import type { SettingsManager } from "../core/settings-manager.ts"; +import { DefaultPackageManager, type ResolvedResource } from "../core/package-manager.ts"; +import { SettingsManager } from "../core/settings-manager.ts"; import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; import { FirstTimeSetupComponent, type FirstTimeSetupResult, } from "../modes/interactive/components/first-time-setup.ts"; -import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; +import { + detectTerminalBackgroundFromEnv, + detectTerminalThemeForAuto, + initTheme, + loadThemeFromPath, + parseAutoThemeSetting, + resolveThemeSetting, + setRegisteredThemes, + setTheme, + type Theme, +} from "../modes/interactive/theme/theme.ts"; const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; const OFFICIAL_APP_NAME = "pi"; @@ -30,14 +41,64 @@ function isOfficialDistribution({ packageName, appName, configDirName }: Distrib ); } -function createStartupTui(settingsManager: SettingsManager): TUI { - initTheme(settingsManager.getTheme()); +function loadThemes(resources: ResolvedResource[]): Theme[] { + const themes: Theme[] = []; + const seen = new Set(); + for (const resource of resources) { + if (!resource.enabled) continue; + try { + const loadedTheme = loadThemeFromPath(resource.path); + if (loadedTheme.name) { + if (seen.has(loadedTheme.name)) continue; + seen.add(loadedTheme.name); + } + themes.push(loadedTheme); + } catch { + // Startup prompts should not fail because a theme is broken. The normal + // resource loader reports theme diagnostics later in startup. + } + } + return themes; +} + +async function loadStartupThemes(settingsManager: SettingsManager): Promise { + const globalSettingsManager = SettingsManager.inMemory(settingsManager.getGlobalSettings(), { + projectTrusted: false, + }); + const packageManager = new DefaultPackageManager({ + cwd: process.cwd(), + agentDir: getAgentDir(), + settingsManager: globalSettingsManager, + }); + const resolvedPaths = await packageManager.resolve(async () => "skip"); + return loadThemes(resolvedPaths.themes); +} + +export async function createStartupTui(settingsManager: SettingsManager): Promise { + setRegisteredThemes(await loadStartupThemes(settingsManager)); + const terminalTheme = detectTerminalBackgroundFromEnv().theme; + initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme); setKeybindings(KeybindingsManager.create()); const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); ui.setClearOnShrink(settingsManager.getClearOnShrink()); return ui; } +export function startStartupTui(ui: TUI, settingsManager: SettingsManager): void { + ui.start(); + void applyDetectedStartupTheme(ui, settingsManager); +} + +async function applyDetectedStartupTheme(ui: TUI, settingsManager: SettingsManager): Promise { + const themeSetting = settingsManager.getThemeSetting(); + if (themeSetting && !parseAutoThemeSetting(themeSetting)) return; + + const terminalTheme = await detectTerminalThemeForAuto({ ui, timeoutMs: 100 }); + setTheme(resolveThemeSetting(themeSetting, terminalTheme) ?? terminalTheme); + ui.invalidate(); + ui.requestRender(); +} + async function clearStartupTui(ui: TUI): Promise { ui.clear(); ui.requestRender(); @@ -75,9 +136,8 @@ export async function showStartupSelector( title: string, options: Array<{ label: string; value: T }>, ): Promise { + const ui = await createStartupTui(settingsManager); return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - let settled = false; const finish = async (result: T | undefined) => { if (settled) { @@ -98,15 +158,14 @@ export async function showStartupSelector( ); ui.addChild(selector); ui.setFocus(selector); - ui.start(); + startStartupTui(ui, settingsManager); }); } /** Show the first-time setup dialog and persist the result */ export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise { + const ui = await createStartupTui(settingsManager); return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - let settled = false; const finish = async (result: FirstTimeSetupResult | undefined) => { if (settled) { @@ -125,10 +184,10 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom const showSetup = async () => { ui.start(); - const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 }); - setTheme(detection.theme); + const detectedTheme = await detectTerminalThemeForAuto({ ui, timeoutMs: 100 }); + setTheme(detectedTheme); const component = new FirstTimeSetupComponent({ - detectedTheme: detection.theme, + detectedTheme, onThemePreview: (themeName) => { setTheme(themeName); ui.requestRender(); @@ -150,9 +209,8 @@ export async function showStartupInput( title: string, placeholder?: string, ): Promise { + const ui = await createStartupTui(settingsManager); return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - let settled = false; const finish = async (result: string | undefined) => { if (settled) { @@ -176,6 +234,6 @@ export async function showStartupInput( ); ui.addChild(input); ui.setFocus(input); - ui.start(); + startStartupTui(ui, settingsManager); }); } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index a90916a4..99ae71a4 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -334,11 +334,11 @@ export class SettingsManager { } /** Create an in-memory SettingsManager (no file I/O) */ - static inMemory(settings: Partial = {}): SettingsManager { + static inMemory(settings: Partial = {}, options: SettingsManagerCreateOptions = {}): SettingsManager { const storage = new InMemorySettingsStorage(); const initialSettings = SettingsManager.migrateSettings(structuredClone(settings) as Record); storage.withLock("global", () => JSON.stringify(initialSettings, null, 2)); - return SettingsManager.fromStorage(storage); + return SettingsManager.fromStorage(storage, options); } private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f66040bb..568d96d1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -308,11 +308,11 @@ async function createSessionManager( } if (parsed.resume) { - initTheme(settingsManager.getTheme(), true); try { const selectedPath = await selectSession( (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), (onProgress) => SessionManager.listAll(sessionDir, onProgress), + settingsManager, ); if (!selectedPath) { console.log(chalk.dim("No session selected")); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts index 9fe9e8cc..43ad620d 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -3,6 +3,7 @@ import type { SettingsManager } from "../../../core/settings-manager.ts"; import { detectTerminalBackgroundFromEnv, detectTerminalBackgroundTheme, + detectTerminalThemeForAuto, initTheme, parseAutoThemeSetting, resolveThemeSetting, @@ -37,7 +38,7 @@ export class InteractiveThemeController { const themeSetting = this.settingsManager.getThemeSetting(); const autoTheme = parseAutoThemeSetting(themeSetting); if (autoTheme) { - this.terminalTheme = await this.detectTerminalThemeForAuto(); + this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 }); this.setAutoSync(true); this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true); return; @@ -109,16 +110,6 @@ export class InteractiveThemeController { this.ui.setTerminalColorSchemeNotifications(enabled); } - private async detectTerminalThemeForAuto(): Promise { - try { - const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 }); - if (colorScheme) return colorScheme; - } catch { - // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. - } - return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme; - } - private applyTerminalTheme(terminalTheme: TerminalTheme): void { if (!this.autoSyncEnabled) return; this.terminalTheme = terminalTheme; diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 58e5aac3..676bc529 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -680,11 +680,20 @@ export interface TerminalBackgroundThemeDetector { queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise; } +export interface TerminalAutoThemeDetector extends TerminalBackgroundThemeDetector { + queryTerminalColorScheme?({ timeoutMs }: { timeoutMs: number }): Promise; +} + export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions { ui: TerminalBackgroundThemeDetector; timeoutMs: number; } +export interface TerminalAutoThemeDetectionOptions extends TerminalThemeDetectionOptions { + ui: TerminalAutoThemeDetector; + timeoutMs: number; +} + function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { const parts = colorfgbg.split(";"); for (let i = parts.length - 1; i >= 0; i--) { @@ -755,6 +764,20 @@ export async function detectTerminalBackgroundTheme({ return detectTerminalBackgroundFromEnv({ env }); } +export async function detectTerminalThemeForAuto({ + ui, + timeoutMs, + env, +}: TerminalAutoThemeDetectionOptions): Promise { + try { + const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs }); + if (colorScheme) return colorScheme; + } catch { + // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. + } + return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme; +} + export function getDefaultTheme(): string { return detectTerminalBackgroundFromEnv().theme; } From f7d3331df6166f6653e6c8046bf20c2ae0ae2ff1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 22 Jun 2026 17:49:18 +0200 Subject: [PATCH 2/6] fix(ai): mock copilot models in oauth test --- packages/ai/test/oauth-auth.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index c008b18c..43be6f72 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -69,7 +69,11 @@ describe.sequential("OAuthAuth adapters", () => { it("github-copilot refresh preserves the enterprise domain", async () => { const fetchedUrls: string[] = []; const fetchMock = vi.fn(async (input: unknown) => { - fetchedUrls.push(typeof input === "string" ? input : String(input)); + const url = typeof input === "string" ? input : String(input); + fetchedUrls.push(url); + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } return jsonResponse({ token: "new-token", expires_at: 9999999999 }); }); vi.stubGlobal("fetch", fetchMock); From d0e0b84cb9d811dc30bb9dc13969c013bebff0c1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 23 Jun 2026 00:17:37 +0200 Subject: [PATCH 3/6] fix(ai): reconnect Codex websocket on connection limit closes #5973 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/openai-codex-responses.ts | 121 +++++++++++------- packages/ai/test/openai-codex-stream.test.ts | 62 +++++++++ 3 files changed, 137 insertions(+), 47 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a43f32f..694e25a7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed +- Fixed OpenAI Codex Responses WebSocket sessions to reconnect once when OpenAI's connection limit is reached before output starts ([#5973](https://github.com/earendil-works/pi/issues/5973)). - Fixed OpenCode Go GLM-5.2 metadata to expose `xhigh` reasoning and send `reasoning_effort: "max"` ([#5967](https://github.com/earendil-works/pi/issues/5967)). - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index ee2503d5..6107bb22 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -61,6 +61,7 @@ const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000; const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; +const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"; const CODEX_RESPONSE_STATUSES = new Set([ "completed", @@ -253,52 +254,62 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons if (transport !== "sse" && !websocketDisabledForSession) { let websocketStarted = false; - try { - await processWebSocketStream( - resolveCodexWebSocketUrl(model.baseUrl), - body, - websocketHeaders, - output, - stream, - model, - () => { - websocketStarted = true; - }, - idleTimeoutMs, - websocketConnectTimeoutMs, - options, - ); + let retriedWebSocketConnectionLimit = false; + while (true) { + websocketStarted = false; + try { + await processWebSocketStream( + resolveCodexWebSocketUrl(model.baseUrl), + body, + websocketHeaders, + output, + stream, + model, + () => { + websocketStarted = true; + }, + idleTimeoutMs, + websocketConnectTimeoutMs, + options, + ); - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + stream.push({ + type: "done", + reason: output.stopReason as "stop" | "length" | "toolUse", + message: output, + }); + stream.end(); + return; + } catch (error) { + const aborted = options?.signal?.aborted; + const connectionLimitBeforeStart = !websocketStarted && isWebSocketConnectionLimitReachedError(error); + if (!aborted && connectionLimitBeforeStart && !retriedWebSocketConnectionLimit) { + retriedWebSocketConnectionLimit = true; + continue; + } + if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) { + throw error; + } + appendAssistantMessageDiagnostic( + output, + createAssistantMessageDiagnostic("provider_transport_failure", error, { + configuredTransport: transport, + fallbackTransport: websocketStarted ? undefined : "sse", + eventsEmitted: websocketStarted, + phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start", + requestBytes: new TextEncoder().encode(bodyJson).byteLength, + }), + ); + recordWebSocketFailure(options?.sessionId, error); + if (websocketStarted) { + throw error; + } + recordWebSocketSseFallback(options?.sessionId); + break; } - stream.push({ - type: "done", - reason: output.stopReason as "stop" | "length" | "toolUse", - message: output, - }); - stream.end(); - return; - } catch (error) { - const aborted = options?.signal?.aborted; - if (aborted || isCodexNonTransportError(error)) { - throw error; - } - appendAssistantMessageDiagnostic( - output, - createAssistantMessageDiagnostic("provider_transport_failure", error, { - configuredTransport: transport, - fallbackTransport: websocketStarted ? undefined : "sse", - eventsEmitted: websocketStarted, - phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start", - requestBytes: new TextEncoder().encode(bodyJson).byteLength, - }), - ); - recordWebSocketFailure(options?.sessionId, error); - if (websocketStarted) { - throw error; - } - recordWebSocketSseFallback(options?.sessionId); } } @@ -582,16 +593,32 @@ function isCodexNonTransportError(error: unknown): boolean { return error instanceof CodexApiError || error instanceof CodexProtocolError; } +function isWebSocketConnectionLimitReachedError(error: unknown): boolean { + return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE; +} + +function extractCodexEventError(event: Record): { code?: string; message?: string } { + const nested = event.error && typeof event.error === "object" ? (event.error as Record) : undefined; + return { + code: typeof event.code === "string" ? event.code : typeof nested?.code === "string" ? nested.code : undefined, + message: + typeof event.message === "string" + ? event.message + : typeof nested?.message === "string" + ? nested.message + : undefined, + }; +} + async function* mapCodexEvents(events: AsyncIterable>): AsyncGenerator { for await (const event of events) { const type = typeof event.type === "string" ? event.type : undefined; if (!type) continue; if (type === "error") { - const code = (event as { code?: string }).code || ""; - const message = (event as { message?: string }).message || ""; + const { code, message } = extractCodexEventError(event); throw new CodexApiError(`Codex error: ${message || code || JSON.stringify(event)}`, { - code: code || undefined, + code, payload: event, }); } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 9c526151..b68d5b73 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1195,6 +1195,68 @@ describe("openai-codex streaming", () => { }); }); + it("reconnects once when the websocket connection limit is reached before output starts", async () => { + const token = mockToken(); + let connections = 0; + + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket extends EventTarget { + private readonly limitReached = connections++ === 0; + + constructor() { + super(); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + + send(): void { + const event = this.limitReached + ? { type: "error", error: { code: "websocket_connection_limit_reached" } } + : { + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }; + queueMicrotask(() => { + this.dispatchEvent(Object.assign(new Event("message"), { data: JSON.stringify(event) })); + }); + } + + close(): void {} + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + + const result = await streamOpenAICodexResponses( + model, + { systemPrompt: "", messages: [] }, + { + apiKey: token, + }, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(connections).toBe(2); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("falls back to SSE when a websocket is idle before the first event", async () => { vi.useFakeTimers(); const token = mockToken(); From 392bae6b67d92c82608e69906283bcebf5f385ae Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 23 Jun 2026 10:36:47 +0200 Subject: [PATCH 4/6] fix(tui): avoid tall dialog redraw loops closes #5990 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/tui.ts | 58 ++++++++++++++++++++++++- packages/tui/test/tui-render.test.ts | 64 ++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a3cf70ef..9230cd04 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `Ctrl+J` as a default newline keybinding alongside `Shift+Enter`. +### Fixed + +- Fixed full redraw loops when offscreen lines change above a tall visible viewport, avoiding flicker for oversized dialogs ([#5990](https://github.com/earendil-works/pi/issues/5990)). + ## [0.79.10] - 2026-06-22 ## [0.79.9] - 2026-06-20 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a7054888..f6e81e01 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -313,6 +313,7 @@ export class TUI extends Container { private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) 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 skippedOffscreenChangeRange: { first: number; last: number } | undefined; private fullRedrawCount = 0; private stopped = false; private pendingOsc11BackgroundReplies = 0; @@ -718,6 +719,7 @@ export class TUI extends Container { this.hardwareCursorRow = 0; this.maxLinesRendered = 0; this.previousViewportTop = 0; + this.skippedOffscreenChangeRange = undefined; if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = undefined; @@ -1172,6 +1174,30 @@ export class TUI extends Container { return this.deleteKittyImages(ids); } + private changedRangeContainsKittyImages(firstChanged: number, lastChanged: number, newLines: string[]): boolean { + for (let i = firstChanged; i <= lastChanged; i++) { + if (extractKittyImageIds(this.previousLines[i] ?? "").length > 0) return true; + if (extractKittyImageIds(newLines[i] ?? "").length > 0) return true; + } + return false; + } + + private markSkippedOffscreenChange(first: number, last: number): void { + if (!this.skippedOffscreenChangeRange) { + this.skippedOffscreenChangeRange = { first, last }; + return; + } + this.skippedOffscreenChangeRange = { + first: Math.min(this.skippedOffscreenChangeRange.first, first), + last: Math.max(this.skippedOffscreenChangeRange.last, last), + }; + } + + private skippedOffscreenChangesIntersect(first: number, last: number): boolean { + const range = this.skippedOffscreenChangeRange; + return range !== undefined && range.first <= last && range.last >= first; + } + /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ private compositeLineAt( baseLine: string, @@ -1322,6 +1348,7 @@ export class TUI extends Container { this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; + this.skippedOffscreenChangeRange = undefined; }; const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; @@ -1349,7 +1376,18 @@ export class TUI extends Container { // Height changes normally need a full re-render to keep the visible viewport aligned, // but Termux changes height when the software keyboard shows or hides. // In that environment, a full redraw causes the entire history to replay on every toggle. - if (heightChanged && !isTermuxSession()) { + // If the new Termux viewport would reveal rows skipped by the offscreen optimization, + // redraw once so stale scrollback does not become visible. + if (heightChanged && isTermuxSession()) { + const viewportBottom = prevViewportTop + height - 1; + if (this.skippedOffscreenChangesIntersect(prevViewportTop, viewportBottom)) { + logRedraw( + `terminal height changed revealing skipped offscreen changes (${this.previousHeight} -> ${height})`, + ); + fullRender(true); + return; + } + } else if (heightChanged) { logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); fullRender(true); return; @@ -1401,6 +1439,24 @@ export class TUI extends Container { return; } + // If all changed lines are above the visible viewport and the buffer length did not change, + // the on-screen content is already correct. Avoid clearing/replaying the viewport for + // offscreen animations such as tall dialogs with an updating header/status line. + if ( + lastChanged < prevViewportTop && + newLines.length === this.previousLines.length && + !this.changedRangeContainsKittyImages(firstChanged, lastChanged, newLines) + ) { + this.markSkippedOffscreenChange(firstChanged, lastChanged); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = prevViewportTop; + return; + } + // All changes are in deleted lines (nothing to render, just clear) if (firstChanged >= newLines.length) { if (this.previousLines.length > newLines.length) { diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index ab038ac7..f20dffd6 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -378,6 +378,44 @@ describe("TUI resize handling", () => { }); }); + it("full re-renders in Termux when height changes reveal skipped offscreen changes", async () => { + await withEnv({ TERMUX_VERSION: "1" }, async () => { + const terminal = new VirtualTerminal(20, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 11 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + component.lines = Array.from({ length: 11 }, (_, i) => (i === 5 ? "Changed 5" : `Line ${i}`)); + tui.requestRender(); + await terminal.waitForRender(); + + const redrawsAfterSkippedChange = tui.fullRedraws; + assert.deepStrictEqual(terminal.getViewport(), ["Line 6", "Line 7", "Line 8", "Line 9", "Line 10"]); + + terminal.resize(20, 6); + await terminal.waitForRender(); + + assert.ok( + tui.fullRedraws > redrawsAfterSkippedChange, + "Height change should redraw before skipped offscreen content becomes visible", + ); + assert.deepStrictEqual(terminal.getViewport(), [ + "Changed 5", + "Line 6", + "Line 7", + "Line 8", + "Line 9", + "Line 10", + ]); + + tui.stop(); + }); + }); + it("triggers full re-render when terminal width changes", async () => { const terminal = new VirtualTerminal(40, 10); const tui = new TUI(terminal); @@ -657,6 +695,32 @@ describe("TUI differential rendering", () => { tui.stop(); }); + it("does not full redraw for offscreen changes above the viewport", async () => { + const terminal = new VirtualTerminal(20, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + const stableVisibleLines = Array.from({ length: 8 }, (_, i) => `Dialog ${i}`); + component.lines = ["Ticker 0", "Intro", "", ...stableVisibleLines]; + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + const initialViewport = terminal.getViewport(); + + for (let i = 1; i <= 3; i++) { + component.lines = [`Ticker ${i}`, "Intro", "", ...stableVisibleLines]; + tui.requestRender(); + await terminal.waitForRender(); + } + + assert.strictEqual(tui.fullRedraws, initialRedraws, "Offscreen changes should not force full redraws"); + assert.deepStrictEqual(terminal.getViewport(), initialViewport, "Visible viewport should remain stable"); + + tui.stop(); + }); + it("full re-renders when deleted lines move the viewport upward", async () => { const terminal = new VirtualTerminal(20, 5); const tui = new TUI(terminal); From 590482cf6d35e66b1ce3988fbc682b7a2037a232 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 23 Jun 2026 11:48:03 +0200 Subject: [PATCH 5/6] Revert "fix(tui): avoid tall dialog redraw loops" This reverts commit 392bae6b67d92c82608e69906283bcebf5f385ae. --- packages/tui/CHANGELOG.md | 4 -- packages/tui/src/tui.ts | 58 +------------------------ packages/tui/test/tui-render.test.ts | 64 ---------------------------- 3 files changed, 1 insertion(+), 125 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9230cd04..a3cf70ef 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,10 +6,6 @@ - Added `Ctrl+J` as a default newline keybinding alongside `Shift+Enter`. -### Fixed - -- Fixed full redraw loops when offscreen lines change above a tall visible viewport, avoiding flicker for oversized dialogs ([#5990](https://github.com/earendil-works/pi/issues/5990)). - ## [0.79.10] - 2026-06-22 ## [0.79.9] - 2026-06-20 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f6e81e01..a7054888 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -313,7 +313,6 @@ export class TUI extends Container { private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) 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 skippedOffscreenChangeRange: { first: number; last: number } | undefined; private fullRedrawCount = 0; private stopped = false; private pendingOsc11BackgroundReplies = 0; @@ -719,7 +718,6 @@ export class TUI extends Container { this.hardwareCursorRow = 0; this.maxLinesRendered = 0; this.previousViewportTop = 0; - this.skippedOffscreenChangeRange = undefined; if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = undefined; @@ -1174,30 +1172,6 @@ export class TUI extends Container { return this.deleteKittyImages(ids); } - private changedRangeContainsKittyImages(firstChanged: number, lastChanged: number, newLines: string[]): boolean { - for (let i = firstChanged; i <= lastChanged; i++) { - if (extractKittyImageIds(this.previousLines[i] ?? "").length > 0) return true; - if (extractKittyImageIds(newLines[i] ?? "").length > 0) return true; - } - return false; - } - - private markSkippedOffscreenChange(first: number, last: number): void { - if (!this.skippedOffscreenChangeRange) { - this.skippedOffscreenChangeRange = { first, last }; - return; - } - this.skippedOffscreenChangeRange = { - first: Math.min(this.skippedOffscreenChangeRange.first, first), - last: Math.max(this.skippedOffscreenChangeRange.last, last), - }; - } - - private skippedOffscreenChangesIntersect(first: number, last: number): boolean { - const range = this.skippedOffscreenChangeRange; - return range !== undefined && range.first <= last && range.last >= first; - } - /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ private compositeLineAt( baseLine: string, @@ -1348,7 +1322,6 @@ export class TUI extends Container { this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; - this.skippedOffscreenChangeRange = undefined; }; const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; @@ -1376,18 +1349,7 @@ export class TUI extends Container { // Height changes normally need a full re-render to keep the visible viewport aligned, // but Termux changes height when the software keyboard shows or hides. // In that environment, a full redraw causes the entire history to replay on every toggle. - // If the new Termux viewport would reveal rows skipped by the offscreen optimization, - // redraw once so stale scrollback does not become visible. - if (heightChanged && isTermuxSession()) { - const viewportBottom = prevViewportTop + height - 1; - if (this.skippedOffscreenChangesIntersect(prevViewportTop, viewportBottom)) { - logRedraw( - `terminal height changed revealing skipped offscreen changes (${this.previousHeight} -> ${height})`, - ); - fullRender(true); - return; - } - } else if (heightChanged) { + if (heightChanged && !isTermuxSession()) { logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); fullRender(true); return; @@ -1439,24 +1401,6 @@ export class TUI extends Container { return; } - // If all changed lines are above the visible viewport and the buffer length did not change, - // the on-screen content is already correct. Avoid clearing/replaying the viewport for - // offscreen animations such as tall dialogs with an updating header/status line. - if ( - lastChanged < prevViewportTop && - newLines.length === this.previousLines.length && - !this.changedRangeContainsKittyImages(firstChanged, lastChanged, newLines) - ) { - this.markSkippedOffscreenChange(firstChanged, lastChanged); - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; - this.previousViewportTop = prevViewportTop; - return; - } - // All changes are in deleted lines (nothing to render, just clear) if (firstChanged >= newLines.length) { if (this.previousLines.length > newLines.length) { diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index f20dffd6..ab038ac7 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -378,44 +378,6 @@ describe("TUI resize handling", () => { }); }); - it("full re-renders in Termux when height changes reveal skipped offscreen changes", async () => { - await withEnv({ TERMUX_VERSION: "1" }, async () => { - const terminal = new VirtualTerminal(20, 5); - const tui = new TUI(terminal); - const component = new TestComponent(); - tui.addChild(component); - - component.lines = Array.from({ length: 11 }, (_, i) => `Line ${i}`); - tui.start(); - await terminal.waitForRender(); - - component.lines = Array.from({ length: 11 }, (_, i) => (i === 5 ? "Changed 5" : `Line ${i}`)); - tui.requestRender(); - await terminal.waitForRender(); - - const redrawsAfterSkippedChange = tui.fullRedraws; - assert.deepStrictEqual(terminal.getViewport(), ["Line 6", "Line 7", "Line 8", "Line 9", "Line 10"]); - - terminal.resize(20, 6); - await terminal.waitForRender(); - - assert.ok( - tui.fullRedraws > redrawsAfterSkippedChange, - "Height change should redraw before skipped offscreen content becomes visible", - ); - assert.deepStrictEqual(terminal.getViewport(), [ - "Changed 5", - "Line 6", - "Line 7", - "Line 8", - "Line 9", - "Line 10", - ]); - - tui.stop(); - }); - }); - it("triggers full re-render when terminal width changes", async () => { const terminal = new VirtualTerminal(40, 10); const tui = new TUI(terminal); @@ -695,32 +657,6 @@ describe("TUI differential rendering", () => { tui.stop(); }); - it("does not full redraw for offscreen changes above the viewport", async () => { - const terminal = new VirtualTerminal(20, 5); - const tui = new TUI(terminal); - const component = new TestComponent(); - tui.addChild(component); - - const stableVisibleLines = Array.from({ length: 8 }, (_, i) => `Dialog ${i}`); - component.lines = ["Ticker 0", "Intro", "", ...stableVisibleLines]; - tui.start(); - await terminal.waitForRender(); - - const initialRedraws = tui.fullRedraws; - const initialViewport = terminal.getViewport(); - - for (let i = 1; i <= 3; i++) { - component.lines = [`Ticker ${i}`, "Intro", "", ...stableVisibleLines]; - tui.requestRender(); - await terminal.waitForRender(); - } - - assert.strictEqual(tui.fullRedraws, initialRedraws, "Offscreen changes should not force full redraws"); - assert.deepStrictEqual(terminal.getViewport(), initialViewport, "Visible viewport should remain stable"); - - tui.stop(); - }); - it("full re-renders when deleted lines move the viewport upward", async () => { const terminal = new VirtualTerminal(20, 5); const tui = new TUI(terminal); From ce6a67fc948c63f991f7f649032ee237d530146b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 23 Jun 2026 11:51:33 +0200 Subject: [PATCH 6/6] fix(coding-agent): allow custom providers to use stored auth closes #5953 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/models.md | 6 ++++-- packages/coding-agent/src/core/model-registry.ts | 6 ++---- packages/coding-agent/test/model-registry.test.ts | 3 ++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e2650200..b71110ca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed - Fixed `pi --resume` to load user package themes and resolve automatic light/dark theme settings. +- Fixed `models.json` custom providers so stored credentials can satisfy auth without a redundant provider-level `apiKey` ([#5953](https://github.com/earendil-works/pi/issues/5953)). ## [0.79.10] - 2026-06-22 diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 5679981a..d17e9928 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -34,7 +34,7 @@ For local models (Ollama, LM Studio, vLLM), only `id` is required per model: } ``` -The `apiKey` is required but Ollama ignores it, so any value works. +The `apiKey` value is a placeholder because Ollama ignores it. pi still treats models as requiring auth before they appear in `/model`, so keyless local servers should keep a dummy value, save a key for that provider with `/login`, or pass `--api-key` when selecting the model. Some OpenAI-compatible servers do not understand the `developer` role used for reasoning-capable models. For those providers, set `compat.supportsDeveloperRole` to `false` so pi sends the system prompt as a `system` message instead. If the server also does not support `reasoning_effort`, set `compat.supportsReasoningEffort` to `false` too. @@ -135,12 +135,14 @@ Set `api` at provider level (default for all models) or model level (override pe |-------|-------------| | `baseUrl` | API endpoint URL | | `api` | API type (see above) | -| `apiKey` | API key (see value resolution below) | +| `apiKey` | Optional API key config (see value resolution below). Omit it when auth is provided by `/login`/`auth.json` or CLI `--api-key`. | | `headers` | Custom headers (see value resolution below) | | `authHeader` | Set `true` to add `Authorization: Bearer ` automatically | | `models` | Array of model configurations | | `modelOverrides` | Per-model overrides for built-in models on this provider | +For providers with `models`, non-built-in provider configs need `baseUrl` and an `api` value at either provider or model level. `apiKey` is not required to load the file: models become available when auth is configured through `/login`/`auth.json`, CLI `--api-key`, or provider `apiKey`. If no auth is configured, the models load but stay unavailable in `/model` and `--list-models`. + ### Value Resolution The `apiKey` and `headers` fields support command execution, environment interpolation, and literals: diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 8f86bf6c..70f39394 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -546,13 +546,11 @@ export class ModelRegistry { ); } } else if (!isBuiltIn) { - // Non-built-in providers with custom models require endpoint + auth. + // Non-built-in providers with custom models require an endpoint. + // Auth can come from auth.json, --api-key, or provider request config. if (!providerConfig.baseUrl) { throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`); } - if (!providerConfig.apiKey) { - throw new Error(`Provider ${providerName}: "apiKey" is required when defining custom models.`); - } } // Built-in providers with custom models: baseUrl/apiKey/api are optional, // inherited from built-in models. Auth comes from env vars / auth storage. diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 2405e557..fb559e1d 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -246,9 +246,10 @@ describe("ModelRegistry", () => { expect(model?.baseUrl).toBe("https://openrouter.ai/api/v1"); }); - test("non-built-in provider custom models still require baseUrl and apiKey", () => { + test("non-built-in provider custom models still require baseUrl", () => { writeRawModelsJson({ "my-custom-provider": { + apiKey: "test-key", models: [ { id: "my-model",