aboutsummaryrefslogtreecommitdiff
path: root/src/client/terminal_sessions.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/terminal_sessions.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/terminal_sessions.rs')
-rw-r--r--src/client/terminal_sessions.rs265
1 files changed, 265 insertions, 0 deletions
diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs
new file mode 100644
index 00000000..7d7bcc8f
--- /dev/null
+++ b/src/client/terminal_sessions.rs
@@ -0,0 +1,265 @@
+use std::io::{self, BufRead, Write as _};
+
+use base64::Engine;
+use interprocess::local_socket::traits::Stream as _;
+use interprocess::TryClone as _;
+use tracing::info;
+
+use crate::ipc::LocalStream;
+use crate::protocol::{
+ self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage,
+ MAX_GRAPHICS_FRAME_SIZE,
+};
+use crate::server::socket_paths::client_socket_path;
+
+use super::{do_handshake, init_logging, write_to_server, ClientError};
+
+/// Runs a read-only terminal session observer and prints one JSON envelope per frame.
+pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> {
+ let mut stream =
+ connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?;
+ write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?;
+ write_terminal_session_output(stream)
+}
+
+/// Runs a writable terminal session controller.
+pub fn run_terminal_session_control(
+ target: String,
+ takeover: bool,
+ cols: u16,
+ rows: u16,
+) -> io::Result<()> {
+ let mut stream = connect_terminal_session_stream(
+ target.clone(),
+ cols,
+ rows,
+ "controlling terminal session",
+ )?;
+ write_to_server(
+ &mut stream,
+ &ClientMessage::ControlTerminal { target, takeover },
+ )?;
+
+ let mut write_stream = stream.try_clone()?;
+ let _input_thread = std::thread::spawn(move || {
+ let stdin = io::stdin();
+ for line in stdin.lock().lines() {
+ let Ok(line) = line else {
+ break;
+ };
+ if line.trim().is_empty() {
+ continue;
+ }
+ match terminal_control_command_from_json(&line) {
+ Ok(message) => {
+ let release = matches!(message, ClientMessage::Detach);
+ if write_to_server(&mut write_stream, &message).is_err() {
+ return;
+ }
+ if release {
+ return;
+ }
+ }
+ Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"),
+ }
+ }
+ let _ = write_to_server(&mut write_stream, &ClientMessage::Detach);
+ });
+
+ write_terminal_session_output(stream)
+}
+
+fn connect_terminal_session_stream(
+ target: String,
+ cols: u16,
+ rows: u16,
+ log_message: &'static str,
+) -> io::Result<LocalStream> {
+ init_logging();
+
+ let socket_path = client_socket_path();
+ crate::logging::startup("client");
+ info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}");
+
+ let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
+ Ok(stream) => stream,
+ Err(err) => {
+ eprintln!("herdr: {}", ClientError::ConnectionFailed(err));
+ std::process::exit(1);
+ }
+ };
+
+ match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) {
+ Ok(RenderEncoding::TerminalAnsi) => {}
+ Ok(encoding) => {
+ eprintln!(
+ "herdr: terminal session observe negotiated unsupported encoding {encoding:?}"
+ );
+ std::process::exit(1);
+ }
+ Err(err) => {
+ eprintln!("herdr: {err}");
+ std::process::exit(1);
+ }
+ }
+
+ stream.set_nonblocking(false)?;
+ Ok(stream)
+}
+
+fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> {
+ let mut stdout = io::stdout().lock();
+ loop {
+ match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) {
+ Ok(ServerMessage::Terminal(frame)) => {
+ let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes);
+ let line = serde_json::json!({
+ "type": "terminal.frame",
+ "seq": frame.seq,
+ "encoding": "ansi",
+ "width": frame.width,
+ "height": frame.height,
+ "full": frame.full,
+ "bytes": encoded,
+ });
+ serde_json::to_writer(&mut stdout, &line)?;
+ stdout.write_all(b"\n")?;
+ stdout.flush()?;
+ }
+ Ok(ServerMessage::ServerShutdown { reason }) => {
+ let line = serde_json::json!({
+ "type": "terminal.closed",
+ "reason": reason,
+ });
+ serde_json::to_writer(&mut stdout, &line)?;
+ stdout.write_all(b"\n")?;
+ stdout.flush()?;
+ return Ok(());
+ }
+ Ok(ServerMessage::Graphics { .. }) => {}
+ Ok(_) => {}
+ Err(protocol::FramingError::UnexpectedEof) => return Ok(()),
+ Err(err) => return Err(io::Error::other(err.to_string())),
+ }
+ }
+}
+
+#[derive(serde::Deserialize)]
+#[serde(tag = "type")]
+enum TerminalControlCommand {
+ #[serde(rename = "terminal.input")]
+ Input {
+ text: Option<String>,
+ bytes: Option<String>,
+ },
+ #[serde(rename = "terminal.resize")]
+ Resize {
+ cols: u16,
+ rows: u16,
+ #[serde(default)]
+ cell_width_px: u32,
+ #[serde(default)]
+ cell_height_px: u32,
+ },
+ #[serde(rename = "terminal.scroll")]
+ Scroll {
+ direction: TerminalControlScrollDirection,
+ lines: u16,
+ #[serde(default)]
+ source: TerminalControlScrollSource,
+ #[serde(default)]
+ column: Option<u16>,
+ #[serde(default)]
+ row: Option<u16>,
+ #[serde(default)]
+ modifiers: u8,
+ },
+ #[serde(rename = "terminal.release")]
+ Release {},
+}
+
+#[derive(Clone, Copy, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum TerminalControlScrollDirection {
+ Up,
+ Down,
+}
+
+#[derive(Clone, Copy, Default, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum TerminalControlScrollSource {
+ #[default]
+ Wheel,
+ PageKey,
+}
+
+pub(super) fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> {
+ let command = serde_json::from_str::<TerminalControlCommand>(raw)
+ .map_err(|err| format!("invalid json command: {err}"))?;
+ match command {
+ TerminalControlCommand::Input { text, bytes } => {
+ let data = match (text, bytes) {
+ (Some(_), Some(_)) => {
+ return Err("terminal.input accepts text or bytes, not both".into())
+ }
+ (Some(text), None) => text.into_bytes(),
+ (None, Some(bytes)) => base64::engine::general_purpose::STANDARD
+ .decode(bytes)
+ .map_err(|err| format!("invalid terminal.input bytes: {err}"))?,
+ (None, None) => Vec::new(),
+ };
+ Ok(ClientMessage::Input { data })
+ }
+ TerminalControlCommand::Resize {
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ } => {
+ if cols == 0 || rows == 0 {
+ return Err("terminal.resize cols and rows must be greater than 0".into());
+ }
+ Ok(ClientMessage::Resize {
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse: false,
+ })
+ }
+ TerminalControlCommand::Scroll {
+ direction,
+ lines,
+ source,
+ column,
+ row,
+ modifiers,
+ } => {
+ if lines == 0 {
+ return Err("terminal.scroll lines must be greater than 0".into());
+ }
+ let direction = match direction {
+ TerminalControlScrollDirection::Up => AttachScrollDirection::Up,
+ TerminalControlScrollDirection::Down => AttachScrollDirection::Down,
+ };
+ let source = match source {
+ TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel,
+ TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey {
+ input: match direction {
+ AttachScrollDirection::Up => b"\x1b[5~".to_vec(),
+ AttachScrollDirection::Down => b"\x1b[6~".to_vec(),
+ },
+ },
+ };
+ Ok(ClientMessage::AttachScroll {
+ source,
+ direction,
+ lines,
+ column,
+ row,
+ modifiers,
+ })
+ }
+ TerminalControlCommand::Release {} => Ok(ClientMessage::Detach),
+ }
+}