use std::fmt;
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionId(String);
impl SessionId {
pub fn new(id: impl Into<String>) -> Result<Self> {
let id = id.into();
ensure!(!id.trim().is_empty(), "session id must be set");
Ok(Self(id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ClientId(String);
impl ClientId {
pub fn new(id: impl Into<String>) -> Result<Self> {
let id = id.into();
ensure!(!id.trim().is_empty(), "client id must be set");
Ok(Self(id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ClientId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionSize {
pub rows: usize,
pub cols: usize,
pub pixel_width: usize,
pub pixel_height: usize,
pub dpi: usize,
}
impl Default for SessionSize {
fn default() -> Self {
Self {
rows: 24,
cols: 80,
pixel_width: 800,
pixel_height: 480,
dpi: 96,
}
}
}
impl SessionSize {
pub fn validate(&self) -> Result<()> {
ensure!(self.rows > 0, "session PTY rows must be greater than zero");
ensure!(self.cols > 0, "session PTY cols must be greater than zero");
ensure!(
self.rows <= u16::MAX as usize,
"session PTY rows must fit in u16"
);
ensure!(
self.cols <= u16::MAX as usize,
"session PTY cols must fit in u16"
);
ensure!(
self.pixel_width <= u16::MAX as usize,
"session PTY pixel width must fit in u16"
);
ensure!(
self.pixel_height <= u16::MAX as usize,
"session PTY pixel height must fit in u16"
);
ensure!(
self.dpi <= u32::MAX as usize,
"session terminal DPI must fit in u32"
);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub output_seq: u64,
pub bytes_logged: u64,
pub size: SessionSize,
pub visible_rows: Vec<String>,
pub styled_rows_start: usize,
pub styled_rows: Vec<StyledRow>,
pub cursor: TerminalCursor,
pub current_working_directory: Option<PathBuf>,
pub context: Option<SessionContext>,
pub bracketed_paste_enabled: bool,
pub exited: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionContext {
pub repository_root: Option<PathBuf>,
pub worktree_root: Option<PathBuf>,
pub branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalCursor {
pub row: usize,
pub col: usize,
pub visible: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StyledRow {
pub spans: Vec<StyledSpan>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StyledSpan {
pub text: String,
pub style: TerminalStyle,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalStyle {
pub foreground: Option<TerminalColor>,
pub background: Option<TerminalColor>,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub reverse: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalColor {
pub red: u8,
pub green: u8,
pub blue: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompletedSession {
pub output_seq: u64,
pub bytes_logged: u64,
pub visible_rows: Vec<String>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AttachMode {
#[default]
Observer,
InteractiveController,
AgentController,
}
impl AttachMode {
pub fn grants_input(self) -> bool {
!matches!(self, Self::Observer)
}
pub fn controller_kind(self) -> Option<InputControllerKind> {
match self {
AttachMode::Observer => None,
AttachMode::InteractiveController => Some(InputControllerKind::Interactive),
AttachMode::AgentController => Some(InputControllerKind::Agent),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputControllerKind {
Interactive,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputLeaseHolder {
pub client_id: ClientId,
pub kind: InputControllerKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputLeaseState {
pub holder: Option<InputLeaseHolder>,
pub generation: u64,
}
impl InputLeaseState {
pub fn observer_only() -> Self {
Self {
holder: None,
generation: 0,
}
}
pub fn acquire(&mut self, client_id: ClientId, kind: InputControllerKind) -> LeaseChange {
let previous = self.holder.replace(InputLeaseHolder { client_id, kind });
self.generation += 1;
let action = if previous.is_some() {
LeaseChangeAction::TakenOver
} else {
LeaseChangeAction::Acquired
};
LeaseChange {
generation: self.generation,
previous,
current: self.holder.clone(),
action,
}
}
pub fn release(&mut self, client_id: &ClientId) -> Option<LeaseChange> {
let current = self.holder.as_ref()?;
if ¤t.client_id != client_id {
return None;
}
let previous = self.holder.take();
self.generation += 1;
Some(LeaseChange {
generation: self.generation,
previous,
current: None,
action: LeaseChangeAction::Released,
})
}
}
impl Default for InputLeaseState {
fn default() -> Self {
Self::observer_only()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LeaseChangeAction {
Acquired,
Released,
TakenOver,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseChange {
pub generation: u64,
pub previous: Option<InputLeaseHolder>,
pub current: Option<InputLeaseHolder>,
pub action: LeaseChangeAction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StartSessionRequest {
pub command: String,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
pub size: SessionSize,
}
impl StartSessionRequest {
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
args: Vec::new(),
cwd: None,
size: SessionSize::default(),
}
}
pub fn validate(&self) -> Result<()> {
ensure!(
!self.command.trim().is_empty(),
"session command must be set"
);
self.size.validate()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachSessionRequest {
pub session_id: SessionId,
pub client_id: ClientId,
pub mode: AttachMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachSessionResponse {
pub snapshot: SessionSnapshot,
pub lease: InputLeaseState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WriteInputRequest {
pub session_id: SessionId,
pub client_id: ClientId,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResizeSessionRequest {
pub session_id: SessionId,
pub size: SessionSize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestoreSessionRequest {
pub session_id: SessionId,
pub size: SessionSize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StyledRowsRequest {
pub session_id: SessionId,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StyledRowsResponse {
pub output_seq: u64,
pub start: usize,
pub rows: Vec<StyledRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputLeaseRequest {
pub session_id: SessionId,
pub client_id: ClientId,
pub kind: InputControllerKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionEvent {
ResyncRequired {
session_id: SessionId,
latest_event_seq: u64,
snapshot: SessionSnapshot,
},
Output {
session_id: SessionId,
output_seq: u64,
bytes: Vec<u8>,
},
Snapshot {
session_id: SessionId,
snapshot: SessionSnapshot,
},
LeaseChanged {
session_id: SessionId,
change: LeaseChange,
},
Exited {
session_id: SessionId,
completed: CompletedSession,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEventEnvelope {
pub event_seq: u64,
pub event: SessionEvent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribeSessionEventsRequest {
pub session_id: SessionId,
pub after_event_seq: Option<u64>,
}
pub type SessionEventReceiver = Receiver<SessionEventEnvelope>;
pub trait SessionApi {
fn list_sessions(&self) -> Result<Vec<SessionId>>;
fn start_session(&self, request: StartSessionRequest) -> Result<SessionId>;
fn attach_session(&self, request: AttachSessionRequest) -> Result<AttachSessionResponse>;
fn subscribe_session_events(&self, session_id: SessionId) -> Result<SessionEventReceiver>;
fn subscribe_session_events_from(
&self,
request: SubscribeSessionEventsRequest,
) -> Result<SessionEventReceiver> {
if request.after_event_seq.is_some() {
anyhow::bail!("event replay is not supported by this session API");
}
self.subscribe_session_events(request.session_id)
}
fn acquire_input_lease(&self, request: InputLeaseRequest) -> Result<LeaseChange>;
fn release_input_lease(
&self,
session_id: SessionId,
client_id: ClientId,
) -> Result<LeaseChange>;
fn write_input(&self, request: WriteInputRequest) -> Result<()>;
fn resize_session(&self, request: ResizeSessionRequest) -> Result<SessionSnapshot>;
fn restore_session(&self, _request: RestoreSessionRequest) -> Result<SessionSnapshot> {
anyhow::bail!("session restore is not supported by this session API")
}
fn snapshot_session(&self, session_id: SessionId) -> Result<SessionSnapshot>;
fn styled_rows(&self, request: StyledRowsRequest) -> Result<StyledRowsResponse>;
fn shutdown_session(&self, session_id: SessionId) -> Result<CompletedSession>;
}
impl<T: SessionApi + ?Sized> SessionApi for std::sync::Arc<T> {
fn list_sessions(&self) -> Result<Vec<SessionId>> {
(**self).list_sessions()
}
fn start_session(&self, request: StartSessionRequest) -> Result<SessionId> {
(**self).start_session(request)
}
fn attach_session(&self, request: AttachSessionRequest) -> Result<AttachSessionResponse> {
(**self).attach_session(request)
}
fn subscribe_session_events(&self, session_id: SessionId) -> Result<SessionEventReceiver> {
(**self).subscribe_session_events(session_id)
}
fn subscribe_session_events_from(
&self,
request: SubscribeSessionEventsRequest,
) -> Result<SessionEventReceiver> {
(**self).subscribe_session_events_from(request)
}
fn acquire_input_lease(&self, request: InputLeaseRequest) -> Result<LeaseChange> {
(**self).acquire_input_lease(request)
}
fn release_input_lease(
&self,
session_id: SessionId,
client_id: ClientId,
) -> Result<LeaseChange> {
(**self).release_input_lease(session_id, client_id)
}
fn write_input(&self, request: WriteInputRequest) -> Result<()> {
(**self).write_input(request)
}
fn resize_session(&self, request: ResizeSessionRequest) -> Result<SessionSnapshot> {
(**self).resize_session(request)
}
fn restore_session(&self, request: RestoreSessionRequest) -> Result<SessionSnapshot> {
(**self).restore_session(request)
}
fn snapshot_session(&self, session_id: SessionId) -> Result<SessionSnapshot> {
(**self).snapshot_session(session_id)
}
fn styled_rows(&self, request: StyledRowsRequest) -> Result<StyledRowsResponse> {
(**self).styled_rows(request)
}
fn shutdown_session(&self, session_id: SessionId) -> Result<CompletedSession> {
(**self).shutdown_session(session_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observer_attach_is_default_and_does_not_grant_input() {
assert_eq!(AttachMode::default(), AttachMode::Observer);
assert!(!AttachMode::Observer.grants_input());
assert!(AttachMode::InteractiveController.grants_input());
assert!(AttachMode::AgentController.grants_input());
}
#[test]
fn input_lease_tracks_acquire_takeover_and_release() {
let mut lease = InputLeaseState::default();
let tui = ClientId::new("local-tui").expect("client id");
let agent = ClientId::new("agent").expect("client id");
let acquired = lease.acquire(tui.clone(), InputControllerKind::Interactive);
assert_eq!(acquired.action, LeaseChangeAction::Acquired);
assert_eq!(acquired.generation, 1);
assert_eq!(lease.holder.as_ref().unwrap().client_id, tui);
let takeover = lease.acquire(agent.clone(), InputControllerKind::Agent);
assert_eq!(takeover.action, LeaseChangeAction::TakenOver);
assert_eq!(takeover.generation, 2);
assert_eq!(takeover.previous.unwrap().client_id, tui);
assert_eq!(lease.holder.as_ref().unwrap().client_id, agent);
assert!(lease.release(&tui).is_none());
let released = lease.release(&agent).expect("release current holder");
assert_eq!(released.action, LeaseChangeAction::Released);
assert_eq!(released.generation, 3);
assert!(lease.holder.is_none());
}
#[test]
fn session_size_validates_transport_bounds() {
SessionSize::default().validate().expect("default size");
let size = SessionSize {
rows: 0,
..SessionSize::default()
};
assert!(size.validate().is_err());
let size = SessionSize {
cols: u16::MAX as usize + 1,
..SessionSize::default()
};
assert!(size.validate().is_err());
}
#[test]
fn start_session_request_requires_command_and_valid_size() {
let request = StartSessionRequest::new("/bin/sh");
request.validate().expect("valid request");
let request = StartSessionRequest::new(" ");
assert!(request.validate().is_err());
}
}