blob: 167a2f8ce09a546ca8602104f4bdac81ab64a677 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use alacritty_terminal::{
Term,
grid::Dimensions,
index::{Column, Line},
term::{Config, TermMode, cell::Flags},
vte::ansi,
};
use maka_agent_facts::terminal::MouseEncoding;
use maka_agent_facts::terminal::MouseTracking;
use maka_agent_facts::terminal::TerminalCursor;
use maka_agent_facts::terminal::TerminalInputModes;
use maka_agent_facts::terminal::TerminalScreen;
use maka_agent_facts::terminal::TerminalSize;
use std::{cell::RefCell, rc::Rc};
pub mod display;
mod handler;
use handler::{Events, State};
const MAX_STRING_BYTES: usize = 64 * 1024;
#[derive(Debug, thiserror::Error)]
#[error("terminal screen failed: {0}")]
pub struct ScreenError(&'static str);
/// Pure terminal state. No OS handles, runtime, threads, or external effects.
/// Parsing is synchronous; callers own scheduling, backpressure and persistence.
pub struct Screen {
parser: ansi::Processor,
state: State,
strings: StringLimit,
failed: bool,
}
impl Screen {
pub fn new(size: TerminalSize) -> Self {
let events = Events(Rc::new(RefCell::new(Default::default())));
let term = Term::new(
Config {
scrolling_history: maka_agent_facts::terminal::history_rows(size.cols()),
..Default::default()
},
&Size(size),
events.clone(),
);
Self {
parser: ansi::Processor::new(),
state: State::new(term, events),
strings: StringLimit::default(),
failed: false,
}
}
pub fn write(&mut self, mut text: &str) -> Result<String, ScreenError> {
self.check()?;
// Native transport decoding belongs to the caller. Keep parser cuts
// on UTF-8 boundaries too: vte's partial-codepoint path can consume a
// following ASCII character when its four-byte lookahead spans both.
while !text.is_empty() {
let end = text.floor_char_boundary(256.min(text.len()));
let chunk = &text.as_bytes()[..end];
text = &text[end..];
let mut filtered = [0; 256];
let length = match self.strings.feed(chunk, &mut filtered) {
Ok(length) => length,
Err(()) => {
self.failed = true;
return Err(ScreenError("terminal string exceeds 64 KiB"));
}
};
self.parser.advance(&mut self.state, &filtered[..length]);
// We have no renderer to batch. Flush synchronized output at the
// parser cut so replies and persisted state never wait for repaint.
self.parser.stop_sync(&mut self.state);
if self.state.failed() {
self.failed = true;
return Err(ScreenError("terminal cell or reply budget exceeded"));
}
}
Ok(self.state.take_replies())
}
pub fn resize(&mut self, size: TerminalSize) -> Result<(), ScreenError> {
self.check()?;
let previous = self.size();
let history = maka_agent_facts::terminal::history_rows(size.cols());
// Set the new cell allowance before reflow: trim before width growth,
// allow additional physical rows when columns shrink.
// set_options targets the primary grid even while the alternate is active.
self.state.truncated |= self.state.term.history_size() > history
|| (self.state.term.mode().contains(TermMode::ALT_SCREEN)
&& size.cols() > previous.cols());
self.state.term.set_options(Config {
scrolling_history: history,
..Default::default()
});
self.state.term.resize(Size(size));
self.state.resized();
self.state.truncated |= self.state.term.history_size() == history
&& (size.cols() < previous.cols() || size.rows() < previous.rows());
Ok(())
}
pub fn reset_after_gap(&mut self) {
let size = self.size();
*self = Self::new(size);
self.state.truncated = true;
}
pub fn size(&self) -> TerminalSize {
TerminalSize::new(
self.state.term.columns() as u16,
self.state.term.screen_lines() as u16,
)
.expect("validated terminal dimensions")
}
/// Input clients must use the application's current paste mode, not infer
/// support from the shell name or terminal environment.
pub fn bracketed_paste(&self) -> bool {
self.state.term.mode().contains(TermMode::BRACKETED_PASTE)
}
pub fn preview(&self, budget: usize) -> Result<TerminalScreen, ScreenError> {
self.check()?;
Ok(self.text_preview(budget))
}
/// Read-only capture remains available when the first failed input freezes
/// the successfully applied prefix of the native grid.
pub fn text_preview(&self, budget: usize) -> TerminalScreen {
let term = &self.state.term;
let mut remaining = budget;
let mut clipped = false;
let screen = render(term, false, &mut remaining, &mut clipped);
let alternate = self.state.last_alternate.as_ref().map(|rows| {
maka_agent_facts::terminal::display::text_rows(
&rows.rows,
false,
&mut remaining,
&mut clipped,
)
});
let scrollback = render(term, true, &mut remaining, &mut clipped);
let point = term.grid().cursor.point;
TerminalScreen {
screen,
scrollback,
last_alternate_screen: alternate.filter(|text| !text.is_empty()),
size: self.size(),
cursor: TerminalCursor {
x: point.column.0 as u16,
y: point.line.0.max(0) as u16,
visible: term.mode().contains(TermMode::SHOW_CURSOR),
},
alternate_screen: term.mode().contains(TermMode::ALT_SCREEN),
truncated: self.state.truncated || clipped || self.failed,
input: self.input_modes(),
}
}
fn input_modes(&self) -> TerminalInputModes {
TerminalInputModes {
application_cursor_keys_mode: self.state.term.mode().contains(TermMode::APP_CURSOR),
mouse_tracking_mode: self.state.mouse,
mouse_encoding: self.state.encoding,
}
}
fn check(&self) -> Result<(), ScreenError> {
if self.failed {
Err(ScreenError("parser is closed after a failed operation"))
} else {
Ok(())
}
}
}
struct Size(TerminalSize);
impl Dimensions for Size {
fn columns(&self) -> usize {
self.0.cols().into()
}
fn screen_lines(&self) -> usize {
self.0.rows().into()
}
fn total_lines(&self) -> usize {
self.screen_lines()
}
}
/// Bound upstream's OSC accumulator across arbitrary native read boundaries.
/// Clipboard requests have no effect in this headless Screen, so discard their
/// payload as it arrives instead of accumulating base64 just to ignore it later.
/// Other OSC strings retain their original budget and vte handling.
#[derive(Default)]
struct StringLimit {
escaped: bool,
osc: Option<OscString>,
clipboard: bool,
}
struct OscString {
length: usize,
clipboard_prefix: Option<usize>,
}
impl StringLimit {
fn feed(&mut self, bytes: &[u8], filtered: &mut [u8]) -> Result<usize, ()> {
let mut written = 0;
for &byte in bytes {
let mut forwarded = byte;
if self.clipboard {
match byte {
7 => {
self.clipboard = false;
continue;
}
0x18 | 0x1a | 0x1b => {
self.clipboard = false;
self.escaped = byte == 0x1b;
}
_ => continue,
}
} else if let Some(osc) = &mut self.osc {
if matches!(byte, 7 | 0x18 | 0x1a | 0x1b) {
self.osc = None;
self.escaped = byte == 0x1b;
} else {
osc.length += 1;
// vte ignores these C0 bytes inside an OSC, including in
// the command number. Match its significant-byte prefix.
if !matches!(byte, 0..=6 | 8..=0x17 | 0x19 | 0x1c..=0x1f) {
osc.clipboard_prefix = osc
.clipboard_prefix
.and_then(|index| (byte == b"52;"[index]).then_some(index + 1));
}
if osc.clipboard_prefix == Some(3) {
self.osc = None;
self.clipboard = true;
// End the upstream's tiny partial OSC before discarding
// subsequent bytes. CAN has no visible or reply effect.
forwarded = 0x18;
} else if osc.length > MAX_STRING_BYTES {
return Err(());
}
}
} else if byte == 0x1b {
self.escaped = true;
} else if self.escaped {
match byte {
b']' => {
self.osc = Some(OscString {
length: 0,
clipboard_prefix: Some(0),
});
self.escaped = false;
}
0..=0x17 | 0x19 | 0x1c..=0x1f => {}
_ => self.escaped = false,
}
}
filtered[written] = forwarded;
written += 1;
}
Ok(written)
}
}
fn render(term: &Term<Events>, history: bool, remaining: &mut usize, clipped: &mut bool) -> String {
let count = if history {
term.history_size()
} else {
term.screen_lines()
};
let mut lines = Vec::new();
for index in 0..count {
if *remaining == 0 {
*clipped = true;
break;
}
*remaining -= 1; // A row costs work even when every cell is blank.
let y = if history {
-1 - index as i32
} else {
index as i32
};
let row = &term.grid()[Line(y)];
let mut text = String::new();
for x in 0..term.columns() {
let cell = &row[Column(x)];
for c in
std::iter::once(cell.c).chain(cell.zerowidth().unwrap_or_default().iter().copied())
{
if c.len_utf8() > *remaining {
*remaining = 0;
*clipped = true;
break;
}
*remaining -= c.len_utf8();
if !cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
text.push(c);
}
}
if *remaining == 0 {
*clipped |= x + 1 < term.columns();
break;
}
}
text.truncate(text.trim_end_matches(' ').len());
let wrapped = row[Column(term.columns() - 1)]
.flags
.contains(Flags::WRAPLINE);
lines.push((text, wrapped));
}
if history {
lines.reverse();
}
let mut out = String::new();
let mut previous_wrapped = true;
for (index, (line, wrapped)) in lines.into_iter().enumerate() {
if index != 0 && (!history || !previous_wrapped) {
out.push('\n');
}
out.push_str(&line);
previous_wrapped = wrapped;
}
if !history {
out.truncate(out.trim_end_matches('\n').len());
}
out
}