Merge branch 'main' into better-autocomplete

This commit is contained in:
Armin Ronacher
2026-05-06 00:37:45 +02:00
19 changed files with 406 additions and 36 deletions
+1
View File
@@ -16,6 +16,7 @@ packages/*/dist-firefox/
.vscode/
.zed/
.idea/
.claude/
*.swp
*.swo
*~
+5
View File
@@ -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
+2
View File
@@ -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";
@@ -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"],
@@ -354,6 +354,16 @@ export async function processResponsesStream<TApi extends Api>(
});
}
}
} 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<TApi extends Api>(
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",
+26 -19
View File
@@ -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<TokenResult> {
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<TokenResult> {
};
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<TokenResult> {
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<OAuthServerInfo> {
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<OAuthCredentials> {
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);
+12
View File
@@ -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<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
/** Show an interactive selector and return the selected option id, or undefined on cancel. */
onSelect?: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
signal?: AbortSignal;
}
@@ -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<Response> => {
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();
});
});
+6
View File
@@ -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
+5
View File
@@ -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) {
@@ -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 =
@@ -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`;
@@ -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<string, ToolExecutionComponent>();
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<string | undefined> {
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<void> {
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,
+10 -2
View File
@@ -35,11 +35,20 @@ function emitOsc52(text: string): boolean {
export async function copyToClipboard(text: string): Promise<void> {
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<void> {
return;
}
const p = platform();
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
if (!copied) {
+15
View File
@@ -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", () => {
@@ -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<string, ToolExecutionComponent>;
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<void>;
function createFakeInteractiveModeThis(): RenderSessionContextThis {
const chatContainer = new Container();
return {
pendingTools: new Map<string, ToolExecutionComponent>(),
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");
});
});
+4
View File
@@ -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
+46 -7
View File
@@ -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;;<url>\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;;<url>\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;
}
+15
View File
@@ -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`;