Merge pull request #101 from ayamir/fix/terminal-cursor-shape

fix(terminal): respect app cursor shape
This commit is contained in:
l0ng-ai
2026-07-16 12:12:44 +08:00
committed by GitHub
3 changed files with 161 additions and 30 deletions
+31 -9
View File
@@ -8,7 +8,7 @@ use alacritty_terminal::grid::Dimensions as _;
use alacritty_terminal::index::{Column as AlacColumn, Line as AlacLine, Point as AlacPoint};
use alacritty_terminal::selection::SelectionRange;
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::vte::ansi::{Color as AnsiColor, NamedColor, Rgb};
use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb};
use gpui::{
App, BorderStyle, Bounds, ContentMask, CursorStyle, Element, ElementId, Font, FontStyle,
FontWeight, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, Hsla, IntoElement, LayoutId,
@@ -841,7 +841,7 @@ struct GridCursor {
row: usize,
col: usize,
hidden: bool,
/// The shape to draw (from `Config::cursor_style`).
/// The shape to draw after resolving terminal DECSCUSR/default state.
style: crate::core::config::CursorStyle,
}
@@ -893,6 +893,14 @@ fn paint_cursor(
}
}
fn cursor_style_from_shape(shape: CursorShape) -> crate::core::config::CursorStyle {
match shape {
CursorShape::Beam => crate::core::config::CursorStyle::Bar,
CursorShape::Underline => crate::core::config::CursorStyle::Underline,
_ => crate::core::config::CursorStyle::Block,
}
}
/// Paint IME pre-edit (composing) text over the cursor cell, underlined so it
/// reads as provisional.
fn paint_marked(
@@ -1046,13 +1054,8 @@ impl TerminalElement {
cursor = Some(GridCursor {
row: row as usize,
col,
hidden: matches!(
cur.shape,
alacritty_terminal::vte::ansi::CursorShape::Hidden
),
// Shape is a user preference, not app-driven: the config style
// wins regardless of what DECSCUSR requested.
style: cx.global::<Config>().cursor_style,
hidden: matches!(cur.shape, CursorShape::Hidden),
style: cursor_style_from_shape(cur.shape),
});
}
}
@@ -1778,6 +1781,25 @@ mod tests {
assert_eq!(drag_overshoot(px(230.), bounds, px(10.)), -3.);
}
#[test]
fn terminal_cursor_shape_maps_to_painted_cursor_style() {
use crate::core::config::CursorStyle;
assert_eq!(cursor_style_from_shape(CursorShape::Beam), CursorStyle::Bar);
assert_eq!(
cursor_style_from_shape(CursorShape::Underline),
CursorStyle::Underline
);
assert_eq!(
cursor_style_from_shape(CursorShape::Block),
CursorStyle::Block
);
assert_eq!(
cursor_style_from_shape(CursorShape::HollowBlock),
CursorStyle::Block
);
}
// ---- segment_row ----
fn cell(c: char) -> RenderCell {
+72 -5
View File
@@ -31,11 +31,12 @@ use std::thread::JoinHandle;
use alacritty_terminal::event::{Event as AlacEvent, EventListener};
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi;
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
use std::collections::VecDeque;
use crate::core::cli_agent::{AgentSessionState, CLIAgent};
use crate::core::config::CursorStyle as ConfigCursorStyle;
use crate::core::osc::OscTokenizer;
use crate::daemon::protocol::{
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
@@ -302,10 +303,8 @@ impl RemoteTerminal {
// Scrollback depth comes from user config (clamped in `Config::sanitize`
// to alacritty's ceiling). Read fresh from disk here: a pane spawn/attach
// is rare, and this runs on the daemon side too, which has no GPUI global.
let config = Config {
scrolling_history: crate::core::config::Config::load().scrollback_limit,
..Config::default()
};
let user_config = crate::core::config::Config::load();
let config = terminal_config_from_user(&user_config);
let term = Term::new(config, &size, proxy.clone());
let term = Arc::new(FairMutex::new(term));
@@ -363,6 +362,11 @@ impl RemoteTerminal {
})
}
pub fn apply_user_config(&self, user_config: &crate::core::config::Config) {
let mut term = self.term.lock();
term.set_options(terminal_config_from_user(user_config));
}
/// The reader thread: decodes framed `DaemonMsg`s off the socket and applies
/// each. `Snapshot`/`Output` feed the same `ansi::Processor` → `Term` path as
/// the in-process backend (so a multi-MB Snapshot is one `advance` call),
@@ -1461,6 +1465,26 @@ fn connect() -> anyhow::Result<Stream> {
})
}
fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Config {
Config {
scrolling_history: user_config.scrollback_limit,
default_cursor_style: alacritty_cursor_style(user_config.cursor_style),
..Config::default()
}
}
fn alacritty_cursor_style(style: ConfigCursorStyle) -> CursorStyle {
let shape = match style {
ConfigCursorStyle::Block => CursorShape::Block,
ConfigCursorStyle::Bar => CursorShape::Beam,
ConfigCursorStyle::Underline => CursorShape::Underline,
};
CursorStyle {
shape,
blinking: false,
}
}
/// Build the protocol `WinSize` from our `TermSize` + cell pixel size.
fn win_size(size: TermSize, cell_w: u16, cell_h: u16) -> WinSize {
WinSize {
@@ -1557,6 +1581,49 @@ mod tests {
assert!(term.exited_flag.load(Ordering::SeqCst));
}
#[test]
fn cursor_style_sequence_overrides_and_resets_to_user_default() {
use alacritty_terminal::vte::ansi::CursorShape;
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let mut user_config = crate::core::config::Config::default();
user_config.cursor_style = ConfigCursorStyle::Underline;
term.apply_user_config(&user_config);
let mut shape = term.term.lock().cursor_style().shape;
assert_eq!(shape, CursorShape::Underline);
// DECSCUSR 6 = steady beam, the sequence nvim uses for insert mode.
DaemonMsg::Output(b"\x1b[6 q".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
for _ in 0..200 {
shape = term.term.lock().cursor_style().shape;
if shape == CursorShape::Beam {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(shape, CursorShape::Beam);
// DECSCUSR 0 clears the application override, so the configured
// terminal default is visible again.
DaemonMsg::Output(b"\x1b[0 q".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
for _ in 0..200 {
shape = term.term.lock().cursor_style().shape;
if shape == CursorShape::Underline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(shape, CursorShape::Underline);
}
/// Native-SSH `AuthPrompt` and `SshStatus` frames must surface through the
/// reader thread into the per-pane queue / phase cell the auth sheet reads.
#[test]
+58 -16
View File
@@ -15,7 +15,9 @@ use std::rc::Rc;
use std::sync::Arc;
use crate::core::actions::*;
use crate::core::config::{Config, NewTabPosition, ShellConfig, TabBarPosition};
use crate::core::config::{
Config, CursorStyle as ConfigCursorStyle, NewTabPosition, ShellConfig, TabBarPosition,
};
use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab};
use crate::core::shells::DetectedShell;
use crate::core::ssh_config;
@@ -262,6 +264,10 @@ pub struct Tty7App {
/// Currently-applied OpenType features for terminal fonts. `None` means the
/// terminal-safe default (ligatures disabled).
pub(crate) font_features: Option<gpui::FontFeatures>,
/// Currently-applied terminal-emulator defaults. Tracked so hot-reload can
/// push only the alacritty-backed options that actually changed.
terminal_cursor_style: ConfigCursorStyle,
terminal_scrollback_limit: usize,
/// Keeps the `observe_global::<Config>` subscription alive for the app's
/// lifetime so external edits to `config.json` (swapped in by the watcher in
/// `main.rs`) live-apply font size / line height / family. Never read.
@@ -445,12 +451,28 @@ impl Tty7App {
cx: &mut Context<Self>,
) -> Self {
// Font size from config (borrow ends before the mutable theme apply).
let font_size = cx.global::<Config>().font_size;
let line_height = cx.global::<Config>().line_height;
let font_family = cx.global::<Config>().font_family.clone();
let font_family_bold = cx.global::<Config>().font_family_bold.clone();
let font_family_italic = cx.global::<Config>().font_family_italic.clone();
let font_features = cx.global::<Config>().font_features.clone();
let (
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
font_features,
terminal_cursor_style,
terminal_scrollback_limit,
) = {
let cfg = cx.global::<Config>();
(
cfg.font_size,
cfg.line_height,
cfg.font_family.clone(),
cfg.font_family_bold.clone(),
cfg.font_family_italic.clone(),
cfg.font_features.clone(),
cfg.cursor_style,
cfg.scrollback_limit,
)
};
let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx);
// Managed-forward add-form inputs (native-SSH panes).
let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1"));
@@ -533,6 +555,8 @@ impl Tty7App {
font_family_bold,
font_family_italic,
font_features,
terminal_cursor_style,
terminal_scrollback_limit,
_config_watch: config_watch,
_keystroke_watch: keystroke_watch,
_activation_watch: activation_watch,
@@ -1054,21 +1078,27 @@ impl Tty7App {
cx.notify();
}
/// Switch the cursor shape and repaint. The element reads `cursor_style` from
/// the global each frame, so we just persist and nudge every pane to redraw.
pub(crate) fn set_cursor_style(
&mut self,
style: crate::core::config::CursorStyle,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.cursor_style = style);
fn apply_terminal_config_to_panes(&self, config: &Config, cx: &mut Context<Self>) {
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |_v, cx| cx.notify());
leaf.update(cx, |v, cx| {
v.terminal.apply_user_config(config);
cx.notify();
});
}
}
}
/// Switch the default cursor shape, update each pane's terminal defaults,
/// and repaint. App-requested DECSCUSR shapes still override this at runtime.
pub(crate) fn set_cursor_style(&mut self, style: ConfigCursorStyle, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.cursor_style = style);
let cfg = cx.global::<Config>().clone();
self.terminal_cursor_style = cfg.cursor_style;
self.terminal_scrollback_limit = cfg.scrollback_limit;
self.apply_terminal_config_to_panes(&cfg, cx);
}
// ── Config setters (Terminal / Window & Tabs / Cursor settings) ─────────
// Each goes through `update_config` (mutate the global, persist, repaint).
// Effect points read the global live (blink task, `poll_foreground`, link
@@ -1371,6 +1401,10 @@ impl Tty7App {
self.update_config(cx, |cfg| {
cfg.scrollback_limit = lines.clamp(100, crate::core::config::MAX_SCROLLBACK)
});
let cfg = cx.global::<Config>().clone();
self.terminal_cursor_style = cfg.cursor_style;
self.terminal_scrollback_limit = cfg.scrollback_limit;
self.apply_terminal_config_to_panes(&cfg, cx);
}
pub(crate) fn set_new_tab_position(&mut self, pos: NewTabPosition, cx: &mut Context<Self>) {
@@ -2915,6 +2949,14 @@ impl Tty7App {
/// writes it), and — because we never write the global or `save()` from here
/// — closes the save → watch → reload loop that would otherwise oscillate.
fn reload_from_config(&mut self, cx: &mut Context<Self>) {
let config = cx.global::<Config>().clone();
if config.cursor_style != self.terminal_cursor_style
|| config.scrollback_limit != self.terminal_scrollback_limit
{
self.terminal_cursor_style = config.cursor_style;
self.terminal_scrollback_limit = config.scrollback_limit;
self.apply_terminal_config_to_panes(&config, cx);
}
let (font_size, line_height, font_family, font_features) = {
let cfg = cx.global::<Config>();
(