feat(desktop): wire terminal sessions to Tauri

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-08-01 21:52:32 -04:00
parent 1a213fe709
commit ba4ff04fea
9 changed files with 857 additions and 0 deletions
+1
View File
@@ -1106,6 +1106,7 @@ dependencies = [
"opus",
"plist",
"png 0.18.1",
"portable-pty",
"regex",
"reqwest 0.13.4",
"rodio",
+1
View File
@@ -105,6 +105,7 @@ buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }
portable-pty = "0.9"
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
@@ -80,6 +80,35 @@ impl Listener {
}
}
/// Resolve an emulator action that can be answered without renderer state.
///
/// Color queries deliberately return `None`: named/indexed colors resolve
/// against the live theme, which this crate does not own.
pub fn reply(
action: Action,
columns: u16,
rows: u16,
cell_width: u16,
cell_height: u16,
) -> Option<String> {
match action {
Action::PtyWrite(text) => Some(text),
Action::SizeReply { format } => Some(format(WindowSize {
num_lines: rows,
num_cols: columns,
cell_width,
cell_height,
})),
// Palette values are renderer-owned. The transport must answer these
// only after it has a renderer palette, never invent one here.
Action::ColorReply { .. }
| Action::Title(_)
| Action::ResetTitle
| Action::Wakeup
| Action::Bell => None,
}
}
impl EventListener for Listener {
fn send_event(&self, event: Event) {
let action = match event {
@@ -199,6 +199,16 @@ impl SharedTerminal {
self.acquire(&self.renderer)
}
/// Modes the renderer/input boundary needs to report alongside frames.
pub fn input_modes(&self) -> (bool, bool) {
let term = self.acquire(&self.renderer);
let mode = term.term().mode();
(
mode.contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE),
mode.contains(alacritty_terminal::term::TermMode::FOCUS_IN_OUT),
)
}
fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> {
let started = Instant::now();
let guard = self.term.lock();
@@ -130,3 +130,9 @@ pub fn login_argv0(shell: &str) -> String {
let basename = shell.rsplit('/').next().unwrap_or(shell);
format!("-{basename}")
}
/// Resolve the command shell on Windows from `ComSpec`, falling back to cmd.
#[cfg(windows)]
pub fn resolve_shell(shell_env: Option<&str>) -> String {
shell_env.unwrap_or("cmd.exe").to_owned()
}
+10
View File
@@ -32,6 +32,7 @@ mod reset;
mod secret_store;
mod shutdown;
mod templates;
mod terminal_runtime;
#[cfg_attr(not(test), allow(dead_code))]
mod terminal_transport;
#[cfg(target_os = "macos")]
@@ -370,6 +371,7 @@ pub fn run() {
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
.manage(terminal_runtime::TerminalSessions::default())
.setup(move |app| {
let app_handle = app.handle().clone();
#[cfg(target_os = "macos")]
@@ -665,6 +667,14 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
terminal_runtime::terminal_attach,
terminal_runtime::terminal_detach,
terminal_runtime::terminal_close,
terminal_runtime::terminal_input,
terminal_runtime::terminal_resize,
terminal_runtime::terminal_ack,
terminal_runtime::terminal_viewport_ready,
terminal_runtime::terminal_focus,
take_pending_community_deep_link,
acknowledge_pending_community_deep_link,
start_builderlab_login,
+4
View File
@@ -19,6 +19,8 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a
.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
app.state::<crate::terminal_runtime::TerminalSessions>()
.shutdown_all();
if let Err(error) = shutdown_managed_agents(app) {
eprintln!("buzz-desktop: failed to stop managed agents: {error}");
}
@@ -40,6 +42,8 @@ pub(crate) fn install_signal_handler(
.shutdown_started
.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
app.state::<crate::terminal_runtime::TerminalSessions>()
.shutdown_all();
let _ = shutdown_managed_agents(&app);
#[cfg(feature = "mesh-llm")]
shutdown_mesh_runtime(&app);
+782
View File
@@ -0,0 +1,782 @@
//! Rust-owned PTY sessions and the typed Tauri transport for Buzz Substrate.
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use buzz_terminal::context::{context_vars, GuiContext};
use buzz_terminal::damage::Style;
use buzz_terminal::{Fences, SharedTerminal, Size, Terminal, Viewport};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;
use uuid::Uuid;
use crate::terminal_transport::{FramePublisher, Publication, SubscriptionId};
const MAX_LIVE_SESSIONS: usize = 20;
const MAX_INPUT_BYTES: usize = 1024 * 1024;
type Result<T> = std::result::Result<T, String>;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachRequest {
/// Present when a renderer remounts onto an existing PTY-backed tab.
session_id: Option<String>,
channel_id: String,
channel_name: String,
thread_id: Option<String>,
npub: String,
relay_url: String,
columns: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WireViewport {
generation: u64,
columns: usize,
screen_lines: usize,
}
impl From<Viewport> for WireViewport {
fn from(value: Viewport) -> Self {
Self {
generation: value.generation,
columns: value.columns,
screen_lines: value.screen_lines,
}
}
}
impl From<WireViewport> for Viewport {
fn from(value: WireViewport) -> Self {
Self {
generation: value.generation,
columns: value.columns,
screen_lines: value.screen_lines,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AttachResponse {
session_id: String,
subscription_id: String,
viewport: WireViewport,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WireStyle {
fg: u32,
bg: u32,
flags: u16,
}
impl From<Style> for WireStyle {
fn from(value: Style) -> Self {
Self {
fg: value.fg,
bg: value.bg,
flags: value.flags,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WireCluster {
column: usize,
text: String,
width: u8,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WireSpan {
style: WireStyle,
clusters: Vec<WireCluster>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WireRow {
line: usize,
spans: Vec<WireSpan>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WireCursor {
line: usize,
column: usize,
visible: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FrameMessage {
subscription_id: String,
sequence: u64,
rows: Vec<WireRow>,
cursor: WireCursor,
full: bool,
viewport: WireViewport,
bracketed_paste: bool,
focus_reporting: bool,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", content = "payload", rename_all = "camelCase")]
pub(crate) enum TerminalMessage {
Frame(FrameMessage),
Title(String),
ResetTitle,
Bell,
Exit,
}
fn wire_publication(publication: Publication) -> Result<FrameMessage> {
let frame = publication.frame;
let rows = frame
.rows
.into_iter()
.map(|row| {
let spans = row
.spans
.into_iter()
.map(|span| {
if !span.counts_are_consistent() {
return Err(
"terminal engine emitted an inconsistent cluster count".to_string()
);
}
let clusters = if span.cluster_count == 1 {
vec![WireCluster {
column: span.column,
text: span.text,
width: span.width,
}]
} else {
span.text
.chars()
.enumerate()
.map(|(index, ch)| WireCluster {
column: span.column + index * usize::from(span.width),
text: ch.to_string(),
width: span.width,
})
.collect()
};
Ok(WireSpan {
style: span.style.into(),
clusters,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(WireRow {
line: row.line,
spans,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(FrameMessage {
subscription_id: publication.subscription_id.to_string(),
sequence: publication.sequence,
rows,
cursor: WireCursor {
line: frame.cursor.line,
column: frame.cursor.column,
visible: frame.cursor.visible,
},
full: frame.full,
viewport: frame.viewport.into(),
bracketed_paste: false,
focus_reporting: false,
})
}
struct ReaderThread(Option<JoinHandle<()>>);
impl buzz_terminal::lifecycle::DrainingReader for ReaderThread {
fn join(mut self: Box<Self>) {
if let Some(handle) = self.0.take() {
let _ = handle.join();
}
}
}
struct Session {
id: Uuid,
terminal: Arc<SharedTerminal>,
master: Option<Box<dyn MasterPty + Send>>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
pty_size: Arc<Mutex<PtySize>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
reader: Option<Box<dyn buzz_terminal::lifecycle::DrainingReader + Send>>,
publisher: Arc<Mutex<FramePublisher>>,
channel: Arc<Mutex<Option<Channel<TerminalMessage>>>>,
}
impl Session {
fn publish(&self, publication: Publication) {
let subscription = publication.subscription_id;
let sent = wire_publication(publication).and_then(|message| {
let (bracketed_paste, focus_reporting) = self.terminal.input_modes();
let mut message = message;
message.bracketed_paste = bracketed_paste;
message.focus_reporting = focus_reporting;
self.channel
.lock()
.map_err(|e| e.to_string())?
.as_ref()
.ok_or_else(|| "terminal renderer detached".to_string())?
.send(TerminalMessage::Frame(message))
.map_err(|e| e.to_string())
});
if sent.is_err() {
if let Ok(mut channel) = self.channel.lock() {
*channel = None;
}
if let Ok(mut publisher) = self.publisher.lock() {
publisher.fault(subscription);
}
}
}
fn shutdown(mut self) {
if let Ok(mut channel) = self.channel.lock() {
*channel = None;
}
// The slave closes on child reap; the reader continues draining until then.
if let Some(reader) = self.reader.take() {
#[cfg(unix)]
{
let _ = buzz_terminal::lifecycle::shutdown_draining(&mut self.child, reader);
}
#[cfg(not(unix))]
{
let _ = self.child.kill();
let _ = self.child.wait();
reader.join();
}
}
self.master.take();
}
}
#[derive(Default)]
pub(crate) struct TerminalSessions(Mutex<Vec<Session>>);
impl TerminalSessions {
fn with_session<T>(&self, id: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
let id = Uuid::parse_str(id).map_err(|_| "invalid terminal session id".to_string())?;
let mut sessions = self.0.lock().map_err(|e| e.to_string())?;
let session = sessions
.iter_mut()
.find(|session| session.id == id)
.ok_or_else(|| "terminal session not found".to_string())?;
f(session)
}
pub(crate) fn shutdown_all(&self) {
let sessions = self
.0
.lock()
.map(|mut sessions| std::mem::take(&mut *sessions))
.unwrap_or_default();
for session in sessions {
session.shutdown();
}
}
}
fn size(columns: u16, rows: u16) -> Result<Size> {
if columns == 0 || rows == 0 {
return Err("terminal dimensions must be non-zero".to_string());
}
Ok(Size {
columns: usize::from(columns),
screen_lines: usize::from(rows),
..Size::default()
})
}
fn pty_size(columns: u16, rows: u16, pixel_width: u16, pixel_height: u16) -> PtySize {
PtySize {
rows,
cols: columns,
pixel_width,
pixel_height,
}
}
#[tauri::command]
pub(crate) fn terminal_attach(
request: AttachRequest,
on_frame: Channel<TerminalMessage>,
state: tauri::State<'_, TerminalSessions>,
) -> Result<AttachResponse> {
let terminal_size = size(request.columns, request.rows)?;
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
if let Some(existing_id) = request.session_id.as_deref() {
let existing_id =
Uuid::parse_str(existing_id).map_err(|_| "invalid terminal session id".to_string())?;
let session = sessions
.iter_mut()
.find(|session| session.id == existing_id)
.ok_or_else(|| "terminal session not found".to_string())?;
let subscription = SubscriptionId::new();
let mut encoder = buzz_terminal::damage::Encoder::new();
let snapshot = session.terminal.snapshot(&mut encoder);
let viewport = snapshot.viewport;
*session.channel.lock().map_err(|e| e.to_string())? = Some(on_frame);
let bootstrap = session
.publisher
.lock()
.map_err(|e| e.to_string())?
.attach(subscription, snapshot)
.map_err(|_| "terminal snapshot rejected".to_string())?;
session.publish(bootstrap);
return Ok(AttachResponse {
session_id: existing_id.to_string(),
subscription_id: subscription.to_string(),
viewport: viewport.into(),
});
}
if sessions.len() >= MAX_LIVE_SESSIONS {
return Err("too many live terminal sessions".to_string());
}
let id = Uuid::new_v4();
let pair = native_pty_system()
.openpty(pty_size(
request.columns,
request.rows,
request.pixel_width,
request.pixel_height,
))
.map_err(|e| e.to_string())?;
let shell = {
#[cfg(unix)]
{
buzz_terminal::shell::resolve_shell(std::env::var("SHELL").ok().as_deref())
}
#[cfg(windows)]
{
buzz_terminal::shell::resolve_shell(std::env::var("ComSpec").ok().as_deref())
}
};
let mut command = CommandBuilder::new_default_prog();
buzz_terminal::env_fence::fence_env(
&mut command,
&buzz_terminal::path::user_shell_path(),
&shell,
);
let context = GuiContext {
channel_id: request.channel_id,
channel_name: request.channel_name,
thread_id: request.thread_id,
npub: request.npub,
relay_url: request.relay_url,
session_id: id.to_string(),
};
for (key, value) in context_vars(&context) {
command.env(key, value);
}
let child = pair
.slave
.spawn_command(command)
.map_err(|e| e.to_string())?;
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?;
let writer = Arc::new(Mutex::new(
pair.master.take_writer().map_err(|e| e.to_string())?,
));
let (terminal, actions) = Terminal::new(terminal_size, Fences::default());
let terminal = Arc::new(SharedTerminal::new(terminal));
let publisher = Arc::new(Mutex::new(FramePublisher::new(terminal.lock().viewport())));
let channel = Arc::new(Mutex::new(Some(on_frame)));
let current_pty_size = Arc::new(Mutex::new(pty_size(
request.columns,
request.rows,
request.pixel_width,
request.pixel_height,
)));
// Emulator replies (DSR/size) must go back through the PTY even when no
// renderer is attached. The action thread ends when the terminal drops.
let action_writer = Arc::clone(&writer);
let action_size = Arc::clone(&current_pty_size);
let action_channel = Arc::clone(&channel);
std::thread::spawn(move || {
while let Ok(action) = actions.recv() {
let presentation = match &action {
buzz_terminal::Action::Title(title) => Some(TerminalMessage::Title(title.clone())),
buzz_terminal::Action::ResetTitle => Some(TerminalMessage::ResetTitle),
buzz_terminal::Action::Bell => Some(TerminalMessage::Bell),
_ => None,
};
if let Some(message) = presentation {
let _ = action_channel
.lock()
.ok()
.and_then(|channel| channel.as_ref()?.send(message).ok());
continue;
}
let size = action_size.lock().map(|size| *size).unwrap_or_default();
if let Some(reply) = buzz_terminal::listener::reply(
action,
size.cols,
size.rows,
size.pixel_width,
size.pixel_height,
) {
if action_writer.lock().map_or(true, |mut writer| {
writer.write_all(reply.as_bytes()).is_err()
}) {
break;
}
}
}
});
let reader_terminal = Arc::clone(&terminal);
let reader_publisher = Arc::clone(&publisher);
let reader_channel = Arc::clone(&channel);
let reader_handle = std::thread::spawn(move || {
let mut buffer = [0u8; 16 * 1024];
let mut encoder = buzz_terminal::damage::Encoder::new();
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
loop {
let count = match reader.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(count) => count,
};
reader_terminal.feed(&buffer[..count]);
let needs_snapshot = reader_publisher
.lock()
.map(|publisher| publisher.requires_snapshot())
.unwrap_or(false);
let frame = if needs_snapshot {
reader_terminal.snapshot(&mut snapshot_encoder)
} else {
reader_terminal.render(&mut encoder)
};
if frame.is_empty() {
continue;
}
let publication = reader_publisher
.lock()
.ok()
.and_then(|mut publisher| publisher.offer(frame).ok().flatten());
if let Some(publication) = publication {
let subscription = publication.subscription_id;
let result = wire_publication(publication).and_then(|mut message| {
let (bracketed_paste, focus_reporting) = reader_terminal.input_modes();
message.bracketed_paste = bracketed_paste;
message.focus_reporting = focus_reporting;
reader_channel
.lock()
.map_err(|e| e.to_string())?
.as_ref()
.ok_or_else(|| "detached".to_string())?
.send(TerminalMessage::Frame(message))
.map_err(|e| e.to_string())
});
if result.is_err() {
if let Ok(mut publisher) = reader_publisher.lock() {
publisher.fault(subscription);
}
}
}
}
let _ = reader_channel
.lock()
.ok()
.and_then(|channel| channel.as_ref()?.send(TerminalMessage::Exit).ok());
});
let subscription = SubscriptionId::new();
let mut snapshot_encoder = buzz_terminal::damage::Encoder::new();
let viewport = terminal.lock().viewport();
let bootstrap = publisher
.lock()
.map_err(|e| e.to_string())?
.attach(subscription, terminal.snapshot(&mut snapshot_encoder))
.map_err(|_| "terminal snapshot rejected".to_string())?;
let session = Session {
id,
terminal,
master: Some(pair.master),
writer,
pty_size: current_pty_size,
child,
reader: Some(Box::new(ReaderThread(Some(reader_handle)))),
publisher,
channel,
};
session.publish(bootstrap);
sessions.push(session);
Ok(AttachResponse {
session_id: id.to_string(),
subscription_id: subscription.to_string(),
viewport: viewport.into(),
})
}
#[tauri::command]
pub(crate) fn terminal_detach(
session_id: String,
subscription_id: String,
state: tauri::State<'_, TerminalSessions>,
) -> Result<()> {
state.with_session(&session_id, |session| {
let id = SubscriptionId::parse(&subscription_id)?;
let detached = session
.publisher
.lock()
.map_err(|e| e.to_string())?
.fault(id);
if detached {
*session.channel.lock().map_err(|e| e.to_string())? = None;
}
Ok(())
})
}
#[tauri::command]
pub(crate) fn terminal_close(
session_id: String,
state: tauri::State<'_, TerminalSessions>,
) -> Result<()> {
let id = Uuid::parse_str(&session_id).map_err(|_| "invalid terminal session id".to_string())?;
let session = {
let mut sessions = state.0.lock().map_err(|e| e.to_string())?;
let index = sessions
.iter()
.position(|session| session.id == id)
.ok_or_else(|| "terminal session not found".to_string())?;
sessions.remove(index)
};
session.shutdown();
Ok(())
}
#[tauri::command]
pub(crate) fn terminal_input(
session_id: String,
data: String,
state: tauri::State<'_, TerminalSessions>,
) -> Result<()> {
if data.len() > MAX_INPUT_BYTES {
return Err("terminal input exceeds 1 MiB".to_string());
}
state.with_session(&session_id, |session| {
session
.writer
.lock()
.map_err(|e| e.to_string())?
.write_all(data.as_bytes())
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub(crate) fn terminal_resize(
session_id: String,
columns: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
state: tauri::State<'_, TerminalSessions>,
) -> Result<WireViewport> {
let terminal_size = size(columns, rows)?;
state.with_session(&session_id, |session| {
let new_pty_size = pty_size(columns, rows, pixel_width, pixel_height);
session
.master
.as_ref()
.ok_or_else(|| "terminal is closing".to_string())?
.resize(new_pty_size)
.map_err(|e| e.to_string())?;
*session.pty_size.lock().map_err(|e| e.to_string())? = new_pty_size;
let viewport = session.terminal.resize(terminal_size);
let publication = {
let mut publisher = session.publisher.lock().map_err(|e| e.to_string())?;
publisher.resize_applied(viewport);
let mut encoder = buzz_terminal::damage::Encoder::new();
publisher
.offer(session.terminal.snapshot(&mut encoder))
.map_err(|_| "terminal resize snapshot rejected".to_string())?
};
if let Some(publication) = publication {
session.publish(publication);
}
Ok(viewport.into())
})
}
#[tauri::command]
pub(crate) fn terminal_ack(
session_id: String,
subscription_id: String,
sequence: u64,
state: tauri::State<'_, TerminalSessions>,
) -> Result<()> {
state.with_session(&session_id, |session| {
let id = SubscriptionId::parse(&subscription_id)?;
let publication = session
.publisher
.lock()
.map_err(|e| e.to_string())?
.acknowledge(id, sequence);
if let Some(publication) = publication {
session.publish(publication);
}
Ok(())
})
}
#[tauri::command]
pub(crate) fn terminal_viewport_ready(
session_id: String,
subscription_id: String,
viewport: WireViewport,
state: tauri::State<'_, TerminalSessions>,
) -> Result<()> {
state.with_session(&session_id, |session| {
let id = SubscriptionId::parse(&subscription_id)?;
let publication = session
.publisher
.lock()
.map_err(|e| e.to_string())?
.viewport_ready(id, viewport.into());
if let Some(publication) = publication {
session.publish(publication);
}
Ok(())
})
}
#[tauri::command]
pub(crate) fn terminal_focus(
session_id: String,
focused: bool,
state: tauri::State<'_, TerminalSessions>,
) -> Result<()> {
state.with_session(&session_id, |session| {
let (_, enabled) = session.terminal.input_modes();
if enabled {
session
.writer
.lock()
.map_err(|e| e.to_string())?
.write_all(if focused { b"\x1b[I" } else { b"\x1b[O" })
.map_err(|e| e.to_string())?;
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_terminal::damage::{CursorFrame, RowFrame, Span};
fn publication(spans: Vec<Span>) -> Publication {
Publication {
subscription_id: SubscriptionId::new(),
sequence: 7,
frame: buzz_terminal::damage::Frame {
rows: vec![RowFrame { line: 3, spans }],
cursor: CursorFrame {
line: 1,
column: 2,
visible: true,
},
full: true,
viewport: Viewport {
generation: 4,
columns: 80,
screen_lines: 24,
},
},
}
}
fn style() -> Style {
Style {
fg: 1,
bg: 2,
flags: 3,
}
}
#[test]
fn mapper_expands_ascii_runs_without_unicode_classification() {
let message = wire_publication(publication(vec![Span {
column: 4,
text: "abc".into(),
width: 1,
cluster_count: 3,
style: style(),
}]))
.unwrap();
let clusters = &message.rows[0].spans[0].clusters;
assert_eq!(
clusters
.iter()
.map(|cluster| (cluster.column, cluster.text.as_str()))
.collect::<Vec<_>>(),
vec![(4, "a"), (5, "b"), (6, "c")]
);
}
#[test]
fn mapper_keeps_a_multi_char_cluster_atomic() {
let message = wire_publication(publication(vec![Span {
column: 9,
text: "1\u{fe0f}\u{20e3}".into(),
width: 2,
cluster_count: 1,
style: style(),
}]))
.unwrap();
let clusters = &message.rows[0].spans[0].clusters;
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].column, 9);
assert_eq!(clusters[0].text, "1\u{fe0f}\u{20e3}");
assert_eq!(clusters[0].width, 2);
}
#[test]
fn mapper_rejects_an_inconsistent_engine_span() {
let result = wire_publication(publication(vec![Span {
column: 0,
text: "ab".into(),
width: 1,
cluster_count: 3,
style: style(),
}]));
assert!(result.is_err());
}
#[test]
fn dimensions_reject_zero_and_preserve_scrollback_default() {
assert!(size(0, 24).is_err());
assert!(size(80, 0).is_err());
let size = size(100, 40).unwrap();
assert_eq!(
(size.columns, size.screen_lines, size.scrollback),
(100, 40, 10_000)
);
}
}
@@ -7,6 +7,8 @@
//! renderer confirms that it knows the applied [`Viewport`].
use buzz_terminal::{damage::Frame, Viewport};
use std::fmt;
use uuid::Uuid;
/// A renderer attachment. Messages from an attachment that has been replaced
@@ -18,6 +20,18 @@ impl SubscriptionId {
pub(crate) fn new() -> Self {
Self(Uuid::new_v4())
}
pub(crate) fn parse(value: &str) -> Result<Self, String> {
Uuid::parse_str(value)
.map(Self)
.map_err(|_| "invalid terminal subscription id".to_string())
}
}
impl fmt::Display for SubscriptionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
/// A frame carrying the identity and sequence that its ACK must repeat.