Shift+Enter newline, OAuth URL truncation, and the auth bridge toggle

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
This commit is contained in:
2026-08-23 08:31:39 -07:00
co-authored by Claude Opus 5
parent 75cace7dde
commit 22d142c70d
21 changed files with 1529 additions and 98 deletions
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import AuthBridgeRow, { bridgeIndicator } from "./AuthBridgeRow";
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
/**
* The bridge shipped with a working backend, a typed IPC wrapper, and no way to
* reach either: `setAuthBridgeEnabled` had zero call sites, and the
* `auth-bridge-changed` event had no listener — so a host port the bridge could
* not take was a silent failure that presented as a login that simply hung.
* These tests hold both halves down.
*/
const getAuthBridgeStatus = vi.fn<() => Promise<AuthBridgeStatus>>();
const setAuthBridgeEnabled = vi.fn<(id: string, on: boolean) => Promise<AuthBridgeStatus>>();
vi.mock("../../../../lib/tauri-commands", () => ({
getAuthBridgeStatus: () => getAuthBridgeStatus(),
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
}));
/** Captured so a test can push an `auth-bridge-changed` payload by hand. */
let emit: ((payload: unknown) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (_name: string, handler: (e: { payload: unknown }) => void) => {
emit = (payload) => handler({ payload });
return () => {
emit = null;
};
}),
}));
const OFF: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const project = {
id: "p1",
name: "api",
status: "running",
auth_bridge_enabled: false,
} as unknown as Project;
beforeEach(() => {
vi.clearAllMocks();
getAuthBridgeStatus.mockResolvedValue(OFF);
setAuthBridgeEnabled.mockResolvedValue({ ...OFF, enabled: true });
});
describe("AuthBridgeRow", () => {
it("turns the bridge on through its own command, not the project save", async () => {
// The dedicated command exists so this can be flipped while the container
// runs — which is exactly when a user discovers they need it. Routing it
// through the Config tab's stopped-only save would make it unreachable at
// the only moment it matters.
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
await waitFor(() =>
expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true),
);
});
it("stays usable while the container is running", async () => {
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled();
});
it("reports a port conflict the poller emitted", async () => {
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(emit).not.toBeNull());
emit!({
project_id: "p1",
status: {
enabled: true,
active_ports: [],
conflicts: [
{ port: 54545, reason: "Host port 54545 is already in use (…); not bridged." },
],
},
});
expect(await screen.findByText(/Port 54545/)).toBeInTheDocument();
expect(screen.getByText("Port conflict")).toBeInTheDocument();
});
it("ignores an event for a different project", async () => {
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(emit).not.toBeNull());
emit!({
project_id: "other",
status: { enabled: true, active_ports: [], conflicts: [{ port: 1, reason: "nope" }] },
});
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
});
it("puts the switch back if the command rejects", async () => {
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
expect(await screen.findByText(/not found/)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeChecked();
});
});
describe("bridgeIndicator", () => {
// Every branch is a glyph plus a word — status is never colour alone.
it("says nothing is on when it is off", () => {
expect(bridgeIndicator(OFF, true)).toEqual({ tone: "off", label: "Off" });
});
it("puts a conflict ahead of everything else", () => {
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
],
conflicts: [{ port: 2, reason: "taken" }],
},
true,
).tone,
).toBe("error");
});
it("flags a port that only took the IPv4 half", () => {
// Node resolves `localhost` to IPv6 first on Linux, so a v4-only listener
// is a callback that never arrives in front of a bridge reporting healthy.
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v6", bridged_at: "", ipv6_warning: "no ::1" },
],
conflicts: [],
},
true,
).label,
).toBe("IPv4 only");
});
it("counts the ports it is holding", () => {
expect(
bridgeIndicator(
{
enabled: true,
active_ports: [
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
{ port: 2, family: "v4", bridged_at: "", ipv6_warning: null },
],
conflicts: [],
},
true,
).label,
).toBe("Bridging 2 ports");
});
it("says it is waiting when the container is not running", () => {
// Enabled and holding nothing is normal; enabled with no container is a
// different thing, and saying so stops it reading as a failure.
expect(bridgeIndicator({ ...OFF, enabled: true }, false).label).toBe(
"Waiting for the container",
);
expect(bridgeIndicator({ ...OFF, enabled: true }, true).label).toBe("Watching");
});
});
@@ -0,0 +1,198 @@
import { useCallback, useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
getAuthBridgeStatus,
setAuthBridgeEnabled,
} from "../../../../lib/tauri-commands";
import type {
AuthBridgeChangedEvent,
AuthBridgeStatus,
Project,
} from "../../../../lib/types";
import { SwitchRow } from "../../../ui/Field";
import StatusIndicator, { type StatusTone } from "../../../ui/StatusIndicator";
import Toggle from "../../../ui/Toggle";
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
const LABEL = "Auth bridge";
/**
* What the indicator beside the switch says.
*
* Split out so the interesting part — that a conflict is a *visible* failure —
* can be tested without a container. Every branch pairs a glyph with a word;
* none of them are distinguished by colour alone.
*/
export function bridgeIndicator(
status: AuthBridgeStatus | null,
containerRunning: boolean,
): { tone: StatusTone; label: string } {
if (!status) return { tone: "unknown", label: "Checking" };
if (!status.enabled) return { tone: "off", label: "Off" };
// A conflict means a login is in progress and its port could not be taken —
// the one state where doing nothing is the wrong answer, and until now the
// one state nothing in the app reported at all.
if (status.conflicts.length > 0) {
return { tone: "error", label: "Port conflict" };
}
if (status.active_ports.some((p) => p.ipv6_warning)) {
return { tone: "busy", label: "IPv4 only" };
}
if (status.active_ports.length > 0) {
const n = status.active_ports.length;
return { tone: "running", label: `Bridging ${n} port${n === 1 ? "" : "s"}` };
}
// Enabled but holding nothing. Normal: there is only something to bridge
// while a login is actually waiting for a callback.
if (!containerRunning) {
return { tone: "stopped", label: "Waiting for the container" };
}
return { tone: "ok", label: "Watching" };
}
/**
* The switch for `auth_bridge_enabled`, and the only place it can be changed.
*
* Two things here are deliberate and easy to undo by accident:
*
* - **It does not go through the Config tab's `save`.** That path is gated on
* a stopped container, because almost everything else in the tab is baked
* into the container at creation. This is not: the bridge is entirely
* host-side, and `set_auth_bridge_enabled` exists precisely so it can be
* flipped *while a login is hanging*, which is when the user finds out they
* need it. Routing it through the generic save would make it unreachable at
* the only moment it matters.
* - **It subscribes to `auth-bridge-changed`.** The poller already emits the
* bridged-port and conflict sets on every change and, before this, nothing
* listened — so a host port the bridge could not take was a completely
* silent failure, indistinguishable from a login that simply hung.
*/
export default function AuthBridgeRow({ project }: { project: Project }) {
const projectId = project.id;
const containerRunning = project.status === "running";
const [status, setStatus] = useState<AuthBridgeStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setStatus(null);
setError(null);
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
setStatus(event.payload.status);
})
.then((un) => {
if (cancelled) un();
else unlisten = un;
})
.catch((e) => console.error("Auth bridge event subscription failed:", e));
return () => {
cancelled = true;
unlisten?.();
};
}, [projectId]);
const toggle = useCallback(
async (next: boolean) => {
setBusy(true);
setError(null);
// Optimistic, so the switch responds even though enabling has to await a
// container probe. The command's return value replaces it either way.
setStatus((s) => (s ? { ...s, enabled: next } : s));
try {
setStatus(await setAuthBridgeEnabled(projectId, next));
} catch (e) {
setStatus((s) => (s ? { ...s, enabled: !next } : s));
setError(String(e));
} finally {
setBusy(false);
}
},
[projectId],
);
// Fall back to the persisted flag until the first status arrives, so the
// switch never renders in the wrong position.
const enabled = status?.enabled ?? project.auth_bridge_enabled;
const indicator = bridgeIndicator(status, containerRunning);
return (
<SwitchRow
label={LABEL}
hint={
<>
Mirrors a port a program inside the container is listening on onto the
host's <code>127.0.0.1</code>, so a browser OAuth callback can reach
the listener waiting inside the container {" "}
<code>claude login</code>, <code>aws sso login</code> and{" "}
<code>gh auth login</code> all work this way, and without it the
browser calls back into nothing and the login hangs. Host-side only:
it never recreates the container, and it can be switched on while one
is running. A bridged port is unauthenticated and reachable by any
local process for as long as the in-container listener exists, so
leave it off unless you need it.
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<StatusIndicator tone={indicator.tone} label={indicator.label} />
{status?.active_ports.map((p) => (
<span
key={p.port}
className="font-mono text-[var(--text-secondary)]"
title={p.ipv6_warning ?? `Bound on host 127.0.0.1:${p.port}`}
>
127.0.0.1:{p.port}
{p.ipv6_warning ? " (IPv4 only)" : ""}
</span>
))}
</span>
{status?.conflicts.map((c) => (
<span
key={c.port}
className="mt-1 block text-[var(--error)]"
role="status"
>
Port {c.port}: {c.reason}
</span>
))}
{status?.active_ports
.filter((p) => p.ipv6_warning)
.map((p) => (
<span key={p.port} className="mt-1 block text-[var(--warning)]">
Port {p.port}: {p.ipv6_warning}
</span>
))}
{error && (
<span className="mt-1 block text-[var(--error)]">{error}</span>
)}
</>
}
control={
<Toggle
label={LABEL}
checked={enabled}
// Never gated on the container being stopped — see the note above.
disabled={busy}
onChange={toggle}
/>
}
/>
);
}
@@ -1,7 +1,21 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import RuntimeSection from "./RuntimeSection";
import type { Project } from "../../../../lib/types";
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
// The auth-bridge row owns its own IPC — see `AuthBridgeRow.tsx` for why it
// does not go through `save`.
const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
const setAuthBridgeEnabled = vi.fn(async () => ({ ...OFF_BRIDGE, enabled: true }));
vi.mock("../../../../lib/tauri-commands", () => ({
getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE),
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
const baseProject: Project = {
id: "p1",
@@ -92,3 +106,24 @@ describe("RuntimeSection — VPN support toggle", () => {
).toBeInTheDocument();
});
});
describe("RuntimeSection — auth bridge toggle", () => {
beforeEach(() => vi.clearAllMocks());
it("is reachable while the container is running", async () => {
// The rest of the tab is gated on a stopped container because those
// settings are baked in at creation. This one is host-side and has its own
// command, and the moment a user needs it is the moment a login is hanging
// in a *running* container — so the tab's `disabled` must not reach it.
renderSection({ status: "running" }, true);
const toggle = screen.getByRole("switch", { name: "Auth bridge" });
await waitFor(() => expect(toggle).not.toBeDisabled());
fireEvent.click(toggle);
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
// And never through the generic project save, which would drop it on the
// floor while the container runs.
expect(save).not.toHaveBeenCalled();
});
});
@@ -4,6 +4,7 @@ import { ConfigGroup, SwitchRow } from "../../../ui/Field";
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
import AuthBridgeRow from "./AuthBridgeRow";
interface Props {
project: Project;
@@ -70,6 +71,12 @@ export default function RuntimeSection({
}
/>
{/* Not gated on `disabled`: the bridge is host-side and has its own
command, so it can be switched on while a login is hanging — which
is the only moment anyone reaches for it. It owns its state rather
than going through `save`. */}
<AuthBridgeRow project={project} />
<SwitchRow
label="Mission Control"
hint="A web dashboard for monitoring and managing Claude sessions remotely."