feat: add interactive VT and workspace command plane
CI / rust (push) Successful in 3m48s

This commit is contained in:
2026-08-31 17:05:49 -07:00
parent 32d190c6c6
commit b838d000db
54 changed files with 3004 additions and 113 deletions
+3 -1
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror = "2.0"
[lints]
workspace = true
+9
View File
@@ -2,6 +2,15 @@
use std::fmt;
mod workspace;
pub use workspace::{
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceApplyOutcome,
WorkspaceCapability, WorkspaceCommand, WorkspaceError, WorkspaceEvent, WorkspaceId,
WorkspaceRequest, WorkspaceState,
};
/// The transport Lumbridge uses to reach a user-owned remote machine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RemoteTransport {
+861
View File
@@ -0,0 +1,861 @@
//! Deterministic workspace command plane shared by UI, CLI, and agents.
use crate::ExecutionTarget;
use std::collections::BTreeMap;
use std::fmt;
use thiserror::Error;
const MIN_SPLIT_PERMILLE: u16 = 100;
const MAX_SPLIT_PERMILLE: u16 = 900;
const MAX_IDENTITY_BYTES: usize = 256;
macro_rules! string_id {
($name:ident, $label:literal) => {
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct $name(String);
impl $name {
/// Creates a stable, non-empty identifier.
///
/// # Errors
///
#[doc = concat!("Returns [`WorkspaceError::InvalidIdentifier`] for an empty ", $label, ".")]
pub fn new(value: impl Into<String>) -> Result<Self, WorkspaceError> {
let value = value.into();
if value.trim().is_empty() || value.chars().any(char::is_control) {
return Err(WorkspaceError::InvalidIdentifier($label));
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
};
}
string_id!(WorkspaceId, "workspace ID");
string_id!(PaneId, "pane ID");
string_id!(CommandRequestId, "command request ID");
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum WorkspaceCapability {
Observe,
Configure,
Execute,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CommandOrigin {
Human,
Agent { identity: String },
Automation { client: String },
}
impl CommandOrigin {
fn validate(&self) -> Result<(), WorkspaceError> {
let value = match self {
Self::Human => return Ok(()),
Self::Agent { identity } => identity,
Self::Automation { client } => client,
};
if value.trim().is_empty()
|| value.len() > MAX_IDENTITY_BYTES
|| value.chars().any(char::is_control)
{
return Err(WorkspaceError::InvalidOrigin);
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneSurface {
Terminal,
Markdown,
Browser,
Tools,
Context,
Goal,
Review,
}
/// A credential-free launch reference. Executable arguments and secret values
/// are deliberately absent from the workspace plan.
#[derive(Clone, Eq, PartialEq)]
pub enum PaneLaunchIntent {
UserShell {
working_directory: Option<String>,
},
HarnessProfile {
profile_id: String,
working_directory: Option<String>,
},
}
/// Pane metadata suitable for persistence and automation. It intentionally has
/// no `Debug` implementation because titles and paths may be private.
#[derive(Clone, Eq, PartialEq)]
pub struct PaneDefinition {
pub id: PaneId,
pub title: String,
pub surface: PaneSurface,
pub execution_target: ExecutionTarget,
pub launch: Option<PaneLaunchIntent>,
}
impl PaneDefinition {
/// Validates a pane definition before it reaches the layout state.
///
/// # Errors
///
/// Returns [`WorkspaceError::InvalidPane`] for an empty title or for a
/// launch intent on a non-terminal pane.
pub fn validate(&self) -> Result<(), WorkspaceError> {
if self.title.trim().is_empty() || self.title.chars().any(char::is_control) {
return Err(WorkspaceError::InvalidPane("pane title"));
}
if self.launch.is_some() && self.surface != PaneSurface::Terminal {
return Err(WorkspaceError::InvalidPane(
"only terminal panes may carry a launch intent",
));
}
match &self.launch {
Some(PaneLaunchIntent::HarnessProfile { profile_id, .. })
if profile_id.trim().is_empty() || profile_id.chars().any(char::is_control) =>
{
Err(WorkspaceError::InvalidPane("harness profile ID"))
}
_ => Ok(()),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SplitAxis {
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PanePlacement {
Before,
After,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneCloseDisposition {
Detach,
Terminate,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SplitRatio(u16);
impl SplitRatio {
/// Creates the first child's share in permille.
///
/// # Errors
///
/// Returns [`WorkspaceError::InvalidSplitRatio`] outside 100..=900.
pub fn from_permille(value: u16) -> Result<Self, WorkspaceError> {
if !(MIN_SPLIT_PERMILLE..=MAX_SPLIT_PERMILLE).contains(&value) {
return Err(WorkspaceError::InvalidSplitRatio(value));
}
Ok(Self(value))
}
#[must_use]
pub const fn permille(self) -> u16 {
self.0
}
}
impl Default for SplitRatio {
fn default() -> Self {
Self(500)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LayoutNode {
Pane(PaneId),
Split {
axis: SplitAxis,
ratio: SplitRatio,
first: Box<Self>,
second: Box<Self>,
},
}
impl LayoutNode {
fn contains(&self, pane: &PaneId) -> bool {
match self {
Self::Pane(candidate) => candidate == pane,
Self::Split { first, second, .. } => first.contains(pane) || second.contains(pane),
}
}
fn split(
&mut self,
target: &PaneId,
new_pane: PaneId,
axis: SplitAxis,
ratio: SplitRatio,
placement: PanePlacement,
) -> bool {
match self {
Self::Pane(candidate) if candidate == target => {
let existing = Self::Pane(candidate.clone());
let inserted = Self::Pane(new_pane);
let (first, second) = match placement {
PanePlacement::Before => (inserted, existing),
PanePlacement::After => (existing, inserted),
};
*self = Self::Split {
axis,
ratio,
first: Box::new(first),
second: Box::new(second),
};
true
}
Self::Pane(_) => false,
Self::Split { first, second, .. } => {
first.split(target, new_pane.clone(), axis, ratio, placement)
|| second.split(target, new_pane, axis, ratio, placement)
}
}
}
fn remove(self, target: &PaneId) -> Option<Self> {
match self {
Self::Pane(pane) => (pane != *target).then_some(Self::Pane(pane)),
Self::Split {
axis,
ratio,
first,
second,
} => match (first.remove(target), second.remove(target)) {
(Some(first), Some(second)) => Some(Self::Split {
axis,
ratio,
first: Box::new(first),
second: Box::new(second),
}),
(Some(remaining), None) | (None, Some(remaining)) => Some(remaining),
(None, None) => None,
},
}
}
fn first_pane(&self) -> &PaneId {
match self {
Self::Pane(pane) => pane,
Self::Split { first, .. } => first.first_pane(),
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub enum WorkspaceCommand {
SplitPane {
target: PaneId,
pane: PaneDefinition,
axis: SplitAxis,
ratio: SplitRatio,
placement: PanePlacement,
},
SelectPane {
pane: PaneId,
},
RenamePane {
pane: PaneId,
title: String,
},
ClosePane {
pane: PaneId,
disposition: PaneCloseDisposition,
},
}
impl WorkspaceCommand {
#[must_use]
pub const fn required_capability(&self) -> WorkspaceCapability {
match self {
Self::ClosePane {
disposition: PaneCloseDisposition::Terminate,
..
} => WorkspaceCapability::Execute,
Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute,
Self::SplitPane { .. }
| Self::SelectPane { .. }
| Self::RenamePane { .. }
| Self::ClosePane {
disposition: PaneCloseDisposition::Detach,
..
} => WorkspaceCapability::Configure,
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct WorkspaceRequest {
pub request_id: CommandRequestId,
pub origin: CommandOrigin,
pub command: WorkspaceCommand,
}
/// State transition emitted after an accepted command. Transcript or input
/// content never appears in these events.
#[derive(Clone, Eq, PartialEq)]
pub enum WorkspaceEvent {
PaneSplit {
target: PaneId,
pane: PaneId,
},
PaneSelected {
pane: PaneId,
},
PaneRenamed {
pane: PaneId,
},
PaneClosed {
pane: PaneId,
disposition: PaneCloseDisposition,
},
}
pub struct WorkspaceApplyOutcome {
pub revision: u64,
pub duplicate: bool,
pub event: Option<WorkspaceEvent>,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum WorkspaceError {
#[error("invalid {0}")]
InvalidIdentifier(&'static str),
#[error("invalid {0}")]
InvalidPane(&'static str),
#[error("split ratio {0} must be between 100 and 900 permille")]
InvalidSplitRatio(u16),
#[error("pane does not exist: {}", .0.as_str())]
PaneNotFound(PaneId),
#[error("pane already exists: {}", .0.as_str())]
PaneAlreadyExists(PaneId),
#[error("command origin identity is invalid")]
InvalidOrigin,
#[error("request ID was already used for a different request: {}", .0.as_str())]
RequestConflict(CommandRequestId),
#[error("the last pane cannot be closed")]
CannotCloseLastPane,
#[error("workspace command requires {required:?} capability but only {granted:?} was granted")]
CapabilityDenied {
required: WorkspaceCapability,
granted: WorkspaceCapability,
},
}
/// Deterministic layout state shared by all command-plane frontends. It has no
/// `Debug` implementation because pane metadata may contain local paths.
#[derive(Clone, Eq, PartialEq)]
pub struct WorkspaceState {
id: WorkspaceId,
name: String,
panes: BTreeMap<PaneId, PaneDefinition>,
layout: LayoutNode,
selected: PaneId,
applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>,
revision: u64,
}
impl WorkspaceState {
/// Creates a workspace with one root pane.
///
/// # Errors
///
/// Returns [`WorkspaceError::InvalidPane`] for invalid names or pane
/// definitions.
pub fn new(
id: WorkspaceId,
name: impl Into<String>,
root: PaneDefinition,
) -> Result<Self, WorkspaceError> {
let name = name.into();
if name.trim().is_empty() || name.chars().any(char::is_control) {
return Err(WorkspaceError::InvalidPane("workspace name"));
}
root.validate()?;
let selected = root.id.clone();
let layout = LayoutNode::Pane(root.id.clone());
let panes = BTreeMap::from([(root.id.clone(), root)]);
Ok(Self {
id,
name,
panes,
layout,
selected,
applied_requests: BTreeMap::new(),
revision: 0,
})
}
#[must_use]
pub const fn id(&self) -> &WorkspaceId {
&self.id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn layout(&self) -> &LayoutNode {
&self.layout
}
#[must_use]
pub const fn selected(&self) -> &PaneId {
&self.selected
}
#[must_use]
pub const fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub fn pane(&self, id: &PaneId) -> Option<&PaneDefinition> {
self.panes.get(id)
}
#[must_use]
pub fn pane_count(&self) -> usize {
self.panes.len()
}
/// Applies one idempotent command after checking its required capability.
///
/// # Errors
///
/// Rejects insufficient authority and invalid state transitions without
/// changing the workspace.
pub fn apply(
&mut self,
request: WorkspaceRequest,
granted: WorkspaceCapability,
) -> Result<WorkspaceApplyOutcome, WorkspaceError> {
if let Some(applied) = self.applied_requests.get(&request.request_id) {
if applied == &request {
return Ok(WorkspaceApplyOutcome {
revision: self.revision,
duplicate: true,
event: None,
});
}
return Err(WorkspaceError::RequestConflict(request.request_id));
}
request.origin.validate()?;
let required = request.command.required_capability();
if granted < required {
return Err(WorkspaceError::CapabilityDenied { required, granted });
}
let event = self.apply_command(request.command.clone())?;
self.applied_requests
.insert(request.request_id.clone(), request);
self.revision = self.revision.saturating_add(1);
Ok(WorkspaceApplyOutcome {
revision: self.revision,
duplicate: false,
event: Some(event),
})
}
/// Applies a setup plan atomically. Any rejected command leaves the
/// original workspace unchanged.
///
/// # Errors
///
/// Returns the first command error without committing partial state.
pub fn apply_plan(
&mut self,
requests: impl IntoIterator<Item = WorkspaceRequest>,
granted: WorkspaceCapability,
) -> Result<Vec<WorkspaceApplyOutcome>, WorkspaceError> {
let mut candidate = self.clone();
let mut outcomes = Vec::new();
for request in requests {
outcomes.push(candidate.apply(request, granted)?);
}
*self = candidate;
Ok(outcomes)
}
fn apply_command(
&mut self,
command: WorkspaceCommand,
) -> Result<WorkspaceEvent, WorkspaceError> {
match command {
WorkspaceCommand::SplitPane {
target,
pane,
axis,
ratio,
placement,
} => {
if !self.layout.contains(&target) {
return Err(WorkspaceError::PaneNotFound(target));
}
if self.panes.contains_key(&pane.id) {
return Err(WorkspaceError::PaneAlreadyExists(pane.id));
}
pane.validate()?;
let pane_id = pane.id.clone();
let did_split = self
.layout
.split(&target, pane_id.clone(), axis, ratio, placement);
debug_assert!(did_split, "target presence was checked");
self.panes.insert(pane_id.clone(), pane);
self.selected = pane_id.clone();
Ok(WorkspaceEvent::PaneSplit {
target,
pane: pane_id,
})
}
WorkspaceCommand::SelectPane { pane } => {
if !self.panes.contains_key(&pane) {
return Err(WorkspaceError::PaneNotFound(pane));
}
self.selected = pane.clone();
Ok(WorkspaceEvent::PaneSelected { pane })
}
WorkspaceCommand::RenamePane { pane, title } => {
if title.trim().is_empty() || title.chars().any(char::is_control) {
return Err(WorkspaceError::InvalidPane("pane title"));
}
let definition = self
.panes
.get_mut(&pane)
.ok_or_else(|| WorkspaceError::PaneNotFound(pane.clone()))?;
definition.title = title;
Ok(WorkspaceEvent::PaneRenamed { pane })
}
WorkspaceCommand::ClosePane { pane, disposition } => {
if self.panes.len() == 1 && self.panes.contains_key(&pane) {
return Err(WorkspaceError::CannotCloseLastPane);
}
if !self.panes.contains_key(&pane) {
return Err(WorkspaceError::PaneNotFound(pane));
}
let layout = self
.layout
.clone()
.remove(&pane)
.ok_or(WorkspaceError::CannotCloseLastPane)?;
self.panes.remove(&pane);
self.layout = layout;
if self.selected == pane {
self.selected = self.layout.first_pane().clone();
}
Ok(WorkspaceEvent::PaneClosed { pane, disposition })
}
}
}
}
impl fmt::Debug for WorkspaceEvent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PaneSplit { target, pane } => formatter
.debug_struct("PaneSplit")
.field("target", target)
.field("pane", pane)
.finish(),
Self::PaneSelected { pane } => formatter
.debug_struct("PaneSelected")
.field("pane", pane)
.finish(),
Self::PaneRenamed { pane } => formatter
.debug_struct("PaneRenamed")
.field("pane", pane)
.finish(),
Self::PaneClosed { pane, disposition } => formatter
.debug_struct("PaneClosed")
.field("pane", pane)
.field("disposition", disposition)
.finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::{
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceCapability,
WorkspaceCommand, WorkspaceError, WorkspaceId, WorkspaceRequest, WorkspaceState,
};
use crate::ExecutionTarget;
fn id<T>(constructor: impl FnOnce(String) -> Result<T, WorkspaceError>, value: &str) -> T {
constructor(value.to_owned()).unwrap()
}
fn pane(value: &str, launch: bool) -> PaneDefinition {
PaneDefinition {
id: id(PaneId::new, value),
title: value.to_owned(),
surface: PaneSurface::Terminal,
execution_target: ExecutionTarget::Local,
launch: launch.then_some(PaneLaunchIntent::UserShell {
working_directory: None,
}),
}
}
fn request(value: &str, command: WorkspaceCommand) -> WorkspaceRequest {
WorkspaceRequest {
request_id: id(CommandRequestId::new, value),
origin: CommandOrigin::Agent {
identity: "test-agent".into(),
},
command,
}
}
fn workspace() -> WorkspaceState {
WorkspaceState::new(id(WorkspaceId::new, "ws"), "Workspace", pane("root", false)).unwrap()
}
#[test]
fn agent_plan_builds_a_deterministic_split_tree() {
let mut state = workspace();
let requests = [
request(
"split-1",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("agent", false),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
request(
"split-2",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "agent"),
pane: pane("tests", false),
axis: SplitAxis::Vertical,
ratio: SplitRatio::from_permille(600).unwrap(),
placement: PanePlacement::Before,
},
),
];
let outcomes = state
.apply_plan(requests, WorkspaceCapability::Configure)
.unwrap();
assert_eq!(outcomes.len(), 2);
assert_eq!(state.pane_count(), 3);
assert_eq!(state.selected().as_str(), "tests");
assert_eq!(state.revision(), 2);
assert!(matches!(state.layout(), LayoutNode::Split { .. }));
}
#[test]
fn repeated_request_is_idempotent() {
let mut state = workspace();
let command = request(
"select-root",
WorkspaceCommand::SelectPane {
pane: id(PaneId::new, "root"),
},
);
let first = state
.apply(command.clone(), WorkspaceCapability::Configure)
.unwrap();
let second = state
.apply(command, WorkspaceCapability::Configure)
.unwrap();
assert!(!first.duplicate);
assert!(second.duplicate);
assert_eq!(state.revision(), 1);
}
#[test]
fn reused_request_id_must_match_origin_and_command() {
let mut state = workspace();
state
.apply(
request(
"shared-id",
WorkspaceCommand::SelectPane {
pane: id(PaneId::new, "root"),
},
),
WorkspaceCapability::Configure,
)
.unwrap();
let conflicting = request(
"shared-id",
WorkspaceCommand::RenamePane {
pane: id(PaneId::new, "root"),
title: "Different".into(),
},
);
assert!(matches!(
state.apply(conflicting, WorkspaceCapability::Configure),
Err(WorkspaceError::RequestConflict(_))
));
assert_eq!(state.pane(&id(PaneId::new, "root")).unwrap().title, "root");
assert_eq!(state.revision(), 1);
}
#[test]
fn rejects_unattributed_agent_requests() {
let mut state = workspace();
let request = WorkspaceRequest {
request_id: id(CommandRequestId::new, "origin"),
origin: CommandOrigin::Agent {
identity: "\n".into(),
},
command: WorkspaceCommand::SelectPane {
pane: id(PaneId::new, "root"),
},
};
assert!(matches!(
state.apply(request, WorkspaceCapability::Configure),
Err(WorkspaceError::InvalidOrigin)
));
assert_eq!(state.revision(), 0);
}
#[test]
fn launch_and_termination_require_execute_capability() {
let mut state = workspace();
let launch = request(
"launch",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("shell", true),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
);
assert!(matches!(
state.apply(launch.clone(), WorkspaceCapability::Configure),
Err(WorkspaceError::CapabilityDenied { .. })
));
state.apply(launch, WorkspaceCapability::Execute).unwrap();
let terminate = request(
"terminate",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "shell"),
disposition: PaneCloseDisposition::Terminate,
},
);
assert!(matches!(
state.apply(terminate, WorkspaceCapability::Configure),
Err(WorkspaceError::CapabilityDenied { .. })
));
}
#[test]
fn failed_plan_is_atomic() {
let mut state = workspace();
let before = state.clone();
let requests = [
request(
"good",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("new", false),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
request(
"bad",
WorkspaceCommand::SelectPane {
pane: id(PaneId::new, "missing"),
},
),
];
assert!(
state
.apply_plan(requests, WorkspaceCapability::Configure)
.is_err()
);
assert!(state == before);
}
#[test]
fn close_promotes_sibling_and_never_closes_last_pane() {
let mut state = workspace();
state
.apply(
request(
"split",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("second", false),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
state
.apply(
request(
"close",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "second"),
disposition: PaneCloseDisposition::Detach,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
assert_eq!(state.selected().as_str(), "root");
assert_eq!(state.pane_count(), 1);
assert!(matches!(
state.apply(
request(
"last",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "root"),
disposition: PaneCloseDisposition::Detach,
},
),
WorkspaceCapability::Configure
),
Err(WorkspaceError::CannotCloseLastPane)
));
}
#[test]
fn rejects_invalid_identifiers_ratios_and_launch_surfaces() {
assert!(PaneId::new(" ").is_err());
assert!(SplitRatio::from_permille(99).is_err());
let invalid = PaneDefinition {
id: id(PaneId::new, "browser"),
title: "Browser".into(),
surface: PaneSurface::Browser,
execution_target: ExecutionTarget::Local,
launch: Some(PaneLaunchIntent::UserShell {
working_directory: None,
}),
};
assert!(invalid.validate().is_err());
}
}
+3
View File
@@ -11,5 +11,8 @@ repository.workspace = true
lumbridge-pty = { path = "../lumbridge-pty" }
thiserror = "2.0"
[dev-dependencies]
lumbridge-terminal = { path = "../lumbridge-terminal" }
[lints]
workspace = true
@@ -0,0 +1,78 @@
use lumbridge_runtime::{
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorOptions, RuntimeCommand, RuntimeEvent,
TerminalSize,
};
use lumbridge_terminal::{
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey,
TerminalKeyEvent,
};
use std::time::{Duration, Instant};
const TEST_TIMEOUT: Duration = Duration::from_secs(3);
#[test]
fn actor_terminal_pipeline_accepts_input_resize_and_vt_output() {
let command = CommandConfig::new("/bin/sh")
.unwrap()
.args([
"-c",
"printf '\x1b[31mready\x1b[0m\\n'; IFS= read -r line; stty size; printf 'got:%s\\n' \"$line\"",
]);
let actor = RuntimeActor::spawn(
command,
PtyOptions::default(),
RuntimeActorOptions::default(),
)
.unwrap();
let dimensions = TerminalDimensions::new(41, 101).unwrap();
let mut terminal = TerminalEngine::new(TerminalEngineOptions {
dimensions: TerminalDimensions::default(),
scrollback_lines: 64,
});
assert!(terminal.resize(dimensions));
actor
.try_send(RuntimeCommand::Resize(TerminalSize::new(41, 101).unwrap()))
.unwrap();
let text = terminal.encode_key(&TerminalKeyEvent {
key: TerminalKey::Text("hello-pipeline".into()),
modifiers: KeyModifiers::default(),
});
actor.try_send(RuntimeCommand::Input(text)).unwrap();
let enter = terminal.encode_key(&TerminalKeyEvent {
key: TerminalKey::Enter,
modifiers: KeyModifiers::default(),
});
actor.try_send(RuntimeCommand::Input(enter)).unwrap();
let deadline = Instant::now() + TEST_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(!remaining.is_zero(), "timed out waiting for pipeline exit");
match actor.recv_timeout(remaining).unwrap() {
RuntimeEvent::Output { bytes, .. } => {
let update = terminal.process(&bytes);
for response in update.outbound {
actor.try_send(RuntimeCommand::Input(response)).unwrap();
}
}
RuntimeEvent::Exited { status, .. } => {
assert!(status.success());
break;
}
RuntimeEvent::Fault { message, .. } => panic!("runtime fault: {message}"),
RuntimeEvent::Started { .. } | RuntimeEvent::InputClosed { .. } => {}
}
}
let contents = terminal.snapshot().plain_rows().join("\n");
assert!(contents.contains("ready"), "missing ready marker");
assert!(
contents.contains("41 101"),
"missing resized PTY dimensions"
);
assert!(
contents.contains("got:hello-pipeline"),
"missing echoed input"
);
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "lumbridge-terminal"
description = "Framework-neutral terminal state, snapshots, and input encoding for Lumbridge"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
alacritty_terminal = { version = "0.26.0", default-features = false }
thiserror = "2.0"
[lints]
workspace = true
+994
View File
@@ -0,0 +1,994 @@
//! Framework-neutral terminal state for Lumbridge.
//!
//! This crate consumes raw PTY bytes, maintains VT state, creates immutable
//! render snapshots, and encodes human input. It deliberately owns neither a
//! PTY nor a UI framework.
#![forbid(unsafe_code)]
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{Config, Osc52, Term, TermMode, point_to_viewport};
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor};
use std::fmt;
use std::sync::{Arc, Mutex, MutexGuard};
use thiserror::Error;
const DEFAULT_SCROLLBACK_LINES: usize = 10_000;
const MAX_TITLE_BYTES: usize = 512;
/// Validated terminal grid and cell dimensions.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalDimensions {
rows: u16,
columns: u16,
cell_width: u16,
cell_height: u16,
}
impl TerminalDimensions {
/// Creates dimensions without pixel-cell measurements.
///
/// # Errors
///
/// Returns [`TerminalEngineError::InvalidDimensions`] for a zero row or
/// column count.
pub fn new(rows: u16, columns: u16) -> Result<Self, TerminalEngineError> {
Self::with_cell_size(rows, columns, 0, 0)
}
/// Creates dimensions with the cell size used for terminal size queries.
///
/// # Errors
///
/// Returns [`TerminalEngineError::InvalidDimensions`] for a zero row or
/// column count.
pub fn with_cell_size(
rows: u16,
columns: u16,
cell_width: u16,
cell_height: u16,
) -> Result<Self, TerminalEngineError> {
if rows == 0 || columns == 0 {
return Err(TerminalEngineError::InvalidDimensions);
}
Ok(Self {
rows,
columns,
cell_width,
cell_height,
})
}
#[must_use]
pub const fn rows(self) -> u16 {
self.rows
}
#[must_use]
pub const fn columns(self) -> u16 {
self.columns
}
const fn window_size(self) -> WindowSize {
WindowSize {
num_lines: self.rows,
num_cols: self.columns,
cell_width: self.cell_width,
cell_height: self.cell_height,
}
}
}
impl Default for TerminalDimensions {
fn default() -> Self {
Self {
rows: 24,
columns: 80,
cell_width: 0,
cell_height: 0,
}
}
}
impl Dimensions for TerminalDimensions {
fn total_lines(&self) -> usize {
usize::from(self.rows)
}
fn screen_lines(&self) -> usize {
usize::from(self.rows)
}
fn columns(&self) -> usize {
usize::from(self.columns)
}
}
/// Configuration for a terminal state engine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalEngineOptions {
pub dimensions: TerminalDimensions,
pub scrollback_lines: usize,
}
impl Default for TerminalEngineOptions {
fn default() -> Self {
Self {
dimensions: TerminalDimensions::default(),
scrollback_lines: DEFAULT_SCROLLBACK_LINES,
}
}
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum TerminalEngineError {
#[error("terminal rows and columns must both be greater than zero")]
InvalidDimensions,
}
/// Color identity retained by an immutable terminal snapshot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TerminalColor {
Named(TerminalNamedColor),
Indexed(u8),
Rgb { red: u8, green: u8, blue: u8 },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TerminalNamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
Foreground,
Background,
Cursor,
DimBlack,
DimRed,
DimGreen,
DimYellow,
DimBlue,
DimMagenta,
DimCyan,
DimWhite,
BrightForeground,
DimForeground,
}
/// Render attributes for one terminal cell.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TerminalCellStyle(u16);
impl TerminalCellStyle {
pub const BOLD: Self = Self(1 << 0);
pub const DIM: Self = Self(1 << 1);
pub const ITALIC: Self = Self(1 << 2);
pub const UNDERLINE: Self = Self(1 << 3);
pub const DOUBLE_UNDERLINE: Self = Self(1 << 4);
pub const UNDERCURL: Self = Self(1 << 5);
pub const DOTTED_UNDERLINE: Self = Self(1 << 6);
pub const DASHED_UNDERLINE: Self = Self(1 << 7);
pub const INVERSE: Self = Self(1 << 8);
pub const HIDDEN: Self = Self(1 << 9);
pub const STRIKEOUT: Self = Self(1 << 10);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
const fn with(mut self, flag: Self, enabled: bool) -> Self {
if enabled {
self.0 |= flag.0;
}
self
}
}
/// One immutable cell. It intentionally has no `Debug` implementation so a
/// transcript cannot be accidentally dumped through snapshot diagnostics.
#[derive(Clone, Eq, PartialEq)]
pub struct TerminalCell {
pub text: String,
pub foreground: TerminalColor,
pub background: TerminalColor,
pub style: TerminalCellStyle,
pub wide: bool,
pub wide_spacer: bool,
pub hyperlink: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TerminalCursorShape {
Block,
Underline,
Beam,
HollowBlock,
Hidden,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalCursor {
pub row: u16,
pub column: u16,
pub shape: TerminalCursorShape,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TerminalModes(u16);
impl TerminalModes {
pub const ALTERNATE_SCREEN: Self = Self(1 << 0);
pub const APPLICATION_CURSOR: Self = Self(1 << 1);
pub const APPLICATION_KEYPAD: Self = Self(1 << 2);
pub const BRACKETED_PASTE: Self = Self(1 << 3);
pub const MOUSE_REPORTING: Self = Self(1 << 4);
pub const FOCUS_REPORTING: Self = Self(1 << 5);
pub const KITTY_KEYBOARD: Self = Self(1 << 6);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
const fn with(mut self, flag: Self, enabled: bool) -> Self {
if enabled {
self.0 |= flag.0;
}
self
}
}
/// Immutable, framework-neutral render state. It intentionally omits `Debug`
/// because cells and titles may contain private terminal content.
#[derive(Clone, Eq, PartialEq)]
pub struct TerminalSnapshot {
pub revision: u64,
pub dimensions: TerminalDimensions,
pub cells: Vec<TerminalCell>,
pub cursor: TerminalCursor,
pub modes: TerminalModes,
pub title: Option<String>,
}
impl TerminalSnapshot {
#[must_use]
pub fn cell(&self, row: u16, column: u16) -> Option<&TerminalCell> {
if row >= self.dimensions.rows() || column >= self.dimensions.columns() {
return None;
}
let index = usize::from(row) * usize::from(self.dimensions.columns()) + usize::from(column);
self.cells.get(index)
}
/// Returns visible plain-text rows for simple UI adapters and tests.
#[must_use]
pub fn plain_rows(&self) -> Vec<String> {
(0..self.dimensions.rows())
.map(|row| {
let mut text = String::new();
for column in 0..self.dimensions.columns() {
let Some(cell) = self.cell(row, column) else {
continue;
};
if cell.wide_spacer {
continue;
}
if cell.text.is_empty() || cell.style.contains(TerminalCellStyle::HIDDEN) {
text.push(' ');
} else {
text.push_str(&cell.text);
}
}
text.trim_end().to_owned()
})
.collect()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TerminalQueryKind {
Color,
Clipboard,
}
/// Semantic terminal event safe to pass to the application boundary.
/// Transcript-bearing title values are redacted from `Debug`.
#[derive(Clone, Eq, PartialEq)]
pub enum TerminalEvent {
Bell,
TitleChanged(String),
TitleReset,
ClipboardOperationBlocked,
UnsupportedQuery(TerminalQueryKind),
ExitRequested,
}
impl fmt::Debug for TerminalEvent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bell => formatter.write_str("Bell"),
Self::TitleChanged(title) => formatter
.debug_struct("TitleChanged")
.field("bytes", &title.len())
.finish_non_exhaustive(),
Self::TitleReset => formatter.write_str("TitleReset"),
Self::ClipboardOperationBlocked => formatter.write_str("ClipboardOperationBlocked"),
Self::UnsupportedQuery(kind) => formatter
.debug_tuple("UnsupportedQuery")
.field(kind)
.finish(),
Self::ExitRequested => formatter.write_str("ExitRequested"),
}
}
}
/// Result of processing PTY output. `outbound` contains terminal-protocol
/// responses that must be serialized through the same actor input queue as
/// human input.
pub struct TerminalUpdate {
pub revision: u64,
pub outbound: Vec<Vec<u8>>,
pub events: Vec<TerminalEvent>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct KeyModifiers(u8);
impl KeyModifiers {
pub const SHIFT: Self = Self(1 << 0);
pub const ALT: Self = Self(1 << 1);
pub const CONTROL: Self = Self(1 << 2);
pub const PLATFORM: Self = Self(1 << 3);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[must_use]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TerminalKey {
Text(String),
Enter,
Backspace,
Tab,
Escape,
Up,
Down,
Left,
Right,
Home,
End,
Insert,
Delete,
PageUp,
PageDown,
Function(u8),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalKeyEvent {
pub key: TerminalKey,
pub modifiers: KeyModifiers,
}
#[derive(Default)]
struct ProxyState {
dimensions: TerminalDimensions,
outbound: Vec<Vec<u8>>,
events: Vec<TerminalEvent>,
title: Option<String>,
}
#[derive(Clone, Default)]
struct EventProxy {
state: Arc<Mutex<ProxyState>>,
}
impl EventProxy {
fn lock(&self) -> MutexGuard<'_, ProxyState> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn set_dimensions(&self, dimensions: TerminalDimensions) {
self.lock().dimensions = dimensions;
}
fn drain(&self, revision: u64) -> TerminalUpdate {
let mut state = self.lock();
TerminalUpdate {
revision,
outbound: std::mem::take(&mut state.outbound),
events: std::mem::take(&mut state.events),
}
}
fn title(&self) -> Option<String> {
self.lock().title.clone()
}
}
impl EventListener for EventProxy {
fn send_event(&self, event: Event) {
let mut state = self.lock();
match event {
Event::PtyWrite(text) => state.outbound.push(text.into_bytes()),
Event::TextAreaSizeRequest(formatter) => {
let window_size = state.dimensions.window_size();
state.outbound.push(formatter(window_size).into_bytes());
}
Event::Title(title) => {
let title = sanitize_title(&title);
state.title = Some(title.clone());
state.events.push(TerminalEvent::TitleChanged(title));
}
Event::ResetTitle => {
state.title = None;
state.events.push(TerminalEvent::TitleReset);
}
Event::Bell => state.events.push(TerminalEvent::Bell),
Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => {
state.events.push(TerminalEvent::ClipboardOperationBlocked);
}
Event::ColorRequest(_, _) => state
.events
.push(TerminalEvent::UnsupportedQuery(TerminalQueryKind::Color)),
Event::Exit => state.events.push(TerminalEvent::ExitRequested),
Event::MouseCursorDirty
| Event::CursorBlinkingChange
| Event::Wakeup
| Event::ChildExit(_) => {}
}
}
}
/// Single-owner terminal state engine.
pub struct TerminalEngine {
parser: Processor,
terminal: Term<EventProxy>,
proxy: EventProxy,
dimensions: TerminalDimensions,
revision: u64,
}
impl TerminalEngine {
#[must_use]
pub fn new(options: TerminalEngineOptions) -> Self {
let proxy = EventProxy::default();
proxy.set_dimensions(options.dimensions);
let config = Config {
scrolling_history: options.scrollback_lines,
osc52: Osc52::Disabled,
..Config::default()
};
Self {
parser: Processor::new(),
terminal: Term::new(config, &options.dimensions, proxy.clone()),
proxy,
dimensions: options.dimensions,
revision: 0,
}
}
/// Applies ordered raw PTY bytes and returns protocol responses/events.
pub fn process(&mut self, bytes: &[u8]) -> TerminalUpdate {
if !bytes.is_empty() {
self.parser.advance(&mut self.terminal, bytes);
self.revision = self.revision.saturating_add(1);
}
self.proxy.drain(self.revision)
}
/// Resizes terminal state. The caller must send the equivalent PTY resize
/// through the runtime actor to keep both sides synchronized.
pub fn resize(&mut self, dimensions: TerminalDimensions) -> bool {
if self.dimensions == dimensions {
return false;
}
self.terminal.resize(dimensions);
self.proxy.set_dimensions(dimensions);
self.dimensions = dimensions;
self.revision = self.revision.saturating_add(1);
true
}
#[must_use]
pub const fn dimensions(&self) -> TerminalDimensions {
self.dimensions
}
#[must_use]
pub const fn revision(&self) -> u64 {
self.revision
}
/// Encodes a key using the terminal's current input modes.
#[must_use]
pub fn encode_key(&self, event: &TerminalKeyEvent) -> Vec<u8> {
encode_key(event, *self.terminal.mode())
}
/// Encodes paste delimiters only when the child enabled bracketed paste.
#[must_use]
pub fn encode_paste(&self, text: &str) -> Vec<u8> {
if self.terminal.mode().contains(TermMode::BRACKETED_PASTE) {
let mut bytes = Vec::with_capacity(text.len() + 12);
bytes.extend_from_slice(b"\x1b[200~");
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
bytes
} else {
text.as_bytes().to_vec()
}
}
#[must_use]
pub fn snapshot(&self) -> TerminalSnapshot {
let renderable = self.terminal.renderable_content();
let cursor_point = point_to_viewport(renderable.display_offset, renderable.cursor.point)
.unwrap_or_default();
let mut cells = vec![
blank_cell();
usize::from(self.dimensions.rows)
* usize::from(self.dimensions.columns)
];
for indexed in renderable.display_iter {
let Some(point) = point_to_viewport(renderable.display_offset, indexed.point) else {
continue;
};
if point.line >= usize::from(self.dimensions.rows)
|| point.column.0 >= usize::from(self.dimensions.columns)
{
continue;
}
let index = point.line * usize::from(self.dimensions.columns) + point.column.0;
cells[index] = snapshot_cell(indexed.cell);
}
TerminalSnapshot {
revision: self.revision,
dimensions: self.dimensions,
cells,
cursor: TerminalCursor {
row: u16::try_from(cursor_point.line).unwrap_or(u16::MAX),
column: u16::try_from(cursor_point.column.0).unwrap_or(u16::MAX),
shape: map_cursor_shape(renderable.cursor.shape),
},
modes: snapshot_modes(renderable.mode),
title: self.proxy.title(),
}
}
}
impl Default for TerminalEngine {
fn default() -> Self {
Self::new(TerminalEngineOptions::default())
}
}
fn sanitize_title(title: &str) -> String {
title
.chars()
.filter(|character| !character.is_control())
.scan(0_usize, |bytes, character| {
let next = *bytes + character.len_utf8();
if next > MAX_TITLE_BYTES {
None
} else {
*bytes = next;
Some(character)
}
})
.collect()
}
fn snapshot_cell(cell: &alacritty_terminal::term::cell::Cell) -> TerminalCell {
let mut text = String::new();
if !cell
.flags
.contains(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
text.push(cell.c);
if let Some(zero_width) = cell.zerowidth() {
text.extend(zero_width);
}
}
TerminalCell {
text,
foreground: map_color(cell.fg),
background: map_color(cell.bg),
style: TerminalCellStyle::default()
.with(TerminalCellStyle::BOLD, cell.flags.contains(Flags::BOLD))
.with(TerminalCellStyle::DIM, cell.flags.contains(Flags::DIM))
.with(
TerminalCellStyle::ITALIC,
cell.flags.contains(Flags::ITALIC),
)
.with(
TerminalCellStyle::UNDERLINE,
cell.flags.contains(Flags::UNDERLINE),
)
.with(
TerminalCellStyle::DOUBLE_UNDERLINE,
cell.flags.contains(Flags::DOUBLE_UNDERLINE),
)
.with(
TerminalCellStyle::UNDERCURL,
cell.flags.contains(Flags::UNDERCURL),
)
.with(
TerminalCellStyle::DOTTED_UNDERLINE,
cell.flags.contains(Flags::DOTTED_UNDERLINE),
)
.with(
TerminalCellStyle::DASHED_UNDERLINE,
cell.flags.contains(Flags::DASHED_UNDERLINE),
)
.with(
TerminalCellStyle::INVERSE,
cell.flags.contains(Flags::INVERSE),
)
.with(
TerminalCellStyle::HIDDEN,
cell.flags.contains(Flags::HIDDEN),
)
.with(
TerminalCellStyle::STRIKEOUT,
cell.flags.contains(Flags::STRIKEOUT),
),
wide: cell.flags.contains(Flags::WIDE_CHAR),
wide_spacer: cell.flags.contains(Flags::WIDE_CHAR_SPACER),
hyperlink: cell.hyperlink().map(|link| link.uri().to_owned()),
}
}
fn blank_cell() -> TerminalCell {
snapshot_cell(&alacritty_terminal::term::cell::Cell::default())
}
fn snapshot_modes(mode: TermMode) -> TerminalModes {
TerminalModes::default()
.with(
TerminalModes::ALTERNATE_SCREEN,
mode.contains(TermMode::ALT_SCREEN),
)
.with(
TerminalModes::APPLICATION_CURSOR,
mode.contains(TermMode::APP_CURSOR),
)
.with(
TerminalModes::APPLICATION_KEYPAD,
mode.contains(TermMode::APP_KEYPAD),
)
.with(
TerminalModes::BRACKETED_PASTE,
mode.contains(TermMode::BRACKETED_PASTE),
)
.with(
TerminalModes::MOUSE_REPORTING,
mode.intersects(TermMode::MOUSE_MODE),
)
.with(
TerminalModes::FOCUS_REPORTING,
mode.contains(TermMode::FOCUS_IN_OUT),
)
.with(
TerminalModes::KITTY_KEYBOARD,
mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL),
)
}
const fn map_cursor_shape(shape: CursorShape) -> TerminalCursorShape {
match shape {
CursorShape::Block => TerminalCursorShape::Block,
CursorShape::Underline => TerminalCursorShape::Underline,
CursorShape::Beam => TerminalCursorShape::Beam,
CursorShape::HollowBlock => TerminalCursorShape::HollowBlock,
CursorShape::Hidden => TerminalCursorShape::Hidden,
}
}
const fn map_color(color: Color) -> TerminalColor {
match color {
Color::Named(named) => TerminalColor::Named(map_named_color(named)),
Color::Indexed(index) => TerminalColor::Indexed(index),
Color::Spec(rgb) => TerminalColor::Rgb {
red: rgb.r,
green: rgb.g,
blue: rgb.b,
},
}
}
const fn map_named_color(color: NamedColor) -> TerminalNamedColor {
match color {
NamedColor::Black => TerminalNamedColor::Black,
NamedColor::Red => TerminalNamedColor::Red,
NamedColor::Green => TerminalNamedColor::Green,
NamedColor::Yellow => TerminalNamedColor::Yellow,
NamedColor::Blue => TerminalNamedColor::Blue,
NamedColor::Magenta => TerminalNamedColor::Magenta,
NamedColor::Cyan => TerminalNamedColor::Cyan,
NamedColor::White => TerminalNamedColor::White,
NamedColor::BrightBlack => TerminalNamedColor::BrightBlack,
NamedColor::BrightRed => TerminalNamedColor::BrightRed,
NamedColor::BrightGreen => TerminalNamedColor::BrightGreen,
NamedColor::BrightYellow => TerminalNamedColor::BrightYellow,
NamedColor::BrightBlue => TerminalNamedColor::BrightBlue,
NamedColor::BrightMagenta => TerminalNamedColor::BrightMagenta,
NamedColor::BrightCyan => TerminalNamedColor::BrightCyan,
NamedColor::BrightWhite => TerminalNamedColor::BrightWhite,
NamedColor::Foreground => TerminalNamedColor::Foreground,
NamedColor::Background => TerminalNamedColor::Background,
NamedColor::Cursor => TerminalNamedColor::Cursor,
NamedColor::DimBlack => TerminalNamedColor::DimBlack,
NamedColor::DimRed => TerminalNamedColor::DimRed,
NamedColor::DimGreen => TerminalNamedColor::DimGreen,
NamedColor::DimYellow => TerminalNamedColor::DimYellow,
NamedColor::DimBlue => TerminalNamedColor::DimBlue,
NamedColor::DimMagenta => TerminalNamedColor::DimMagenta,
NamedColor::DimCyan => TerminalNamedColor::DimCyan,
NamedColor::DimWhite => TerminalNamedColor::DimWhite,
NamedColor::BrightForeground => TerminalNamedColor::BrightForeground,
NamedColor::DimForeground => TerminalNamedColor::DimForeground,
}
}
fn encode_key(event: &TerminalKeyEvent, mode: TermMode) -> Vec<u8> {
let modifiers = event.modifiers;
match &event.key {
TerminalKey::Text(text) => encode_text(text, modifiers),
TerminalKey::Enter => with_alt(b"\r".to_vec(), modifiers.contains(KeyModifiers::ALT)),
TerminalKey::Backspace => with_alt(vec![0x7f], modifiers.contains(KeyModifiers::ALT)),
TerminalKey::Tab if modifiers.contains(KeyModifiers::SHIFT) => b"\x1b[Z".to_vec(),
TerminalKey::Tab => with_alt(b"\t".to_vec(), modifiers.contains(KeyModifiers::ALT)),
TerminalKey::Escape => b"\x1b".to_vec(),
TerminalKey::Up => encode_cursor_key('A', modifiers, mode),
TerminalKey::Down => encode_cursor_key('B', modifiers, mode),
TerminalKey::Right => encode_cursor_key('C', modifiers, mode),
TerminalKey::Left => encode_cursor_key('D', modifiers, mode),
TerminalKey::Home => encode_cursor_key('H', modifiers, mode),
TerminalKey::End => encode_cursor_key('F', modifiers, mode),
TerminalKey::Insert => encode_csi_tilde(2, modifiers),
TerminalKey::Delete => encode_csi_tilde(3, modifiers),
TerminalKey::PageUp => encode_csi_tilde(5, modifiers),
TerminalKey::PageDown => encode_csi_tilde(6, modifiers),
TerminalKey::Function(number) => encode_function(*number, modifiers),
}
}
fn encode_text(text: &str, modifiers: KeyModifiers) -> Vec<u8> {
let mut bytes = if modifiers.contains(KeyModifiers::CONTROL) {
control_text(text).unwrap_or_else(|| text.as_bytes().to_vec())
} else {
text.as_bytes().to_vec()
};
if modifiers.contains(KeyModifiers::ALT) {
bytes.insert(0, 0x1b);
}
bytes
}
fn control_text(text: &str) -> Option<Vec<u8>> {
let mut characters = text.chars();
let character = characters.next()?;
if characters.next().is_some() || !character.is_ascii() {
return None;
}
let byte = character as u8;
let control = match byte {
b'@'..=b'_' => byte - b'@',
b'a'..=b'z' => byte - b'a' + 1,
b'?' => 0x7f,
b' ' => 0,
_ => return None,
};
Some(vec![control])
}
fn with_alt(mut bytes: Vec<u8>, alt: bool) -> Vec<u8> {
if alt {
bytes.insert(0, 0x1b);
}
bytes
}
fn modifier_parameter(modifiers: KeyModifiers) -> u8 {
1 + u8::from(modifiers.contains(KeyModifiers::SHIFT))
+ 2 * u8::from(modifiers.contains(KeyModifiers::ALT))
+ 4 * u8::from(modifiers.contains(KeyModifiers::CONTROL))
+ 8 * u8::from(modifiers.contains(KeyModifiers::PLATFORM))
}
fn encode_cursor_key(final_byte: char, modifiers: KeyModifiers, mode: TermMode) -> Vec<u8> {
let parameter = modifier_parameter(modifiers);
if parameter == 1 && mode.contains(TermMode::APP_CURSOR) {
format!("\x1bO{final_byte}").into_bytes()
} else if parameter == 1 {
format!("\x1b[{final_byte}").into_bytes()
} else {
format!("\x1b[1;{parameter}{final_byte}").into_bytes()
}
}
fn encode_csi_tilde(number: u8, modifiers: KeyModifiers) -> Vec<u8> {
let parameter = modifier_parameter(modifiers);
if parameter == 1 {
format!("\x1b[{number}~").into_bytes()
} else {
format!("\x1b[{number};{parameter}~").into_bytes()
}
}
fn encode_function(number: u8, modifiers: KeyModifiers) -> Vec<u8> {
let parameter = modifier_parameter(modifiers);
if let Some(final_byte) = [None, Some('P'), Some('Q'), Some('R'), Some('S')]
.get(usize::from(number))
.copied()
.flatten()
{
if parameter == 1 {
return format!("\x1bO{final_byte}").into_bytes();
}
return format!("\x1b[1;{parameter}{final_byte}").into_bytes();
}
let code = match number {
5 => 15,
6 => 17,
7 => 18,
8 => 19,
9 => 20,
10 => 21,
11 => 23,
12 => 24,
_ => return Vec::new(),
};
encode_csi_tilde(code, modifiers)
}
#[cfg(test)]
mod tests {
use super::{
KeyModifiers, TerminalCellStyle, TerminalDimensions, TerminalEngine, TerminalEngineOptions,
TerminalEvent, TerminalKey, TerminalKeyEvent, TerminalModes, TerminalNamedColor,
};
fn engine(rows: u16, columns: u16) -> TerminalEngine {
TerminalEngine::new(TerminalEngineOptions {
dimensions: TerminalDimensions::new(rows, columns).unwrap(),
scrollback_lines: 32,
})
}
#[test]
fn parses_cells_styles_unicode_cursor_and_title() {
let mut engine = engine(3, 8);
let update = engine.process(b"\x1b]2;safe\x07\x1b[31;1mhi\x1b[0m \xf0\x9f\x91\xa9\r\nnext");
assert_eq!(update.events, [TerminalEvent::TitleChanged("safe".into())]);
let snapshot = engine.snapshot();
assert_eq!(snapshot.title.as_deref(), Some("safe"));
let first = snapshot.cell(0, 0).unwrap();
assert_eq!(first.text, "h");
assert!(first.style.contains(TerminalCellStyle::BOLD));
assert_eq!(
first.foreground,
super::TerminalColor::Named(TerminalNamedColor::Red)
);
assert_eq!(snapshot.cell(0, 3).unwrap().text, "👩");
assert!(snapshot.cell(0, 3).unwrap().wide);
assert!(snapshot.cell(0, 4).unwrap().wide_spacer);
assert_eq!(snapshot.plain_rows()[1], "next");
assert_eq!((snapshot.cursor.row, snapshot.cursor.column), (1, 4));
}
#[test]
fn tracks_alternate_screen_and_bracketed_paste() {
let mut engine = engine(2, 8);
engine.process(b"primary\x1b[?1049h\x1b[H\x1b[?2004halt");
let snapshot = engine.snapshot();
assert!(snapshot.modes.contains(TerminalModes::ALTERNATE_SCREEN));
assert!(snapshot.modes.contains(TerminalModes::BRACKETED_PASTE));
assert_eq!(snapshot.plain_rows()[0], "alt");
assert_eq!(engine.encode_paste("hello"), b"\x1b[200~hello\x1b[201~");
engine.process(b"\x1b[?1049l");
assert_eq!(engine.snapshot().plain_rows()[0], "primary");
}
#[test]
fn emits_protocol_replies_without_logging_input_content() {
let mut engine = engine(4, 10);
let update = engine.process(b"\x1b[6n\x1b[18t");
assert!(update.outbound.iter().any(|reply| reply == b"\x1b[1;1R"));
assert!(update.outbound.iter().any(|reply| reply == b"\x1b[8;4;10t"));
assert!(update.events.is_empty());
}
#[test]
fn sanitizes_titles_and_blocks_clipboard_protocol() {
let mut engine = engine(2, 8);
let update = engine.process(b"\x1b]2;hello\x01world\x07\x1b]52;c;aGVsbG8=\x07");
assert_eq!(engine.snapshot().title.as_deref(), Some("helloworld"));
assert!(
update
.events
.contains(&TerminalEvent::TitleChanged("helloworld".into()))
);
assert!(update.outbound.is_empty());
}
#[test]
fn encodes_text_control_cursor_modifiers_and_function_keys() {
let mut engine = engine(2, 8);
let text = TerminalKeyEvent {
key: TerminalKey::Text("c".into()),
modifiers: KeyModifiers::CONTROL,
};
assert_eq!(engine.encode_key(&text), [3]);
let up = TerminalKeyEvent {
key: TerminalKey::Up,
modifiers: KeyModifiers::default(),
};
assert_eq!(engine.encode_key(&up), b"\x1b[A");
engine.process(b"\x1b[?1h");
assert_eq!(engine.encode_key(&up), b"\x1bOA");
let modified = TerminalKeyEvent {
key: TerminalKey::Left,
modifiers: KeyModifiers::SHIFT.union(KeyModifiers::CONTROL),
};
assert_eq!(engine.encode_key(&modified), b"\x1b[1;6D");
let function = TerminalKeyEvent {
key: TerminalKey::Function(12),
modifiers: KeyModifiers::default(),
};
assert_eq!(engine.encode_key(&function), b"\x1b[24~");
}
#[test]
fn resize_updates_snapshots_and_terminal_size_queries() {
let mut engine = engine(2, 8);
let dimensions = TerminalDimensions::with_cell_size(5, 12, 9, 18).unwrap();
assert!(engine.resize(dimensions));
assert!(!engine.resize(dimensions));
assert_eq!(engine.snapshot().dimensions, dimensions);
let update = engine.process(b"\x1b[14t\x1b[18t");
assert!(
update
.outbound
.iter()
.any(|reply| reply == b"\x1b[4;90;108t")
);
assert!(update.outbound.iter().any(|reply| reply == b"\x1b[8;5;12t"));
}
#[test]
fn rejects_zero_dimensions() {
assert!(TerminalDimensions::new(0, 80).is_err());
assert!(TerminalDimensions::new(24, 0).is_err());
}
}