feat: replace vt100 with wezterm-term for the screen emulator
Some checks failed
CI / Version Badge (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / test-matrix (macos-latest) (pull_request) Has been cancelled
CI / test-matrix (ubuntu-latest) (pull_request) Has been cancelled
CI / Test (pull_request) Has been cancelled
CI / Integration Tests (macos-latest) (pull_request) Has been cancelled
CI / Integration Tests (ubuntu-latest) (pull_request) Has been cancelled
CI / Nix Build (macos-latest) (pull_request) Has been cancelled
CI / Nix Build (ubuntu-latest) (pull_request) Has been cancelled
Some checks failed
CI / Version Badge (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / test-matrix (macos-latest) (pull_request) Has been cancelled
CI / test-matrix (ubuntu-latest) (pull_request) Has been cancelled
CI / Test (pull_request) Has been cancelled
CI / Integration Tests (macos-latest) (pull_request) Has been cancelled
CI / Integration Tests (ubuntu-latest) (pull_request) Has been cancelled
CI / Nix Build (macos-latest) (pull_request) Has been cancelled
CI / Nix Build (ubuntu-latest) (pull_request) Has been cancelled
Swap the rendering core. The previous backend (vt100 0.16) only stored
six attributes — fg/bg color, bold, dim, italic, underline, inverse —
and silently dropped everything else when re-emitting the screen. With
modern Claude Code that meant losing OSC 8 hyperlinks, strikethrough,
fancy underline styles, underline color, and so on.
New stack:
* wezterm-term::Terminal handles the byte-driven VT emulation. Its
cell model preserves the full modern attribute set including
hyperlinks and underline color.
* termwiz::surface::Surface is used as a "mirror of last emitted
state." Each render diffs the terminal's current visible lines
against this mirror to compute the minimum change set.
* termwiz::render::TerminfoRenderer encodes those Changes as bytes
using a TrueColor + hyperlink-capable capability profile.
A defensive OSC 8 close is appended to every render to handle the case
where the diff ends inside a hyperlinked region (the renderer only
emits the close on transition to a non-hyperlinked cell).
Both termwiz and wezterm-term are pulled from the wezterm git repo at
a pinned rev so the Line types unify across the dep graph.
Test results, against the color-preservation harness from the previous
commit (run via cargo test color_preservation -- --ignored on master):
baseline (vt100): 0 / 5 passing
after this commit: 3 / 5 passing (osc8, strikethrough, double underline)
The two still-ignored tests (curly underline, underline color) are
upstream renderer limitations: termwiz's TerminfoRenderer only emits
Single/Double underline and never emits SGR 58. The cell model stores
both correctly; only the emit step drops them. Tests are kept #[ignore]
with comments calling out the upstream gap.
Total: 118 passed, 4 ignored, 0 failed.
This commit is contained in:
1315
Cargo.lock
generated
1315
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"]
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
vt100 = "0.16"
|
termwiz = { git = "https://github.com/wezterm/wezterm", rev = "577474d89ee61aef4a48145cdec82a638d874751" }
|
||||||
termwiz = "0.23"
|
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "577474d89ee61aef4a48145cdec82a638d874751" }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
|||||||
@@ -4,17 +4,20 @@
|
|||||||
//! force a render, capture what the proxy emits to its stdout side, and
|
//! force a render, capture what the proxy emits to its stdout side, and
|
||||||
//! assert that the styling survives the round-trip.
|
//! assert that the styling survives the round-trip.
|
||||||
//!
|
//!
|
||||||
//! They currently FAIL on master because the underlying `vt100 0.16`
|
//! After the swap from `vt100 0.16` to `wezterm-term` + `termwiz` Surface
|
||||||
//! emulator only models a handful of attributes (fg/bg color, bold, dim,
|
//! diffing, most of the modern attribute set is preserved end-to-end:
|
||||||
//! italic, underline, inverse). Anything else — OSC 8 hyperlinks,
|
//! OSC 8 hyperlinks, strikethrough, double underline, and truecolor all
|
||||||
//! strikethrough, fancy underline styles, underline color — is silently
|
//! round-trip. Two known gaps remain — both are limitations of termwiz's
|
||||||
//! dropped on render.
|
//! `TerminfoRenderer`, not of the cell model:
|
||||||
//!
|
//!
|
||||||
//! They are gated behind `#[ignore]` so CI stays green. Run them with:
|
//! * Curly / dotted / dashed underline styles (`SGR 4:3`, `4:4`, `4:5`):
|
||||||
//! cargo test -p claude-chill --lib color_preservation -- --ignored
|
//! stored on the cell, never emitted on render.
|
||||||
|
//! * Underline color (`SGR 58`): stored on the cell, never emitted on
|
||||||
|
//! render.
|
||||||
//!
|
//!
|
||||||
//! The expectation is that they pass once the emulator is swapped to
|
//! Tests for those two are kept `#[ignore]` so CI stays green. They will
|
||||||
//! termwiz's `Surface`. Remove the `#[ignore]` attributes at that point.
|
//! pass if/when termwiz's renderer learns to emit them (or we replace
|
||||||
|
//! the emit step with a custom serializer).
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -119,7 +122,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
|
||||||
fn osc8_hyperlink_survives_render() {
|
fn osc8_hyperlink_survives_render() {
|
||||||
// OSC 8 hyperlinks — used for clickable file paths / URLs.
|
// OSC 8 hyperlinks — used for clickable file paths / URLs.
|
||||||
// Format: ESC ] 8 ; ; URL ESC \ ... ESC ] 8 ; ; ESC \
|
// Format: ESC ] 8 ; ; URL ESC \ ... ESC ] 8 ; ; ESC \
|
||||||
@@ -142,7 +144,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
|
||||||
fn strikethrough_survives_render() {
|
fn strikethrough_survives_render() {
|
||||||
// SGR 9 = strikethrough. vt100 0.16 has no field for it.
|
// SGR 9 = strikethrough. vt100 0.16 has no field for it.
|
||||||
let input = b"\x1b[9mstruck through text\x1b[0m";
|
let input = b"\x1b[9mstruck through text\x1b[0m";
|
||||||
@@ -155,7 +156,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
#[ignore] // termwiz TerminfoRenderer only emits Single/Double underline.
|
||||||
fn curly_underline_survives_render() {
|
fn curly_underline_survives_render() {
|
||||||
// SGR 4:3 = curly underline (kitty/wezterm/ghostty all support).
|
// SGR 4:3 = curly underline (kitty/wezterm/ghostty all support).
|
||||||
let input = b"\x1b[4:3mcurly underline\x1b[0m";
|
let input = b"\x1b[4:3mcurly underline\x1b[0m";
|
||||||
@@ -168,7 +169,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
#[ignore] // termwiz TerminfoRenderer doesn't emit SGR 58 underline color.
|
||||||
fn underline_color_survives_render() {
|
fn underline_color_survives_render() {
|
||||||
// SGR 58:2::R:G:B = truecolor underline color.
|
// SGR 58:2::R:G:B = truecolor underline color.
|
||||||
let input = b"\x1b[4m\x1b[58:2::255:0:0mcolored underline\x1b[0m";
|
let input = b"\x1b[4m\x1b[58:2::255:0:0mcolored underline\x1b[0m";
|
||||||
@@ -181,7 +182,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
|
||||||
fn double_underline_survives_render() {
|
fn double_underline_survives_render() {
|
||||||
// SGR 21 = doubly underlined.
|
// SGR 21 = doubly underlined.
|
||||||
let input = b"\x1b[21mdouble underlined\x1b[0m";
|
let input = b"\x1b[21mdouble underlined\x1b[0m";
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pub mod key_parser;
|
|||||||
pub mod line_buffer;
|
pub mod line_buffer;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod redraw_throttler;
|
pub mod redraw_throttler;
|
||||||
|
pub mod screen_engine;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::escape_sequences::{
|
|||||||
};
|
};
|
||||||
use crate::history_filter::HistoryFilter;
|
use crate::history_filter::HistoryFilter;
|
||||||
use crate::line_buffer::LineBuffer;
|
use crate::line_buffer::LineBuffer;
|
||||||
|
use crate::screen_engine::ScreenEngine;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use memchr::memmem;
|
use memchr::memmem;
|
||||||
@@ -102,8 +103,7 @@ pub struct Proxy {
|
|||||||
original_termios: Option<Termios>,
|
original_termios: Option<Termios>,
|
||||||
history: LineBuffer,
|
history: LineBuffer,
|
||||||
history_filter: HistoryFilter,
|
history_filter: HistoryFilter,
|
||||||
vt_parser: vt100::Parser,
|
screen: ScreenEngine,
|
||||||
vt_prev_screen: Option<vt100::Screen>,
|
|
||||||
last_output_time: Option<Instant>,
|
last_output_time: Option<Instant>,
|
||||||
last_render_time: Option<Instant>,
|
last_render_time: Option<Instant>,
|
||||||
last_stdin_time: Option<Instant>,
|
last_stdin_time: Option<Instant>,
|
||||||
@@ -270,7 +270,7 @@ impl Proxy {
|
|||||||
let stdout_fd = io::stdout();
|
let stdout_fd = io::stdout();
|
||||||
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
|
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
|
||||||
|
|
||||||
let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0);
|
let screen = ScreenEngine::new(winsize.ws_row, winsize.ws_col);
|
||||||
|
|
||||||
// Seed history with clear screen so replay starts fresh
|
// Seed history with clear screen so replay starts fresh
|
||||||
let mut history = LineBuffer::new(config.max_history_lines);
|
let mut history = LineBuffer::new(config.max_history_lines);
|
||||||
@@ -288,8 +288,7 @@ impl Proxy {
|
|||||||
pty_master: pty.master,
|
pty_master: pty.master,
|
||||||
child,
|
child,
|
||||||
original_termios: terminal_guard.take(),
|
original_termios: terminal_guard.take(),
|
||||||
vt_parser,
|
screen,
|
||||||
vt_prev_screen: None,
|
|
||||||
last_output_time: None,
|
last_output_time: None,
|
||||||
last_render_time: None,
|
last_render_time: None,
|
||||||
last_stdin_time: None,
|
last_stdin_time: None,
|
||||||
@@ -330,7 +329,7 @@ impl Proxy {
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let vt_parser = vt100::Parser::new(rows, cols, 0);
|
let screen = ScreenEngine::new(rows, cols);
|
||||||
let mut history = LineBuffer::new(config.max_history_lines);
|
let mut history = LineBuffer::new(config.max_history_lines);
|
||||||
history.push_bytes(CLEAR_SCREEN);
|
history.push_bytes(CLEAR_SCREEN);
|
||||||
history.push_bytes(CURSOR_HOME);
|
history.push_bytes(CURSOR_HOME);
|
||||||
@@ -343,8 +342,7 @@ impl Proxy {
|
|||||||
pty_master,
|
pty_master,
|
||||||
child,
|
child,
|
||||||
original_termios: None,
|
original_termios: None,
|
||||||
vt_parser,
|
screen,
|
||||||
vt_prev_screen: None,
|
|
||||||
last_output_time: None,
|
last_output_time: None,
|
||||||
last_render_time: None,
|
last_render_time: None,
|
||||||
last_stdin_time: None,
|
last_stdin_time: None,
|
||||||
@@ -403,8 +401,8 @@ impl Proxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn vt_screen_text(&self) -> String {
|
pub(crate) fn vt_screen_text(&mut self) -> String {
|
||||||
self.vt_parser.screen().contents()
|
self.screen.screen_text()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -532,7 +530,7 @@ impl Proxy {
|
|||||||
// Feed VT but NOT history while in alt screen
|
// Feed VT but NOT history while in alt screen
|
||||||
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history
|
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history
|
||||||
if feed_vt {
|
if feed_vt {
|
||||||
self.vt_parser.process(data);
|
self.screen.process(data);
|
||||||
}
|
}
|
||||||
return self.process_output_alt_screen(data, stdout_fd);
|
return self.process_output_alt_screen(data, stdout_fd);
|
||||||
}
|
}
|
||||||
@@ -545,7 +543,7 @@ impl Proxy {
|
|||||||
|
|
||||||
// Feed data to VT emulator (unless already fed by caller)
|
// Feed data to VT emulator (unless already fed by caller)
|
||||||
if feed_vt {
|
if feed_vt {
|
||||||
self.vt_parser.process(data);
|
self.screen.process(data);
|
||||||
}
|
}
|
||||||
self.vt_render_pending = true;
|
self.vt_render_pending = true;
|
||||||
self.last_output_time = Some(Instant::now());
|
self.last_output_time = Some(Instant::now());
|
||||||
@@ -621,7 +619,7 @@ impl Proxy {
|
|||||||
|
|
||||||
// Force full VT render to restore main screen content
|
// Force full VT render to restore main screen content
|
||||||
debug!("process_output_alt_screen: rendering VT screen after alt exit");
|
debug!("process_output_alt_screen: rendering VT screen after alt exit");
|
||||||
self.vt_prev_screen = None;
|
self.screen.force_full_next();
|
||||||
self.render_vt_screen(stdout_fd)?;
|
self.render_vt_screen(stdout_fd)?;
|
||||||
|
|
||||||
// Data after ALT_EXIT was already fed to VT and history when we processed
|
// Data after ALT_EXIT was already fed to VT and history when we processed
|
||||||
@@ -828,32 +826,14 @@ impl Proxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_vt_screen<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
fn render_vt_screen<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||||
let is_diff = self.vt_prev_screen.is_some();
|
let body = self.screen.render()?;
|
||||||
self.output_buffer.clear();
|
self.output_buffer.clear();
|
||||||
self.output_buffer.extend_from_slice(SYNC_START);
|
self.output_buffer.extend_from_slice(SYNC_START);
|
||||||
|
self.output_buffer.extend_from_slice(&body);
|
||||||
match &self.vt_prev_screen {
|
|
||||||
Some(prev) => {
|
|
||||||
// Diff-based render: only send changes
|
|
||||||
self.output_buffer
|
|
||||||
.extend_from_slice(&self.vt_parser.screen().contents_diff(prev));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// First render: full screen
|
|
||||||
self.output_buffer
|
|
||||||
.extend_from_slice(&self.vt_parser.screen().contents_formatted());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.output_buffer
|
|
||||||
.extend_from_slice(&self.vt_parser.screen().cursor_state_formatted());
|
|
||||||
self.output_buffer.extend_from_slice(SYNC_END);
|
self.output_buffer.extend_from_slice(SYNC_END);
|
||||||
|
|
||||||
debug!(
|
debug!("render_vt_screen: output_len={}", self.output_buffer.len());
|
||||||
"render_vt_screen: diff={} output_len={}\n",
|
|
||||||
is_diff,
|
|
||||||
self.output_buffer.len()
|
|
||||||
);
|
|
||||||
// Can't use write_to_terminal here due to borrow checker - can't pass
|
// Can't use write_to_terminal here due to borrow checker - can't pass
|
||||||
// &self.output_buffer while also taking &mut self
|
// &self.output_buffer while also taking &mut self
|
||||||
Self::update_kitty_mode_helper(
|
Self::update_kitty_mode_helper(
|
||||||
@@ -864,8 +844,6 @@ impl Proxy {
|
|||||||
);
|
);
|
||||||
write_all(stdout_fd, &self.output_buffer)?;
|
write_all(stdout_fd, &self.output_buffer)?;
|
||||||
|
|
||||||
// Store current screen for next diff
|
|
||||||
self.vt_prev_screen = Some(self.vt_parser.screen().clone());
|
|
||||||
self.vt_render_pending = false;
|
self.vt_render_pending = false;
|
||||||
self.last_render_time = Some(Instant::now());
|
self.last_render_time = Some(Instant::now());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -942,7 +920,7 @@ impl Proxy {
|
|||||||
write_all(stdout_fd, &self.output_buffer)?;
|
write_all(stdout_fd, &self.output_buffer)?;
|
||||||
|
|
||||||
// Force full VT render on next output since terminal now shows history
|
// Force full VT render on next output since terminal now shows history
|
||||||
self.vt_prev_screen = None;
|
self.screen.force_full_next();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,7 +1142,7 @@ impl Proxy {
|
|||||||
|
|
||||||
// Force full render since terminal was showing history
|
// Force full render since terminal was showing history
|
||||||
debug!("exit_lookback_mode: rendering VT screen");
|
debug!("exit_lookback_mode: rendering VT screen");
|
||||||
self.vt_prev_screen = None;
|
self.screen.force_full_next();
|
||||||
self.render_vt_screen(stdout_fd)?;
|
self.render_vt_screen(stdout_fd)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1177,11 +1155,7 @@ impl Proxy {
|
|||||||
winsize.ws_row, winsize.ws_col
|
winsize.ws_row, winsize.ws_col
|
||||||
);
|
);
|
||||||
// Resize VT emulator
|
// Resize VT emulator
|
||||||
self.vt_parser
|
self.screen.resize(winsize.ws_row, winsize.ws_col);
|
||||||
.screen_mut()
|
|
||||||
.set_size(winsize.ws_row, winsize.ws_col);
|
|
||||||
// Force full render on next frame since size changed
|
|
||||||
self.vt_prev_screen = None;
|
|
||||||
// Forward to child process
|
// Forward to child process
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::ioctl(
|
libc::ioctl(
|
||||||
|
|||||||
264
crates/claude-chill/src/screen_engine.rs
Normal file
264
crates/claude-chill/src/screen_engine.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//! Screen emulator backed by `wezterm-term`.
|
||||||
|
//!
|
||||||
|
//! This replaces the previous `vt100` backing. wezterm-term gives us full
|
||||||
|
//! modern attribute coverage — OSC 8 hyperlinks, strikethrough, fancy
|
||||||
|
//! underline styles, underline color — that vt100 0.16 dropped on render.
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! - `wezterm_term::Terminal` is the byte-driven VT emulator. Feed bytes
|
||||||
|
//! via `advance_bytes`; query state via `screen().visible_lines()`.
|
||||||
|
//! - A `termwiz::surface::Surface` mirrors the last bytes we *emitted*
|
||||||
|
//! downstream; the next render diffs current visible lines against
|
||||||
|
//! this Surface to compute the minimum change set.
|
||||||
|
//! - `termwiz::render::TerminfoRenderer` serializes those Changes into
|
||||||
|
//! bytes using a TrueColor / hyperlink-capable capability set.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use termwiz::caps::{Capabilities, ColorLevel, ProbeHints};
|
||||||
|
use termwiz::render::RenderTty;
|
||||||
|
use termwiz::render::terminfo::TerminfoRenderer;
|
||||||
|
use termwiz::surface::{Change, Position, Surface};
|
||||||
|
use wezterm_term::color::ColorPalette;
|
||||||
|
use wezterm_term::{Terminal, TerminalConfiguration, TerminalSize};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProxyTermConfig;
|
||||||
|
|
||||||
|
impl TerminalConfiguration for ProxyTermConfig {
|
||||||
|
fn color_palette(&self) -> ColorPalette {
|
||||||
|
ColorPalette::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Write + RenderTty` adapter that buffers everything in memory.
|
||||||
|
struct CapturedTty {
|
||||||
|
buf: Vec<u8>,
|
||||||
|
cols: usize,
|
||||||
|
rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for CapturedTty {
|
||||||
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.buf.extend_from_slice(data);
|
||||||
|
Ok(data.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderTty for CapturedTty {
|
||||||
|
fn get_size_in_cells(&mut self) -> termwiz::Result<(usize, usize)> {
|
||||||
|
Ok((self.cols, self.rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScreenEngine {
|
||||||
|
terminal: Terminal,
|
||||||
|
/// Mirror of what we've *already emitted* downstream. Diffing the
|
||||||
|
/// current terminal screen against this gives us the minimal change
|
||||||
|
/// set to send.
|
||||||
|
mirror: Surface,
|
||||||
|
renderer: TerminfoRenderer,
|
||||||
|
rows: usize,
|
||||||
|
cols: usize,
|
||||||
|
/// When set, the next render emits the full screen instead of a diff.
|
||||||
|
force_full: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScreenEngine {
|
||||||
|
pub fn new(rows: u16, cols: u16) -> Self {
|
||||||
|
let rows = rows as usize;
|
||||||
|
let cols = cols as usize;
|
||||||
|
|
||||||
|
let size = TerminalSize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let config: Arc<dyn TerminalConfiguration + Send + Sync> = Arc::new(ProxyTermConfig);
|
||||||
|
// We don't currently surface terminal responses (DA queries, cursor
|
||||||
|
// position reports, etc) back upstream. Matches the previous vt100
|
||||||
|
// behavior, which simply ignored them.
|
||||||
|
let writer: Box<dyn Write + Send> = Box::new(std::io::sink());
|
||||||
|
let terminal = Terminal::new(
|
||||||
|
size,
|
||||||
|
config,
|
||||||
|
"claude-chill",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
writer,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mirror = Surface::new(cols, rows);
|
||||||
|
|
||||||
|
let caps = Capabilities::new_with_hints(
|
||||||
|
ProbeHints::default()
|
||||||
|
.color_level(Some(ColorLevel::TrueColor))
|
||||||
|
.hyperlinks(Some(true))
|
||||||
|
.bracketed_paste(Some(true)),
|
||||||
|
)
|
||||||
|
.expect("Capabilities::new_with_hints");
|
||||||
|
let renderer = TerminfoRenderer::new(caps);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
terminal,
|
||||||
|
mirror,
|
||||||
|
renderer,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
force_full: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed bytes from the child pty into the emulator.
|
||||||
|
pub fn process(&mut self, data: &[u8]) {
|
||||||
|
self.terminal.advance_bytes(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||||
|
let rows = rows as usize;
|
||||||
|
let cols = cols as usize;
|
||||||
|
self.terminal.resize(TerminalSize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
self.mirror.resize(cols, rows);
|
||||||
|
self.rows = rows;
|
||||||
|
self.cols = cols;
|
||||||
|
self.force_full = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the next `render()` call as a full screen redraw rather than a diff.
|
||||||
|
pub fn force_full_next(&mut self) {
|
||||||
|
self.force_full = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the change stream needed to bring downstream from `mirror` to
|
||||||
|
/// the current emulator state, encode it as bytes, and update `mirror`.
|
||||||
|
pub fn render(&mut self) -> Result<Vec<u8>> {
|
||||||
|
let lines = {
|
||||||
|
let screen = self.terminal.screen();
|
||||||
|
let rows = self.rows as i64;
|
||||||
|
let start = screen.phys_row(0);
|
||||||
|
let end = screen.phys_row(rows - 1) + 1;
|
||||||
|
screen.lines_in_phys_range(start..end)
|
||||||
|
};
|
||||||
|
let line_refs: Vec<&_> = lines.iter().collect();
|
||||||
|
|
||||||
|
// If forced, reset the mirror so diff_lines emits the full screen.
|
||||||
|
if self.force_full {
|
||||||
|
self.mirror = Surface::new(self.cols, self.rows);
|
||||||
|
self.force_full = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changes = self.mirror.diff_lines(line_refs);
|
||||||
|
|
||||||
|
// Position the downstream cursor where the emulator's cursor is.
|
||||||
|
let cursor = self.terminal.cursor_pos();
|
||||||
|
let row = cursor.y.max(0) as usize;
|
||||||
|
let col = cursor.x;
|
||||||
|
changes.push(Change::CursorPosition {
|
||||||
|
x: Position::Absolute(col),
|
||||||
|
y: Position::Absolute(row),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the mirror in sync with what we just emitted (minus the final
|
||||||
|
// cursor reposition, which isn't a content change).
|
||||||
|
self.mirror.add_changes(changes.clone());
|
||||||
|
|
||||||
|
let mut tty = CapturedTty {
|
||||||
|
buf: Vec::new(),
|
||||||
|
cols: self.cols,
|
||||||
|
rows: self.rows,
|
||||||
|
};
|
||||||
|
self.renderer
|
||||||
|
.render_to(&changes, &mut tty)
|
||||||
|
.map_err(|e| anyhow::anyhow!("renderer: {}", e))?;
|
||||||
|
|
||||||
|
// Defensive OSC 8 close: TerminfoRenderer only emits the hyperlink
|
||||||
|
// close when transitioning to a non-hyperlinked cell. If our diff
|
||||||
|
// ends inside the hyperlinked region (rest of the row was blank
|
||||||
|
// and didn't need updating), the close is never emitted and the
|
||||||
|
// downstream terminal may keep the hyperlink active across the
|
||||||
|
// boundary. Appending a close here is a no-op when there's no
|
||||||
|
// active hyperlink.
|
||||||
|
tty.buf.extend_from_slice(b"\x1b]8;;\x1b\\");
|
||||||
|
|
||||||
|
Ok(tty.buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain-text contents of the visible screen, for tests / lookback.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn screen_text(&mut self) -> String {
|
||||||
|
let lines = {
|
||||||
|
let screen = self.terminal.screen();
|
||||||
|
let rows = self.rows as i64;
|
||||||
|
let start = screen.phys_row(0);
|
||||||
|
let end = screen.phys_row(rows - 1) + 1;
|
||||||
|
screen.lines_in_phys_range(start..end)
|
||||||
|
};
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&line.as_str());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
haystack.windows(needle.len()).any(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(input: &[u8]) -> Vec<u8> {
|
||||||
|
let mut e = ScreenEngine::new(24, 80);
|
||||||
|
e.process(input);
|
||||||
|
e.render().expect("render")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc8_hyperlink_round_trip() {
|
||||||
|
let mut input = Vec::new();
|
||||||
|
input.extend_from_slice(b"\x1b]8;;https://example.com\x1b\\");
|
||||||
|
input.extend_from_slice(b"link");
|
||||||
|
input.extend_from_slice(b"\x1b]8;;\x1b\\");
|
||||||
|
let out = render(&input);
|
||||||
|
assert!(
|
||||||
|
contains(&out, b"\x1b]8;;https://example.com"),
|
||||||
|
"OSC 8 missing in: {:?}",
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strikethrough_round_trip() {
|
||||||
|
let out = render(b"\x1b[9mstruck\x1b[0m");
|
||||||
|
assert!(
|
||||||
|
contains(&out, b"[9m") || contains(&out, b";9m"),
|
||||||
|
"SGR 9 missing in: {:?}",
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truecolor_round_trip() {
|
||||||
|
let out = render(b"\x1b[38;2;255;128;0morange\x1b[0m");
|
||||||
|
assert!(
|
||||||
|
contains(&out, b"38:2::255:128:0")
|
||||||
|
|| contains(&out, b"38;2;255;128;0")
|
||||||
|
|| contains(&out, b"38:2:255:128:0"),
|
||||||
|
"truecolor fg missing in: {:?}",
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user