diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index 560a2e41..de70e7c7 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -919,6 +919,7 @@ pub async fn write_to_session( &session_id, SessionCommand::Write { data: data.into_bytes(), + raw: false, automated, origin, sensitivity, @@ -955,6 +956,26 @@ pub async fn write_to_session( result } +#[tauri::command] +pub async fn write_bytes_to_session( + state: tauri::State<'_, Arc>, + session_id: String, + data: Vec, +) -> AppResult<()> { + state + .send_command( + &session_id, + SessionCommand::Write { + data, + raw: true, + automated: false, + origin: InputOrigin::Keyboard, + sensitivity: InputSensitivity::Normal, + }, + ) + .await +} + #[tauri::command] pub async fn set_session_output_paused( state: tauri::State<'_, Arc>, diff --git a/src-tauri/src/core/capabilities/terminal.rs b/src-tauri/src/core/capabilities/terminal.rs index 1f510c23..1d9599d6 100644 --- a/src-tauri/src/core/capabilities/terminal.rs +++ b/src-tauri/src/core/capabilities/terminal.rs @@ -116,6 +116,7 @@ pub async fn execute_terminal_command( &request.session_id, SessionCommand::Write { data, + raw: false, automated: true, origin: InputOrigin::AiAgent, sensitivity: InputSensitivity::Normal, diff --git a/src-tauri/src/core/session.rs b/src-tauri/src/core/session.rs index 780bc820..2a27520b 100644 --- a/src-tauri/src/core/session.rs +++ b/src-tauri/src/core/session.rs @@ -245,6 +245,7 @@ pub enum SessionCommand { /// Input to send to the terminal. Write { data: Vec, + raw: bool, automated: bool, origin: InputOrigin, sensitivity: InputSensitivity, @@ -1474,6 +1475,7 @@ mod tests { "sftp-only", SessionCommand::Write { data: b"ignored".to_vec(), + raw: false, automated: false, origin: InputOrigin::Keyboard, sensitivity: crate::core::InputSensitivity::Normal, @@ -1567,6 +1569,7 @@ mod tests { "local-startup", SessionCommand::Write { data: b"x".to_vec(), + raw: false, automated: false, origin: InputOrigin::Keyboard, sensitivity: super::InputSensitivity::Normal, diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index f5f0f943..10241ffa 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -3,11 +3,12 @@ use super::client::{ }; use crate::config::SftpCwdFollowMode; use crate::core::capture::OutputCaptureProcessor; -use crate::core::input::remap_del_to_bs; use crate::core::monitoring::stats::RemoteStatsSampler; use crate::core::ssh::osc::{self, OscStripper, ShellKind}; use crate::core::terminal_session::local::split_startup_passthrough; -use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; +use crate::core::terminal_session::{ + TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input, +}; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain, ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer, @@ -1328,7 +1329,7 @@ pub(super) async fn ssh_io_loop( Some(SessionCommand::DetachRenderer) => { output.detach(); } - Some(SessionCommand::Write { mut data, origin, .. }) => { + Some(SessionCommand::Write { data, raw, origin, .. }) => { if zmodem_transfer.is_some() || zmodem_upload_drain.should_suppress(std::time::Instant::now()) { @@ -1344,10 +1345,7 @@ pub(super) async fn ssh_io_loop( &mut post_login_deadline, ) .await; - if backspace_as_bs { - remap_del_to_bs(&mut data); - } - let send_data = encode_terminal_input(&data, &encoding); + let send_data = prepare_terminal_write_input(data, &encoding, raw, backspace_as_bs); if phase == IoPhase::Suppressing { suppression_diagnostics.record_pre_ready_write(send_data.len()); } diff --git a/src-tauri/src/core/terminal_session/local/mod.rs b/src-tauri/src/core/terminal_session/local/mod.rs index df07a0a4..f686519c 100644 --- a/src-tauri/src/core/terminal_session/local/mod.rs +++ b/src-tauri/src/core/terminal_session/local/mod.rs @@ -13,7 +13,9 @@ use crate::core::session::{ StartupInjectionAttempt, StartupInputBarrier, session_command_channel, }; use crate::core::ssh::osc::{CwdPayloadEvent, OscStripper, build_ready_marker}; -use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; +use crate::core::terminal_session::{ + TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input, +}; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, ZmodemTransfer, start_zmodem_transfer, diff --git a/src-tauri/src/core/terminal_session/local/session.rs b/src-tauri/src/core/terminal_session/local/session.rs index 5a2464a4..a6ef74ec 100644 --- a/src-tauri/src/core/terminal_session/local/session.rs +++ b/src-tauri/src/core/terminal_session/local/session.rs @@ -1327,7 +1327,7 @@ fn pty_session_thread( SessionCommand::DetachRenderer => { output.detach(); } - SessionCommand::Write { data, origin, .. } => { + SessionCommand::Write { data, raw, origin, .. } => { if input_cancels_startup_injection(origin) && startup_input_barrier .as_ref() @@ -1353,7 +1353,7 @@ fn pty_session_thread( if zmodem_input_blocked.load(Ordering::Acquire) { continue; } - let send_data = encode_terminal_input(&data, &encoding); + let send_data = prepare_terminal_write_input(data, &encoding, raw, false); let write_started_at = Instant::now(); match write_to_pty(&mut *writer, &send_data) { Ok(()) => { diff --git a/src-tauri/src/core/terminal_session/mod.rs b/src-tauri/src/core/terminal_session/mod.rs index 7b7fe8fc..7f04489e 100644 --- a/src-tauri/src/core/terminal_session/mod.rs +++ b/src-tauri/src/core/terminal_session/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod local; pub(crate) mod serial; pub(crate) mod telnet; +use crate::core::input::remap_del_to_bs; use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8}; pub(crate) fn terminal_encoding(label: &str) -> &'static Encoding { @@ -88,6 +89,21 @@ pub(crate) fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec { result } +pub(crate) fn prepare_terminal_write_input( + mut data: Vec, + encoding: &str, + raw: bool, + backspace_as_bs: bool, +) -> Vec { + if raw { + return data; + } + if backspace_as_bs { + remap_del_to_bs(&mut data); + } + encode_terminal_input(&data, encoding) +} + fn ansi_sequence_end(data: &[u8], start: usize) -> usize { let mut i = start + 1; if i >= data.len() { @@ -111,7 +127,20 @@ fn ansi_sequence_end(data: &[u8], start: usize) -> usize { #[cfg(test)] mod tests { - use super::{TerminalOutputDecoder, encode_terminal_input}; + use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input}; + + #[test] + fn write_input_encodes_text_but_preserves_raw_bytes_and_del() { + assert_eq!( + prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true), + vec![0xB2, 0xE2, 0x08] + ); + let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f]; + assert_eq!( + prepare_terminal_write_input(raw.clone(), "GBK", true, true), + raw + ); + } #[test] fn gbk_decoder_preserves_split_multibyte_character() { diff --git a/src-tauri/src/core/terminal_session/serial/mod.rs b/src-tauri/src/core/terminal_session/serial/mod.rs index 28dd468b..e8e1a0ad 100644 --- a/src-tauri/src/core/terminal_session/serial/mod.rs +++ b/src-tauri/src/core/terminal_session/serial/mod.rs @@ -2,13 +2,14 @@ use crate::config::AiExecutionProfile; use crate::core::capture::OutputCaptureProcessor; -use crate::core::input::remap_del_to_bs; use crate::core::session::{ DynamicTitleCapabilities, SessionCommand, SessionCommandReceiver, SessionCommandSender, SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd, session_command_channel, }; -use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; +use crate::core::terminal_session::{ + TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input, +}; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, ZmodemTransfer, start_zmodem_transfer, diff --git a/src-tauri/src/core/terminal_session/serial/session.rs b/src-tauri/src/core/terminal_session/serial/session.rs index 93b65a96..5551e9bc 100644 --- a/src-tauri/src/core/terminal_session/serial/session.rs +++ b/src-tauri/src/core/terminal_session/serial/session.rs @@ -304,15 +304,12 @@ fn serial_session_thread( SessionCommand::DetachRenderer => { output.detach(); } - SessionCommand::Write { mut data, .. } => { + SessionCommand::Write { data, raw, .. } => { if zmodem_state.lock().unwrap().is_some() || xymodem_state.lock().unwrap().is_some() { continue; } - if backspace_as_bs { - remap_del_to_bs(&mut data); - } - let send_data = encode_terminal_input(&data, &encoding); + let send_data = prepare_terminal_write_input(data, &encoding, raw, backspace_as_bs); let mut p = port_writer.lock().unwrap(); let _ = p.write_all(&send_data); let _ = p.flush(); diff --git a/src-tauri/src/core/terminal_session/telnet/mod.rs b/src-tauri/src/core/terminal_session/telnet/mod.rs index a8218b16..810be3c5 100644 --- a/src-tauri/src/core/terminal_session/telnet/mod.rs +++ b/src-tauri/src/core/terminal_session/telnet/mod.rs @@ -8,7 +8,9 @@ use crate::core::session::{ SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd, session_command_channel, }; -use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; +use crate::core::terminal_session::{ + TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input, +}; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain, ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer, diff --git a/src-tauri/src/core/terminal_session/telnet/session.rs b/src-tauri/src/core/terminal_session/telnet/session.rs index 953277dc..790884f1 100644 --- a/src-tauri/src/core/terminal_session/telnet/session.rs +++ b/src-tauri/src/core/terminal_session/telnet/session.rs @@ -375,7 +375,7 @@ async fn telnet_session_task( Some(SessionCommand::DetachRenderer) => { output.detach(); } - Some(SessionCommand::Write { mut data, automated, .. }) => { + Some(SessionCommand::Write { mut data, raw, automated, .. }) => { if !automated { let mut auto = auto_login.lock().await; if let Some(auto) = auto.as_mut() { @@ -398,7 +398,12 @@ async fn telnet_session_task( } let mut write_failed = None; - if line_edit_active { + if raw { + let send_data = prepare_terminal_write_input(data, &encoding, true, false); + if let Err(e) = writer.write_all(&send_data).await { + write_failed = Some(e); + } + } else if line_edit_active { let edit_result = line_editor.process(&data, config.enter_mode); if !edit_result.display.is_empty() { output.push_owned(edit_result.display); @@ -422,7 +427,7 @@ async fn telnet_session_task( output.push_owned(echoed); } } - let send_data = encode_terminal_input(&data, &encoding); + let send_data = prepare_terminal_write_input(data, &encoding, false, false); for chunk in split_write_chunks(&send_data, config.force_character_at_a_time) { if let Err(e) = writer.write_all(&chunk).await { write_failed = Some(e); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eb3cc875..4c970a03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -236,6 +236,7 @@ pub fn run() { cmd::session::cancel_session_creation, cmd::session::list_serial_ports, cmd::session::write_to_session, + cmd::session::write_bytes_to_session, cmd::session::set_session_output_paused, cmd::session::ack_session_output, cmd::session::resize_session, diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index 6364506f..173c5c0d 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -51,6 +51,7 @@ import { normalizeTerminalCommandInput, type SendSessionInputOptions, type SessionInputPreview, + sendSessionBinaryInput, sendSessionInput, sendSessionInputWithSync, } from "@/lib/sessionInput"; @@ -2354,6 +2355,16 @@ export default function XTerminal({ sendRawInput(data, data === "\r" && command ? command : null); }); + const binaryDisposable = terminal.onBinary((data) => { + if (shouldBlockXTerminalData(appLockedRef.current, "keyboard")) return; + if (aiCapturingRef.current) return; + if (hibernationPhaseRef.current !== "idle") { + requestWake("input"); + } + if (disconnectedRef.current) return; + void sendSessionBinaryInput(sessionIdRef.current, data).catch(() => {}); + }); + const resizeDisposable = terminal.onResize(({ cols, rows }) => { sendBackendResize(cols, rows, "xterm.onResize"); refreshGutter(); @@ -2499,6 +2510,7 @@ export default function XTerminal({ clipboardOscDisposable.dispose(); writeParsedDisposable.dispose(); dataDisposable.dispose(); + binaryDisposable.dispose(); resizeDisposable.dispose(); scrollDisposable.dispose(); for (const disposable of searchLifecycleDisposables) { diff --git a/src/lib/sessionInput.test.ts b/src/lib/sessionInput.test.ts index 9a237350..01ae0757 100644 --- a/src/lib/sessionInput.test.ts +++ b/src/lib/sessionInput.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("./invoke", () => ({ invoke: mocks.invoke })); -import { sendSessionInput, sendSessionInputWithSync } from "./sessionInput"; +import { sendSessionBinaryInput, sendSessionInput, sendSessionInputWithSync } from "./sessionInput"; describe("sendSessionInputWithSync command confirmation", () => { beforeEach(() => { @@ -27,6 +27,16 @@ describe("sendSessionInputWithSync command confirmation", () => { }); }); + it("sends binary input as exact low-byte values without text bookkeeping", async () => { + await sendSessionBinaryInput("primary", "\x1b[M \x80\xff"); + + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(mocks.invoke).toHaveBeenCalledWith("write_bytes_to_session", { + sessionId: "primary", + data: [0x1b, 0x5b, 0x4d, 0x20, 0x80, 0xff], + }); + }); + it("registers each peer candidate before synchronized command input", async () => { await sendSessionInputWithSync( "primary", diff --git a/src/lib/sessionInput.ts b/src/lib/sessionInput.ts index bafe762d..5a9f2f39 100644 --- a/src/lib/sessionInput.ts +++ b/src/lib/sessionInput.ts @@ -175,6 +175,13 @@ export async function sendSessionInput( }); } +export async function sendSessionBinaryInput(sessionId: string, data: string): Promise { + await invoke("write_bytes_to_session", { + sessionId, + data: Array.from(data, (char) => char.charCodeAt(0) & 0xff), + }); +} + /** * Send input to a session and broadcast to all sync-group peers. * Peers do not emit frontend preview/history UI, but an executable command