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:
@@ -82,6 +82,10 @@ pub struct BridgedPort {
|
||||
pub family: PortFamily,
|
||||
/// RFC 3339 timestamp of when the host listener was bound.
|
||||
pub bridged_at: String,
|
||||
/// Set when only the IPv4 half of the host listener could be bound. The
|
||||
/// port still works, but not for a client that insists on `::1` — see
|
||||
/// [`tunnel::PortForward::ipv6_warning`].
|
||||
pub ipv6_warning: Option<String>,
|
||||
}
|
||||
|
||||
/// A loopback listener that was discovered but could not be bridged.
|
||||
@@ -132,6 +136,7 @@ impl BridgeState {
|
||||
port: f.port,
|
||||
family: f.family,
|
||||
bridged_at: f.bridged_at.clone(),
|
||||
ipv6_warning: f.ipv6_warning.clone(),
|
||||
})
|
||||
.collect(),
|
||||
conflicts: self
|
||||
|
||||
@@ -52,6 +52,15 @@ pub struct PortForward {
|
||||
pub port: u16,
|
||||
pub family: PortFamily,
|
||||
pub bridged_at: String,
|
||||
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
|
||||
///
|
||||
/// A half-bound forward is the one failure mode that looks like a success:
|
||||
/// the status says the port is bridged, and a browser that resolves
|
||||
/// `localhost` to `::1` and does not fall back still gets a refused
|
||||
/// connection. It is not a conflict — the IPv4 half really is carrying
|
||||
/// traffic — so it rides along with the port it belongs to and the UI says
|
||||
/// so, rather than being logged at debug where nobody sees it.
|
||||
pub ipv6_warning: Option<String>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -86,18 +95,33 @@ impl PortForward {
|
||||
// first, so a v4-only host listener would miss those callbacks. This is
|
||||
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => Some(l),
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||
port,
|
||||
port,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let (v6, ipv6_warning) =
|
||||
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => (Some(l), None),
|
||||
Err(e) => {
|
||||
// Warn, not debug. Best-effort is about whether to *fail*,
|
||||
// not about whether to say anything: on a host where
|
||||
// `localhost` resolves to `::1` and the client does not
|
||||
// fall back to IPv4, the callback is refused while the
|
||||
// bridge reports itself healthy — a silent failure with no
|
||||
// thread back to this line.
|
||||
log::warn!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \
|
||||
a client that resolves localhost to ::1 without falling back will not reach it",
|
||||
port,
|
||||
port,
|
||||
e
|
||||
);
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
|
||||
localhost to ::1 without falling back will not reach this port.",
|
||||
port, e
|
||||
)),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let target = family.socat_target(port);
|
||||
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||
@@ -106,6 +130,7 @@ impl PortForward {
|
||||
port,
|
||||
family,
|
||||
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||
ipv6_warning,
|
||||
task,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user