|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import { afterEach, describe, expect, it, spyOn } from "bun:test";
|
|
|
|
|
import * as settingsModule from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
|
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
|
|
|
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
|
|
|
import { fileHyperlink, isHyperlinkEnabled, tryResolveInternalUrlSync } from "@oh-my-pi/pi-coding-agent/tui/hyperlink";
|
|
|
|
|
import * as terminalCaps from "@oh-my-pi/pi-tui";
|
|
|
|
|
|
|
|
|
@@ -7,6 +7,7 @@ import * as terminalCaps from "@oh-my-pi/pi-tui";
|
|
|
|
|
const OSC = "\x1b]";
|
|
|
|
|
const ST = "\x1b\\";
|
|
|
|
|
const LINK_END = `${OSC}8;;${ST}`;
|
|
|
|
|
const ORIGINAL_NO_COLOR = Bun.env.NO_COLOR;
|
|
|
|
|
|
|
|
|
|
/** Extract the hyperlink URI from a wrapped string. Returns undefined if not wrapped. */
|
|
|
|
|
function extractLinkUri(text: string): string | undefined {
|
|
|
|
@@ -19,133 +20,128 @@ function isHyperlinked(text: string): boolean {
|
|
|
|
|
return text.includes(`${OSC}8;`) && text.includes(LINK_END);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("isHyperlinkEnabled", () => {
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
delete Bun.env.NO_COLOR;
|
|
|
|
|
});
|
|
|
|
|
/** Set the `tui.hyperlinks` mode via a non-persistent runtime override. */
|
|
|
|
|
function setHyperlinkMode(mode: "off" | "auto" | "always"): void {
|
|
|
|
|
settings.override("tui.hyperlinks", mode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
resetSettingsForTest();
|
|
|
|
|
await Settings.init({ inMemory: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
|
|
|
resetSettingsForTest();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
settings.clearOverride("tui.hyperlinks");
|
|
|
|
|
if (ORIGINAL_NO_COLOR === undefined) {
|
|
|
|
|
delete Bun.env.NO_COLOR;
|
|
|
|
|
} else {
|
|
|
|
|
Bun.env.NO_COLOR = ORIGINAL_NO_COLOR;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("isHyperlinkEnabled", () => {
|
|
|
|
|
it('returns false when mode is "off"', () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "off" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("off");
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(false);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns true when mode is "always" regardless of TTY', () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(true);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false in auto mode when NO_COLOR is set", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "auto" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("auto");
|
|
|
|
|
Bun.env.NO_COLOR = "1";
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(false);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false in auto mode when stdout is not a TTY", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "auto" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("auto");
|
|
|
|
|
const origTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(false);
|
|
|
|
|
if (origTTY) Object.defineProperty(process.stdout, "isTTY", origTTY);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
try {
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
if (origTTY) {
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", origTTY);
|
|
|
|
|
} else {
|
|
|
|
|
Reflect.deleteProperty(process.stdout, "isTTY");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns TERMINAL.hyperlinks value in auto mode when conditions are met", () => {
|
|
|
|
|
const settingsSpy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "auto" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("auto");
|
|
|
|
|
delete Bun.env.NO_COLOR;
|
|
|
|
|
const origTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
|
|
|
// TERMINAL.hyperlinks may be true or false depending on the test runner env;
|
|
|
|
|
// what matters is that isHyperlinkEnabled mirrors it.
|
|
|
|
|
const expected = terminalCaps.TERMINAL.hyperlinks;
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(expected);
|
|
|
|
|
if (origTTY) Object.defineProperty(process.stdout, "isTTY", origTTY);
|
|
|
|
|
settingsSpy.mockRestore();
|
|
|
|
|
try {
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
|
|
|
// TERMINAL.hyperlinks may be true or false depending on the test runner env;
|
|
|
|
|
// what matters is that isHyperlinkEnabled mirrors it.
|
|
|
|
|
const expected = terminalCaps.TERMINAL.hyperlinks;
|
|
|
|
|
expect(isHyperlinkEnabled()).toBe(expected);
|
|
|
|
|
} finally {
|
|
|
|
|
if (origTTY) {
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", origTTY);
|
|
|
|
|
} else {
|
|
|
|
|
Reflect.deleteProperty(process.stdout, "isTTY");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("fileHyperlink", () => {
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
delete Bun.env.NO_COLOR;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns plain text when hyperlinks are disabled (mode=off)", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "off" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("off");
|
|
|
|
|
const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts");
|
|
|
|
|
expect(result).toBe("bar.ts");
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("wraps text in OSC 8 when hyperlinks are enabled (mode=always)", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts");
|
|
|
|
|
expect(isHyperlinked(result)).toBe(true);
|
|
|
|
|
expect(result).toContain("bar.ts");
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("builds a valid file:// URI with the absolute path", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts");
|
|
|
|
|
const uri = extractLinkUri(result);
|
|
|
|
|
expect(uri).toMatch(/^file:\/\//);
|
|
|
|
|
expect(uri).toContain("bar.ts");
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("encodes spaces in the path", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const result = fileHyperlink("/Users/foo/my file.ts", "my file.ts");
|
|
|
|
|
const uri = extractLinkUri(result);
|
|
|
|
|
expect(uri).toContain("%20");
|
|
|
|
|
expect(uri).not.toContain(" ");
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("appends line and col as query params when provided", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts", { line: 42, col: 7 });
|
|
|
|
|
const uri = extractLinkUri(result);
|
|
|
|
|
expect(uri).toContain("line=42");
|
|
|
|
|
expect(uri).toContain("col=7");
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("omits query params when line/col are not provided", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts");
|
|
|
|
|
const uri = extractLinkUri(result);
|
|
|
|
|
expect(uri).not.toContain("?");
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("produces a stable id for the same path", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const r1 = fileHyperlink("/Users/foo/bar.ts", "bar.ts");
|
|
|
|
|
const r2 = fileHyperlink("/Users/foo/bar.ts", "different display text");
|
|
|
|
|
// Extract id= from params (between "id=" and next ";")
|
|
|
|
@@ -153,29 +149,22 @@ describe("fileHyperlink", () => {
|
|
|
|
|
const id2 = r2.match(/id=([^;]+)/)?.[1];
|
|
|
|
|
expect(id1).toBeDefined();
|
|
|
|
|
expect(id1).toBe(id2);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not double-wrap text that already contains an OSC 8 sequence", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const alreadyWrapped = `${OSC}8;id=abc123;file:///foo/bar.ts${ST}bar.ts${LINK_END}`;
|
|
|
|
|
const result = fileHyperlink("/Users/foo/other.ts", alreadyWrapped);
|
|
|
|
|
// Should return the already-wrapped text unchanged
|
|
|
|
|
expect(result).toBe(alreadyWrapped);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves ANSI color codes inside the hyperlink", () => {
|
|
|
|
|
const spy = spyOn(settingsModule.settings, "get").mockImplementation(
|
|
|
|
|
(key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never,
|
|
|
|
|
);
|
|
|
|
|
setHyperlinkMode("always");
|
|
|
|
|
const colored = "\x1b[32mbar.ts\x1b[0m";
|
|
|
|
|
const result = fileHyperlink("/Users/foo/bar.ts", colored);
|
|
|
|
|
expect(result).toContain(colored);
|
|
|
|
|
expect(isHyperlinked(result)).toBe(true);
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|