Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/task/AuxTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ export function AuxTerminal({ taskId, taskPath, active, autoFocus, onExited, onT
// GH #58: mouse-reporting-proof Cmd/Ctrl+click opener — the user can run
// a TUI in the scratch shell too (htop, an agent CLI by hand). See
// TerminalPane / lib/termLinkOpener.
const disposeLinkOpener = attachCmdClickLinkOpener(term, host, openLink("capture"));
// urlsOnly: file-path open is a TerminalPane feature. Without this the
// opener would arm on path tokens here and swallow the click (dead in the
// scratch shell, and eaten from under a mouse-reporting TUI).
const disposeLinkOpener = attachCmdClickLinkOpener(term, host, (target) => {
if (target.kind === "url") openLink("capture")(target.uri);
}, { urlsOnly: true });
// Korean/CJK IME (WKWebView). WebKit composes via textarea `input` events
// (insertText + insertReplacementText), not compositionstart/end, and
// xterm drops the replacement events — so input gets mangled (안녕 → ㅇㄴ).
Expand Down
53 changes: 51 additions & 2 deletions src/components/task/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { cn } from "@/lib/utils";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { attachCmdClickLinkOpener } from "@/lib/termLinkOpener";
import { attachCmdClickLinkOpener, registerPathLinkProvider, type ClickTarget } from "@/lib/termLinkOpener";
import { resolvePathClick } from "@/lib/pathMatch";
import { TerminalPathMenu } from "@/components/task/TerminalPathMenu";
import { openUrl } from "@tauri-apps/plugin-opener";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { Osc52Base64 } from "@/lib/osc52";
Expand Down Expand Up @@ -121,6 +123,15 @@ export function TerminalPane({ task, tab, active }: Props) {
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [pathMenu, setPathMenu] = useState<{ x: number; y: number; candidates: string[]; line?: number; col?: number } | null>(null);
const openPathFile = useCallback((path: string, line?: number, col?: number) => {
useApp.getState().openPreviewTab(task.id, {
type: "edit",
path,
title: path.split("/").pop() || path,
revealAt: line ? { line, col } : undefined,
});
}, [task.id]);
const unlistenDataRef = useRef<(() => void) | null>(null);
const unlistenExitRef = useRef<(() => void) | null>(null);
const ptyRef = useRef<string | null>(null);
Expand Down Expand Up @@ -444,14 +455,35 @@ const captureArmedRef = useRef(false);
term.loadAddon(new WebLinksAddon((event, uri) => {
if (event.metaKey || event.ctrlKey) openLink("addon")(uri);
}));
// Hover-underline for file-path references, mirroring the URL addon above.
const disposePathLinks = registerPathLinkProvider(term, (path, line, col, event) => {
if (event.metaKey || event.ctrlKey) handlePathTarget({ path, line, col }, event.clientX, event.clientY);
});
term.open(host);
// GH #58: when the agent TUI enables xterm mouse reporting, the modified
// click is consumed by the mouse pipeline before the addon's activation
// runs — links "randomly" die inside agents. The capture-phase opener
// sees the gesture first, resolves the URL from the buffer itself, and
// swallows the click so nothing double-fires. Must attach AFTER
// term.open (it reads .xterm-screen geometry).
const disposeLinkOpener = attachCmdClickLinkOpener(term, host, openLink("capture"));
// GH #117: the same opener also resolves file-path references.
function handlePathTarget(target: { path: string; line?: number; col?: number }, x: number, y: number) {
ipc.taskListFilesForFinder(task.id)
.then(files => {
const matches = resolvePathClick(files, target.path);
if (matches.length === 1) {
openPathFile(matches[0], target.line, target.col);
} else {
setPathMenu({ x, y, candidates: matches, line: target.line, col: target.col });
}
})
.catch(() => useUI.getState().pushToast("Couldn't list files to open that path", "error"));
}
const onActivate = (target: ClickTarget, x: number, y: number) => {
if (target.kind === "url") openLink("capture")(target.uri);
else handlePathTarget(target, x, y);
};
const disposeLinkOpener = attachCmdClickLinkOpener(term, host, onActivate);
termRef.current = term;
fitRef.current = fit;

Expand Down Expand Up @@ -1612,6 +1644,7 @@ const captureArmedRef = useRef(false);
ro.disconnect();
disposeCopyOnSelect();
disposeLinkOpener();
disposePathLinks.dispose();
unregisterDrop();
disposeImeBridge();
unlistenDataRef.current?.();
Expand Down Expand Up @@ -2012,6 +2045,22 @@ const captureArmedRef = useRef(false);
/>
)}
<div ref={hostRef} className="min-h-0 flex-1 bg-[var(--color-bg)]" />
{pathMenu && (
<TerminalPathMenu
x={pathMenu.x}
y={pathMenu.y}
candidates={pathMenu.candidates}
onPick={(path) => { openPathFile(path, pathMenu.line, pathMenu.col); setPathMenu(null); }}
onClose={() => setPathMenu(null)}
onCloseAutoFocus={(e, picked) => {
// The anchor is an invisible, non-focusable div, so never let Radix
// return focus to it. On dismiss, hand focus back to the terminal;
// on a pick, leave it for the editor that just opened.
e.preventDefault();
if (!picked) termRef.current?.focus();
}}
/>
)}
{searchOpen && (
<div className="absolute right-2 top-2 z-20 flex items-center gap-0.5 rounded border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2 py-1 shadow-lg">
<input
Expand Down
46 changes: 46 additions & 0 deletions src/components/task/TerminalPathMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useRef } from "react";
import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem } from "@/components/ui/Dropdown";
import { fileIconUrl } from "@/lib/explorer/iconResolver";

export function TerminalPathMenu({ x, y, candidates, onPick, onClose, onCloseAutoFocus }: {
x: number;
y: number;
candidates: string[];
onPick: (path: string) => void;
onClose: () => void;
// `picked` distinguishes a candidate selection from a dismiss (Escape /
// click-away), so the caller can route focus accordingly.
onCloseAutoFocus?: (e: Event, picked: boolean) => void;
}) {
const picked = useRef(false);
return (
<DropdownRoot open onOpenChange={(v) => { if (!v) onClose(); }}>
<DropdownTrigger asChild>
{/* invisible anchor at the click point; Radix positions the menu off it */}
<div style={{ position: "fixed", left: x, top: y, width: 1, height: 1, pointerEvents: "none" }} />
</DropdownTrigger>
<DropdownMenu align="start" side="bottom" sideOffset={4}
onCloseAutoFocus={onCloseAutoFocus && ((e) => onCloseAutoFocus(e, picked.current))}>
{candidates.length === 0 ? (
<div className="px-3 py-3 text-[13px] text-[var(--color-fg-faint)]">
No matches
</div>
) : candidates.map(path => {
const name = path.split("/").pop() || path;
const dir = path.slice(0, path.length - name.length);
return (
<DropdownItem key={path} onSelect={() => { picked.current = true; onPick(path); }}>
<img src={fileIconUrl(name)} alt="" className="h-4 w-4 shrink-0 file-icon" />
<span className="truncate">{name}</span>
{dir && (
<span className="ml-2 min-w-0 flex-1 truncate text-[12px] text-[var(--color-fg-faint)]">
{dir.replace(/\/$/, "")}
</span>
)}
</DropdownItem>
);
})}
</DropdownMenu>
</DropdownRoot>
);
}
63 changes: 63 additions & 0 deletions src/lib/pathMatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { normalizePath, matchesSuffix, resolvePathClick } from "./pathMatch";

describe("normalizePath", () => {
it("strips a leading ./", () => {
expect(normalizePath("./src/a.ts")).toBe("src/a.ts");
});
it("strips all leading ./ and / segments", () => {
expect(normalizePath(".//src/a.ts")).toBe("src/a.ts");
expect(normalizePath("././src/a.ts")).toBe("src/a.ts");
expect(normalizePath("/./src/a.ts")).toBe("src/a.ts");
});
it("strips a leading absolute slash", () => {
expect(normalizePath("/src/a.ts")).toBe("src/a.ts");
});
it("leaves ../ alone (parent traversal is meaningful)", () => {
expect(normalizePath("../src/a.ts")).toBe("../src/a.ts");
});
it("leaves an already-bare path untouched", () => {
expect(normalizePath("src/a.ts")).toBe("src/a.ts");
});
});

describe("matchesSuffix", () => {
it("matches an identical path", () => {
expect(matchesSuffix("src/file.ts", "src/file.ts")).toBe(true);
});
it("matches a segment-boundary suffix", () => {
expect(matchesSuffix("foo/src/file.ts", "src/file.ts")).toBe(true);
});
it("matches a bare filename against a deeper path", () => {
expect(matchesSuffix("foo/src/file.ts", "file.ts")).toBe(true);
});
it("rejects a different leading segment", () => {
expect(matchesSuffix("abc/file.ts", "src/file.ts")).toBe(false);
});
it("rejects a raw (non-segment-boundary) suffix", () => {
expect(matchesSuffix("foo/barfile.ts", "file.ts")).toBe(false);
});
it("normalizes both sides before comparing", () => {
expect(matchesSuffix("/foo/src/file.ts", "./src/file.ts")).toBe(true);
});
it("does not match when the candidate is shorter than the query", () => {
expect(matchesSuffix("file.ts", "src/file.ts")).toBe(false);
});
});

describe("resolvePathClick", () => {
const files = ["src/app/dup.ts", "src/lib/dup.ts", "src/main.ts", "README.md"];

it("returns a single match (handler opens it directly)", () => {
expect(resolvePathClick(files, "main.ts")).toEqual(["src/main.ts"]);
});
it("returns every duplicate-basename match (handler shows the picker)", () => {
expect(resolvePathClick(files, "dup.ts")).toEqual(["src/app/dup.ts", "src/lib/dup.ts"]);
});
it("disambiguates a duplicate down to one when the click is dir-qualified", () => {
expect(resolvePathClick(files, "app/dup.ts")).toEqual(["src/app/dup.ts"]);
});
it("returns nothing for an unknown path (handler shows the no-matches row)", () => {
expect(resolvePathClick(files, "nope.ts")).toEqual([]);
});
});
17 changes: 17 additions & 0 deletions src/lib/pathMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Matching a file-path fragment from terminal output against the workspace
// file list, on segment boundaries (not raw string suffix).

export function normalizePath(p: string): string {
// Strip every leading "./" and "/" (but not "../", which is meaningful).
return p.replace(/^(?:\.?\/)+/, "");
}

export function matchesSuffix(candidate: string, clicked: string): boolean {
const c = normalizePath(candidate);
const q = normalizePath(clicked);
return c === q || c.endsWith("/" + q);
}

export function resolvePathClick(files: string[], clicked: string): string[] {
return files.filter(f => matchesSuffix(f, clicked));
}
87 changes: 87 additions & 0 deletions src/lib/termLinkOpener.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { PATH_TOKEN_RE, parsePathToken, scanPathTokens } from "./termLinkOpener";

const firstMatch = (s: string): string | null => PATH_TOKEN_RE.exec(s)?.[0] ?? null;
const scan = (s: string): string[] => scanPathTokens(s).map(t => t.raw);

describe("PATH_TOKEN_RE", () => {
it("matches a dir-qualified path", () => {
expect(firstMatch("src/index.ts")).toBe("src/index.ts");
});
it("matches a bare filename with a letter-led extension", () => {
expect(firstMatch("file.ts")).toBe("file.ts");
});
it("matches a multi-dotted filename", () => {
expect(firstMatch("file.min.js")).toBe("file.min.js");
});
it("captures a trailing :line:col", () => {
expect(firstMatch("app.tsx:45:2")).toBe("app.tsx:45:2");
});
it("captures a trailing :line", () => {
expect(firstMatch("src/a.ts:12")).toBe("src/a.ts:12");
});
it("does not match a bare version string", () => {
expect(firstMatch("1.2.3")).toBeNull();
expect(firstMatch("v1.2.3")).toBeNull();
});
it("does not match an extension-less, slash-less word", () => {
expect(firstMatch("README")).toBeNull();
expect(firstMatch("just words here")).toBeNull();
});
it("matches an @ filename (retina asset)", () => {
expect(firstMatch("logo@2x.png")).toBe("logo@2x.png");
});
it("stays fast on a long slash-less/dot-less blob (no O(n^2) backtracking)", () => {
// A wrapped base64/hash line can reach tens of thousands of chars; the
// hover-underline pass runs this regex on every row, so a quadratic blowup
// would stall the main thread. Bounded segment runs keep it linear.
const blob = "a".repeat(40000);
const start = performance.now();
expect(firstMatch(blob)).toBeNull();
expect(performance.now() - start).toBeLessThan(100);
});
});

describe("scanPathTokens", () => {
it("skips a schemed URL (WebLinksAddon owns it) but keeps a nearby path", () => {
expect(scan("https://example.com/a/b.ts and src/file.ts:10")).toEqual(["src/file.ts:10"]);
});
it("skips an scp-style host:path git remote entirely", () => {
expect(scan("git@github.com:dancras/termic.git clone")).toEqual([]);
});
it("keeps a :line:col position ref (colon before a digit is not a connector)", () => {
expect(scan("error at src/app.ts:45:2 today")).toEqual(["src/app.ts:45:2"]);
});
it("keeps a path before a prose colon ((path): description)", () => {
expect(scan("see (my/path.ts): description here")).toEqual(["my/path.ts"]);
expect(scan("file.ts: some description")).toEqual(["file.ts"]);
});
it("keeps @ filenames and scoped-package paths", () => {
expect(scan("retina logo@2x.png and node_modules/@types/node/index.d.ts"))
.toEqual(["logo@2x.png", "node_modules/@types/node/index.d.ts"]);
});
it("still underlines bare domains (addon does not; they are path-shaped)", () => {
expect(scan("bare example.com/page utils.ts")).toEqual(["example.com/page", "utils.ts"]);
});
it("stays fast on a long connector-heavy blob (bounded first run)", () => {
const blob = ("x".repeat(300) + ":").repeat(200) + "@".repeat(40000);
const start = performance.now();
scan(blob);
expect(performance.now() - start).toBeLessThan(150);
});
});

describe("parsePathToken", () => {
it("splits path, line, and col", () => {
expect(parsePathToken("src/file.ts:123:5")).toEqual({ path: "src/file.ts", line: 123, col: 5 });
});
it("splits path and line with no col", () => {
expect(parsePathToken("src/file.ts:123")).toEqual({ path: "src/file.ts", line: 123, col: undefined });
});
it("returns just the path when there is no trailing line", () => {
expect(parsePathToken("src/file.ts")).toEqual({ path: "src/file.ts" });
});
it("handles a bare filename", () => {
expect(parsePathToken("file.ts")).toEqual({ path: "file.ts" });
});
});
Loading