aboutsummaryrefslogtreecommitdiff
path: root/src/protocol
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/protocol
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/protocol')
-rw-r--r--src/protocol/render_ansi.rs375
-rw-r--r--src/protocol/wire.rs1332
2 files changed, 1450 insertions, 257 deletions
diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs
index 97685d4d..fe3ab68f 100644
--- a/src/protocol/render_ansi.rs
+++ b/src/protocol/render_ansi.rs
@@ -31,7 +31,9 @@ use std::io::Write;
use unicode_width::UnicodeWidthStr;
-use crate::protocol::{underline_style_from_modifier, CellData, FrameData};
+use crate::protocol::{
+ underline_style_from_modifier, CellData, CursorState, FrameData, PaneSurfacePatchRow,
+};
const REVERSED_MODIFIER: u16 = 1 << 6;
const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l";
@@ -135,8 +137,101 @@ impl BlitEncoder {
self.last_frame.as_ref() == Some(frame)
}
- pub(crate) fn last_frame(&self) -> Option<&FrameData> {
- self.last_frame.as_ref()
+ pub(crate) fn encode_patch(
+ &self,
+ rows: &[PaneSurfacePatchRow],
+ cursor: Option<CursorState>,
+ suppress_visible_cursor: bool,
+ ) -> Option<EncodedBlit> {
+ let frame = self.last_frame.as_ref()?;
+ if rows.iter().any(|row| !patch_row_fits(frame, row)) || patch_rows_overlap(rows) {
+ return None;
+ }
+ let mut bytes = Vec::new();
+ let mut next_last_visible_cursor = self.last_visible_cursor;
+ let mut next_last_cursor_shape = self.last_cursor_shape;
+ blit_patch_to(
+ &mut bytes,
+ frame,
+ rows,
+ cursor,
+ &mut next_last_visible_cursor,
+ &mut next_last_cursor_shape,
+ repeat_ime_anchor_after_sync(),
+ suppress_visible_cursor,
+ );
+ Some(EncodedBlit {
+ bytes,
+ full: false,
+ next_last_visible_cursor,
+ next_last_cursor_shape,
+ })
+ }
+
+ pub(crate) fn patch_rows_with_drawn_cursor(
+ &self,
+ rows: &[PaneSurfacePatchRow],
+ cursor: Option<&CursorState>,
+ ) -> Option<Vec<PaneSurfacePatchRow>> {
+ let frame = self.last_frame.as_ref()?;
+ let mut rows = rows.to_vec();
+ let previous = frame
+ .cursor
+ .as_ref()
+ .filter(|cursor| cursor.visible)
+ .map(|cursor| clamp_cursor_position(frame, cursor.x, cursor.y));
+ let next = cursor
+ .filter(|cursor| cursor.visible)
+ .map(|cursor| clamp_cursor_position(frame, cursor.x, cursor.y));
+
+ if let Some((x, y)) = previous.filter(|position| Some(*position) != next) {
+ if patch_cell_mut(&mut rows, x, y).is_none() {
+ let mut cell = frame.cells.get(frame_cell_index(frame, x, y)?)?.clone();
+ cell.modifier ^= REVERSED_MODIFIER;
+ rows.push(PaneSurfacePatchRow {
+ x,
+ y,
+ cells: vec![cell],
+ });
+ }
+ }
+ if let Some((x, y)) = next {
+ if let Some(cell) = patch_cell_mut(&mut rows, x, y) {
+ cell.modifier ^= REVERSED_MODIFIER;
+ } else if previous != next {
+ let mut cell = frame.cells.get(frame_cell_index(frame, x, y)?)?.clone();
+ cell.modifier ^= REVERSED_MODIFIER;
+ rows.push(PaneSurfacePatchRow {
+ x,
+ y,
+ cells: vec![cell],
+ });
+ }
+ }
+ Some(rows)
+ }
+
+ pub(crate) fn commit_patch(
+ &mut self,
+ rows: &[PaneSurfacePatchRow],
+ cursor: Option<CursorState>,
+ encoded: EncodedBlit,
+ ) -> bool {
+ let Some(frame) = self.last_frame.as_mut() else {
+ return false;
+ };
+ for row in rows {
+ let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x);
+ let end = start + row.cells.len();
+ let Some(target) = frame.cells.get_mut(start..end) else {
+ return false;
+ };
+ target.clone_from_slice(&row.cells);
+ }
+ frame.cursor = cursor;
+ self.last_visible_cursor = encoded.next_last_visible_cursor;
+ self.last_cursor_shape = encoded.next_last_cursor_shape;
+ true
}
}
@@ -373,7 +468,6 @@ fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String {
// ---------------------------------------------------------------------------
/// Checks if two cells are visually identical.
-#[cfg(test)]
fn cells_equal(a: &CellData, b: &CellData) -> bool {
a.symbol == b.symbol
&& a.fg == b.fg
@@ -444,6 +538,116 @@ fn blit_frame_to_with_cursor_memory_and_policy(
);
}
+fn frame_cell_index(frame: &FrameData, x: u16, y: u16) -> Option<usize> {
+ (x < frame.width && y < frame.height)
+ .then(|| usize::from(y) * usize::from(frame.width) + usize::from(x))
+}
+
+fn patch_cell_mut(rows: &mut [PaneSurfacePatchRow], x: u16, y: u16) -> Option<&mut CellData> {
+ rows.iter_mut().rev().find_map(|row| {
+ if row.y != y || x < row.x {
+ return None;
+ }
+ row.cells.get_mut(usize::from(x - row.x))
+ })
+}
+
+fn patch_rows_overlap(rows: &[PaneSurfacePatchRow]) -> bool {
+ rows.iter().enumerate().any(|(index, left)| {
+ let left_end = left.x.saturating_add(left.cells.len() as u16);
+ rows[index + 1..].iter().any(|right| {
+ if left.y != right.y {
+ return false;
+ }
+ let right_end = right.x.saturating_add(right.cells.len() as u16);
+ left.x < right_end && right.x < left_end
+ })
+ })
+}
+
+fn patch_row_fits(frame: &FrameData, row: &PaneSurfacePatchRow) -> bool {
+ let Ok(len) = u16::try_from(row.cells.len()) else {
+ return false;
+ };
+ if row.y >= frame.height
+ || row.x.saturating_add(len) > frame.width
+ || row.cells.iter().any(|cell| cell.hyperlink.is_some())
+ {
+ return false;
+ }
+ let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x);
+ let end = start + row.cells.len();
+ frame
+ .cells
+ .get(start..end)
+ .is_some_and(|cells| cells.iter().all(|cell| cell.hyperlink.is_none()))
+}
+
+fn blit_patch_to(
+ mut writer: impl Write,
+ frame: &FrameData,
+ rows: &[PaneSurfacePatchRow],
+ cursor: Option<CursorState>,
+ last_visible_cursor: &mut Option<(u16, u16)>,
+ last_cursor_shape: &mut u8,
+ repeat_ime_anchor: bool,
+ suppress_visible_cursor: bool,
+) {
+ let _ = writer.write_all(b"\x1b[?2026h\x1b[?25l\x1b]8;;\x1b\\");
+ let mut last_sgr = String::new();
+ let mut active_hyperlink = None;
+ for row in rows {
+ let mut invalidated = 0usize;
+ let mut to_skip = 0usize;
+ let mut next_inline_col = None;
+ for (offset, cell) in row.cells.iter().enumerate() {
+ let col = row.x + offset as u16;
+ let idx = usize::from(row.y) * usize::from(frame.width) + usize::from(col);
+ let prev_cell = &frame.cells[idx];
+ if !cell.skip && (!cells_equal(cell, prev_cell) || invalidated > 0) && to_skip == 0 {
+ let cursor_position =
+ (next_inline_col != Some(col) || invalidated > 0).then_some((col, row.y));
+ write_cell(
+ &mut writer,
+ cursor_position,
+ cell,
+ &mut last_sgr,
+ &mut active_hyperlink,
+ frame,
+ );
+ next_inline_col = (cell.symbol.is_ascii() && cell_width(cell) == 1)
+ .then_some(col.saturating_add(1));
+ }
+ to_skip = cell_width(cell).saturating_sub(1);
+ let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell));
+ invalidated = cmp::max(affected_width, invalidated).saturating_sub(1);
+ }
+ }
+ close_hyperlink(&mut writer, &mut active_hyperlink);
+ if !last_sgr.is_empty() {
+ let _ = writer.write_all(b"\x1b[0m");
+ }
+
+ let cursor_frame = FrameData {
+ cells: Vec::new(),
+ width: frame.width,
+ height: frame.height,
+ cursor,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let mut host_cursor = resolve_host_cursor_state(&cursor_frame, last_visible_cursor);
+ if suppress_visible_cursor && host_cursor.visible {
+ host_cursor.visible = false;
+ }
+ write_host_cursor_state(&mut writer, host_cursor, last_cursor_shape);
+ let _ = writer.write_all(b"\x1b[?2026l");
+ if repeat_ime_anchor {
+ write_ime_anchor_cursor_state(&mut writer, host_cursor);
+ }
+ let _ = writer.flush();
+}
+
fn blit_frame_to_with_cursor_memory_and_clear_policy(
mut writer: impl Write,
frame: &FrameData,
@@ -1545,6 +1749,169 @@ mod tests {
}
#[test]
+ fn retained_patch_matches_full_diff_and_updates_the_encoder_baseline() {
+ let previous = make_frame(
+ 4,
+ 2,
+ vec![
+ make_cell("a", 0, 0, 0),
+ make_cell("b", 0, 0, 0),
+ make_cell("c", 0, 0, 0),
+ make_cell("d", 0, 0, 0),
+ make_cell("e", 0, 0, 0),
+ make_cell("f", 0, 0, 0),
+ make_cell("g", 0, 0, 0),
+ make_cell("h", 0, 0, 0),
+ ],
+ );
+ let mut encoder = BlitEncoder::new();
+ let initial = encoder.encode(&previous, false);
+ encoder.commit(previous.clone(), initial);
+
+ let rows = vec![PaneSurfacePatchRow {
+ x: 0,
+ y: 1,
+ cells: vec![
+ make_cell("E", 0, 0, 0),
+ make_cell("f", 0, 0, 0),
+ make_cell("G", 0, 0, 0),
+ make_cell("h", 0, 0, 0),
+ ],
+ }];
+ let cursor = Some(CursorState {
+ x: 3,
+ y: 1,
+ visible: true,
+ shape: 2,
+ });
+ let mut expected = previous;
+ expected.cells[4..8].clone_from_slice(&rows[0].cells);
+ expected.cursor = cursor.clone();
+
+ let full_diff = encoder.encode(&expected, false);
+ let patch = encoder
+ .encode_patch(&rows, cursor.clone(), false)
+ .expect("valid retained patch");
+ assert_eq!(patch.bytes, full_diff.bytes);
+ assert!(encoder.commit_patch(&rows, cursor, patch));
+ assert!(encoder.is_current(&expected));
+ }
+
+ #[test]
+ fn retained_patch_width_transition_matches_full_diff_with_following_cell() {
+ let previous = make_frame(
+ 3,
+ 1,
+ vec![
+ make_cell("界", 0, 0, 0),
+ make_cell("z", 0, 0, 0),
+ make_cell("q", 0, 0, 0),
+ ],
+ );
+ let mut encoder = BlitEncoder::new();
+ let initial = encoder.encode(&previous, false);
+ encoder.commit(previous.clone(), initial);
+
+ let rows = vec![PaneSurfacePatchRow {
+ x: 0,
+ y: 0,
+ cells: vec![make_cell("x", 0, 0, 0), make_cell("z", 0, 0, 0)],
+ }];
+ let mut expected = previous;
+ expected.cells[0..2].clone_from_slice(&rows[0].cells);
+
+ let full_diff = encoder.encode(&expected, false);
+ let patch = encoder
+ .encode_patch(&rows, None, false)
+ .expect("valid retained patch");
+ assert_eq!(patch.bytes, full_diff.bytes);
+ }
+
+ #[test]
+ fn retained_patch_rejects_overlapping_rows() {
+ let frame = make_frame(
+ 3,
+ 1,
+ vec![
+ make_cell("a", 0, 0, 0),
+ make_cell("b", 0, 0, 0),
+ make_cell("c", 0, 0, 0),
+ ],
+ );
+ let mut encoder = BlitEncoder::new();
+ let initial = encoder.encode(&frame, false);
+ encoder.commit(frame, initial);
+ let rows = vec![
+ PaneSurfacePatchRow {
+ x: 0,
+ y: 0,
+ cells: vec![make_cell("A", 0, 0, 0), make_cell("B", 0, 0, 0)],
+ },
+ PaneSurfacePatchRow {
+ x: 1,
+ y: 0,
+ cells: vec![make_cell("C", 0, 0, 0)],
+ },
+ ];
+
+ assert!(encoder.encode_patch(&rows, None, false).is_none());
+ }
+
+ #[test]
+ fn retained_patch_preserves_the_client_drawn_cursor_overlay() {
+ let mut previous = make_frame(
+ 3,
+ 1,
+ vec![
+ make_cell("a", 0, 0, 0),
+ make_cell("b", 0, 0, 0),
+ make_cell("c", 0, 0, 0),
+ ],
+ );
+ previous.cursor = Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 0,
+ });
+ let previous_drawn = frame_with_drawn_cursor(previous.clone());
+ let mut encoder = BlitEncoder::new();
+ let initial = encoder.encode_with_suppressed_visible_cursor(&previous_drawn, false);
+ encoder.commit(previous_drawn, initial);
+
+ let rows = vec![PaneSurfacePatchRow {
+ x: 0,
+ y: 0,
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell("b", 0, 0, 0),
+ make_cell("c", 0, 0, 0),
+ ],
+ }];
+ let cursor = Some(CursorState {
+ x: 1,
+ y: 0,
+ visible: true,
+ shape: 0,
+ });
+ let drawn_rows = encoder
+ .patch_rows_with_drawn_cursor(&rows, cursor.as_ref())
+ .expect("drawn cursor patch rows");
+ let mut expected = previous;
+ expected.cells[0..3].clone_from_slice(&rows[0].cells);
+ expected.cursor = cursor.clone();
+ let expected = frame_with_drawn_cursor(expected);
+
+ let full_diff = encoder.encode_with_suppressed_visible_cursor(&expected, false);
+ let patch = encoder
+ .encode_patch(&drawn_rows, cursor.clone(), true)
+ .expect("valid drawn cursor patch");
+ assert_eq!(patch.bytes, full_diff.bytes);
+ assert!(encoder.commit_patch(&drawn_rows, cursor, patch));
+ assert!(encoder.is_current(&expected));
+ }
+
+ #[test]
fn blit_frame_positions_cursor() {
let frame = FrameData {
cells: vec![make_cell("A", 0, 0, 0)],
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 3680a557..b9b2eef8 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -43,24 +43,11 @@ pub enum RenderEncoding {
TerminalAnsi,
}
-/// Keybinding profile requested by an attached app client.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub enum ClientKeybindings {
- /// Use the server's own keybinding config.
- Server,
- /// Use this attached client's normalized local `[keys]` config.
- Local { keys_toml: String },
-}
-
-/// Client behavior requested at connection time.
+/// Size of the pane surface requested by a client-owned shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum ClientLaunchMode {
- /// Full app client.
- App,
- /// Full app client eligible for audited local direct graphics.
- AppDirectGraphics,
- /// Direct terminal attach client.
- TerminalAttach,
+pub struct ClientSurfaceSize {
+ pub cols: u16,
+ pub rows: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -70,7 +57,7 @@ pub enum ClientKeyKind {
Release,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ClientKeyCode {
Backspace,
Enter,
@@ -92,7 +79,7 @@ pub enum ClientKeyCode {
Null,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ClientMouseButton {
Left,
Right,
@@ -111,6 +98,29 @@ pub enum ClientMouseKind {
ScrollRight,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientMousePosition {
+ Cell {
+ column: u16,
+ row: u16,
+ },
+ Pixels {
+ x: u32,
+ y: u32,
+ column: u16,
+ row: u16,
+ },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientMouseGeometry {
+ pub cols: u16,
+ pub rows: u16,
+ pub width_px: u32,
+ pub height_px: u32,
+}
+
+#[cfg(any(windows, test))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientInputEvent {
Key {
@@ -135,6 +145,34 @@ pub enum ClientInputEvent {
FocusLost,
}
+/// Pane-domain input after the client has classified and consumed shell actions.
+///
+/// Keys are semantic rather than outer-terminal VT bytes so the target pane can
+/// encode them for the child application's negotiated keyboard protocol.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientPaneInputEvent {
+ Key {
+ code: ClientKeyCode,
+ modifiers: u8,
+ kind: ClientKeyKind,
+ repeat_count: u16,
+ shifted_codepoint: Option<u32>,
+ generated_text: Option<String>,
+ tracks_release: bool,
+ physical_key_id: Option<u32>,
+ },
+ TextCommit(String),
+ Mouse {
+ kind: ClientMouseKind,
+ position: ClientMousePosition,
+ geometry: Option<ClientMouseGeometry>,
+ modifiers: u8,
+ lines: u16,
+ },
+ Paste(String),
+}
+
+#[cfg(any(windows, test))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientKeySource {
Synthesized,
@@ -147,7 +185,6 @@ pub enum ClientKeySource {
}
impl ClientKeyKind {
- #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self {
match kind {
crossterm::event::KeyEventKind::Press => Self::Press,
@@ -166,7 +203,6 @@ impl ClientKeyKind {
}
impl ClientKeyCode {
- #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option<Self> {
use crossterm::event::KeyCode;
Some(match code {
@@ -218,7 +254,6 @@ impl ClientKeyCode {
}
impl ClientMouseButton {
- #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self {
match button {
crossterm::event::MouseButton::Left => Self::Left,
@@ -237,7 +272,6 @@ impl ClientMouseButton {
}
impl ClientMouseKind {
- #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option<Self> {
use crossterm::event::MouseEventKind;
Some(match kind {
@@ -267,8 +301,74 @@ impl ClientMouseKind {
}
}
+impl ClientPaneInputEvent {
+ pub(crate) fn from_terminal_key(key: crate::input::TerminalKey) -> Option<Self> {
+ let tracks_release = key.generated_text.is_none() || key.has_physical_identity();
+ let physical_key_id = key.physical_key_id();
+ Some(Self::Key {
+ code: ClientKeyCode::from_crossterm(key.code)?,
+ modifiers: key.modifiers.bits(),
+ kind: ClientKeyKind::from_crossterm(key.kind),
+ repeat_count: key.repeat_count,
+ shifted_codepoint: key.shifted_codepoint,
+ generated_text: key.generated_text,
+ tracks_release,
+ physical_key_id,
+ })
+ }
+
+ pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent {
+ match self {
+ Self::Key {
+ code,
+ modifiers,
+ kind,
+ repeat_count,
+ shifted_codepoint,
+ generated_text,
+ tracks_release,
+ ..
+ } => {
+ let mut key = crate::input::TerminalKey::new(
+ code.to_crossterm(),
+ crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
+ )
+ .with_kind(kind.to_crossterm())
+ .with_repeat_count(*repeat_count)
+ .with_generated_text(generated_text.clone())
+ .with_physical_identity_hint(*tracks_release && generated_text.is_some());
+ if let Some(shifted_codepoint) = shifted_codepoint {
+ key = key.with_shifted_codepoint(*shifted_codepoint);
+ }
+ crate::raw_input::RawInputEvent::Key(key)
+ }
+ Self::TextCommit(text) => {
+ crate::raw_input::RawInputEvent::Text(crate::input::TextCommit::new(text.clone()))
+ }
+ Self::Mouse {
+ kind,
+ position,
+ modifiers,
+ ..
+ } => {
+ let (column, row) = match position {
+ ClientMousePosition::Cell { column, row }
+ | ClientMousePosition::Pixels { column, row, .. } => (*column, *row),
+ };
+ crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: kind.to_crossterm(),
+ column,
+ row,
+ modifiers: crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
+ })
+ }
+ Self::Paste(text) => crate::raw_input::RawInputEvent::Paste(text.clone()),
+ }
+ }
+}
+
+#[cfg(any(windows, test))]
impl ClientInputEvent {
- #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option<Self> {
match event {
crossterm::event::Event::Key(key) => Some(Self::Key {
@@ -341,24 +441,14 @@ impl ClientInputEvent {
/// Messages sent from the client to the server over the client protocol socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
- /// Handshake: client announces its protocol version and terminal dimensions.
- Hello {
- /// Protocol version the client speaks.
+ /// Direct terminal handshake: announces protocol version and terminal dimensions.
+ TerminalHello {
version: u32,
- /// Terminal width in columns.
cols: u16,
- /// Terminal height in rows.
rows: u16,
- /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_width_px: u32,
- /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_height_px: u32,
- /// Render encoding requested by the client.
- requested_encoding: RenderEncoding,
- /// Keybinding profile requested by the client.
- keybindings: ClientKeybindings,
- /// Whether this connection will render the full app or attach directly to a pane terminal.
- launch_mode: ClientLaunchMode,
+ pixel_mouse: bool,
},
/// Raw input bytes read from the client's stdin.
@@ -369,6 +459,8 @@ pub enum ClientMessage {
/// Image bytes read from the client's local clipboard for remote paste bridging.
ClipboardImage {
+ /// Stable terminal target selected by the client that read the clipboard.
+ target: ClientClipboardImageTarget,
/// Image file extension without a leading dot.
extension: String,
/// Raw image bytes.
@@ -383,8 +475,10 @@ pub enum ClientMessage {
rows: u16,
/// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_width_px: u32,
- /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
+ /// Height of a terminal cell in physical pixels, or 0 when unavailable.
cell_height_px: u32,
+ /// Whether this resize carries coherent exact geometry for SGR pixel mouse input.
+ pixel_mouse: bool,
},
/// Graceful disconnect request.
@@ -414,9 +508,6 @@ pub enum ClientMessage {
modifiers: u8,
},
- /// Structured input events from platform clients that do not expose Unix-style raw bytes.
- InputEvents { events: Vec<ClientInputEvent> },
-
/// Switch this connection into read-only terminal observe mode.
ObserveTerminal {
/// Pane, terminal, or agent target to observe.
@@ -438,17 +529,120 @@ pub enum ClientMessage {
success: bool,
},
- /// One confirmed SGR 1016 mouse report with read-time host geometry.
- InputPixels {
- data: Vec<u8>,
- cols: u16,
- rows: u16,
- width_px: u32,
- height_px: u32,
- },
-
/// The direct command was written and flushed; terminal response timing starts now.
GraphicsTransmissionStarted { transfer_id: u64, image_id: u32 },
+
+ /// Handshake for the client-owned shell around one pane surface.
+ ClientShellHello {
+ version: u32,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ surface_size: ClientSurfaceSize,
+ pixel_mouse: bool,
+ direct_graphics: bool,
+ /// Whether the endpoint's keymap, rather than the client's, owns shell bindings.
+ endpoint_keybindings: bool,
+ /// Whether this client wants shell mouse capture even without pane demand.
+ mouse_capture: bool,
+ },
+
+ /// Resize the pane viewport of a client-owned shell.
+ ClientShellResize {
+ cell_width_px: u32,
+ cell_height_px: u32,
+ surface_size: ClientSurfaceSize,
+ /// Whether this resize carries coherent exact geometry for SGR pixel mouse input.
+ pixel_mouse: bool,
+ },
+
+ /// Deliver client-classified semantic input directly to a stable pane target.
+ ClientShellPaneInput {
+ pane_id: String,
+ events: Vec<ClientPaneInputEvent>,
+ },
+
+ /// Deliver client-classified semantic input to the active popup terminal.
+ ClientShellPopupInput {
+ terminal_id: String,
+ events: Vec<ClientPaneInputEvent>,
+ },
+
+ /// Invoke one endpoint operation through this client shell's selected connection.
+ ClientShellEndpointRequest { boot_id: String, request: String },
+
+ /// Deliver one structured mouse event to a directly attached terminal.
+ AttachMouse {
+ kind: ClientMouseKind,
+ position: ClientMousePosition,
+ geometry: Option<ClientMouseGeometry>,
+ modifiers: u8,
+ lines: u16,
+ },
+
+ /// Publish one host terminal color or appearance update observed by a client-owned shell.
+ ClientShellHostTheme { update: ClientHostThemeUpdate },
+
+ /// Publish whether the outer terminal containing a client shell has focus.
+ ClientShellFocus { focused: bool },
+
+ /// Update this client's shell mouse-capture preference after config reload.
+ ClientShellMouseCapture { enabled: bool },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientHostColor {
+ pub r: u8,
+ pub g: u8,
+ pub b: u8,
+}
+
+impl From<crate::terminal_theme::RgbColor> for ClientHostColor {
+ fn from(color: crate::terminal_theme::RgbColor) -> Self {
+ Self {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ }
+ }
+}
+
+impl From<ClientHostColor> for crate::terminal_theme::RgbColor {
+ fn from(color: ClientHostColor) -> Self {
+ Self {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientHostDefaultColorKind {
+ Foreground,
+ Background,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientHostAppearance {
+ Dark,
+ Light,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientHostThemeUpdate {
+ DefaultColor {
+ kind: ClientHostDefaultColorKind,
+ color: ClientHostColor,
+ },
+ PaletteColors(Vec<(u8, ClientHostColor)>),
+ Appearance(ClientHostAppearance),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientClipboardImageTarget {
+ DirectTerminal,
+ Pane(String),
+ Popup(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -600,11 +794,37 @@ impl FrameData {
}
}
+ pub(crate) fn replace_from_ratatui_buffer_preserving_effects(
+ &mut self,
+ buffer: &ratatui::buffer::Buffer,
+ cursor: Option<CursorState>,
+ ) {
+ let width = self.width;
+ let hyperlinks = if width == 0 {
+ Vec::new()
+ } else {
+ self.cells
+ .iter()
+ .enumerate()
+ .filter_map(|(index, cell)| {
+ let uri = self.hyperlinks.get(cell.hyperlink? as usize)?;
+ let x = u16::try_from(index % usize::from(width)).ok()?;
+ let y = u16::try_from(index / usize::from(width)).ok()?;
+ Some(((x, y), cell.symbol.clone(), uri.clone()))
+ })
+ .collect::<Vec<_>>()
+ };
+ let graphics = std::mem::take(&mut self.graphics);
+ let mut replacement =
+ Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &hyperlinks);
+ replacement.graphics = graphics;
+ *self = replacement;
+ }
+
/// Reconstructs a ratatui `Buffer` from this frame data.
///
/// Returns `None` if the cells vector length doesn't match `width * height`.
- #[cfg(test)]
- pub fn to_ratatui_buffer(&self) -> Option<ratatui::buffer::Buffer> {
+ pub(crate) fn to_ratatui_buffer(&self) -> Option<ratatui::buffer::Buffer> {
let expected = (self.width as usize) * (self.height as usize);
if self.cells.len() != expected {
return None;
@@ -630,6 +850,361 @@ impl FrameData {
}
}
+/// Initial resource projection used by the experimental client-owned shell.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellSnapshot {
+ /// Changes whenever the endpoint process restarts.
+ pub boot_id: String,
+ /// Monotonic replacement revision within one endpoint boot.
+ pub revision: u64,
+ /// Endpoint startup/reload config warning, filtered for client-owned keybindings.
+ pub config_diagnostic: Option<String>,
+ /// Unseen announcement owned and persisted by this endpoint.
+ pub product_announcement: Option<ClientShellProductAnnouncement>,
+ /// Future-version update advertised by the endpoint.
+ pub update_available: Option<String>,
+ /// Endpoint-specific command shown in update instructions.
+ pub update_install_command: String,
+ /// Endpoint's normalized built-in keybindings, used only when a remote client selects server bindings.
+ pub server_keybindings_toml: Option<String>,
+ /// Whether the endpoint has a What's New entry, even if its body is unavailable.
+ pub latest_release_notes_available: bool,
+ /// Whether endpoint-owned integration assets need an update.
+ pub integration_updates_available: bool,
+ /// Endpoint-owned base directory used for new linked worktree checkouts.
+ pub worktree_directory: String,
+ /// Cached endpoint-owned notes used by the client-rendered overlay.
+ pub release_notes: Option<ClientShellReleaseNotes>,
+ pub focused_workspace_id: Option<String>,
+ pub focused_tab_id: Option<String>,
+ pub focused_pane_id: Option<String>,
+ pub tab_bar_right: Vec<ClientShellTabStatusSegment>,
+ pub tab_bar_right_separator: String,
+ pub agent_view_label: Option<String>,
+ pub agent_order: Vec<String>,
+ pub workspaces: Vec<ClientShellWorkspace>,
+ pub tabs: Vec<ClientShellTab>,
+ pub panes: Vec<ClientShellPane>,
+ pub agents: Vec<ClientShellAgent>,
+ pub commands: Vec<ClientShellCommand>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellProductAnnouncement {
+ pub version: String,
+ pub id: String,
+ pub title: String,
+ pub body: String,
+ pub preview: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellReleaseNotes {
+ pub version: String,
+ pub body: String,
+ pub preview: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientShellCommandAction {
+ Shell,
+ Pane,
+ Popup,
+ PluginAction,
+}
+
+impl From<crate::config::CustomCommandAction> for ClientShellCommandAction {
+ fn from(action: crate::config::CustomCommandAction) -> Self {
+ match action {
+ crate::config::CustomCommandAction::Shell => Self::Shell,
+ crate::config::CustomCommandAction::Pane => Self::Pane,
+ crate::config::CustomCommandAction::Popup => Self::Popup,
+ crate::config::CustomCommandAction::PluginAction => Self::PluginAction,
+ }
+ }
+}
+
+impl From<ClientShellCommandAction> for crate::config::CustomCommandAction {
+ fn from(action: ClientShellCommandAction) -> Self {
+ match action {
+ ClientShellCommandAction::Shell => Self::Shell,
+ ClientShellCommandAction::Pane => Self::Pane,
+ ClientShellCommandAction::Popup => Self::Popup,
+ ClientShellCommandAction::PluginAction => Self::PluginAction,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellCommand {
+ pub command_id: String,
+ pub binding_label: String,
+ pub binding_labels: Vec<String>,
+ pub action: ClientShellCommandAction,
+ pub description: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellTabStatusSegment {
+ pub text: String,
+ pub accent: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellWorkspace {
+ pub workspace_id: String,
+ pub active_tab_id: String,
+ pub new_workspace_cwd: String,
+ pub number: usize,
+ pub label: String,
+ pub custom_label: bool,
+ pub branch: Option<String>,
+ pub git_ahead_behind: Option<(usize, usize)>,
+ pub tokens: Vec<(String, String)>,
+ pub worktree: Option<ClientShellWorktree>,
+ pub focused: bool,
+ pub agent_status: crate::api::schema::AgentStatus,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellWorktree {
+ pub key: String,
+ pub label: String,
+ pub is_linked_worktree: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellTab {
+ pub tab_id: String,
+ pub workspace_id: String,
+ pub number: usize,
+ pub label: String,
+ pub custom_label: bool,
+ pub zoomed: bool,
+ pub focused: bool,
+ pub agent_status: crate::api::schema::AgentStatus,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellPane {
+ pub pane_id: String,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub label: Option<String>,
+ pub cwd: Option<String>,
+ pub foreground_cwd: Option<String>,
+ pub focused: bool,
+ pub right_click_passthrough: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellAgent {
+ pub pane_id: String,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub name: Option<String>,
+ pub display_agent: Option<String>,
+ pub agent: Option<String>,
+ pub title: Option<String>,
+ pub terminal_title: Option<String>,
+ pub terminal_title_stripped: Option<String>,
+ pub agent_status: crate::api::schema::AgentStatus,
+ pub state_change_seq: u64,
+ pub state_labels: Vec<(String, String)>,
+ pub tokens: Vec<(String, String)>,
+ pub focused: bool,
+}
+
+/// Origin-relative geometry for one pane in a rendered pane surface.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSurfacePane {
+ pub pane_id: String,
+ pub content_revision: u64,
+ pub rect: SurfaceRect,
+ pub inner_rect: SurfaceRect,
+ pub scrollbar_rect: Option<SurfaceRect>,
+ pub scroll: Option<PaneSurfaceScrollMetrics>,
+ pub focused: bool,
+ pub mouse_reporting: bool,
+ pub sgr_pixel_mouse: bool,
+ pub alternate_screen_active: bool,
+ pub pixel_width: u32,
+ pub pixel_height: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSurfaceScrollMetrics {
+ pub offset_from_bottom: u64,
+ pub max_offset_from_bottom: u64,
+ pub viewport_rows: u64,
+}
+
+/// One draggable BSP split handle relative to a pane surface.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSurfaceSplit {
+ pub direction: PaneSurfaceSplitDirection,
+ pub pos: u16,
+ pub area: SurfaceRect,
+ pub hit_rect: SurfaceRect,
+ pub path: Vec<bool>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum PaneSurfaceSplitDirection {
+ Horizontal,
+ Vertical,
+}
+
+/// Wire-safe rectangle relative to a pane surface.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SurfaceRect {
+ pub x: u16,
+ pub y: u16,
+ pub width: u16,
+ pub height: u16,
+}
+
+impl From<ratatui::layout::Rect> for SurfaceRect {
+ fn from(rect: ratatui::layout::Rect) -> Self {
+ Self {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SurfaceGraphicsTarget {
+ Pane { pane_id: String },
+ Popup { terminal_id: String },
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SurfaceGraphicsSource {
+ Terminal {
+ target: SurfaceGraphicsTarget,
+ image_id: u32,
+ },
+ PaneLayer {
+ pane_id: String,
+ layer_id: String,
+ },
+}
+
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SurfaceGraphicsFormat {
+ Rgb,
+ Rgba,
+ Png,
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SurfaceGraphicsAssetKey {
+ pub source: SurfaceGraphicsSource,
+ pub image_width: u32,
+ pub image_height: u32,
+ pub format: SurfaceGraphicsFormat,
+ pub data_len: u64,
+ pub data_fingerprint: u64,
+}
+
+/// Image bytes newly needed by this connection's complete desired scene.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SurfaceGraphicsAsset {
+ pub key: SurfaceGraphicsAssetKey,
+ pub data: Vec<u8>,
+}
+
+/// One already-clipped desired placement relative to its target surface.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SurfaceGraphicsPlacement {
+ pub asset: SurfaceGraphicsAssetKey,
+ pub logical_placement_id: u32,
+ pub x: u16,
+ pub y: u16,
+ pub cols: u32,
+ pub rows: u32,
+ pub source_x: u32,
+ pub source_y: u32,
+ pub source_width: u32,
+ pub source_height: u32,
+ pub x_offset: u32,
+ pub y_offset: u32,
+ pub z: i32,
+ pub scrollback_offset: u32,
+}
+
+/// Complete desired placements plus only the image bytes not already sent for
+/// the current live scene on this connection.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SurfaceGraphicsScene {
+ pub assets: Vec<SurfaceGraphicsAsset>,
+ pub placements: Vec<SurfaceGraphicsPlacement>,
+ /// Direct-uploaded assets that remain live for this client even while their
+ /// pane is outside the selected scene.
+ pub retained_assets: Vec<SurfaceGraphicsAssetKey>,
+}
+
+/// One server-rendered active-tab surface without sidebar, tab bar, or overlays.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSurfaceFrame {
+ /// Endpoint process identity that produced this surface.
+ pub boot_id: String,
+ /// Projection revision whose focused IDs and topology produced this surface.
+ pub projection_revision: u64,
+ /// Monotonic revision for full surfaces and incremental patches on one connection.
+ pub surface_revision: u64,
+ pub frame: FrameData,
+ pub panes: Vec<PaneSurfacePane>,
+ pub splits: Vec<PaneSurfaceSplit>,
+ pub popup: Option<Box<ClientShellPopupSurface>>,
+ pub graphics: SurfaceGraphicsScene,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientShellPopupSize {
+ Cells(u16),
+ Percent(u8),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSurfacePatchRow {
+ /// Origin-relative surface column where this changed cell span starts.
+ pub x: u16,
+ /// Origin-relative surface row.
+ pub y: u16,
+ pub cells: Vec<CellData>,
+}
+
+/// Incremental terminal-cell update against one committed complete pane surface.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSurfacePatch {
+ pub boot_id: String,
+ pub projection_revision: u64,
+ pub base_surface_revision: u64,
+ pub surface_revision: u64,
+ pub rows: Vec<PaneSurfacePatchRow>,
+ /// Updated metadata for panes whose terminal content changed.
+ pub panes: Vec<PaneSurfacePane>,
+ /// Final cursor relative to the pane surface.
+ pub cursor: Option<CursorState>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ClientShellPopupSurface {
+ pub terminal_id: String,
+ pub title: String,
+ pub width: Option<ClientShellPopupSize>,
+ pub height: Option<ClientShellPopupSize>,
+ pub frame: FrameData,
+ pub mouse_reporting: bool,
+ pub sgr_pixel_mouse: bool,
+ pub pixel_width: u32,
+ pub pixel_height: u32,
+}
+
/// Terminal ANSI bytes encoded by the server for network-efficient clients.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalFrame {
@@ -656,6 +1231,35 @@ pub enum NotifyKind {
SystemToast,
}
+/// A client-rendered notification category. The server reports the semantic
+/// event; each connected shell client chooses how (or whether) to present it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SemanticNotificationKind {
+ NeedsAttention,
+ Finished,
+ UpdateInstalled,
+ Custom,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SemanticNotificationSound {
+ Done,
+ Request,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SemanticNotification {
+ pub kind: SemanticNotificationKind,
+ pub title: String,
+ pub body: Option<String>,
+ pub sound: Option<SemanticNotificationSound>,
+ pub agent: Option<String>,
+ pub workspace_id: Option<String>,
+ pub tab_id: Option<String>,
+ pub pane_id: Option<String>,
+ pub position: Option<crate::config::ToastHerdrPosition>,
+}
+
/// Messages sent from the server to the client over the client protocol socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServerMessage {
@@ -670,9 +1274,6 @@ pub enum ServerMessage {
error: Option<String>,
},
- /// A rendered frame to be displayed by a semantic-frame client.
- Frame(FrameData),
-
/// Terminal bytes to write directly for a terminal-ANSI client.
Terminal(TerminalFrame),
@@ -721,20 +1322,6 @@ pub enum ServerMessage {
sgr_pixels: bool,
},
- /// Whether the focused terminal requests Kitty report-all keyboard input.
- KittyKeyboardReportAll {
- /// True only while the focused pane requests `REPORT_ALL_KEYS_AS_ESCAPE_CODES`.
- enabled: bool,
- },
-
- /// Apply the prefix-mode ASCII input-source change on the foreground client.
- /// `active = true` → switch to an ASCII-capable source (saving the current one);
- /// `active = false` → restore the saved source.
- PrefixInputSource {
- /// Whether the ASCII input source should be active.
- active: bool,
- },
-
/// Ring the foreground client's outer terminal for pane-originated BEL characters.
TerminalBell {
/// Number of BEL characters parsed from one PTY read.
@@ -749,10 +1336,46 @@ pub enum ServerMessage {
transfer_id: u64,
leading: Vec<u8>,
control: String,
+ /// ClientShell upload identity. `None` targets a direct terminal client.
+ surface_asset: Option<SurfaceGraphicsAssetKey>,
},
/// Suppress a direct command that expired before terminal delivery.
GraphicsTransmissionRetired { transfer_id: u64, image_id: u32 },
+
+ /// Initial metadata for a client-owned shell.
+ ClientShellSnapshot(Box<ClientShellSnapshot>),
+
+ /// Active-tab pane content rendered at a client-requested origin-relative size.
+ PaneSurface(PaneSurfaceFrame),
+
+ /// Ephemeral semantic notification delivered over the private control lane.
+ /// It is sent only to currently connected client-rendered shells.
+ SemanticNotification(SemanticNotification),
+
+ /// Immediate endpoint error that the client-rendered shell must show regardless of notification policy.
+ ClientShellError { message: String },
+
+ /// Exact Kitty keyboard flags requested by a directly attached terminal.
+ /// Zero restores the host terminal's previous keyboard mode.
+ DirectTerminalKeyboardProtocol {
+ flags: u16,
+ modify_other_keys_level: u8,
+ },
+
+ /// Whether the focused pane or popup needs the shell host to report every key.
+ ClientShellKeyboardReportAll { enabled: bool },
+
+ /// One ordered chunk of the final response to an endpoint operation.
+ ClientShellEndpointResponseChunk {
+ boot_id: String,
+ request_id: String,
+ final_chunk: bool,
+ data: Vec<u8>,
+ },
+
+ /// Incremental terminal-cell update for a previously committed pane surface.
+ PaneSurfacePatch(PaneSurfacePatch),
}
// ---------------------------------------------------------------------------
@@ -792,7 +1415,6 @@ pub(crate) fn color_to_u32(color: ratatui::style::Color) -> u32 {
}
/// Converts a packed u32 back to a ratatui `Color`.
-#[cfg(test)]
fn u32_to_color(val: u32) -> ratatui::style::Color {
match val >> 24 {
0x00 => match val & 0xFF {
@@ -847,7 +1469,6 @@ pub(crate) fn modifier_with_underline_style(
}
/// Converts a u16 back to a ratatui `Modifier`.
-#[cfg(test)]
fn u16_to_modifier(val: u16) -> ratatui::style::Modifier {
ratatui::style::Modifier::from_bits_truncate(val & !UNDERLINE_STYLE_MASK)
}
@@ -1033,15 +1654,45 @@ mod tests {
#[test]
fn client_hello_roundtrip() {
- let msg = ClientMessage::Hello {
+ let msg = ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::SemanticFrame,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: true,
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_shell_hello_roundtrip() {
+ let msg = ClientMessage::ClientShellHello {
+ version: PROTOCOL_VERSION,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: ClientSurfaceSize { cols: 80, rows: 29 },
+ pixel_mouse: true,
+ direct_graphics: false,
+ endpoint_keybindings: true,
+ mouse_capture: true,
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_shell_resize_roundtrip() {
+ let msg = ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: ClientSurfaceSize { cols: 74, rows: 29 },
+ pixel_mouse: true,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
@@ -1061,7 +1712,7 @@ mod tests {
}
#[test]
- fn client_message_wire_tags_preserve_protocol_15_order() {
+ fn client_message_wire_tags_reflect_current_order() {
fn tag(msg: &ClientMessage) -> u8 {
*bincode::serde::encode_to_vec(msg, bincode::config::standard())
.unwrap()
@@ -1070,21 +1721,33 @@ mod tests {
}
assert_eq!(
- tag(&ClientMessage::Hello {
+ tag(&ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::SemanticFrame,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: false,
}),
0
);
+ assert_eq!(
+ tag(&ClientMessage::ClientShellHello {
+ version: PROTOCOL_VERSION,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: ClientSurfaceSize { cols: 80, rows: 29 },
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ }),
+ 11
+ );
assert_eq!(tag(&ClientMessage::Input { data: Vec::new() }), 1);
assert_eq!(
tag(&ClientMessage::ClipboardImage {
+ target: ClientClipboardImageTarget::DirectTerminal,
extension: "png".to_owned(),
data: Vec::new(),
}),
@@ -1096,6 +1759,7 @@ mod tests {
rows: 24,
cell_width_px: 8,
cell_height_px: 16,
+ pixel_mouse: false,
}),
3
);
@@ -1118,162 +1782,181 @@ mod tests {
}),
6
);
- assert_eq!(tag(&ClientMessage::InputEvents { events: Vec::new() }), 7);
assert_eq!(
tag(&ClientMessage::ObserveTerminal {
target: "w1:p1".to_owned(),
}),
- 8
+ 7
);
assert_eq!(
tag(&ClientMessage::ControlTerminal {
target: "w1:p1".to_owned(),
takeover: false,
}),
- 9
+ 8
+ );
+ assert_eq!(
+ tag(&ClientMessage::AttachMouse {
+ kind: ClientMouseKind::Down(ClientMouseButton::Left),
+ position: ClientMousePosition::Cell { column: 10, row: 5 },
+ geometry: None,
+ modifiers: 0,
+ lines: 1,
+ }),
+ 16
+ );
+ assert_eq!(
+ tag(&ClientMessage::ClientShellHostTheme {
+ update: ClientHostThemeUpdate::Appearance(ClientHostAppearance::Dark),
+ }),
+ 17
+ );
+ assert_eq!(tag(&ClientMessage::ClientShellFocus { focused: true }), 18);
+ assert_eq!(
+ tag(&ClientMessage::ClientShellMouseCapture { enabled: true }),
+ 19
);
}
#[test]
- fn client_input_events_roundtrip() {
- let msg = ClientMessage::InputEvents {
- events: vec![
- ClientInputEvent::Key {
- code: ClientKeyCode::Char('N'),
- modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
- kind: ClientKeyKind::Press,
-
- repeat_count: 1,
- generated_text: None,
- source: crate::protocol::ClientKeySource::Synthesized,
- },
- ClientInputEvent::Key {
- code: ClientKeyCode::Backspace,
- modifiers: 0,
- kind: ClientKeyKind::Press,
- repeat_count: 3,
- generated_text: None,
- source: crate::protocol::ClientKeySource::Vt {
- bytes: b"\x1b[127;1u".to_vec(),
- },
- },
- ClientInputEvent::Key {
- code: ClientKeyCode::Esc,
- modifiers: 0,
- kind: ClientKeyKind::Release,
- repeat_count: 1,
- generated_text: None,
- source: crate::protocol::ClientKeySource::WindowsConsole {
- record: crate::input::WindowsKeyRecord {
- key_down: false,
- repeat_count: 1,
- virtual_key_code: 27,
- virtual_scan_code: 1,
- unicode: 27,
- control_key_state: 0,
- },
- },
- },
- ClientInputEvent::TextCommit("你🙂".to_owned()),
- ClientInputEvent::Mouse {
- kind: ClientMouseKind::Down(ClientMouseButton::Left),
- column: 3,
- row: 4,
- modifiers: 0,
- },
- ],
+ fn client_shell_pane_input_roundtrips_semantic_keys() {
+ let message = ClientMessage::ClientShellPaneInput {
+ pane_id: "w1:p2".into(),
+ events: vec![ClientPaneInputEvent::Key {
+ code: ClientKeyCode::Char('l'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: ClientKeyKind::Release,
+ repeat_count: 1,
+ shifted_codepoint: Some('L' as u32),
+ generated_text: None,
+ tracks_release: true,
+ physical_key_id: None,
+ }],
};
- let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
- // Freeze the protocol 20 input envelope before it is published.
- assert_eq!(
- encoded,
- vec![
- 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49,
- 117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153,
- 130, 2, 0, 0, 3, 4, 0,
- ]
- );
+ let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard())
+ .expect("encode targeted semantic input");
let (decoded, _): (ClientMessage, _) =
- bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
- assert_eq!(msg, decoded);
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
+ .expect("decode targeted semantic input");
+ assert_eq!(decoded, message);
+ let ClientMessage::ClientShellPaneInput { events, .. } = decoded else {
+ panic!("expected targeted semantic input");
+ };
+ let crate::raw_input::RawInputEvent::Key(key) = events[0].to_raw_input_event() else {
+ panic!("expected semantic key");
+ };
+ assert_eq!(key.shifted_codepoint, Some('L' as u32));
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
}
#[test]
- fn wire_release_cannot_restore_a_grouped_repeat_count() {
- let event = ClientInputEvent::Key {
- code: ClientKeyCode::Esc,
- modifiers: 0,
- kind: ClientKeyKind::Release,
- repeat_count: 3,
- generated_text: Some("ignored".to_owned()),
- source: ClientKeySource::Synthesized,
+ fn client_shell_key_roundtrip_preserves_physical_generated_text_encoding() {
+ let key = crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Char('/'),
+ crossterm::event::KeyModifiers::SHIFT,
+ )
+ .with_generated_text(Some("/".into()))
+ .with_windows_record(crate::input::WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x37,
+ virtual_scan_code: 0x08,
+ unicode: '/' as u16,
+ control_key_state: 0,
+ });
+ let event = ClientPaneInputEvent::from_terminal_key(key).expect("semantic pane key");
+ assert!(matches!(
+ event,
+ ClientPaneInputEvent::Key {
+ tracks_release: true,
+ ..
+ }
+ ));
+ let crate::raw_input::RawInputEvent::Key(roundtripped) = event.to_raw_input_event() else {
+ panic!("pane key should remain a key");
};
- match event.to_raw_input_event() {
- crate::raw_input::RawInputEvent::Key(key) => {
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
- assert_eq!(key.repeat_count, 1);
- assert_eq!(key.generated_text, None);
- }
- other => panic!("expected key event, got {other:?}"),
- }
+ assert!(roundtripped.has_physical_identity());
+ let encoded = crate::input::encode_terminal_key(
+ roundtripped,
+ crate::input::KeyboardProtocol::Kitty { flags: 8 },
+ );
+ assert_ne!(encoded, b"/");
+ assert!(encoded.starts_with(b"\x1b["));
}
#[test]
- fn client_input_events_convert_to_raw_keys() {
- let record = crate::input::WindowsKeyRecord {
- key_down: false,
- repeat_count: 1,
- virtual_key_code: 78,
- virtual_scan_code: 49,
- unicode: 78,
- control_key_state: 16,
- };
- let shifted = ClientInputEvent::Key {
- code: ClientKeyCode::Char('N'),
- modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
- kind: ClientKeyKind::Press,
- repeat_count: 1,
- generated_text: None,
- source: ClientKeySource::WindowsConsole { record },
- }
- .to_raw_input_event();
- match shifted {
- crate::raw_input::RawInputEvent::Key(key) => {
- assert_eq!(key.code, crossterm::event::KeyCode::Char('N'));
- assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT);
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
- assert_eq!(
- key.windows_record().map(|record| record.key_down),
- Some(false)
- );
- }
- other => panic!("expected shifted key event, got {other:?}"),
- }
+ fn client_shell_focus_roundtrip() {
+ let message = ClientMessage::ClientShellFocus { focused: false };
+ let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard())
+ .expect("encode client shell focus");
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
+ .expect("decode client shell focus");
+ assert_eq!(decoded, message);
+ }
- let backspace = ClientInputEvent::Key {
- code: ClientKeyCode::Backspace,
- modifiers: 0,
- kind: ClientKeyKind::Press,
+ #[test]
+ fn client_shell_mouse_capture_roundtrip() {
+ let message = ClientMessage::ClientShellMouseCapture { enabled: false };
+ let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard())
+ .expect("encode client shell mouse capture");
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
+ .expect("decode client shell mouse capture");
+ assert_eq!(decoded, message);
+ }
- repeat_count: 1,
- generated_text: None,
- source: crate::protocol::ClientKeySource::Synthesized,
- }
- .to_raw_input_event();
- match backspace {
- crate::raw_input::RawInputEvent::Key(key) => {
- assert_eq!(key.code, crossterm::event::KeyCode::Backspace);
- assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty());
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
- }
- other => panic!("expected backspace key event, got {other:?}"),
- }
+ #[test]
+ fn client_shell_host_theme_roundtrip() {
+ let message = ClientMessage::ClientShellHostTheme {
+ update: ClientHostThemeUpdate::PaletteColors(vec![(
+ 4,
+ ClientHostColor {
+ r: 10,
+ g: 20,
+ b: 30,
+ },
+ )]),
+ };
+ let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard())
+ .expect("encode host theme update");
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
+ .expect("decode host theme update");
+ assert_eq!(decoded, message);
}
#[test]
+ fn client_shell_endpoint_messages_roundtrip() {
+ let request = ClientMessage::ClientShellEndpointRequest {
+ boot_id: "boot-a".into(),
+ request: r#"{"id":"request-a","method":"session.snapshot","params":{}}"#.into(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&request, bincode::config::standard())
+ .expect("encode endpoint request");
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
+ .expect("decode endpoint request");
+ assert_eq!(decoded, request);
+
+ let response = ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id: "boot-a".into(),
+ request_id: "request-a".into(),
+ final_chunk: true,
+ data: br#"{"id":"request-a","result":{"type":"ok"}}"#.to_vec(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&response, bincode::config::standard())
+ .expect("encode endpoint response");
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard())
+ .expect("decode endpoint response");
+ assert_eq!(decoded, response);
+ }
+ #[test]
fn client_clipboard_image_roundtrip() {
let msg = ClientMessage::ClipboardImage {
+ target: ClientClipboardImageTarget::Pane("w1:p1".into()),
extension: "png".to_owned(),
data: vec![0x89, b'P', b'N', b'G'],
};
@@ -1307,6 +1990,7 @@ mod tests {
rows: 24,
cell_width_px: 8,
cell_height_px: 16,
+ pixel_mouse: true,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
@@ -1467,21 +2151,149 @@ mod tests {
hyperlinks: vec!["https://example.com".to_owned()],
graphics: Vec::new(),
};
- let msg = ServerMessage::Frame(frame.clone());
+ let msg = ServerMessage::PaneSurface(PaneSurfaceFrame {
+ boot_id: "boot-1".into(),
+ projection_revision: 1,
+ surface_revision: 1,
+ frame: frame.clone(),
+ panes: Vec::new(),
+ splits: Vec::new(),
+ popup: None,
+ graphics: SurfaceGraphicsScene::default(),
+ });
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
match decoded {
- ServerMessage::Frame(frame) => {
- assert_eq!(frame.cells[2].hyperlink, Some(0));
- assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]);
+ ServerMessage::PaneSurface(surface) => {
+ assert_eq!(surface.frame.cells[2].hyperlink, Some(0));
+ assert_eq!(
+ surface.frame.hyperlinks,
+ vec!["https://example.com".to_owned()]
+ );
}
- other => panic!("expected frame, got {other:?}"),
+ other => panic!("expected pane surface, got {other:?}"),
}
}
#[test]
+ fn pane_surface_patch_roundtrip() {
+ let msg = ServerMessage::PaneSurfacePatch(PaneSurfacePatch {
+ boot_id: "boot-1".into(),
+ projection_revision: 3,
+ base_surface_revision: 7,
+ surface_revision: 8,
+ rows: vec![PaneSurfacePatchRow {
+ x: 2,
+ y: 4,
+ cells: vec![CellData {
+ symbol: "x".into(),
+ fg: 1,
+ bg: 2,
+ modifier: 3,
+ skip: false,
+ hyperlink: None,
+ }],
+ }],
+ panes: Vec::new(),
+ cursor: Some(CursorState {
+ x: 2,
+ y: 4,
+ visible: true,
+ shape: 2,
+ }),
+ });
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(decoded, msg);
+ }
+
+ #[test]
+ fn client_shell_snapshot_roundtrip() {
+ let msg = ServerMessage::ClientShellSnapshot(Box::new(ClientShellSnapshot {
+ boot_id: "boot-1".into(),
+ revision: 1,
+ config_diagnostic: Some("endpoint config warning".into()),
+ product_announcement: Some(ClientShellProductAnnouncement {
+ version: "0.8.2".into(),
+ id: "client-shell".into(),
+ title: "Client shell".into(),
+ body: "### New\n- Client-owned chrome".into(),
+ preview: false,
+ }),
+ update_available: Some("0.8.3".into()),
+ update_install_command: "herdr update".into(),
+ server_keybindings_toml: Some("[keys]\nprefix = \"ctrl+a\"\n".into()),
+ latest_release_notes_available: true,
+ integration_updates_available: true,
+ worktree_directory: "/tmp/herdr-worktrees".into(),
+ release_notes: Some(ClientShellReleaseNotes {
+ version: "0.8.3".into(),
+ body: "### New\n- Update ready".into(),
+ preview: true,
+ }),
+ focused_workspace_id: Some("w1".into()),
+ focused_tab_id: Some("w1:t1".into()),
+ focused_pane_id: Some("w1:p1".into()),
+ tab_bar_right: vec![ClientShellTabStatusSegment {
+ text: "host".into(),
+ accent: false,
+ }],
+ tab_bar_right_separator: " · ".into(),
+ agent_view_label: None,
+ agent_order: Vec::new(),
+ workspaces: vec![ClientShellWorkspace {
+ workspace_id: "w1".into(),
+ active_tab_id: "w1:t1".into(),
+ new_workspace_cwd: "/tmp".into(),
+ number: 1,
+ label: "shell".into(),
+ custom_label: false,
+ branch: Some("main".into()),
+ git_ahead_behind: None,
+ tokens: Vec::new(),
+ worktree: None,
+ focused: true,
+ agent_status: crate::api::schema::AgentStatus::Idle,
+ }],
+ tabs: vec![ClientShellTab {
+ tab_id: "w1:t1".into(),
+ workspace_id: "w1".into(),
+ number: 1,
+ label: "main".into(),
+ custom_label: true,
+ zoomed: false,
+ focused: true,
+ agent_status: crate::api::schema::AgentStatus::Idle,
+ }],
+ panes: vec![ClientShellPane {
+ pane_id: "w1:p1".into(),
+ workspace_id: "w1".into(),
+ tab_id: "w1:t1".into(),
+ label: None,
+ cwd: Some("/repo".into()),
+ foreground_cwd: Some("/repo".into()),
+ focused: true,
+ right_click_passthrough: false,
+ }],
+ agents: Vec::new(),
+ commands: vec![ClientShellCommand {
+ command_id: "cmd_0123456789abcdef0123456789abcdef".into(),
+ binding_label: "prefix+z".into(),
+ binding_labels: vec!["prefix+z".into()],
+ action: ClientShellCommandAction::Shell,
+ description: Some("deploy".into()),
+ }],
+ }));
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
fn server_shutdown_roundtrip() {
let msg = ServerMessage::ServerShutdown {
reason: Some("updating".to_owned()),
@@ -1493,6 +2305,25 @@ mod tests {
}
#[test]
+ fn semantic_notification_roundtrip() {
+ let msg = ServerMessage::SemanticNotification(SemanticNotification {
+ kind: SemanticNotificationKind::NeedsAttention,
+ title: "codex needs attention".into(),
+ body: Some("repo · 1".into()),
+ sound: Some(SemanticNotificationSound::Request),
+ agent: Some("codex".into()),
+ workspace_id: Some("w1".into()),
+ tab_id: Some("w1:t1".into()),
+ pane_id: Some("w1:p1".into()),
+ position: Some(crate::config::ToastHerdrPosition::TopRight),
+ });
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
fn server_notify_roundtrip() {
for kind in [
NotifyKind::Sound,
@@ -1581,8 +2412,20 @@ mod tests {
}
#[test]
- fn server_kitty_keyboard_report_all_roundtrip() {
- let msg = ServerMessage::KittyKeyboardReportAll { enabled: true };
+ fn client_shell_keyboard_report_all_roundtrip() {
+ let msg = ServerMessage::ClientShellKeyboardReportAll { enabled: true };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn direct_terminal_keyboard_mode_roundtrip() {
+ let msg = ServerMessage::DirectTerminalKeyboardProtocol {
+ flags: 15,
+ modify_other_keys_level: 1,
+ };
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
@@ -1608,34 +2451,12 @@ mod tests {
transfer_id: 7,
leading: b"\x1b[2;3H".to_vec(),
control: "a=T,f=32,i=42,q=0".into(),
+ surface_asset: None,
};
let encoded = bincode::serde::encode_to_vec(&server, bincode::config::standard()).unwrap();
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(server, decoded);
-
- let pixels = ClientMessage::InputPixels {
- data: b"\x1b[<35;321;241M".to_vec(),
- cols: 80,
- rows: 24,
- width_px: 800,
- height_px: 480,
- };
- let encoded = bincode::serde::encode_to_vec(&pixels, bincode::config::standard()).unwrap();
- let (decoded, _): (ClientMessage, _) =
- bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
- assert_eq!(pixels, decoded);
- }
-
- #[test]
- fn server_prefix_input_source_roundtrip() {
- for active in [true, false] {
- let msg = ServerMessage::PrefixInputSource { active };
- let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
- let (decoded, _): (ServerMessage, _) =
- bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
- assert_eq!(msg, decoded);
- }
}
#[test]
@@ -1651,15 +2472,13 @@ mod tests {
#[test]
fn framing_small_message_roundtrip() {
- let msg = ClientMessage::Hello {
+ let msg = ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::SemanticFrame,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: false,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -1669,7 +2488,7 @@ mod tests {
#[test]
fn framing_large_payload_roundtrip() {
- // Create a Frame message that is ≥128 KB.
+ // Create a pane-surface message that is ≥128 KB.
// Use a large frame with verbose cell data to exceed 128 KB after bincode encoding.
// 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB.
let width: u16 = 200;
@@ -1702,7 +2521,16 @@ mod tests {
hyperlinks: Vec::new(),
graphics: Vec::new(),
};
- let msg = ServerMessage::Frame(frame);
+ let msg = ServerMessage::PaneSurface(PaneSurfaceFrame {
+ boot_id: "boot-1".into(),
+ projection_revision: 1,
+ surface_revision: 1,
+ frame,
+ panes: Vec::new(),
+ splits: Vec::new(),
+ popup: None,
+ graphics: SurfaceGraphicsScene::default(),
+ });
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -1725,20 +2553,19 @@ mod tests {
for i in 0..150u32 {
let msg = match i % 5 {
- 0 => ClientMessage::Hello {
+ 0 => ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: (80 + (i % 40) as u16),
rows: (24 + (i % 20) as u16),
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::SemanticFrame,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: i % 2 == 0,
},
1 => ClientMessage::Input {
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
},
2 => ClientMessage::ClipboardImage {
+ target: ClientClipboardImageTarget::DirectTerminal,
extension: "png".to_owned(),
data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8],
},
@@ -1747,6 +2574,7 @@ mod tests {
rows: (30 + (i % 10) as u16),
cell_width_px: 8,
cell_height_px: 16,
+ pixel_mouse: i % 2 == 0,
},
4 => ClientMessage::Detach,
_ => unreachable!(),
@@ -2161,15 +2989,13 @@ mod tests {
#[test]
fn read_message_accepts_exact_payload() {
// A normally-framed message should decode without error.
- let msg = ClientMessage::Hello {
+ let msg = ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::SemanticFrame,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: false,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -2197,20 +3023,19 @@ mod tests {
let (mut a, mut b) = UnixStream::pair().expect("socketpair");
let messages = vec![
- ClientMessage::Hello {
+ ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: 200,
rows: 60,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::SemanticFrame,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: true,
},
ClientMessage::Input {
data: b"hello world".to_vec(),
},
ClientMessage::ClipboardImage {
+ target: ClientClipboardImageTarget::DirectTerminal,
extension: "png".to_owned(),
data: vec![0x89, b'P', b'N', b'G'],
},
@@ -2219,6 +3044,7 @@ mod tests {
rows: 30,
cell_width_px: 8,
cell_height_px: 16,
+ pixel_mouse: true,
},
ClientMessage::Detach,
];