Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions apps/desktop/native-backend/src/devtools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,46 @@ fn release_session_runtime_resources(
}
}

/// Decode a PTY read into a UTF-8 string, carrying any incomplete trailing
/// multi-byte sequence across to the next read via `carry`. Genuinely invalid
/// bytes are replaced with U+FFFD; only an unfinished-but-valid prefix is held
/// back. Mirrors Node's `StringDecoder('utf8')`.
fn decode_utf8_with_carry(carry: &mut Vec<u8>, bytes: &[u8]) -> String {
carry.extend_from_slice(bytes);
let mut out = String::new();
let mut start = 0;
loop {
match std::str::from_utf8(&carry[start..]) {
Ok(valid) => {
out.push_str(valid);
carry.clear();
return out;
}
Err(error) => {
let valid_up_to = error.valid_up_to();
// SAFETY: `valid_up_to` is, by contract, a valid UTF-8 boundary.
out.push_str(unsafe {
std::str::from_utf8_unchecked(&carry[start..start + valid_up_to])
});
match error.error_len() {
// A real invalid sequence: emit one replacement char and
// advance past it, then keep decoding the rest.
Some(len) => {
out.push('\u{FFFD}');
start += valid_up_to + len;
}
// Input ended mid-sequence: hold the tail for the next read.
None => {
let remainder = carry[start + valid_up_to..].to_vec();
*carry = remainder;
return out;
}
}
}
}
}
}

