diff --git a/.gitignore b/.gitignore index e2e244ec7..12a09a8f0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ packages/*/dist-firefox/ .vscode/ .zed/ .idea/ +.claude/ *.swp *.swo *~ diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 365f46470..aecc4c551 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Responses reasoning text streaming for LM Studio and other compatible providers that emit `response.reasoning_text.delta` events ([#4191](https://github.com/badlogic/pi-mono/pull/4191) by [@yaanfpv](https://github.com/yaanfpv)). +- Fixed OpenAI Codex OAuth refresh failures writing directly to stderr while the TUI is active ([#4141](https://github.com/badlogic/pi-mono/issues/4141)). + ## [0.73.0] - 2026-05-04 ### Breaking Changes diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 9f9960590..2502008aa 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -34,6 +34,8 @@ export type { OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface, + OAuthSelectOption, + OAuthSelectPrompt, } from "./utils/oauth/types.js"; export * from "./utils/overflow.js"; export * from "./utils/typebox-helpers.js"; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 80eaf496a..dc6b64633 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -352,7 +352,7 @@ function buildRequestBody( model: model.id, store: false, stream: true, - instructions: context.systemPrompt, + instructions: context.systemPrompt || "You are a helpful assistant.", input: messages, text: { verbosity: options?.textVerbosity || "low" }, include: ["reasoning.encrypted_content"], diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index f25d6dcb4..0d68cd7f4 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -354,6 +354,16 @@ export async function processResponsesStream( }); } } + } else if (event.type === "response.reasoning_text.delta") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentBlock.thinking += event.delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: event.delta, + partial: output, + }); + } } else if (event.type === "response.content_part.added") { if (currentItem?.type === "message") { currentItem.content = currentItem.content || []; @@ -429,7 +439,9 @@ export async function processResponsesStream( const item = event.item; if (item.type === "reasoning" && currentBlock?.type === "thinking") { - currentBlock.thinking = item.summary?.map((s) => s.text).join("\n\n") || ""; + const summaryText = item.summary?.map((s) => s.text).join("\n\n") || ""; + const contentText = item.content?.map((c) => c.text).join("\n\n") || ""; + currentBlock.thinking = summaryText || contentText || currentBlock.thinking; currentBlock.thinkingSignature = JSON.stringify(item); stream.push({ type: "thinking_end", diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 0bc705a52..62c4c3521 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -30,7 +30,7 @@ const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed" }; +type TokenFailure = { type: "failed"; message: string; status?: number }; type TokenResult = TokenSuccess | TokenFailure; type JwtPayload = { @@ -108,8 +108,11 @@ async function exchangeAuthorizationCode( if (!response.ok) { const text = await response.text().catch(() => ""); - console.error("[openai-codex] code->token failed:", response.status, text); - return { type: "failed" }; + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, + }; } const json = (await response.json()) as { @@ -119,8 +122,10 @@ async function exchangeAuthorizationCode( }; if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - console.error("[openai-codex] token response missing fields:", json); - return { type: "failed" }; + return { + type: "failed", + message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`, + }; } return { @@ -145,8 +150,11 @@ async function refreshAccessToken(refreshToken: string): Promise { if (!response.ok) { const text = await response.text().catch(() => ""); - console.error("[openai-codex] Token refresh failed:", response.status, text); - return { type: "failed" }; + return { + type: "failed", + status: response.status, + message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, + }; } const json = (await response.json()) as { @@ -156,8 +164,10 @@ async function refreshAccessToken(refreshToken: string): Promise { }; if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - console.error("[openai-codex] Token refresh response missing fields:", json); - return { type: "failed" }; + return { + type: "failed", + message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`, + }; } return { @@ -167,8 +177,10 @@ async function refreshAccessToken(refreshToken: string): Promise { expires: Date.now() + json.expires_in * 1000, }; } catch (error) { - console.error("[openai-codex] Token refresh error:", error); - return { type: "failed" }; + return { + type: "failed", + message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, + }; } } @@ -258,12 +270,7 @@ function startLocalOAuthServer(state: string): Promise { waitForCode: () => waitForCodePromise, }); }) - .on("error", (err: NodeJS.ErrnoException) => { - console.error( - `[openai-codex] Failed to bind http://${CALLBACK_HOST}:1455 (`, - err.code, - ") Falling back to manual paste.", - ); + .on("error", (_err: NodeJS.ErrnoException) => { settleWait?.(null); resolve({ close: () => { @@ -386,7 +393,7 @@ export async function loginOpenAICodex(options: { const tokenResult = await exchangeAuthorizationCode(code, verifier); if (tokenResult.type !== "success") { - throw new Error("Token exchange failed"); + throw new Error(tokenResult.message); } const accountId = getAccountId(tokenResult.access); @@ -411,7 +418,7 @@ export async function loginOpenAICodex(options: { export async function refreshOpenAICodexToken(refreshToken: string): Promise { const result = await refreshAccessToken(refreshToken); if (result.type !== "success") { - throw new Error("Failed to refresh OpenAI Codex token"); + throw new Error(result.message); } const accountId = getAccountId(result.access); diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index e3520342d..03cbf101d 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -23,11 +23,23 @@ export type OAuthAuthInfo = { instructions?: string; }; +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: OAuthSelectOption[]; +}; + export interface OAuthLoginCallbacks { onAuth: (info: OAuthAuthInfo) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect?: (prompt: OAuthSelectPrompt) => Promise; signal?: AbortSignal; } diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts new file mode 100644 index 000000000..276abe1bf --- /dev/null +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.js"; + +describe("OpenAI Codex OAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("does not write token refresh failures to stderr", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (): Promise => { + return new Response( + JSON.stringify({ + error: { + message: "Could not validate your token. Please try signing in again.", + type: "invalid_request_error", + }, + }), + { status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" } }, + ); + }), + ); + + await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow( + /OpenAI Codex token refresh failed \(401\).*Could not validate your token/, + ); + expect(consoleError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 52a804092..2f8621aa9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Fixed + +- Fixed `pi -p` treating prompts that start with YAML frontmatter as extension flags instead of user messages ([#4163](https://github.com/badlogic/pi-mono/issues/4163)). +- Fixed pending tool results not updating in the live TUI after toggling thinking block visibility while the tool is running ([#4167](https://github.com/badlogic/pi-mono/issues/4167)). +- Fixed `/copy` reporting success on Linux without writing the clipboard on Wayland-only compositors (Hyprland, Niri, ...) by skipping the X11-only native addon on Linux and routing through `wl-copy`/`xclip`/`xsel` instead ([#4177](https://github.com/badlogic/pi-mono/issues/4177)). + ## [0.73.0] - 2026-05-04 ### New Features diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 6c451ccb1..ad66b9e25 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -122,6 +122,11 @@ export function parseArgs(args: string[]): Args { } } else if (arg === "--print" || arg === "-p") { result.print = true; + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("@") && (!next.startsWith("-") || next.startsWith("---"))) { + result.messages.push(next); + i++; + } } else if (arg === "--export" && i + 1 < args.length) { result.export = args[++i]; } else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) { diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 798b8f8cb..f2584aa9e 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -213,6 +213,13 @@ function formatValidationPath(error: TLocalizedValidationError): string { return path || "root"; } +/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ +function stripJsonComments(input: string): string { + return input + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); +} + /** Provider override config (baseUrl, compat) without request auth/headers */ interface ProviderOverride { baseUrl?: string; @@ -450,7 +457,7 @@ export class ModelRegistry { try { const content = readFileSync(modelsJsonPath, "utf-8"); - const parsed = JSON.parse(content) as unknown; + const parsed = JSON.parse(stripJsonComments(content)) as unknown; if (!validateModelsConfig.Check(parsed)) { const errors = diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 98d2096b1..f12bd4171 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -87,7 +87,8 @@ export class LoginDialogComponent extends Container implements Focusable { showAuth(url: string, instructions?: string): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); + const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0)); const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2fab6f56a..4eecad9f0 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -15,6 +15,7 @@ import { type Message, type Model, type OAuthProviderId, + type OAuthSelectPrompt, } from "@mariozechner/pi-ai"; import type { AutocompleteItem, @@ -2632,6 +2633,7 @@ export class InteractiveMode { switch (event.type) { case "agent_start": + this.pendingTools.clear(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } @@ -3099,6 +3101,7 @@ export class InteractiveMode { options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.pendingTools.clear(); + const renderedPendingTools = new Map(); if (options.updateFooter) { this.footer.invalidate(); @@ -3140,16 +3143,16 @@ export class InteractiveMode { } component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { - this.pendingTools.set(content.id, component); + renderedPendingTools.set(content.id, component); } } } } else if (message.role === "toolResult") { // Match tool results to pending tool components - const component = this.pendingTools.get(message.toolCallId); + const component = renderedPendingTools.get(message.toolCallId); if (component) { component.updateResult(message); - this.pendingTools.delete(message.toolCallId); + renderedPendingTools.delete(message.toolCallId); } } else { // All other messages use standard rendering @@ -3157,7 +3160,9 @@ export class InteractiveMode { } } - this.pendingTools.clear(); + for (const [toolCallId, component] of renderedPendingTools) { + this.pendingTools.set(toolCallId, component); + } this.ui.requestRender(); } @@ -4645,6 +4650,34 @@ export class InteractiveMode { } } + private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise { + return new Promise((resolve) => { + const restoreDialog = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(dialog); + this.ui.setFocus(dialog); + this.ui.requestRender(); + }; + const labels = prompt.options.map((option) => option.label); + const selector = new ExtensionSelectorComponent( + prompt.message, + labels, + (optionLabel) => { + restoreDialog(); + resolve(prompt.options.find((option) => option.label === optionLabel)?.id); + }, + () => { + restoreDialog(); + resolve(undefined); + }, + ); + this.editorContainer.clear(); + this.editorContainer.addChild(selector); + this.ui.setFocus(selector); + this.ui.requestRender(); + }); + } + private async showLoginDialog(providerId: string, providerName: string): Promise { const providerInfo = this.session.modelRegistry.authStorage .getOAuthProviders() @@ -4722,6 +4755,8 @@ export class InteractiveMode { dialog.showProgress(message); }, + onSelect: (prompt: OAuthSelectPrompt) => this.showOAuthLoginSelect(dialog, prompt), + onManualCodeInput: () => manualCodePromise, signal: dialog.signal, diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 4393679f4..84166e95e 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -35,11 +35,20 @@ function emitOsc52(text: string): boolean { export async function copyToClipboard(text: string): Promise { let copied = false; + const p = platform(); + // Prefer direct clipboard writes. Emitting OSC 52 first can make terminals // write the same native clipboard concurrently with the addon, and very large // OSC 52 payloads can desynchronize terminal rendering. + // + // On Linux, skip the native addon. The underlying `clipboard-rs` crate is + // X11-only and does not retain selection ownership after `set_text` + // resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even + // some X11 sessions the call resolves successfully without populating the + // clipboard. The platform tools below (wl-copy, xclip, xsel) properly + // daemonize and keep ownership. try { - if (clipboard) { + if (clipboard && p !== "linux") { await clipboard.setText(text); copied = true; } @@ -52,7 +61,6 @@ export async function copyToClipboard(text: string): Promise { return; } - const p = platform(); const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] }; if (!copied) { diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 3e53d7f6e..ab3ce2a5b 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -43,6 +43,21 @@ describe("parseArgs", () => { const result = parseArgs(["-p"]); expect(result.print).toBe(true); }); + + test("parses prompt after -p even when it starts with YAML frontmatter", () => { + const prompt = "---\ntitle: hello\n---\nSay hi."; + const result = parseArgs(["-p", prompt]); + expect(result.print).toBe(true); + expect(result.messages).toEqual([prompt]); + expect(result.unknownFlags.size).toBe(0); + }); + + test("does not consume options after -p as prompts", () => { + const result = parseArgs(["-p", "--provider", "openai", "Say hi."]); + expect(result.print).toBe(true); + expect(result.provider).toBe("openai"); + expect(result.messages).toEqual(["Say hi."]); + }); }); describe("--continue flag", () => { diff --git a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts new file mode 100644 index 000000000..21a0114ef --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts @@ -0,0 +1,164 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { AssistantMessage, ToolResultMessage, Usage } from "@mariozechner/pi-ai"; +import { Container, Text, type TUI } from "@mariozechner/pi-tui"; +import stripAnsi from "strip-ansi"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { AgentSessionEvent } from "../../../src/core/agent-session.js"; +import type { SessionContext } from "../../../src/core/session-manager.js"; +import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.js"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; + +const TOOL_CALL_ID = "tool-4167"; +const TOOL_NAME = "slow_tool"; + +const EMPTY_USAGE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +}; + +type RenderSessionContextThis = { + pendingTools: Map; + chatContainer: Container; + footer: { invalidate(): void }; + ui: TUI; + settingsManager: { + getShowImages(): boolean; + getImageWidthCells(): number; + }; + sessionManager: { getCwd(): string }; + session: { retryAttempt: number }; + toolOutputExpanded: boolean; + isInitialized: boolean; + updateEditorBorderColor(): void; + getRegisteredToolDefinition(toolName: string): undefined; + addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void; +}; + +type RenderSessionContext = ( + this: RenderSessionContextThis, + sessionContext: SessionContext, + options?: { updateFooter?: boolean; populateHistory?: boolean }, +) => void; + +type HandleEvent = (this: RenderSessionContextThis, event: AgentSessionEvent) => Promise; + +function createFakeInteractiveModeThis(): RenderSessionContextThis { + const chatContainer = new Container(); + return { + pendingTools: new Map(), + chatContainer, + footer: { invalidate: vi.fn() }, + ui: { requestRender: vi.fn() } as unknown as TUI, + settingsManager: { + getShowImages: () => false, + getImageWidthCells: () => 60, + }, + sessionManager: { getCwd: () => process.cwd() }, + session: { retryAttempt: 0 }, + toolOutputExpanded: false, + isInitialized: true, + updateEditorBorderColor: vi.fn(), + getRegisteredToolDefinition: (_toolName: string) => undefined, + addMessageToChat(message: AgentMessage) { + chatContainer.addChild(new Text(message.role, 0, 0)); + }, + }; +} + +function createAssistantToolCallMessage(): AssistantMessage { + return { + role: "assistant", + content: [ + { + type: "toolCall", + id: TOOL_CALL_ID, + name: TOOL_NAME, + arguments: { delayMs: 10_000 }, + }, + ], + api: "test-api", + provider: "test-provider", + model: "test-model", + usage: EMPTY_USAGE, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + +function createToolResultMessage(text: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId: TOOL_CALL_ID, + toolName: TOOL_NAME, + content: [{ type: "text", text }], + isError: false, + timestamp: Date.now(), + }; +} + +function createSessionContext(messages: AgentMessage[]): SessionContext { + return { + messages, + thinkingLevel: "off", + model: null, + }; +} + +function renderChat(container: Container): string { + return stripAnsi(container.render(120).join("\n")); +} + +describe("InteractiveMode.renderSessionContext", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("keeps unresolved rendered tool calls registered for live completion events", async () => { + const fakeThis = createFakeInteractiveModeThis(); + const renderSessionContext = ( + InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext } + ).renderSessionContext; + const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent; + + renderSessionContext.call(fakeThis, createSessionContext([createAssistantToolCallMessage()])); + + expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true); + + await handleEvent.call(fakeThis, { + type: "tool_execution_end", + toolCallId: TOOL_CALL_ID, + toolName: TOOL_NAME, + result: { content: [{ type: "text", text: "FINAL_RESULT" }], details: undefined }, + isError: false, + }); + + expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(false); + expect(renderChat(fakeThis.chatContainer)).toContain("FINAL_RESULT"); + }); + + test("does not keep completed historical tool calls registered as pending", () => { + const fakeThis = createFakeInteractiveModeThis(); + const renderSessionContext = ( + InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext } + ).renderSessionContext; + + renderSessionContext.call( + fakeThis, + createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]), + ); + + expect(fakeThis.pendingTools.size).toBe(0); + expect(renderChat(fakeThis.chatContainer)).toContain("HISTORICAL_RESULT"); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1001d5bda..1444bbffe 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed wrapped OSC 8 hyperlinks to preserve BEL terminators so OAuth login URLs remain clickable on every wrapped line. + ## [0.73.0] - 2026-05-04 ### Fixed diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 59d213284..a6bcc8a5f 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -312,6 +312,42 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt return null; } +type Osc8Terminator = "\x07" | "\x1b\\"; + +interface ActiveHyperlink { + params: string; + url: string; + terminator: Osc8Terminator; +} + +function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined { + if (!ansiCode.startsWith("\x1b]8;")) { + return undefined; + } + + const terminator: Osc8Terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\"; + const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2); + const separatorIndex = body.indexOf(";"); + if (separatorIndex === -1) { + return undefined; + } + + const params = body.slice(0, separatorIndex); + const url = body.slice(separatorIndex + 1); + if (!url) { + return null; + } + return { params, url, terminator }; +} + +function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string { + return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`; +} + +function formatOsc8Close(terminator: Osc8Terminator): string { + return `\x1b]8;;${terminator}`; +} + /** * Track active ANSI SGR codes to preserve styling across line breaks. */ @@ -327,13 +363,16 @@ class AnsiCodeTracker { private strikethrough = false; private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240" private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240" - private activeHyperlink: string | null = null; // Active OSC 8 hyperlink URL, or null + private activeHyperlink: ActiveHyperlink | null = null; process(ansiCode: string): void { - // OSC 8 hyperlink: \x1b]8;;\x1b\\ (open) or \x1b]8;;\x1b\\ (close) - if (ansiCode.startsWith("\x1b]8;")) { - const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/); - this.activeHyperlink = m?.[1] ? m[1] : null; + // OSC 8 hyperlink: \x1b]8;;\x1b\\ (open) or \x1b]8;;\x1b\\ (close). + // Preserve the original terminator because some terminals only make BEL-terminated + // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST + // made only the first physical line clickable in those terminals. + const hyperlink = parseOsc8Hyperlink(ansiCode); + if (hyperlink !== undefined) { + this.activeHyperlink = hyperlink; return; } @@ -495,7 +534,7 @@ class AnsiCodeTracker { let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : ""; if (this.activeHyperlink) { - result += `\x1b]8;;${this.activeHyperlink}\x1b\\`; + result += formatOsc8Hyperlink(this.activeHyperlink); } return result; } @@ -528,7 +567,7 @@ class AnsiCodeTracker { result += "\x1b[24m"; // Underline off only } if (this.activeHyperlink) { - result += "\x1b]8;;\x1b\\"; // Close hyperlink; re-opened at line start via getActiveCodes() + result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes() } return result; } diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index e0944ded1..bd24e8159 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -191,6 +191,21 @@ describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => { } }); + it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => { + const url = `https://example.com/oauth/${"a".repeat(32)}`; + const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; + const lines = wrapTextWithAnsi(input, 20); + + assert.ok(lines.length > 1); + for (const line of lines) { + assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`); + assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`); + } + for (const line of lines.slice(0, -1)) { + assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`); + } + }); + it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => { const url = "https://example.com"; const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;