Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/web
Steps to reproduce
- Use a clean checkout at the commit under Version or commit, with its locked dependencies installed and the Playwright Chromium browser used by the repository's browser test project available.
- From the top of the checkout, run the script in the collapsed block below with
sh repro.sh. It creates a temporary project containing a realacme/widgets.gitfile, generates a temporary browser test from the existingapps/web/src/components/ThreadTerminalDrawer.browser.test.tsxscaffolding, mounts the productionThreadTerminalDrawerwith the real Ghostty terminal surface (WASM core, buffer, canvas renderer and pointer handlers), writes each comparison line into the terminal, and dispatches hover and plain-click pointer events. It supplies the file's metadata as{kind: "file"}at the fixture's filesystem RPC boundary and records editor requests instead of launching an editor. It removes the generated test and project afterward, leaves the checkout's Git status unchanged, and saves canvas screenshots to a printed temporary directory. - Read the printed JSON lines. The script exits 0 only when the SCP-style line with an existing tail is linked and opened, and every control behaves as described under Actual behavior, so its assertions pin the current defect.
Reproduction script: repro.sh
#!/bin/sh
# Run from the top of a T3 Code checkout with dependencies and the Playwright Chromium browser installed.
set -eu
CHECKOUT=$(pwd -P)
OUT=${OUT:-$(mktemp -d)}
tmp=$(mktemp -d)
fixture="$CHECKOUT/apps/web/src/components/TerminalScpVerify.browser.test.tsx"
trap 'rm -f "$fixture"; rm -rf "$tmp" "$CHECKOUT/.scp-verify-captures"' EXIT INT TERM
mkdir -p "$tmp/userhome" "$tmp/tmp" "$tmp/t3home" "$tmp/project/acme"
printf '%s\n' 'real relative local file' > "$tmp/project/acme/widgets.git"
mkdir -p "$CHECKOUT/.scp-verify-captures"
cat > "$tmp/build-fixture.py" <<'PY'
from pathlib import Path
import sys
root=Path(sys.argv[1]); out=Path(sys.argv[2]); tmp=Path(sys.argv[3])
s=(root/'apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx').read_text()
s=s[:s.index('it("closes an exited terminal')]
s=s.replace('surfaceOptions: [] as GhosttyTerminalSurfaceOptions[],','surfaceOptions: [] as GhosttyTerminalSurfaceOptions[],\n surfaces: [] as any[],\n opened: [] as string[],\n lookups: [] as string[],')
a=s.index('// Every surface method'); b=s.index('vi.mock("../state/terminalSessions"',a)
s=s[:a]+'''vi.mock("~/terminal/ghostty/surface", async () => {
const actual = await vi.importActual<typeof import("~/terminal/ghostty/surface")>("~/terminal/ghostty/surface");
return {
...actual,
GhosttyTerminalSurface: {
create: async (mount: HTMLElement, options: GhosttyTerminalSurfaceOptions) => {
mount.style.width = "1000px";
mount.style.height = "220px";
fixture.surfaceOptions.push(options);
const surface = await actual.GhosttyTerminalSurface.create(mount, options);
fixture.surfaces.push(surface);
return surface;
},
},
};
});
'''+s[b:]
s=s.replace('const key = JSON.stringify([environmentId, input.path]);','fixture.lookups.push(input.path);\n const key = JSON.stringify([environmentId, input.path]);')
s=s.replace('useOpenInPreferredEditor: () => () => {}','useOpenInPreferredEditor: () => async (target: string) => { fixture.opened.push(target); return AsyncResult.success("cursor"); }')
s+='''
import { page } from "vite-plus/test/browser";
it("observes scp remote hover and click through the real terminal surface and viewport", async () => {
const { other: threadRef } = setup();
const cwd = CWD_PLACEHOLDER;
const relative = "acme/widgets.git";
fixture.metadata.set(JSON.stringify([threadRef.environmentId, `${cwd}/${relative}`]),
Atom.make(AsyncResult.success({kind: "file"})));
const container = document.createElement("div");
container.style.cssText = "width:1100px;height:300px";
await using screen = await render(
<AppAtomRegistryProvider><Drawer threadRef={threadRef} cwd={cwd} onCloseTerminal={() => {}} /></AppAtomRegistryProvider>,
{ container },
);
await vi.waitFor(() => expect(fixture.surfaces.length).toBe(1));
const surface = fixture.surfaces[0];
const options = fixture.surfaceOptions[0]!;
let capture = 0;
console.log(JSON.stringify({browser:navigator.userAgent}));
async function run(line: string, target: string, expectedPointer: boolean, expectedOpen: string | null) {
surface.canvas.dispatchEvent(new PointerEvent("pointerleave"));
await act(async () => surface.write(`\\x1b[2J\\x1b[H${line}`));
await vi.waitFor(() => expect(surface.snapshot.rowData[0].text).toBe(line));
const col = line.indexOf(target) + 2;
expect(col).toBeGreaterThan(1);
const bounds = surface.canvas.getBoundingClientRect();
const x = bounds.left + 4 + (col + .5) * surface.metrics.width;
const y = bounds.top + surface.originY + surface.metrics.height / 2;
const lookupCount = fixture.lookups.length;
await act(async () => surface.canvas.dispatchEvent(new PointerEvent("pointermove", {clientX:x,clientY:y,pointerId:1,pointerType:"mouse",bubbles:true})));
if (extractTerminalLinks(line).length) await vi.waitFor(() => expect(fixture.lookups.length).toBeGreaterThan(lookupCount));
await vi.waitFor(() => expect(surface.canvas.style.cursor).toBe(expectedPointer ? "pointer" : ""));
expect(surface.hoveredLink?.text ?? null).toBe(expectedPointer ? target : null);
await vi.waitFor(() => expect(surface.forceFullRender).toBe(false));
await page.elementLocator(surface.canvas).screenshot({path: `OUT_PLACEHOLDER/hover-${++capture}.png`});
const before = fixture.opened.length;
await act(async () => {
for (const type of ["pointerdown", "pointerup"]) surface.canvas.dispatchEvent(new PointerEvent(type, {
clientX:x,clientY:y,pointerId:1,pointerType:"mouse",bubbles:true,button:0,buttons:type === "pointerdown" ? 1 : 0,
}));
});
if (expectedOpen) await vi.waitFor(() => expect(fixture.opened.at(-1)).toBe(expectedOpen));
else {
await options.validateLink?.(target);
expect(fixture.opened.length).toBe(before);
}
console.log(JSON.stringify({line,extract:extractTerminalLinks(line),hoverCandidate:surface.linkHoverCandidate?.text ?? null,
visibleUnderline:surface.hoveredLink?.text ?? null,cursor:surface.canvas.style.cursor,
localOpen:fixture.opened.slice(before),previewElements:screen.container.querySelectorAll('[role="tooltip"],[role="dialog"]').length}));
}
await run("git clone git@github.com:missing/widgets.git", "missing/widgets.git", false, null);
await run("connected to redis://127.0.0.1:6379/0", "6379", false, null);
await run("git clone git@github.com:acme/widgets.git", relative, true, `${cwd}/${relative}`);
await run("git clone ssh://git@github.com/acme/widgets.git", relative, false, null);
await run("wrote acme/widgets.git", relative, true, `${cwd}/${relative}`);
});
'''
import json
s=s.replace('CWD_PLACEHOLDER',json.dumps(str(tmp))).replace('OUT_PLACEHOLDER',str(root/'.scp-verify-captures'))
(root/'apps/web/src/components/TerminalScpVerify.browser.test.tsx').write_text(s)
PY
python3 "$tmp/build-fixture.py" "$CHECKOUT" "$OUT" "$tmp/project"
case "$(uname -s)" in
Darwin) browser_cache="$HOME/Library/Caches/ms-playwright" ;;
*) browser_cache="${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;;
esac
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$browser_cache}"
export HOME="$tmp/userhome" TMPDIR="$tmp/tmp/" T3CODE_HOME="$tmp/t3home"
export PATH="$CHECKOUT/node_modules/.bin:$PATH"
cd "$CHECKOUT/apps/web"
env -u NODE_ENV vp test run --project browser src/components/TerminalScpVerify.browser.test.tsx --reporter verbose --silent false
mv "$CHECKOUT"/.scp-verify-captures/*.png "$OUT/"
echo "screenshots: $OUT"Expected behavior
A Git remote token should not become a link to an unrelated local file merely because the repository path also exists under the terminal's working directory, and the SCP-style and ssh:// spellings of the same remote should behave consistently. The terminal link extractor already treats the tail of a scheme:// URL as not a path, as the comment and SCHEME_URL_PATTERN in terminal-links.ts and its filtering pass in extractTerminalLinks show. Git's clone documentation (git help clone, section GIT URLS) describes an alternative scp-like syntax for SSH remotes, recognized only when there is no slash before the first colon, so git@github.com:acme/widgets.git names a remote repository rather than the local relative path acme/widgets.git. The change that added the scheme-URL exclusion (#28189) did not mention SCP-style remotes, so this is a case it did not cover, not a regression of it.
Actual behavior
Summary. When terminal output contains an SCP-style Git remote such as git clone git@github.com:acme/widgets.git and a file named acme/widgets.git exists under the terminal's working directory, the terminal turns the remote's path tail into a local-file link: hovering underlines acme/widgets.git with a pointer cursor, and a plain click opens the unrelated local path in the preferred editor. The equivalent ssh://git@github.com/acme/widgets.git URL produces no link with the same local file present. A tail that does not exist locally stays inert. This is a misleading link affordance with incidental local-file navigation; no file mutation or loss of work was observed, and Git operations are unaffected.
Observed in the browser test: extractTerminalLinks("git clone git@github.com:acme/widgets.git") returns a path match {kind: "path", text: "acme/widgets.git", start: 25, end: 41}. With that file present, hovering underlines only acme/widgets.git and sets a pointer cursor (first screenshot below), and a plain click reaches the drawer's preferred-editor callback with the temporary project's acme/widgets.git as its target. No hover preview element appears. With the same file present, git clone ssh://git@github.com/acme/widgets.git produces no match, no underline and no editor request (second screenshot below).
A nonexistent missing/widgets.git tail is still extracted, but hovering shows no underline or pointer and clicking dispatches no editor request: the drawer's metadata check allows a path link only when the resolved path is an existing file or directory. So the visible failure needs a real local collision. For that collision, the click handler resolves the tail against the terminal's working directory and sends it to the preferred editor. The production caller is the Ghostty surface's link hit test, which runs extractTerminalLinks on the terminal row text (surface.ts).
The test intercepts the preferred-editor function instead of launching a native editor, and supplies session, permission and file-metadata fixtures at the external boundaries; the terminal surface, buffer, extractor, renderer, hover gating and click handling are production code. The test covered a colliding file, not a colliding directory. Packaged desktop, mobile, a native editor, a real server metadata transport and wrapped rows were not exercised.
Code locator, as a breadcrumb rather than a prescribed fix: SCHEME_URL_PATTERN requires ://, so the relative-path matcher can start right after an SCP remote's colon.
Evidence
- Expected source: the scheme-URL exclusion in
apps/web/src/terminal-links.tslines 46-48 and 113-136; Git's documented SCP-like remote syntax ingit help clone(GIT URLS); thessh://control in the reproduction. - Failure source: the reproduction output under Logs or stack traces, produced by the production
ThreadTerminalDrawer, Ghostty surface andextractTerminalLinks, and the screenshots under Screenshots, recordings, or supporting files. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The script passed from a clean checkout at the baseline commit. Fetched upstream main at ee90bdc9405d8d4b8ff78ce2133ccb04844298ab has no changes to apps/web/src/terminal-links.ts, apps/web/src/terminal/ghostty/surface.ts or apps/web/src/components/ThreadTerminalDrawer.tsx relative to the baseline; the script was not rerun there.
Related
Merged #28189 (and its predecessor #25563) fixed scheme:// URL tails and did not discuss SCP-style remotes. Open #34397 changes delimiter-trimming performance while preserving link classification. Open #31917 fixes IPv6 SCP parsing in source-control code, not terminal links. Open #30505 chooses the destination for terminal file links, not their classification. Closed #15757 concerned logged request routes and older unguarded editor activation. None addresses this case.
Restoration check
With the script's input and the local acme/widgets.git file present, hovering acme/widgets.git in git clone git@github.com:acme/widgets.git shows no underline or pointer cursor and a plain click sends no editor request, matching the ssh://git@github.com/acme/widgets.git control. (The script asserts the current defect and will fail once it is fixed.) Controls keep their behavior: the bare local path in wrote acme/widgets.git is still underlined and opens acme/widgets.git under the working directory, redis://127.0.0.1:6379/0 and the ssh:// remote stay unlinked, and a nonexistent tail stays inert.
Impact
Minor bug or occasional failure
Version or commit
T3 Code 0.1.9 at d9805b2, rerun from a clean checkout. Upstream main at ee90bdc has no changes to the three terminal files involved; the script was not rerun there.
Environment
macOS on arm64, Node v26.12.1, the repository's browser test project with Vitest 5.0.5 and HeadlessChrome 156.0.8078.4, private temporary HOME, TMPDIR and T3CODE_HOME; no live user data, server or native editor.
Logs or stack traces
Script output (the temporary project root shown as $project):
{"line":"git clone git@github.com:missing/widgets.git","extract":[{"kind":"path","text":"missing/widgets.git","start":25,"end":44}],"hoverCandidate":"missing/widgets.git","visibleUnderline":null,"cursor":"","localOpen":[],"previewElements":0}
{"line":"connected to redis://127.0.0.1:6379/0","extract":[],"hoverCandidate":null,"visibleUnderline":null,"cursor":"","localOpen":[],"previewElements":0}
{"line":"git clone git@github.com:acme/widgets.git","extract":[{"kind":"path","text":"acme/widgets.git","start":25,"end":41}],"hoverCandidate":"acme/widgets.git","visibleUnderline":"acme/widgets.git","cursor":"pointer","localOpen":["$project/acme/widgets.git"],"previewElements":0}
{"line":"git clone ssh://git@github.com/acme/widgets.git","extract":[],"hoverCandidate":null,"visibleUnderline":null,"cursor":"","localOpen":[],"previewElements":0}
{"line":"wrote acme/widgets.git","extract":[{"kind":"path","text":"acme/widgets.git","start":6,"end":22}],"hoverCandidate":"acme/widgets.git","visibleUnderline":"acme/widgets.git","cursor":"pointer","localOpen":["$project/acme/widgets.git"],"previewElements":0}
✓ |browser (chromium)| src/components/TerminalScpVerify.browser.test.tsx > observes scp remote hover and click through the real terminal surface and viewport
Test Files 1 passed (1)
Tests 1 passed (1)
Screenshots, recordings, or supporting files
Workaround
Ignore the false link, or hold Shift while clicking: the surface's pointer handler skips link activation for Shift-clicks (surface.ts); this was read in source, not exercised. The ssh:// spelling of the remote is not linked in the tested case.


Activity