aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 20:53:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 20:53:55 +0300
commit73dba97521f5a0e3104518ef48b775a178998f52 (patch)
treead9f95b4836303a35974b9ccdde229a1b05b03f4 /src
parent4cbe72ec2d8a174e3c4f9bde2101aa189c7f38e7 (diff)
fix: forward pane cursor shape
fixes #116
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/client/blit.rs122
-rw-r--r--src/ghostty/mod.rs43
-rw-r--r--src/pane.rs1
-rw-r--r--src/pane/terminal.rs54
-rw-r--r--src/server/headless.rs2
-rw-r--r--src/server/protocol.rs15
-rw-r--r--src/server/render_stream.rs2
8 files changed, 232 insertions, 9 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs
index cfd681ca..d2bf3ff6 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -994,7 +994,7 @@ mod tests {
id: "req_1".into(),
result: ResponseResult::Pong {
version: "0.1.2".into(),
- protocol: 5,
+ protocol: 6,
},
};
diff --git a/src/client/blit.rs b/src/client/blit.rs
index 4a5c90c3..2196e1ff 100644
--- a/src/client/blit.rs
+++ b/src/client/blit.rs
@@ -18,6 +18,7 @@
//! - `CSI H` (CUP) — move cursor to (row, col)
//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.)
//! - `CSI ? 2026 h/l` — begin/end synchronized output
+//! - `CSI Ps SP q` — DECSCUSR cursor shape
//! - `ESC ] 52 ; c ; <base64> BEL` — OSC 52 clipboard write
//!
//! The goal is minimal output: skip unchanged cells, batch adjacent changes,
@@ -37,6 +38,7 @@ pub(crate) struct EncodedBlit {
/// Whether this frame was encoded as a full redraw.
pub(crate) full: bool,
next_last_visible_cursor: Option<(u16, u16)>,
+ next_last_cursor_shape: u8,
}
/// Stateful encoder that diffs semantic frames into terminal ANSI bytes.
@@ -44,6 +46,7 @@ pub(crate) struct EncodedBlit {
pub(crate) struct BlitEncoder {
last_frame: Option<FrameData>,
last_visible_cursor: Option<(u16, u16)>,
+ last_cursor_shape: u8,
}
impl BlitEncoder {
@@ -62,16 +65,25 @@ impl BlitEncoder {
|| prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
let mut bytes = Vec::new();
let mut next_last_visible_cursor = self.last_visible_cursor;
- blit_frame_to_with_cursor_memory(&mut bytes, frame, prev, &mut next_last_visible_cursor);
+ let mut next_last_cursor_shape = self.last_cursor_shape;
+ blit_frame_to_with_cursor_memory(
+ &mut bytes,
+ frame,
+ prev,
+ &mut next_last_visible_cursor,
+ &mut next_last_cursor_shape,
+ );
EncodedBlit {
bytes,
full,
next_last_visible_cursor,
+ next_last_cursor_shape,
}
}
pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) {
self.last_visible_cursor = encoded.next_last_visible_cursor;
+ self.last_cursor_shape = encoded.next_last_cursor_shape;
self.last_frame = Some(frame);
}
@@ -244,7 +256,14 @@ fn cells_equal(a: &CellData, b: &CellData) -> bool {
#[cfg(test)]
fn blit_frame_to(writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) {
let mut last_visible_cursor = None;
- blit_frame_to_with_cursor_memory(writer, frame, prev, &mut last_visible_cursor);
+ let mut last_cursor_shape = 0;
+ blit_frame_to_with_cursor_memory(
+ writer,
+ frame,
+ prev,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
}
fn blit_frame_to_with_cursor_memory(
@@ -252,6 +271,7 @@ fn blit_frame_to_with_cursor_memory(
frame: &FrameData,
prev: Option<&FrameData>,
last_visible_cursor: &mut Option<(u16, u16)>,
+ last_cursor_shape: &mut u8,
) {
// On first frame or size change, do a full redraw.
let full_redraw =
@@ -288,7 +308,7 @@ fn blit_frame_to_with_cursor_memory(
// hides its cursor, still park the host cursor intentionally so IMEs do not
// anchor to whichever cell happened to be painted last.
let host_cursor = resolve_host_cursor_state(frame, last_visible_cursor);
- write_host_cursor_state(&mut writer, host_cursor);
+ write_host_cursor_state(&mut writer, host_cursor, last_cursor_shape);
// End the synchronized output block immediately after the final cursor
// state is emitted so supporting terminals can present the frame atomically.
@@ -312,6 +332,8 @@ fn cell_width(cell: &CellData) -> usize {
struct HostCursorState {
position: (u16, u16),
visible: bool,
+ /// DECSCUSR parameter (0–6). 0 means terminal default.
+ shape: u8,
}
fn resolve_host_cursor_state(
@@ -325,6 +347,7 @@ fn resolve_host_cursor_state(
return HostCursorState {
position,
visible: true,
+ shape: normalize_cursor_shape(cursor.shape),
};
}
@@ -332,6 +355,7 @@ fn resolve_host_cursor_state(
return HostCursorState {
position,
visible: false,
+ shape: normalize_cursor_shape(cursor.shape),
};
}
@@ -341,6 +365,15 @@ fn resolve_host_cursor_state(
HostCursorState {
position,
visible: false,
+ shape: 0,
+ }
+}
+
+fn normalize_cursor_shape(shape: u8) -> u8 {
+ if shape <= 6 {
+ shape
+ } else {
+ 0
}
}
@@ -363,8 +396,12 @@ fn write_cursor_position(writer: &mut impl Write, (x, y): (u16, u16)) {
let _ = write!(writer, "\x1b[{};{}H", y + 1, x + 1);
}
-fn write_host_cursor_state(writer: &mut impl Write, cursor: HostCursorState) {
+fn write_host_cursor_state(writer: &mut impl Write, cursor: HostCursorState, last_shape: &mut u8) {
write_cursor_position(writer, cursor.position);
+ if cursor.shape != *last_shape {
+ let _ = write!(writer, "\x1b[{} q", cursor.shape);
+ *last_shape = cursor.shape;
+ }
if cursor.visible {
// Show cursor only after it is already at the final position.
let _ = writer.write_all(b"\x1b[?25h");
@@ -780,6 +817,7 @@ mod tests {
x: 2,
y: 1,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -800,6 +838,43 @@ mod tests {
}
#[test]
+ fn blit_frame_emits_cursor_shape_before_visibility_without_touching_ime_anchor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 6,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ let final_cursor = output_str
+ .find("\x1b[1;1H\x1b[6 q\x1b[?25h")
+ .expect("should set cursor shape before showing cursor");
+ let sync_end = output_str
+ .find("\x1b[?2026l")
+ .expect("should end synchronized output");
+ assert!(
+ final_cursor < sync_end,
+ "shape should be part of the synchronized final cursor state"
+ );
+ let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
+ assert_eq!(
+ trailing_cursor, "\x1b[1;1H\x1b[?25h",
+ "IME anchor update should preserve the existing position/visibility-only contract"
+ );
+ }
+
+ #[test]
fn blit_frame_repeats_explicit_hidden_cursor_anchor_after_synchronized_output() {
let visible = FrameData {
cells: vec![make_cell("A", 0, 0, 0); 9],
@@ -809,6 +884,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -821,20 +897,29 @@ mod tests {
x: 2,
y: 1,
visible: false,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
};
let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
let mut output = Vec::new();
- blit_frame_to_with_cursor_memory(&mut output, &visible, None, &mut last_visible_cursor);
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &visible,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
output.clear();
blit_frame_to_with_cursor_memory(
&mut output,
&hidden,
Some(&visible),
&mut last_visible_cursor,
+ &mut last_cursor_shape,
);
let output_str = String::from_utf8(output).unwrap();
@@ -976,6 +1061,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1001,6 +1087,7 @@ mod tests {
x: 0,
y: 0,
visible: false,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1048,6 +1135,7 @@ mod tests {
x: 0,
y: 0,
visible: false,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1069,6 +1157,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1097,6 +1186,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1107,6 +1197,7 @@ mod tests {
x: 2,
y: 2,
visible: true,
+ shape: 0,
});
let mut output = Vec::new();
@@ -1135,6 +1226,7 @@ mod tests {
x: 1,
y: 1,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1148,15 +1240,23 @@ mod tests {
graphics: Vec::new(),
};
let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
let mut output = Vec::new();
- blit_frame_to_with_cursor_memory(&mut output, &visible, None, &mut last_visible_cursor);
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &visible,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
output.clear();
blit_frame_to_with_cursor_memory(
&mut output,
&hidden,
Some(&visible),
&mut last_visible_cursor,
+ &mut last_cursor_shape,
);
let output_str = String::from_utf8(output).unwrap();
@@ -1180,9 +1280,16 @@ mod tests {
graphics: Vec::new(),
};
let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
let mut output = Vec::new();
- blit_frame_to_with_cursor_memory(&mut output, &frame, None, &mut last_visible_cursor);
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &frame,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
let output_str = String::from_utf8(output).unwrap();
assert!(
@@ -1201,6 +1308,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index a0ff3226..ef03716a 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -204,6 +204,31 @@ pub struct KittyPlacementRenderInfo {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CursorVisualStyle {
+ Bar,
+ Block,
+ Underline,
+ BlockHollow,
+}
+
+impl CursorVisualStyle {
+ fn from_raw(value: ffi::GhosttyRenderStateCursorVisualStyle) -> Self {
+ match value {
+ ffi::GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK => {
+ Self::Block
+ }
+ ffi::GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE => {
+ Self::Underline
+ }
+ ffi::GhosttyRenderStateCursorVisualStyle_GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW => {
+ Self::BlockHollow
+ }
+ _ => Self::Bar,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActiveScreen {
Primary,
Alternate,
@@ -1278,6 +1303,24 @@ impl RenderState {
self.get_bool(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE)
}
+ pub fn cursor_blinking(&self) -> Result<bool, Error> {
+ self.get_bool(ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING)
+ }
+
+ pub fn cursor_visual_style(&self) -> Result<CursorVisualStyle, Error> {
+ let mut out: ffi::GhosttyRenderStateCursorVisualStyle = 0;
+ // SAFETY: out points to the matching enum storage for the requested data kind.
+ unsafe {
+ ffi::ghostty_render_state_get(
+ self.raw,
+ ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE,
+ (&mut out as *mut ffi::GhosttyRenderStateCursorVisualStyle).cast(),
+ )
+ .into_result()?;
+ }
+ Ok(CursorVisualStyle::from_raw(out))
+ }
+
pub fn cursor_viewport(&self) -> Result<Option<CursorViewport>, Error> {
if !self.get_bool(
ffi::GhosttyRenderStateData_GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE,
diff --git a/src/pane.rs b/src/pane.rs
index d9b63fb9..522365df 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -790,6 +790,7 @@ impl PaneRuntime {
x: area.x + cursor.x,
y: area.y + cursor.y,
visible: cursor.visible,
+ shape: cursor.shape,
})
}
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 33cd8ed5..f0535c7a 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -39,6 +39,21 @@ pub struct TerminalCursorState {
pub x: u16,
pub y: u16,
pub visible: bool,
+ /// DECSCUSR parameter (0–6). 0 means terminal default.
+ pub shape: u8,
+}
+
+fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: bool) -> u8 {
+ match (style, blinking) {
+ (crate::ghostty::CursorVisualStyle::Block, true)
+ | (crate::ghostty::CursorVisualStyle::BlockHollow, true) => 1,
+ (crate::ghostty::CursorVisualStyle::Block, false)
+ | (crate::ghostty::CursorVisualStyle::BlockHollow, false) => 2,
+ (crate::ghostty::CursorVisualStyle::Underline, true) => 3,
+ (crate::ghostty::CursorVisualStyle::Underline, false) => 4,
+ (crate::ghostty::CursorVisualStyle::Bar, true) => 5,
+ (crate::ghostty::CursorVisualStyle::Bar, false) => 6,
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -530,10 +545,17 @@ impl GhosttyPaneTerminal {
} = &mut *core;
render_state.update(terminal).ok()?;
let cursor = render_state.cursor_viewport().ok()??;
+ let shape = render_state
+ .cursor_visual_style()
+ .ok()
+ .zip(render_state.cursor_blinking().ok())
+ .map(|(style, blinking)| decscusr_cursor_shape(style, blinking))
+ .unwrap_or(0);
Some(TerminalCursorState {
x: cursor.x,
y: cursor.y,
visible: render_state.cursor_visible().ok()?,
+ shape,
})
}
@@ -1203,6 +1225,38 @@ mod tests {
}
#[test]
+ fn decscusr_cursor_shape_preserves_blinking_variants() {
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Block, true),
+ 1
+ );
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Block, false),
+ 2
+ );
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Underline, true),
+ 3
+ );
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Underline, false),
+ 4
+ );
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Bar, true),
+ 5
+ );
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::Bar, false),
+ 6
+ );
+ assert_eq!(
+ decscusr_cursor_shape(crate::ghostty::CursorVisualStyle::BlockHollow, false),
+ 2
+ );
+ }
+
+ #[test]
fn host_terminal_theme_restore_probe_skips_when_no_transient_override() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index cc57c1f5..9bb3181f 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2137,6 +2137,7 @@ mod tests {
x: pane.inner_rect.x + 4,
y: pane.inner_rect.y,
visible: true,
+ shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0),
})
);
}
@@ -2172,6 +2173,7 @@ mod tests {
x: pane.inner_rect.x + 4,
y: pane.inner_rect.y,
visible: false,
+ shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0),
})
);
}
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index e9b7975e..7d0d3e2d 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 5;
+pub const PROTOCOL_VERSION: u32 = 6;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -103,6 +103,13 @@ pub struct CellData {
pub hyperlink: Option<u32>,
}
+/// Cursor shape encoded as a DECSCUSR parameter.
+///
+/// 0 = terminal default, 1 = blinking block, 2 = steady block,
+/// 3 = blinking underline, 4 = steady underline, 5 = blinking bar,
+/// 6 = steady bar.
+pub type CursorShapeParam = u8;
+
/// Cursor position within a rendered frame.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CursorState {
@@ -112,6 +119,9 @@ pub struct CursorState {
pub y: u16,
/// Whether the cursor is visible.
pub visible: bool,
+ /// Cursor shape as a DECSCUSR parameter.
+ #[serde(default)]
+ pub shape: CursorShapeParam,
}
/// A rendered frame to be displayed by the client.
@@ -725,6 +735,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 6,
}),
hyperlinks: vec!["https://example.com".to_owned()],
graphics: Vec::new(),
@@ -875,6 +886,7 @@ mod tests {
x: 10,
y: 5,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1151,6 +1163,7 @@ mod tests {
x: 1,
y: 0,
visible: true,
+ shape: 0,
};
let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone()));
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index ca0ecddd..a921960c 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -151,6 +151,7 @@ impl CursorTrackingBackend {
x: pos.x,
y: pos.y,
visible: true,
+ shape: 0,
})
}
}
@@ -296,5 +297,6 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> {
x: cursor.x,
y: cursor.y,
visible: cursor.visible && !crate::ui::pane_is_scrolled_back(rt),
+ shape: cursor.shape,
})
}