feat(terminal): add binary input handling to XTerminal and session management (#444)

- Implemented `sendSessionBinaryInput` function to send binary data to terminal sessions.
- Enhanced `XTerminal` component to handle binary input events, ensuring proper data transmission.
- Updated session commands to support raw data writing, allowing for better handling of binary inputs.
- Added tests for binary input functionality to ensure accurate data processing without text bookkeeping.
This commit is contained in:
Kang committed 2026-09-26 23:27:23 +08:00
1 parent f63dd15f2f
commit 280272aaaf
15 files changed
+112 -23

No files matched your search

+21
View File
@@ -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<SessionManager>>,
session_id: String,
data: Vec<u8>,
) -> 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<SessionManager>>,
@@ -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,
+3
View File
@@ -245,6 +245,7 @@ pub enum SessionCommand {
/// Input to send to the terminal.
Write {
data: Vec<u8>,
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,
+5 -7
View File
@@ -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());
}
@@ -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,
@@ -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(()) => {
+30 -1
View File
@@ -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<u8> {
result
}
pub(crate) fn prepare_terminal_write_input(
mut data: Vec<u8>,
encoding: &str,
raw: bool,
backspace_as_bs: bool,
) -> Vec<u8> {
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() {
@@ -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,
@@ -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();
@@ -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,
@@ -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);
+1
View File
@@ -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,
+12
View File
@@ -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) {
+11 -1
View File
@@ -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",
+7
View File
@@ -175,6 +175,13 @@ export async function sendSessionInput(
});
}
export async function sendSessionBinaryInput(sessionId: string, data: string): Promise<void> {
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