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
+5
View File
@@ -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
+37 -12
View File
@@ -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,
})
}
+32 -1
View File
@@ -334,6 +334,9 @@
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
enterkeyhint="send" inputmode="text">
<button class="key-btn" id="btnEnter">Enter</button>
<!-- A newline *without* submitting. There is no Shift on a phone keyboard,
so the chord the desktop app binds needs a key of its own here. -->
<button class="key-btn" id="btnNewline" title="Insert a newline without submitting (Shift+Enter)">&#8629;+</button>
<button class="key-btn" id="btnTab">Tab</button>
<button class="key-btn" id="btnCtrlC">^C</button>
</div>
@@ -360,6 +363,7 @@
const emptyState = document.getElementById('emptyState');
const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter');
const btnNewline = document.getElementById('btnNewline');
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
@@ -647,6 +651,28 @@
});
});
// Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter, so
// without this Shift+Enter is byte-identical to Enter.
//
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
// meta, and it is the same sequence its own `/terminal-setup` installs for
// VS Code, Cursor, Alacritty and Zed. Do not "simplify" it to `\n`: that
// also works in Claude Code, but a shell would *run* the line, so the two
// session types would diverge. Claude sessions only, for that reason —
// `bash -l` has no readline binding for `\e\r`.
term.attachCustomKeyEventHandler(e => {
if (
e.type === 'keydown' && e.key === 'Enter' && e.shiftKey &&
!e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing &&
sessionType === 'claude'
) {
sendTerminalInput('\x1b\r');
return false; // xterm must not also send a bare CR, which submits
}
return true;
});
// Track scroll position for scroll-to-bottom button
term.onScroll(() => updateScrollButton());
@@ -799,7 +825,11 @@
sendTerminalInput(val);
mobileInput.value = '';
}
sendTerminalInput('\r');
// Shift+Enter is a newline, not a submit — same bytes, and the same
// reasoning, as the terminal's own key handler above. A hardware
// keyboard on a tablet is the only way to reach this; the phone case is
// the dedicated newline button beside Enter.
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
} else if (e.key === 'Tab') {
e.preventDefault();
sendTerminalInput('\t');
@@ -807,6 +837,7 @@
});
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };