Three fixes that all land on the same journey: sign in, paste a prompt, and have the terminal behave the way every other Claude Code host does. Shift+Enter inserts a newline ----------------------------- xterm.js does not consult `shiftKey` for Enter (`Keyboard.ts`, case 13), so Shift+Enter was byte-identical to Enter and submitted the prompt. Both terminals now send `\x1b\r` (ESC+CR) instead, which Claude Code parses as return+meta — the same bytes its own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and Zed keymaps, so this is in-band rather than a guess. Not `\n`: Claude Code accepts it, but a shell would run the line, so the two session types would diverge. Bound in Claude sessions only for that reason. `entrypoint.sh` sets `shiftEnterKeyBindingInstalled` in `~/.claude.json` so the CLI stops printing its "run /terminal-setup" tip. Purely cosmetic — the decoding is unconditional either way. Alt+Enter has always done the same thing (xterm ESC-prefixes on altKey) and was simply never documented. It is now, along with the rest. OAuth login URL truncation -------------------------- Two producers wrote one toast slot, last-writer-wins. The OSC 7777 relay delivers the URL base64-encoded and therefore exact; ~300 ms later the screen-scraper's debounce fired and overwrote it with a truncated guess at the same link — a URL that parses, points at the right host, and authorises nothing. The user is the one who has to notice. Why the scraper truncated: `ANSI_RE` strips OSC sequences wholesale, including the OSC 8 hyperlink whose parameter carries the complete URL. Claude Code slices the *visible* text of that hyperlink to the terminal width while every emission carries the whole URL in its parameter. The backend already knew this (`commands/auth_token_commands.rs`); the frontend did not. - `urlDetector` now reads OSC 8 targets out of the raw buffer before stripping, filtered by a port of `usable_sign_in_link`, and tags every candidate with its provenance. - The prompt slot gained `supersedes`: better provenance always wins, worse never does, and between equals only a candidate that *extends* what is showing may replace it. That last rule is `extendsUrl`, factored out of `pickSignInUrl` rather than copied — same rule, same reason, one implementation. - `flatten` splits on a bare `\r` as well as on `\r?\n`, so a `\r`-repainted TUI frame no longer inflates a line past the width and suppresses a join that should have happened; and the width is now sampled at `feed()` rather than read at `scan()`, so a resize inside the 300 ms debounce cannot reassemble 80-column text against a 120-column rule. Also corrects the comment claiming `acquire_claude_token` enables the auth bridge. It deliberately does not, and the module comment in `auth_token_commands.rs` explains at length why not. The auth bridge toggle ---------------------- `setAuthBridgeEnabled` and `getAuthBridgeStatus` had zero call sites: the Rust was complete, the IPC wrapper shipped, and there was nowhere to click — so the docs told users to "enable the Auth Bridge" for a switch that did not exist. `AuthBridgeRow` is that switch, in Config → Runtime. It deliberately does not go through the tab's stopped-only save: the dedicated command exists so the bridge can be flipped while a login is hanging in a running container, which is the only moment anyone reaches for it. It also subscribes to `auth-bridge-changed`, which the poller has been emitting to nobody — so a host port the bridge could not take was a completely silent failure, indistinguishable from a login that hung. `tunnel.rs` promotes the best-effort `::1` bind failure from debug to a warning recorded on the port. Half-bound is the failure mode that looks like success: the status says bridged, and a client that resolves `localhost` to `::1` without falling back is still refused. Finally, for a recognised Anthropic sign-in URL the toast now leads with "In container" and demotes the host "Open". The callback listener is inside the container, so the container-side browser closes the loop with no host round trip and no auth bridge; the host button stays as the fallback. Ordinary URLs are unchanged. Tests: 402 frontend (was 359), 285 Rust (unchanged). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import UrlToast from "./UrlToast";
|
|
|
|
/**
|
|
* The toast is the *only* thing standing between a container-chosen URL and
|
|
* the host's browser, so what it shows has to be what will be opened — and the
|
|
* part that decides that is the origin.
|
|
*/
|
|
describe("UrlToast", () => {
|
|
const noop = () => {};
|
|
|
|
it("shows the origin separately from the truncatable remainder", () => {
|
|
render(
|
|
<UrlToast
|
|
url="https://github.com/login/device?code=ABCD-EFGH"
|
|
onOpen={noop}
|
|
onDismiss={noop}
|
|
/>,
|
|
);
|
|
expect(screen.getByTestId("url-toast-origin")).toHaveTextContent(
|
|
"https://github.com",
|
|
);
|
|
expect(screen.getByTestId("url-toast-rest")).toHaveTextContent(
|
|
"/login/device?code=ABCD-EFGH",
|
|
);
|
|
});
|
|
|
|
it("keeps the origin intact when the path is long enough to push it out", () => {
|
|
const url = `https://evil.tld/${"padding/".repeat(200)}end`;
|
|
render(<UrlToast url={url} onOpen={noop} onDismiss={noop} />);
|
|
// The registrable domain must be present in its own element, whole. A
|
|
// single ellipsised line would render this and show only the padding.
|
|
expect(screen.getByTestId("url-toast-origin")).toHaveTextContent(
|
|
"https://evil.tld",
|
|
);
|
|
});
|
|
|
|
it("exposes the whole URL as a tooltip", () => {
|
|
const url = "https://example.com/a/b?c=d";
|
|
render(<UrlToast url={url} onOpen={noop} onDismiss={noop} />);
|
|
expect(screen.getByTestId("url-toast-url")).toHaveAttribute("title", url);
|
|
});
|
|
|
|
it("announces itself, so a replacement prompt is not silent", () => {
|
|
render(
|
|
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
|
|
);
|
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
});
|
|
|
|
it("opens only via the button, never on its own", () => {
|
|
const onOpen = vi.fn();
|
|
render(
|
|
<UrlToast url="https://example.com/" onOpen={onOpen} onDismiss={noop} />,
|
|
);
|
|
expect(onOpen).not.toHaveBeenCalled();
|
|
screen.getByRole("button", { name: "Open" }).click();
|
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
describe("Anthropic sign-in links", () => {
|
|
// The callback listener a `claude login` is waiting on is *inside* the
|
|
// container. Sending the user to their host browser completes the sign-in
|
|
// and then posts the result where nothing is listening, and the terminal
|
|
// hangs to its timeout — so for these, and only these, the container-side
|
|
// browser leads.
|
|
const SIGN_IN =
|
|
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
|
|
|
|
function actions() {
|
|
return screen
|
|
.getAllByRole("button")
|
|
.map((b) => b.textContent)
|
|
.filter((t) => t === "Open" || t === "In container");
|
|
}
|
|
|
|
it("puts the container browser first", () => {
|
|
render(
|
|
<UrlToast
|
|
url={SIGN_IN}
|
|
onOpen={noop}
|
|
onOpenInContainer={noop}
|
|
onDismiss={noop}
|
|
/>,
|
|
);
|
|
expect(actions()).toEqual(["In container", "Open"]);
|
|
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
|
|
/callback listener is inside the container/i,
|
|
);
|
|
});
|
|
|
|
it("keeps the host browser available as a fallback", () => {
|
|
const onOpen = vi.fn();
|
|
render(
|
|
<UrlToast
|
|
url={SIGN_IN}
|
|
onOpen={onOpen}
|
|
onOpenInContainer={noop}
|
|
onDismiss={noop}
|
|
/>,
|
|
);
|
|
screen.getByRole("button", { name: "Open" }).click();
|
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves an ordinary URL alone", () => {
|
|
// A `gh auth login` device code, a docs page, a preview build — the host
|
|
// browser is the right answer for all of them and stays the default.
|
|
render(
|
|
<UrlToast
|
|
url="https://github.com/login/device?code=ABCD-EFGH"
|
|
onOpen={noop}
|
|
onOpenInContainer={noop}
|
|
onDismiss={noop}
|
|
/>,
|
|
);
|
|
expect(actions()).toEqual(["Open", "In container"]);
|
|
expect(screen.queryByTestId("url-toast-signin-hint")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("is not fooled by a lookalike host", () => {
|
|
// `isAnthropicSignInUrl` uses the same allowlist the sign-in flow does,
|
|
// so a URL that merely says "claude.ai" somewhere is not one.
|
|
render(
|
|
<UrlToast
|
|
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
|
|
onOpen={noop}
|
|
onOpenInContainer={noop}
|
|
onDismiss={noop}
|
|
/>,
|
|
);
|
|
expect(actions()).toEqual(["Open", "In container"]);
|
|
});
|
|
});
|
|
});
|