fn spawn_output_reader(
mut reader: Box<dyn Read + Send>,
closed: Arc<AtomicBool>,
Expand All @@ -499,6 +539,12 @@ fn spawn_output_reader(
) {
thread::spawn(move || {
let mut buffer = [0_u8; OUTPUT_CHUNK_SIZE];
// Holds the bytes of a multi-byte UTF-8 sequence that was split across a
// read boundary. Without this, `from_utf8_lossy` would replace the split
// codepoint with U+FFFD, corrupting box-drawing / emoji / powerline
// glyphs that TUIs like Claude Code emit heavily. This mirrors Node's
// StringDecoder: decode the valid prefix, carry the incomplete tail.
let mut carry: Vec<u8> = Vec::new();
loop {
if closed.load(Ordering::Relaxed) {
break;
Expand All @@ -509,8 +555,10 @@ fn spawn_output_reader(
if closed.load(Ordering::Relaxed) {
break;
}
let chunk = String::from_utf8_lossy(&buffer[..read]).into_owned();
emit_terminal_output(&event_tx, &session_id, chunk);
let chunk = decode_utf8_with_carry(&mut carry, &buffer[..read]);
if !chunk.is_empty() {
emit_terminal_output(&event_tx, &session_id, chunk);
}
}
Err(error) => {
if !closed.load(Ordering::Relaxed) {
Expand Down Expand Up @@ -817,6 +865,60 @@ mod tests {
use super::*;
use std::fs;

#[test]
fn decodes_utf8_sequence_split_across_reads() {
// "é" (U+00E9) is 0xC3 0xA9. Split it across two reads.
let mut carry = Vec::new();
let first = decode_utf8_with_carry(&mut carry, &[b'a', 0xC3]);
assert_eq!(first, "a");
assert_eq!(carry, vec![0xC3]);
let second = decode_utf8_with_carry(&mut carry, &[0xA9, b'b']);
assert_eq!(second, "\u{00E9}b");
assert!(carry.is_empty());
}

#[test]
fn decodes_three_byte_sequence_split_at_every_boundary() {
// "→" (U+2192) is 0xE2 0x86 0x92. Feed one byte per read.
let mut carry = Vec::new();
assert_eq!(decode_utf8_with_carry(&mut carry, &[0xE2]), "");
assert_eq!(decode_utf8_with_carry(&mut carry, &[0x86]), "");
assert_eq!(decode_utf8_with_carry(&mut carry, &[0x92]), "\u{2192}");
assert!(carry.is_empty());
}

#[test]
fn decodes_four_byte_emoji_split_across_reads() {
// "😀" (U+1F600) is 0xF0 0x9F 0x98 0x80. Split it across three reads.
let mut carry = Vec::new();
assert_eq!(decode_utf8_with_carry(&mut carry, &[0xF0, 0x9F]), "");
assert_eq!(decode_utf8_with_carry(&mut carry, &[0x98]), "");
assert_eq!(
decode_utf8_with_carry(&mut carry, &[0x80, b'!']),
"\u{1F600}!"
);
assert!(carry.is_empty());
}

#[test]
fn leaves_no_carry_for_complete_input() {
let mut carry = Vec::new();
assert_eq!(
decode_utf8_with_carry(&mut carry, "plain ascii ✓".as_bytes()),
"plain ascii ✓"
);
assert!(carry.is_empty());
}

#[test]
fn replaces_genuinely_invalid_bytes_without_stalling() {
// 0xFF is never valid UTF-8; it must become U+FFFD and not be carried.
let mut carry = Vec::new();
let out = decode_utf8_with_carry(&mut carry, &[b'a', 0xFF, b'b']);
assert_eq!(out, "a\u{FFFD}b");
assert!(carry.is_empty());
}

#[test]
fn resolves_requested_cwd_when_directory_exists() {
let dir = tempfile::tempdir().expect("temp dir");
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@replit/codemirror-vim": "^6.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
Expand Down
76 changes: 73 additions & 3 deletions apps/desktop/src/features/ai/components/AIAuthTerminalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { appendTerminalRawOutput } from "../../terminal/terminalRawOutput";
import { TerminalViewport } from "../../terminal/TerminalViewport";
import {
EMPTY_TERMINAL_SNAPSHOT,
type TerminalOutputCommand,
type TerminalSessionView,
} from "../../terminal/terminalTypes";
import { APP_BRAND_NAME } from "../../../app/utils/branding";
Expand Down Expand Up @@ -98,6 +99,37 @@ export function AIAuthTerminalModal({
const [rawOutput, setRawOutput] = useState("");
const [busy, setBusy] = useState(false);
const onRefreshSetupRef = useRef(onRefreshSetup);
// Local output channel feeding the viewport (it pipes commands straight to
// xterm). rawOutput state is kept separately, only for auth-success scanning.
const outputListenersRef = useRef(
new Set<(command: TerminalOutputCommand) => void>(),
);
const outputBacklogRef = useRef<TerminalOutputCommand[]>([]);
const replaySnapshotRef = useRef<string | null>(null);

const emitOutputCommand = useCallback((command: TerminalOutputCommand) => {
const listeners = outputListenersRef.current;
if (listeners.size === 0) {
outputBacklogRef.current.push(command);
return;
}
for (const listener of listeners) {
listener(command);
}
}, []);

// Reset the viewport screen and feed it an initial buffer (e.g. on (re)start).
const resetOutputWithBuffer = useCallback(
(buffer: string) => {
replaySnapshotRef.current = null;
outputBacklogRef.current = [];
emitOutputCommand({ type: "clear" });
if (buffer) {
emitOutputCommand({ type: "write", data: buffer });
}
},
[emitOutputCommand],
);

useEffect(() => {
onRefreshSetupRef.current = onRefreshSetup;
Expand Down Expand Up @@ -137,6 +169,7 @@ export function AIAuthTerminalModal({
if (payload.sessionId !== sessionIdRef.current) return;
setSnapshot(payload);
setRawOutput(payload.buffer);
resetOutputWithBuffer(payload.buffer);
setBusy(false);
}),
),
Expand All @@ -146,6 +179,10 @@ export function AIAuthTerminalModal({
setRawOutput((current) =>
appendTerminalRawOutput(current, payload.chunk),
);
emitOutputCommand({
type: "write",
data: payload.chunk,
});
}),
),
registerListener(
Expand Down Expand Up @@ -173,6 +210,7 @@ export function AIAuthTerminalModal({
const startSession = async () => {
setBusy(true);
setRawOutput("");
resetOutputWithBuffer("");
setSnapshot((current) => ({
...current,
sessionId: "",
Expand All @@ -197,6 +235,7 @@ export function AIAuthTerminalModal({
sessionIdRef.current = nextSnapshot.sessionId;
setSnapshot(nextSnapshot);
setRawOutput(nextSnapshot.buffer);
resetOutputWithBuffer(nextSnapshot.buffer);
setBusy(false);
} catch (error) {
if (disposed) return;
Expand All @@ -216,6 +255,7 @@ export function AIAuthTerminalModal({
}
void startSession();
});
// emitOutputCommand and resetOutputWithBuffer are stable (useCallback).

return () => {
disposed = true;
Expand All @@ -230,7 +270,15 @@ export function AIAuthTerminalModal({
void unlisten();
});
};
}, [open, runtimeId, methodId, vaultPath, customBinaryPath]);
}, [
open,
runtimeId,
methodId,
vaultPath,
customBinaryPath,
emitOutputCommand,
resetOutputWithBuffer,
]);

const handleClose = useCallback(() => {
const sessionId = sessionIdRef.current;
Expand All @@ -250,6 +298,7 @@ export function AIAuthTerminalModal({
}

setRawOutput("");
resetOutputWithBuffer("");
setSnapshot(buildInitialSnapshot(runtimeId, runtimeName, vaultPath));
setBusy(true);

Expand All @@ -263,6 +312,7 @@ export function AIAuthTerminalModal({
sessionIdRef.current = nextSnapshot.sessionId;
setSnapshot(nextSnapshot);
setRawOutput(nextSnapshot.buffer);
resetOutputWithBuffer(nextSnapshot.buffer);
setBusy(false);
} catch (error) {
setSnapshot((current) => ({
Expand All @@ -279,12 +329,13 @@ export function AIAuthTerminalModal({
runtimeId,
runtimeName,
vaultPath,
resetOutputWithBuffer,
]);

const sessionView = useMemo<TerminalSessionView>(
() => ({
snapshot,
rawOutput,
hasOutput: rawOutput.length > 0,
busy,
writeInput: async (input) => {
const sessionId = sessionIdRef.current;
Expand All @@ -304,9 +355,28 @@ export function AIAuthTerminalModal({
restart: handleRetry,
clearViewport: () => {
setRawOutput("");
resetOutputWithBuffer("");
},
subscribeOutput: (listener) => {
const listeners = outputListenersRef.current;
if (listeners.size === 0 && outputBacklogRef.current.length > 0) {
const pending = outputBacklogRef.current;
outputBacklogRef.current = [];
for (const command of pending) {
listener(command);
}
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getReplaySnapshot: () => replaySnapshotRef.current,
saveReplaySnapshot: (serialized) => {
replaySnapshotRef.current = serialized;
},
}),
[snapshot, rawOutput, busy, handleRetry],
[snapshot, rawOutput, busy, handleRetry, resetOutputWithBuffer],
);

if (!open) return null;
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/features/editor/EditorPaneContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,20 @@ function seedTerminalRuntime(terminalId: string, rawOutput = "ready\n") {
snapshot: makeSnapshot({
sessionId: `session-${terminalId}`,
}),
rawOutput,
hasOutput: false,
busy: false,
launchError: null,
},
},
});
// Output is piped through the channel; emitted now, it buffers until the
// viewport mounts and subscribes, then lands in xterm.
if (rawOutput) {
useTerminalRuntimeStore.getState().handleTerminalOutput({
sessionId: `session-${terminalId}`,
chunk: rawOutput,
});
}
}

function getEditorView() {
Expand Down
Loading
Loading