Lines
0 %
Functions
Branches
100 %
//! [`tui::transport::Transport`] that drives the TUI runtime over a
//! russh channel.
//!
//! The bridge bridges three impedance mismatches:
//! - **sync ↔ async.** `tui::run_loop` is sync; russh's
//! `Handle::data` is async. We capture a `tokio::runtime::Handle`
//! at construction and `block_on` from inside the spawned blocking
//! task that runs the loop.
//! - **single byte stream ↔ ratatui frames + raw passthrough.** The
//! ratatui [`CrosstermBackend`] writes ANSI cell escapes; chart
//! code emits kitty graphics APC bytes that must reach the same
//! stream but bypass ratatui's grid. We expose the second path via
//! [`Transport::write_passthrough`] using a separate
//! [`ChannelWriter`] clone so the two emit paths don't fight over
//! the same buffer.
//! - **input from `Handler::data` ↔ blocking `Transport::poll`.** The
//! handler shoves [`RawEvent`]s into an `mpsc` channel; the
//! transport's `poll` blocks the runtime thread on `recv` with a
//! tokio timeout via the captured runtime handle.
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use russh::ChannelId;
use russh::server::Handle;
use std::io::{self, Write};
use std::time::Duration;
use tokio::sync::mpsc::UnboundedReceiver;
use tui::transport::{RawEvent, Transport};
/// `std::io::Write` that forwards bytes to a russh channel via the
/// session's [`Handle`]. `flush` drives the async `Handle::data`
/// call from sync code by `block_on`-ing on the runtime captured at
/// construction.
pub struct ChannelWriter {
handle: Handle,
channel: ChannelId,
runtime: tokio::runtime::Handle,
buffer: Vec<u8>,
}
impl ChannelWriter {
pub fn new(handle: Handle, channel: ChannelId, runtime: tokio::runtime::Handle) -> Self {
Self {
handle,
channel,
runtime,
buffer: Vec::with_capacity(4096),
fn drain_buffer(&mut self) -> io::Result<()> {
if self.buffer.is_empty() {
return Ok(());
let bytes = std::mem::take(&mut self.buffer);
let result = self.runtime.block_on(self.handle.data(self.channel, bytes));
match result {
Ok(()) => Ok(()),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"ssh channel closed",
)),
impl Write for ChannelWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
Ok(buf.len())
fn flush(&mut self) -> io::Result<()> {
self.drain_buffer()
/// SSH-side transport. Wraps a [`CrosstermBackend`] backed by a
/// [`ChannelWriter`] and dequeues input events the handler pushed
/// into the session's mpsc.
pub struct SshTransport {
terminal: Terminal<CrosstermBackend<ChannelWriter>>,
inputs: UnboundedReceiver<RawEvent>,
passthrough: ChannelWriter,
term: Option<String>,
impl SshTransport {
/// Build a transport bound to the given russh `channel` and
/// `handle`. `term` is the terminal type the peer announced via
/// `pty-req` (e.g. `xterm-kitty`); pass `None` if no PTY was
/// requested.
///
/// # Errors
/// Returns `Err` if `ratatui::Terminal::new` rejects the
/// backend. Practically never — `CrosstermBackend::new` is
/// infallible.
pub fn new(
cols: u16,
rows: u16,
) -> io::Result<Self> {
let backend_writer = ChannelWriter::new(handle.clone(), channel, runtime.clone());
let passthrough = ChannelWriter::new(handle.clone(), channel, runtime.clone());
let backend = CrosstermBackend::new(backend_writer);
let mut terminal = Terminal::with_options(
backend,
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)),
},
)?;
terminal.clear()?;
Ok(Self {
terminal,
inputs,
passthrough,
term,
})
impl Transport for SshTransport {
type Backend = CrosstermBackend<ChannelWriter>;
fn terminal_mut(&mut self) -> &mut Terminal<Self::Backend> {
&mut self.terminal
fn poll(&mut self, timeout: Duration) -> io::Result<Option<RawEvent>> {
self.runtime.block_on(async {
match tokio::time::timeout(timeout, self.inputs.recv()).await {
Ok(Some(ev)) => Ok(Some(ev)),
Ok(None) => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
Err(_) => Ok(None),
fn finish(mut self) -> io::Result<()> {
// Best-effort screen reset so the user's prompt comes back
// clean. We don't enter raw mode on the client (the client's
// own terminal is in raw mode) so we only need a reset, not
// a full crossterm teardown.
let _ = self.passthrough.write_all(b"\x1b[?25h\x1b[0m\r\n");
let _ = self.passthrough.flush();
let _ = self.runtime.block_on(self.handle.close(self.channel));
Ok(())
fn write_passthrough(&mut self, bytes: &[u8]) -> io::Result<()> {
self.passthrough.write_all(bytes)?;
self.passthrough.flush()
fn client_term(&self) -> Option<&str> {
self.term.as_deref()