pub mod config;
use std::collections::VecDeque;
#[cfg(feature = "agent-harness")]
use std::fs;
use std::path::Path;
#[cfg(feature = "agent-harness")]
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use log::*;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
use winit::event::{ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
#[cfg(target_os = "macos")]
use winit::platform::macos::WindowAttributesExtMacOS;
use winit::window::{CursorIcon, Window, WindowId};
use crate::UserEvent;
use crate::ansi;
use crate::app::config::Config;
use crate::input;
use crate::input::keyboard::{BackspaceMode, KeyAction, action_to_bytes, winit_to_action};
use crate::renderer::{
CursorVisual, RenderFrame, RenderSettings, RenderTab, Renderer, TabChromeHit,
};
use crate::terminal::{CursorStyle, MouseEncoding, MouseTracking, Selection, TerminalState};
const PERF_REPORT_INTERVAL: Duration = Duration::from_secs(2);
const MAX_COALESCED_PTY_CHUNK: usize = 512 * 1024;
const TAB_TITLE_MAX_CHARS: usize = 24;
const SEARCH_STATUS_MAX_CHARS: usize = 48;
const MULTI_CLICK_MAX_INTERVAL: Duration = Duration::from_millis(500);
const MULTI_CLICK_MAX_DISTANCE_CELLS: usize = 1;
static NEXT_TERMINAL_SESSION_ID: AtomicU64 = AtomicU64::new(1);
type TerminalSessionId = u64;
#[cfg(feature = "agent-harness")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TestProfile {
#[default]
Agent,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LaunchOptions {
#[cfg(feature = "agent-harness")]
pub test_profile: Option<TestProfile>,
}
impl LaunchOptions {
pub(crate) fn config(&self) -> Config {
#[cfg(feature = "agent-harness")]
match self.test_profile {
Some(TestProfile::Agent) => Config::agent_test_profile(),
None => Config::load(),
}
#[cfg(not(feature = "agent-harness"))]
{
Config::load()
}
}
fn window_size(&self) -> LogicalSize<f64> {
#[cfg(feature = "agent-harness")]
match self.test_profile {
Some(TestProfile::Agent) => LogicalSize::new(1280.0, 820.0),
None => LogicalSize::new(1024.0, 768.0),
}
#[cfg(not(feature = "agent-harness"))]
{
LogicalSize::new(1024.0, 768.0)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppRequest {
CloseWindow,
}
#[cfg(feature = "agent-harness")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentCommand {
Smoke,
Burst,
Scrollback,
NewTab,
NextTab,
PreviousTab,
CloseTab,
}
#[cfg(feature = "agent-harness")]
impl AgentCommand {
fn parse(value: &str) -> Option<Self> {
match value.trim() {
"smoke" | "unicode" => Some(Self::Smoke),
"burst" | "logs" => Some(Self::Burst),
"scrollback" | "count" => Some(Self::Scrollback),
"new-tab" | "tab-new" => Some(Self::NewTab),
"next-tab" | "tab-next" => Some(Self::NextTab),
"previous-tab" | "prev-tab" | "tab-prev" => Some(Self::PreviousTab),
"close-tab" | "tab-close" => Some(Self::CloseTab),
_ => None,
}
}
}
struct PtyChunk {
data: Vec<u8>,
offset: usize,
}
impl PtyChunk {
fn new(data: Vec<u8>) -> Self {
Self { data, offset: 0 }
}
fn remaining(&self) -> usize {
self.data.len().saturating_sub(self.offset)
}
fn can_append(&self, len: usize) -> bool {
self.offset == 0 && self.data.len() + len <= MAX_COALESCED_PTY_CHUNK
}
}
#[derive(Debug, Clone, Copy)]
struct SelectionClick {
cell: (usize, usize),
at: Instant,
count: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectionCellKind {
Blank,
Word,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchDirection {
Next,
Previous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SearchMatch {
absolute_row: usize,
start_col: usize,
end_col: usize,
start_byte: usize,
end_byte: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MouseReportKey {
row: usize,
col: usize,
code: u8,
x: usize,
y: usize,
}
#[derive(Debug, Clone, Default)]
struct SearchState {
active: bool,
query: String,
current: Option<SearchMatch>,
last_had_match: bool,
}
struct TerminalSession {
id: TerminalSessionId,
shell_name: String,
pty: crate::pty::Session,
terminal: TerminalState,
parser: ansi::Parser,
reader_stop: Arc<AtomicBool>,
reader_master: Option<std::os::fd::OwnedFd>,
selection: Selection,
selection_dragging: bool,
viewport_offset: usize,
mouse_pressed_button: Option<u8>,
last_mouse_report: Option<MouseReportKey>,
hovered_hyperlink_id: u32,
ime_preedit: String,
search: SearchState,
status_message: Option<String>,
pty_pending: VecDeque<PtyChunk>,
pty_pending_bytes: usize,
cursor_visible: bool,
cursor_blink_timer: Instant,
cursor_blink_active: bool,
cursor_last_activity: Instant,
parse_budget_bytes: usize,
}
impl TerminalSession {
fn at_bottom(&self) -> bool {
self.viewport_offset == 0
}
fn reset_cursor_blink(&mut self, cursor_blink: bool) {
self.cursor_visible = true;
self.cursor_blink_active = cursor_blink;
self.cursor_blink_timer = Instant::now();
self.cursor_last_activity = Instant::now();
}
fn push_pty_output(&mut self, mut data: Vec<u8>, coalesce_redraws: bool) {
if data.is_empty() {
return;
}
self.pty_pending_bytes += data.len();
if coalesce_redraws
&& let Some(last) = self.pty_pending.back_mut()
&& last.can_append(data.len())
{
last.data.append(&mut data);
} else {
self.pty_pending.push_back(PtyChunk::new(data));
}
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
self.reader_stop.store(true, Ordering::Relaxed);
}
}
pub struct App {
pub window: Arc<Window>,
pub renderer: Renderer,
sessions: Vec<TerminalSession>,
active_session: usize,
proxy: EventLoopProxy<UserEvent>,
pub config: Config,
#[cfg(feature = "agent-harness")]
agent_test_profile: bool,
needs_redraw: bool,
modifiers: ModifiersState,
mouse_pos: PhysicalPosition<f64>,
scroll_accum: f32,
last_selection_click: Option<SelectionClick>,
hovered_tab: Option<usize>,
backspace_mode: BackspaceMode,
last_frame_ms: f64,
last_window_title: String,
last_ime_cursor_area: Option<(i32, i32, u32, u32)>,
#[cfg(feature = "agent-harness")]
agent_state_path: Option<PathBuf>,
#[cfg(feature = "agent-harness")]
last_agent_state_json: Option<String>,
input_events: u64,
bytes_written: u64,
bytes_received: u64,
redraw_count: u64,
frames_rendered: u64,
last_perf_report: Instant,
frame_start: Instant,
accum_parse_time: Duration,
accum_render_time: Duration,
accum_frame_time: Duration,
perf_frames: u64,
}
impl App {
pub fn new(
event_loop: &ActiveEventLoop,
proxy: winit::event_loop::EventLoopProxy<UserEvent>,
launch_options: &LaunchOptions,
) -> Result<Self, Box<dyn std::error::Error>> {
let config = launch_options.config();
let window_attrs = Window::default_attributes()
.with_title("Panasyn")
.with_inner_size(launch_options.window_size());
#[cfg(target_os = "macos")]
let window_attrs = window_attrs
.with_fullsize_content_view(true)
.with_titlebar_transparent(true)
.with_title_hidden(true);
let window = event_loop.create_window(window_attrs)?;
let window = Arc::new(window);
window.set_ime_allowed(true);
let window_size = window.inner_size();
let render_settings = RenderSettings::new(
config.font.family.clone(),
config.font.size,
config.font.line_height,
platform_top_padding_lines(),
config.performance.cache_rows,
config.appearance.render_theme(),
config.appearance.background_image_settings(),
);
let (grid_cols, grid_rows) = Renderer::grid_dimensions(
window_size.width,
window_size.height,
window.scale_factor(),
&render_settings,
);
let renderer = pollster::block_on(Renderer::new(
window.clone(),
window_size.width,
window_size.height,
render_settings,
))?;
info!(
"grid={}x{} cell={}x{} font={}px scale={}",
renderer.cols(),
renderer.rows(),
renderer.cell_width,
renderer.cell_height,
renderer.font_size_physical,
window.scale_factor(),
);
let backspace_mode = BackspaceMode::from_config(&config.keyboard.backspace);
let first_session_id = allocate_terminal_session_id();
let first_session = Self::spawn_terminal_session(
&config,
proxy.clone(),
first_session_id,
grid_cols,
grid_rows,
)?;
let mut app = Self {
window,
renderer,
sessions: vec![first_session],
active_session: 0,
proxy,
config,
#[cfg(feature = "agent-harness")]
agent_test_profile: launch_options.test_profile.is_some(),
needs_redraw: true,
modifiers: ModifiersState::default(),
mouse_pos: PhysicalPosition::new(0.0, 0.0),
scroll_accum: 0.0,
last_selection_click: None,
hovered_tab: None,
backspace_mode,
last_frame_ms: 0.0,
last_window_title: String::new(),
last_ime_cursor_area: None,
#[cfg(feature = "agent-harness")]
agent_state_path: agent_state_path(launch_options.test_profile.is_some()),
#[cfg(feature = "agent-harness")]
last_agent_state_json: None,
input_events: 0,
bytes_written: 0,
bytes_received: 0,
redraw_count: 0,
frames_rendered: 0,
last_perf_report: Instant::now(),
frame_start: Instant::now(),
accum_parse_time: Duration::ZERO,
accum_render_time: Duration::ZERO,
accum_frame_time: Duration::ZERO,
perf_frames: 0,
};
app.request_redraw();
Ok(app)
}
pub fn apply_config(&mut self, config: Config) {
let old_cols = self.renderer.cols();
let old_rows = self.renderer.rows();
let render_settings = RenderSettings::new(
config.font.family.clone(),
config.font.size,
config.font.line_height,
platform_top_padding_lines(),
config.performance.cache_rows,
config.appearance.render_theme(),
config.appearance.background_image_settings(),
);
let (new_cols, new_rows) = self.renderer.reconfigure(render_settings);
self.renderer.set_cols_rows(new_cols, new_rows);
let cursor_style = CursorStyle::from_config(&config.cursor.style);
let cursor_blink = config.cursor.blink;
for session in &mut self.sessions {
session.terminal.set_cursor_style_config(cursor_style);
session
.terminal
.set_scrollback_limit(config.scrollback.lines);
session.parse_budget_bytes = config.performance.max_parse_bytes_per_frame.max(1);
session.reset_cursor_blink(cursor_blink);
if new_cols != old_cols || new_rows != old_rows {
session.terminal.resize(new_cols, new_rows);
if let Err(error) = session.pty.resize(new_cols as u16, new_rows as u16) {
error!(
"PTY resize after config reload failed for tab {}: {}",
session.id, error
);
}
} else {
session.terminal.grid.mark_all_dirty();
}
}
self.backspace_mode = BackspaceMode::from_config(&config.keyboard.backspace);
self.config = config;
self.renderer.mark_viewport_changed();
self.set_status_message("Config reloaded".to_string());
info!(
"config reloaded: tabs={} cols={} rows={}",
self.sessions.len(),
new_cols,
new_rows
);
}
pub fn window_id(&self) -> WindowId {
self.window.id()
}
pub fn owns_session(&self, session_id: TerminalSessionId) -> bool {
self.session_index_by_id(session_id).is_some()
}
pub fn start_pending_readers(&mut self) {
for session in &mut self.sessions {
let Some(reader_master) = session.reader_master.take() else {
continue;
};
let id = session.id;
let proxy = self.proxy.clone();
let stop = session.reader_stop.clone();
thread::spawn(move || {
crate::pty::reader_thread(id, reader_master, proxy, stop);
});
}
}
fn spawn_terminal_session(
config: &Config,
_proxy: EventLoopProxy<UserEvent>,
id: TerminalSessionId,
cols: usize,
rows: usize,
) -> Result<TerminalSession, Box<dyn std::error::Error>> {
let shell = config.resolved_shell();
let pty =
crate::pty::Session::spawn(&shell, &config.terminal.term, cols as u16, rows as u16)?;
let reader_master = pty.master().try_clone()?;
let stop_flag = Arc::new(AtomicBool::new(false));
let mut terminal = TerminalState::new(cols, rows, config.scrollback.lines);
terminal.set_cursor_style_config(CursorStyle::from_config(&config.cursor.style));
Ok(TerminalSession {
id,
shell_name: shell_display_name(&shell),
pty,
terminal,
parser: ansi::Parser::new(),
reader_stop: stop_flag,
reader_master: Some(reader_master),
selection: Selection::new(),
selection_dragging: false,
viewport_offset: 0,
mouse_pressed_button: None,
last_mouse_report: None,
hovered_hyperlink_id: 0,
ime_preedit: String::new(),
search: SearchState::default(),
status_message: None,
pty_pending: VecDeque::new(),
pty_pending_bytes: 0,
cursor_visible: true,
cursor_blink_timer: Instant::now(),
cursor_blink_active: config.cursor.blink,
cursor_last_activity: Instant::now(),
parse_budget_bytes: config.performance.max_parse_bytes_per_frame.max(1),
})
}
fn at_bottom(&self) -> bool {
self.active_session().at_bottom()
}
fn snap_to_bottom(&mut self) {
if self.active_session().viewport_offset != 0 {
self.active_session_mut().viewport_offset = 0;
self.renderer.mark_viewport_changed();
self.needs_redraw = true;
self.request_redraw();
}
}
fn active_session(&self) -> &TerminalSession {
&self.sessions[self.active_session]
}
fn active_session_mut(&mut self) -> &mut TerminalSession {
&mut self.sessions[self.active_session]
}
fn session_index_by_id(&self, id: TerminalSessionId) -> Option<usize> {
self.sessions.iter().position(|session| session.id == id)
}
fn request_redraw(&mut self) {
self.window.request_redraw();
}
fn reset_cursor_blink(&mut self) {
let cursor_blink = self.config.cursor.blink;
self.active_session_mut().reset_cursor_blink(cursor_blink);
}
fn scroll_viewport(&mut self, delta: isize) {
if self.active_session().terminal.in_alt_screen() {
return;
}
let max_offset = self
.active_session()
.terminal
.scrollback
.len()
.saturating_add(self.active_session().terminal.grid.rows)
.saturating_sub(1);
let old = self.active_session().viewport_offset;
let new = (self.active_session().viewport_offset as isize + delta)
.clamp(0, max_offset as isize) as usize;
if new != old {
self.active_session_mut().viewport_offset = new;
self.renderer.mark_viewport_changed();
self.needs_redraw = true;
self.request_redraw();
}
}
fn send_alt_screen_wheel_keys(&mut self, lines: f32, wheel_mult: f32) {
let Some((action, count)) = alt_screen_wheel_action(lines, wheel_mult) else {
return;
};
let Some(bytes) = action_to_bytes(
&action,
self.active_session().terminal.app_cursor(),
self.backspace_mode,
) else {
return;
};
for _ in 0..count {
self.write_pty(&bytes);
}
}
fn push_pty_output(&mut self, session_id: TerminalSessionId, data: Vec<u8>) {
let Some(index) = self.session_index_by_id(session_id) else {
trace!(
"dropping PTY output for unknown session {} ({} bytes)",
session_id,
data.len()
);
return;
};
trace!(
"PTY output event: session={} tab={} active={} bytes={}",
session_id,
index,
self.active_session,
data.len()
);
self.sessions[index].push_pty_output(data, self.config.performance.coalesce_redraws);
self.flush_session_pending(index);
}
fn flush_pty_pending(&mut self) {
for index in 0..self.sessions.len() {
self.flush_session_pending(index);
}
}
fn flush_session_pending(&mut self, index: usize) {
if self.sessions[index].pty_pending.is_empty() {
return;
}
let parse_start = Instant::now();
let max_bytes = self.sessions[index].parse_budget_bytes.max(1);
let max_chunks = self.config.performance.max_pty_chunks_per_frame.max(1);
let pending_bytes = self.sessions[index].pty_pending_bytes;
let parse_time_budget = if pending_bytes > max_bytes * 8 {
Duration::from_millis(12)
} else if pending_bytes > max_bytes * 2 {
Duration::from_millis(9)
} else if self.last_frame_ms < 8.0 {
Duration::from_millis(6)
} else {
Duration::from_millis(3)
};
let mut budget = max_bytes;
let mut drained = 0usize;
let mut parsed_bytes = 0usize;
let mut alt_switched = false;
let cursor_blink = self.config.cursor.blink;
while drained < max_chunks && budget > 0 && parse_start.elapsed() < parse_time_budget {
let session = &mut self.sessions[index];
let Some(chunk) = session.pty_pending.front_mut() else {
break;
};
let parse_len = chunk.remaining().min(budget);
let start = chunk.offset;
let end = start + parse_len;
let was_alt = session.terminal.in_alt_screen();
session
.parser
.advance(&chunk.data[start..end], &mut session.terminal);
chunk.offset = end;
budget = budget.saturating_sub(parse_len);
session.pty_pending_bytes = session.pty_pending_bytes.saturating_sub(parse_len);
if session.terminal.in_alt_screen() != was_alt {
session.viewport_offset = 0;
session.selection.clear();
session.selection_dragging = false;
alt_switched = true;
}
parsed_bytes += parse_len;
drained += 1;
session.reset_cursor_blink(cursor_blink);
if session
.pty_pending
.front()
.is_some_and(|chunk| chunk.remaining() == 0)
{
session.pty_pending.pop_front();
} else {
break;
}
}
let parse_elapsed = parse_start.elapsed();
self.bytes_received += parsed_bytes as u64;
self.accum_parse_time += parse_elapsed;
self.tune_parse_budget(index, parse_elapsed);
trace!(
"PTY parsed: tab={} active={} bytes={} remaining={} elapsed={:.2}ms",
index,
self.active_session,
parsed_bytes,
self.sessions[index].pty_pending_bytes,
parse_elapsed.as_secs_f64() * 1000.0
);
if index != self.active_session {
return;
}
if alt_switched {
self.renderer.mark_viewport_changed();
}
self.sync_window_title();
if self.active_session().at_bottom() {
self.needs_redraw = true;
self.request_redraw();
}
if !self.active_session().pty_pending.is_empty() {
self.request_redraw();
}
}
fn tune_parse_budget(&mut self, index: usize, parse_elapsed: Duration) {
let base = self.config.performance.max_parse_bytes_per_frame.max(1);
let min_budget = (base / 8).max(32 * 1024);
let max_budget = base.saturating_mul(32).max(4 * 1024 * 1024);
let frame_budget_ms = 1000.0 / self.config.performance.max_fps.max(1) as f64;
let grow_parse_ms = frame_budget_ms * 0.70;
let shrink_parse_ms = frame_budget_ms * 0.85;
let parse_ms = parse_elapsed.as_secs_f64() * 1000.0;
let session = &mut self.sessions[index];
if session.pty_pending_bytes > session.parse_budget_bytes / 2
&& self.last_frame_ms < frame_budget_ms * 0.85
&& parse_ms < grow_parse_ms
{
session.parse_budget_bytes =
session.parse_budget_bytes.saturating_mul(2).min(max_budget);
} else if self.last_frame_ms > frame_budget_ms * 0.95 || parse_ms > shrink_parse_ms {
session.parse_budget_bytes = (session.parse_budget_bytes / 2).max(min_budget);
} else if session.pty_pending_bytes == 0 && session.parse_budget_bytes > base {
session.parse_budget_bytes = (session.parse_budget_bytes / 2).max(base);
}
}
fn create_new_tab(&mut self) {
let id = allocate_terminal_session_id();
let cols = self.renderer.cols();
let rows = self.renderer.rows();
match Self::spawn_terminal_session(&self.config, self.proxy.clone(), id, cols, rows) {
Ok(session) => {
self.sessions.push(session);
self.active_session = self.sessions.len() - 1;
self.start_pending_readers();
self.on_active_session_changed();
}
Err(error) => {
error!("new tab failed: {error}");
}
}
}
fn close_active_tab(&mut self) -> Option<AppRequest> {
self.close_tab(self.active_session)
}
fn close_tab(&mut self, index: usize) -> Option<AppRequest> {
if index >= self.sessions.len() {
return None;
}
let Some(next_active) =
Self::active_index_after_tab_close(self.active_session, index, self.sessions.len())
else {
return Some(AppRequest::CloseWindow);
};
self.sessions.remove(index);
self.active_session = next_active;
self.on_active_session_changed();
None
}
fn active_index_after_tab_close(active: usize, closing: usize, len: usize) -> Option<usize> {
if closing >= len || len <= 1 {
return None;
}
let remaining = len - 1;
if closing < active {
Some(active - 1)
} else if closing == active && active >= remaining {
Some(remaining - 1)
} else {
Some(active.min(remaining - 1))
}
}
fn switch_tab_relative(&mut self, delta: isize) {
let len = self.sessions.len();
if len <= 1 {
return;
}
let next = (self.active_session as isize + delta).rem_euclid(len as isize) as usize;
self.switch_to_tab(next);
}
fn switch_to_tab(&mut self, index: usize) {
if index >= self.sessions.len() || index == self.active_session {
return;
}
self.active_session = index;
self.on_active_session_changed();
}
fn on_active_session_changed(&mut self) {
self.scroll_accum = 0.0;
self.last_selection_click = None;
self.renderer.mark_viewport_changed();
self.update_hovered_hyperlink();
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
}
pub fn handle_window_event(
&mut self,
_window_id: WindowId,
event: WindowEvent,
) -> Option<AppRequest> {
match event {
WindowEvent::CloseRequested => return Some(AppRequest::CloseWindow),
WindowEvent::Focused(focused) => {
if focused {
self.reset_cursor_blink();
}
if self.active_session().terminal.focus_reporting() {
self.write_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });
}
}
WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers.state(),
WindowEvent::Resized(new_size) => {
let (new_cols, new_rows) = self
.renderer
.resize(new_size.width.max(1), new_size.height.max(1));
if new_cols != self.active_session().terminal.grid.cols
|| new_rows != self.active_session().terminal.grid.rows
{
for session in &mut self.sessions {
session.terminal.resize(new_cols, new_rows);
if let Err(e) = session.pty.resize(new_cols as u16, new_rows as u16) {
error!("PTY resize error for tab {}: {}", session.id, e);
}
}
self.renderer.set_cols_rows(new_cols, new_rows);
}
self.needs_redraw = true;
self.request_redraw();
}
WindowEvent::RedrawRequested => self.render_frame(),
WindowEvent::Ime(event) => match event {
Ime::Commit(text) => {
self.active_session_mut().ime_preedit.clear();
if !text.is_empty() {
self.snap_to_bottom();
self.reset_cursor_blink();
self.write_pty(text.as_bytes());
}
}
Ime::Preedit(text, _cursor) => {
self.active_session_mut().ime_preedit = text;
self.needs_redraw = true;
self.request_redraw();
}
Ime::Enabled => self.window.set_ime_allowed(true),
Ime::Disabled => {
self.active_session_mut().ime_preedit.clear();
self.needs_redraw = true;
self.request_redraw();
}
},
WindowEvent::KeyboardInput { event, .. } => {
self.input_events += 1;
if event.state == winit::event::ElementState::Pressed {
#[cfg(feature = "agent-harness")]
if self.handle_agent_shortcut(&event) {
return None;
}
if Self::is_new_window_shortcut(&event, self.modifiers) {
let _ = self.proxy.send_event(UserEvent::NewWindow);
return None;
}
if Self::is_new_tab_shortcut(&event, self.modifiers) {
self.create_new_tab();
return None;
}
if Self::is_close_tab_shortcut(&event, self.modifiers) {
return self.close_active_tab();
}
if Self::is_next_tab_shortcut(&event, self.modifiers) {
self.switch_tab_relative(1);
return None;
}
if Self::is_previous_tab_shortcut(&event, self.modifiers) {
self.switch_tab_relative(-1);
return None;
}
if let Some(index) = Self::tab_number_shortcut(&event, self.modifiers) {
self.switch_to_tab(index.min(self.sessions.len().saturating_sub(1)));
return None;
}
if Self::is_search_shortcut(&event, self.modifiers) {
self.start_search();
return None;
}
if self.active_session().search.active {
self.handle_search_key(&event);
return None;
}
if Self::is_copy_shortcut(&event, self.modifiers) {
if self.active_session().selection.active {
self.copy_selection_to_clipboard();
} else if let Some(cell) = self.current_mouse_cell() {
self.copy_hyperlink_at(cell);
}
return None;
}
if Self::is_paste_shortcut(&event, self.modifiers) {
self.paste_clipboard();
return None;
}
if self.modifiers.super_key() {
match event.physical_key {
PhysicalKey::Code(KeyCode::ArrowUp) => {
let lines = (self.active_session().terminal.scrollback.len()
+ self.active_session().terminal.grid.rows)
as isize;
self.scroll_viewport(lines);
return None;
}
PhysicalKey::Code(KeyCode::ArrowDown) => {
self.snap_to_bottom();
return None;
}
_ => return None, }
}
if Self::is_modifier_key(&event) {
return None;
}
}
if event.state == winit::event::ElementState::Pressed {
let page = self.active_session().terminal.grid.rows as isize;
match event.physical_key {
PhysicalKey::Code(KeyCode::PageUp) => {
self.scroll_viewport(page);
return None;
}
PhysicalKey::Code(KeyCode::PageDown) => {
self.scroll_viewport(-page);
return None;
}
_ => {}
}
}
let action =
winit_to_action(&event, &self.modifiers, self.config.keyboard.option_as_meta);
if !matches!(action, KeyAction::None) {
self.clear_selection();
self.snap_to_bottom();
self.reset_cursor_blink();
}
self.dispatch_action(action);
}
WindowEvent::MouseWheel { delta, .. } => {
if self.active_session().terminal.mouse_reporting()
&& !self.modifiers.shift_key()
&& let Some(cell) = self.current_mouse_cell()
{
let code = match delta {
MouseScrollDelta::LineDelta(_, y) if y > 0.0 => Some(64),
MouseScrollDelta::LineDelta(_, y) if y < 0.0 => Some(65),
MouseScrollDelta::PixelDelta(pos) if pos.y > 0.0 => Some(64),
MouseScrollDelta::PixelDelta(pos) if pos.y < 0.0 => Some(65),
_ => None,
};
if let Some(code) = code {
self.send_mouse_report(code, true, cell);
return None;
}
}
let px_per_line = self.config.scrollback.trackpad_pixels_per_line;
let wheel_mult = self.config.scrollback.wheel_multiplier;
let lines = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(pos) => {
self.scroll_accum += pos.y as f32;
let l = (self.scroll_accum / px_per_line) as i32;
self.scroll_accum -= l as f32 * px_per_line;
l as f32
}
};
if lines.abs() >= 1.0 {
if self.active_session().terminal.in_alt_screen() {
self.send_alt_screen_wheel_keys(lines, wheel_mult);
} else {
self.scroll_viewport((lines * wheel_mult) as isize);
}
}
}
WindowEvent::CursorMoved { position, .. } => {
self.mouse_pos = position;
self.update_hovered_hyperlink();
if self.active_session().terminal.mouse_reporting()
&& self.at_bottom()
&& !self.modifiers.shift_key()
&& let Some(cell) = self.current_mouse_cell()
{
match self.active_session().terminal.mouse_tracking() {
MouseTracking::ButtonMotion => {
if let Some(button) = self.active_session().mouse_pressed_button {
self.send_mouse_motion_report(button | 32, cell);
}
}
MouseTracking::AnyMotion => {
self.send_mouse_motion_report(
self.active_session().mouse_pressed_button.unwrap_or(3) | 32,
cell,
);
}
MouseTracking::Off | MouseTracking::X10 | MouseTracking::Normal => {}
}
}
if self.active_session().selection_dragging
&& let Some(cell) = self.current_selection_cell()
{
self.active_session_mut().selection.extend_to(cell);
self.needs_redraw = true;
self.request_redraw();
}
}
WindowEvent::MouseInput { state, button, .. } => {
if button == MouseButton::Left
&& state == ElementState::Released
&& self.active_session().selection_dragging
{
self.finish_selection_drag();
return None;
}
if button == MouseButton::Left
&& state == ElementState::Pressed
&& let Some(hit) = self.tab_hit_at_mouse_position()
{
return match hit {
TabChromeHit::Select(index) => {
self.switch_to_tab(index);
None
}
TabChromeHit::Close(index) => self.close_tab(index),
};
}
if button == MouseButton::Left
&& state == ElementState::Pressed
&& self.modifiers.super_key()
&& let Some(cell) = self.current_mouse_cell()
&& self.open_hyperlink_at(cell)
{
return None;
}
if button == MouseButton::Right
&& state == ElementState::Pressed
&& (!self.active_session().terminal.mouse_reporting()
|| !self.at_bottom()
|| self.modifiers.shift_key())
&& let Some(cell) = self.current_mouse_cell()
&& self.copy_hyperlink_at(cell)
{
return None;
}
if self.active_session().terminal.mouse_reporting()
&& self.at_bottom()
&& !self.modifiers.shift_key()
&& let Some(cell) = self.current_mouse_cell()
&& let Some(code) = Self::mouse_button_code(button)
{
let pressed = state == ElementState::Pressed;
match (self.active_session().terminal.mouse_tracking(), pressed) {
(MouseTracking::X10, true) => self.send_mouse_report(code, true, cell),
(MouseTracking::X10, false) => {}
(_, true) => {
self.active_session_mut().mouse_pressed_button = Some(code);
self.active_session_mut().last_mouse_report = None;
self.send_mouse_report(code, true, cell);
}
(_, false) => {
if self.active_session().mouse_pressed_button == Some(code) {
self.active_session_mut().mouse_pressed_button = None;
}
self.active_session_mut().last_mouse_report = None;
self.send_mouse_report(3, false, cell);
}
}
return None;
}
if button == MouseButton::Left
&& let ElementState::Pressed = state
&& let Some(cell) = self.current_selection_cell()
{
self.exit_search(false);
self.clear_status_message();
match self.selection_click_count(cell) {
1 => {
self.active_session_mut().selection.start_at(cell);
self.active_session_mut().selection_dragging = true;
}
2 => {
self.active_session_mut().selection_dragging = false;
if !self.select_word_at(cell) {
self.clear_selection();
}
}
_ => {
self.active_session_mut().selection_dragging = false;
if !self.select_line_at(cell.0) {
self.clear_selection();
}
}
}
self.needs_redraw = true;
self.request_redraw();
}
}
_ => {}
}
None
}
fn finish_selection_drag(&mut self) {
if let Some(cell) = self.current_selection_cell() {
self.active_session_mut().selection.extend_to(cell);
}
self.active_session_mut().selection_dragging = false;
if self.active_session().selection.is_empty() {
self.clear_selection();
return;
}
self.needs_redraw = true;
self.request_redraw();
}
fn selection_click_count(&mut self, cell: (usize, usize)) -> u8 {
let now = Instant::now();
let next_count = self
.last_selection_click
.filter(|last| {
now.duration_since(last.at) <= MULTI_CLICK_MAX_INTERVAL
&& cell_distance(last.cell, cell) <= MULTI_CLICK_MAX_DISTANCE_CELLS
})
.map(|last| if last.count >= 3 { 1 } else { last.count + 1 })
.unwrap_or(1);
self.last_selection_click = Some(SelectionClick {
cell,
at: now,
count: next_count,
});
next_count
}
fn select_word_at(&mut self, cell: (usize, usize)) -> bool {
let Some(cells) = self.viewport_row_cells(cell.0) else {
return false;
};
let Some((start_col, end_col)) = word_span_in_cells(cells, cell.1) else {
return false;
};
self.active_session_mut()
.selection
.set_span((cell.0, start_col), (cell.0, end_col));
true
}
fn select_line_at(&mut self, row: usize) -> bool {
let Some(cells) = self.viewport_row_cells(row) else {
return false;
};
let Some((start_col, end_col)) = line_span_in_cells(cells) else {
return false;
};
self.active_session_mut()
.selection
.set_span((row, start_col), (row, end_col));
true
}
fn copy_selection_to_clipboard(&mut self) -> bool {
let selection = self.active_session().selection.clone();
let text = selection.extract_text_by_cell(|r| self.viewport_row_text_and_offsets(r));
if text.is_empty() {
debug!("copy skipped: empty selection");
return false;
}
let Ok(mut clipboard) = arboard::Clipboard::new() else {
warn!("copy failed: clipboard unavailable");
return false;
};
if let Err(error) = clipboard.set_text(text) {
warn!("copy failed: {error}");
return false;
}
debug!("copied selection to clipboard");
self.active_session_mut().selection_dragging = false;
true
}
fn copy_hyperlink_at(&mut self, cell: (usize, usize)) -> bool {
let Some(uri) = self.hyperlink_uri_at(cell).map(str::to_string) else {
return false;
};
if !Self::is_openable_uri(&uri) {
warn!("refusing to copy unsafe OSC 8 URI: {uri:?}");
self.set_status_message("Unsafe link ignored".to_string());
return true;
}
let Ok(mut clipboard) = arboard::Clipboard::new() else {
warn!("copy link failed: clipboard unavailable");
self.set_status_message("Copy link failed".to_string());
return true;
};
if let Err(error) = clipboard.set_text(uri) {
warn!("copy link failed: {error}");
self.set_status_message("Copy link failed".to_string());
return true;
}
self.set_status_message("Copied link".to_string());
true
}
fn set_status_message(&mut self, message: String) {
self.active_session_mut().status_message = Some(message);
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
}
fn clear_status_message(&mut self) {
if self.active_session().status_message.is_some() {
self.active_session_mut().status_message = None;
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
}
}
fn clear_selection(&mut self) {
if self.active_session().selection.active || self.active_session().selection_dragging {
self.active_session_mut().selection.clear();
self.active_session_mut().selection_dragging = false;
self.needs_redraw = true;
self.request_redraw();
}
}
fn viewport_row_text_and_offsets(&self, row: usize) -> Option<(String, Vec<usize>)> {
let cells = self.viewport_row_cells(row)?;
Some(row_text_and_offsets(cells))
}
fn viewport_row_cells(&self, row: usize) -> Option<&[crate::terminal::Cell]> {
viewport_row_cells_for_session(self.active_session(), row)
}
fn viewport_cell(&self, row: usize, col: usize) -> Option<&crate::terminal::Cell> {
let session = self.active_session();
if row >= session.terminal.grid.rows() || col >= session.terminal.grid.cols() {
return None;
}
let sb_visible = session
.viewport_offset
.min(session.terminal.scrollback.len());
if row < sb_visible {
let idx = session.terminal.scrollback.len() - sb_visible + row;
session
.terminal
.scrollback
.row_cells(idx)
.and_then(|cells| cells.get(col))
} else {
let grid_row = row - sb_visible;
session.terminal.grid.cell(grid_row, col)
}
}
fn hyperlink_uri_at(&self, cell: (usize, usize)) -> Option<&str> {
let hyperlink_id = self
.viewport_cell(cell.0, cell.1)
.map(|cell| cell.hyperlink_id)?;
self.active_session().terminal.hyperlink_uri(hyperlink_id)
}
fn open_hyperlink_at(&self, cell: (usize, usize)) -> bool {
let Some(uri) = self.hyperlink_uri_at(cell) else {
return false;
};
if !Self::is_openable_uri(uri) {
warn!("refusing to open unsafe OSC 8 URI: {uri:?}");
return false;
}
if let Err(error) = Self::open_uri(uri) {
warn!("failed to open OSC 8 URI {uri:?}: {error}");
return false;
}
true
}
fn update_hovered_hyperlink(&mut self) {
let hovered_tab = self
.tab_hit_at_mouse_position()
.map(TabChromeHit::tab_index);
let hovered_tab_changed = self.hovered_tab != hovered_tab;
self.hovered_tab = hovered_tab;
let id = self
.current_mouse_cell()
.and_then(|cell| self.viewport_cell(cell.0, cell.1))
.map(|cell| cell.hyperlink_id)
.unwrap_or(0);
self.window.set_cursor(if hovered_tab.is_some() || id != 0 {
CursorIcon::Pointer
} else {
CursorIcon::Default
});
if id == self.active_session().hovered_hyperlink_id && !hovered_tab_changed {
return;
}
self.active_session_mut().hovered_hyperlink_id = id;
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
}
fn current_selection_cell(&self) -> Option<(usize, usize)> {
let hit = self.current_mouse_cell_hit()?;
Some(self.text_boundary_cell(hit.row, hit.col, hit.x_fraction))
}
fn paste_clipboard(&mut self) {
if let Ok(mut clipboard) = arboard::Clipboard::new()
&& let Ok(text) = clipboard.get_text()
{
let bytes = if self.active_session().terminal.bracketed_paste() {
let mut wrapped = b"\x1b[200~".to_vec();
wrapped.extend_from_slice(text.as_bytes());
wrapped.extend_from_slice(b"\x1b[201~");
wrapped
} else {
text.into_bytes()
};
if let Err(e) = self.active_session().pty.write(&bytes) {
error!("PTY paste error: {}", e);
} else {
self.exit_search(false);
self.clear_selection();
self.clear_status_message();
self.snap_to_bottom();
self.reset_cursor_blink();
}
}
}
fn start_search(&mut self) {
{
let session = self.active_session_mut();
session.search.active = true;
session.search.last_had_match = true;
session.selection_dragging = false;
session.status_message = None;
}
if self.active_session().search.query.is_empty() {
self.active_session_mut().selection.clear();
self.needs_redraw = true;
self.request_redraw();
} else {
self.find_search_match(SearchDirection::Next);
}
self.sync_window_title();
}
fn exit_search(&mut self, clear_match: bool) {
if !self.active_session().search.active {
return;
}
{
let session = self.active_session_mut();
session.search.active = false;
if clear_match {
session.search.current = None;
session.selection.clear();
}
}
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
}
fn handle_search_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed || Self::is_modifier_key(event) {
return;
}
if Self::is_copy_shortcut(event, self.modifiers) {
if self.active_session().selection.active {
self.copy_selection_to_clipboard();
}
return;
}
if Self::is_paste_shortcut(event, self.modifiers) {
if let Ok(mut clipboard) = arboard::Clipboard::new()
&& let Ok(text) = clipboard.get_text()
{
self.append_search_text(&text);
}
return;
}
match event.physical_key {
PhysicalKey::Code(KeyCode::Escape) => {
self.exit_search(true);
return;
}
PhysicalKey::Code(KeyCode::Enter) => {
let direction = if self.modifiers.shift_key() {
SearchDirection::Previous
} else {
SearchDirection::Next
};
self.find_search_match(direction);
return;
}
PhysicalKey::Code(KeyCode::Backspace) => {
let changed = self.active_session_mut().search.query.pop().is_some();
if changed {
self.active_session_mut().search.current = None;
self.find_search_match(SearchDirection::Next);
}
return;
}
_ => {}
}
if Self::is_key_char(event, KeyCode::KeyG, "g") && self.modifiers.super_key() {
let direction = if self.modifiers.shift_key() {
SearchDirection::Previous
} else {
SearchDirection::Next
};
self.find_search_match(direction);
return;
}
if self.modifiers.super_key() || self.modifiers.control_key() || self.modifiers.alt_key() {
return;
}
let Some(text) = event.text.as_ref() else {
return;
};
self.append_search_text(text);
}
fn append_search_text(&mut self, text: &str) {
let mut changed = false;
{
let query = &mut self.active_session_mut().search.query;
for ch in text.chars().filter(|ch| !ch.is_control()) {
query.push(ch);
changed = true;
}
}
if changed {
self.active_session_mut().search.current = None;
self.find_search_match(SearchDirection::Next);
}
}
fn find_search_match(&mut self, direction: SearchDirection) {
let query = self.active_session().search.query.clone();
if query.is_empty() {
let session = self.active_session_mut();
session.search.current = None;
session.search.last_had_match = true;
session.selection.clear();
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
return;
}
let current = self.active_session().search.current;
let found = find_search_match_in_session(self.active_session(), &query, current, direction);
{
let session = self.active_session_mut();
session.search.current = found;
session.search.last_had_match = found.is_some();
session.selection.clear();
}
if let Some(search_match) = found {
self.reveal_search_match(search_match);
}
self.renderer.mark_viewport_changed();
self.sync_window_title();
self.needs_redraw = true;
self.request_redraw();
}
fn reveal_search_match(&mut self, search_match: SearchMatch) {
let session = self.active_session_mut();
let scrollback_len = session.terminal.scrollback.len();
let rows = session.terminal.grid.rows();
let top = viewport_top_absolute(session);
let bottom = top + rows;
if search_match.absolute_row < top {
session.viewport_offset = scrollback_len
.saturating_sub(search_match.absolute_row)
.min(scrollback_len);
} else if search_match.absolute_row >= bottom {
if search_match.absolute_row >= scrollback_len {
session.viewport_offset = 0;
} else {
let desired_top = search_match
.absolute_row
.saturating_add(1)
.saturating_sub(rows);
session.viewport_offset = scrollback_len
.saturating_sub(desired_top)
.min(scrollback_len);
}
}
let top = viewport_top_absolute(session);
let Some(viewport_row) = search_match.absolute_row.checked_sub(top) else {
return;
};
if viewport_row >= rows {
return;
}
session.selection.set_span(
(viewport_row, search_match.start_col),
(viewport_row, search_match.end_col),
);
session.selection_dragging = false;
}
#[cfg(feature = "agent-harness")]
fn handle_agent_shortcut(&mut self, event: &KeyEvent) -> bool {
if !self.agent_test_profile {
return false;
}
let agent_digit = self.modifiers.control_key()
&& self.modifiers.shift_key()
&& !self.modifiers.super_key()
&& !self.modifiers.alt_key();
let command = match event.physical_key {
PhysicalKey::Code(KeyCode::F5) | PhysicalKey::Code(KeyCode::Digit5)
if !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Digit5))
|| agent_digit =>
{
AgentCommand::Smoke
}
PhysicalKey::Code(KeyCode::F6)
| PhysicalKey::Code(KeyCode::F8)
| PhysicalKey::Code(KeyCode::Digit6)
if !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Digit6))
|| agent_digit =>
{
AgentCommand::Burst
}
PhysicalKey::Code(KeyCode::F7) | PhysicalKey::Code(KeyCode::Digit7)
if !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Digit7))
|| agent_digit =>
{
AgentCommand::Scrollback
}
_ => return false,
};
self.run_agent_command(command);
true
}
#[cfg(feature = "agent-harness")]
fn handle_agent_command(&mut self, raw: &str) -> Option<AppRequest> {
if !self.agent_test_profile {
return None;
}
let Some(command) = AgentCommand::parse(raw) else {
warn!("ignoring unknown agent command: {raw:?}");
return None;
};
self.run_agent_command(command)
}
#[cfg(feature = "agent-harness")]
fn run_agent_command(&mut self, command: AgentCommand) -> Option<AppRequest> {
info!("agent command: {command:?}");
match command {
AgentCommand::Smoke => self.inject_agent_output(agent_smoke_output()),
AgentCommand::Burst => self.inject_agent_output(agent_burst_output()),
AgentCommand::Scrollback => self.inject_agent_output(agent_scrollback_output()),
AgentCommand::NewTab => self.create_new_tab(),
AgentCommand::NextTab => self.switch_tab_relative(1),
AgentCommand::PreviousTab => self.switch_tab_relative(-1),
AgentCommand::CloseTab => return self.close_active_tab(),
}
None
}
#[cfg(feature = "agent-harness")]
fn inject_agent_output(&mut self, data: Vec<u8>) {
self.clear_selection();
self.snap_to_bottom();
self.reset_cursor_blink();
let session_id = self.active_session().id;
self.push_pty_output(session_id, data);
self.active_session_mut().terminal.grid.mark_all_dirty();
self.renderer.mark_viewport_changed();
self.needs_redraw = true;
self.request_redraw();
}
fn dispatch_action(&mut self, action: KeyAction) {
if let Some(bytes) = action_to_bytes(
&action,
self.active_session().terminal.app_cursor(),
self.backspace_mode,
) {
self.clear_status_message();
self.write_pty(&bytes);
}
}
fn write_pty(&mut self, bytes: &[u8]) {
self.bytes_written += bytes.len() as u64;
if let Err(e) = self.active_session().pty.write(bytes) {
error!("PTY write error: {}", e);
}
}
fn current_mouse_cell(&self) -> Option<(usize, usize)> {
input::mouse::pixel_to_cell(
self.mouse_pos,
self.renderer.cell_width,
self.renderer.cell_height,
self.renderer.padding_x(),
self.renderer.padding_y(),
self.active_session().terminal.grid.rows,
self.active_session().terminal.grid.cols,
)
}
fn current_mouse_pixel(&self) -> Option<(usize, usize)> {
let cell_width = self.renderer.cell_width;
let cell_height = self.renderer.cell_height;
if cell_width <= 0.0 || cell_height <= 0.0 {
return None;
}
let x = self.mouse_pos.x as f32 - self.renderer.padding_x();
let y = self.mouse_pos.y as f32 - self.renderer.padding_y();
let max_x = cell_width * self.active_session().terminal.grid.cols as f32;
let max_y = cell_height * self.active_session().terminal.grid.rows as f32;
if x < 0.0 || y < 0.0 || x >= max_x || y >= max_y {
return None;
}
Some((x.floor() as usize + 1, y.floor() as usize + 1))
}
fn current_mouse_cell_hit(&self) -> Option<input::mouse::CellHit> {
input::mouse::pixel_to_cell_with_fraction(
self.mouse_pos,
self.renderer.cell_width,
self.renderer.cell_height,
self.renderer.padding_x(),
self.renderer.padding_y(),
self.active_session().terminal.grid.rows,
self.active_session().terminal.grid.cols,
)
}
fn text_boundary_cell(&self, row: usize, col: usize, x_fraction: f32) -> (usize, usize) {
let Some(cell) = self.viewport_cell(row, col) else {
return (row, col);
};
if !cell.continuation {
return (row, col);
}
let mut start = col;
while start > 0
&& self
.viewport_cell(row, start)
.is_some_and(|cell| cell.continuation)
{
start -= 1;
}
let width = self
.viewport_cell(row, start)
.map(|cell| cell.display_width().max(1))
.unwrap_or(1);
let cluster_x = (col.saturating_sub(start)) as f32 + x_fraction;
if cluster_x < width as f32 * 0.5 {
(row, start)
} else {
(
row,
(start + width - 1).min(self.active_session().terminal.grid.cols.saturating_sub(1)),
)
}
}
fn mouse_report_coordinates(&self, cell: (usize, usize)) -> Option<(usize, usize)> {
match self.active_session().terminal.mouse_encoding() {
MouseEncoding::SgrPixels => self.current_mouse_pixel(),
_ => Some((cell.1 + 1, cell.0 + 1)),
}
}
fn mouse_report_key(&self, code: u8, cell: (usize, usize)) -> Option<MouseReportKey> {
let (x, y) = self.mouse_report_coordinates(cell)?;
Some(MouseReportKey {
row: cell.0,
col: cell.1,
code: mouse_report_cb(code, self.modifiers),
x,
y,
})
}
fn send_mouse_report(&mut self, code: u8, pressed: bool, cell: (usize, usize)) {
let Some((x, y)) = self.mouse_report_coordinates(cell) else {
return;
};
let cb = mouse_report_cb(code, self.modifiers);
if let Some(bytes) = encode_mouse_report(
self.active_session().terminal.mouse_encoding(),
cb,
pressed,
x,
y,
) {
self.write_pty(&bytes);
}
}
fn send_mouse_motion_report(&mut self, code: u8, cell: (usize, usize)) {
let Some(key) = self.mouse_report_key(code, cell) else {
return;
};
if self.active_session().last_mouse_report == Some(key) {
return;
}
self.active_session_mut().last_mouse_report = Some(key);
self.send_mouse_report(code, true, cell);
}
fn mouse_button_code(button: MouseButton) -> Option<u8> {
match button {
MouseButton::Left => Some(0),
MouseButton::Middle => Some(1),
MouseButton::Right => Some(2),
_ => None,
}
}
fn is_copy_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
Self::is_key_char(event, KeyCode::KeyC, "c")
&& (modifiers.super_key() || modifiers.control_key() && modifiers.shift_key())
}
fn is_paste_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
Self::is_key_char(event, KeyCode::KeyV, "v")
&& (modifiers.super_key() || modifiers.control_key() && modifiers.shift_key())
}
fn is_search_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
Self::is_key_char(event, KeyCode::KeyF, "f")
&& ((modifiers.super_key()
&& !modifiers.control_key()
&& !modifiers.alt_key()
&& !modifiers.shift_key())
|| (modifiers.control_key()
&& modifiers.shift_key()
&& !modifiers.super_key()
&& !modifiers.alt_key()))
}
fn is_new_window_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
Self::is_new_window_modifier(modifiers) && Self::is_key_char(event, KeyCode::KeyN, "n")
}
fn is_new_window_modifier(modifiers: ModifiersState) -> bool {
let mac = modifiers.super_key()
&& !modifiers.shift_key()
&& !modifiers.control_key()
&& !modifiers.alt_key();
let linux = modifiers.control_key()
&& modifiers.shift_key()
&& !modifiers.super_key()
&& !modifiers.alt_key();
mac || linux
}
fn is_new_tab_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
Self::is_tab_create_close_modifier(modifiers)
&& Self::is_key_char(event, KeyCode::KeyT, "t")
}
fn is_close_tab_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
Self::is_tab_create_close_modifier(modifiers)
&& Self::is_key_char(event, KeyCode::KeyW, "w")
}
fn is_next_tab_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
(modifiers.super_key()
&& modifiers.shift_key()
&& !modifiers.control_key()
&& !modifiers.alt_key()
&& matches!(event.physical_key, PhysicalKey::Code(KeyCode::BracketRight)))
|| (Self::is_ctrl_page_tab_modifier(modifiers)
&& matches!(event.physical_key, PhysicalKey::Code(KeyCode::PageDown)))
}
fn is_previous_tab_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> bool {
(modifiers.super_key()
&& modifiers.shift_key()
&& !modifiers.control_key()
&& !modifiers.alt_key()
&& matches!(event.physical_key, PhysicalKey::Code(KeyCode::BracketLeft)))
|| (Self::is_ctrl_page_tab_modifier(modifiers)
&& matches!(event.physical_key, PhysicalKey::Code(KeyCode::PageUp)))
}
fn tab_number_shortcut(event: &KeyEvent, modifiers: ModifiersState) -> Option<usize> {
if !modifiers.super_key() || modifiers.shift_key() || modifiers.control_key() {
return None;
}
let index = match event.physical_key {
PhysicalKey::Code(KeyCode::Digit1) => 0,
PhysicalKey::Code(KeyCode::Digit2) => 1,
PhysicalKey::Code(KeyCode::Digit3) => 2,
PhysicalKey::Code(KeyCode::Digit4) => 3,
PhysicalKey::Code(KeyCode::Digit5) => 4,
PhysicalKey::Code(KeyCode::Digit6) => 5,
PhysicalKey::Code(KeyCode::Digit7) => 6,
PhysicalKey::Code(KeyCode::Digit8) => 7,
PhysicalKey::Code(KeyCode::Digit9) => 8,
_ => return None,
};
Some(index)
}
fn is_tab_create_close_modifier(modifiers: ModifiersState) -> bool {
let mac = modifiers.super_key()
&& !modifiers.shift_key()
&& !modifiers.control_key()
&& !modifiers.alt_key();
let linux = modifiers.control_key()
&& modifiers.shift_key()
&& !modifiers.super_key()
&& !modifiers.alt_key();
mac || linux
}
fn is_ctrl_page_tab_modifier(modifiers: ModifiersState) -> bool {
modifiers.control_key()
&& !modifiers.shift_key()
&& !modifiers.super_key()
&& !modifiers.alt_key()
}
fn is_key_char(event: &KeyEvent, physical: KeyCode, logical: &str) -> bool {
matches!(event.physical_key, PhysicalKey::Code(code) if code == physical)
|| matches!(&event.logical_key, Key::Character(value) if value.as_str().eq_ignore_ascii_case(logical))
}
fn is_modifier_key(event: &KeyEvent) -> bool {
matches!(
event.physical_key,
PhysicalKey::Code(
KeyCode::ShiftLeft
| KeyCode::ShiftRight
| KeyCode::ControlLeft
| KeyCode::ControlRight
| KeyCode::AltLeft
| KeyCode::AltRight
| KeyCode::SuperLeft
| KeyCode::SuperRight
| KeyCode::Hyper
)
) || matches!(
event.logical_key,
Key::Named(
NamedKey::Shift
| NamedKey::Control
| NamedKey::Alt
| NamedKey::AltGraph
| NamedKey::Super
| NamedKey::Hyper
)
)
}
fn is_openable_uri(uri: &str) -> bool {
let Some((scheme, rest)) = uri.split_once(':') else {
return false;
};
!scheme.is_empty()
&& !rest.is_empty()
&& ["http", "https", "mailto", "ssh"]
.iter()
.any(|allowed| scheme.eq_ignore_ascii_case(allowed))
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
&& !uri.chars().any(char::is_control)
}
fn open_uri(uri: &str) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
let program = "open";
#[cfg(all(unix, not(target_os = "macos")))]
let program = "xdg-open";
Command::new(program).arg(uri).spawn().map(|_| ())
}
fn sync_window_title(&mut self) {
let title = self.effective_window_title();
if title != self.last_window_title {
self.window.set_title(&title);
self.last_window_title = title;
}
}
fn effective_window_title(&self) -> String {
let session = self.active_session();
let tab = format!("[{}/{}]", self.active_session + 1, self.sessions.len());
if let Some(status) = session_status_text(session) {
return format!("Panasyn {tab} - {status}");
}
if let Some(uri) = session.terminal.hyperlink_uri(session.hovered_hyperlink_id) {
return format!("Panasyn {tab} - {}", truncate_title(uri, 160));
}
if session.terminal.title().is_empty() {
format!("Panasyn {tab}")
} else {
format!("Panasyn {tab} - {}", session.terminal.title())
}
}
fn tab_titles(&self) -> Vec<String> {
self.sessions
.iter()
.map(|session| {
clean_title_for_chrome(
session.terminal.title(),
session.shell_name.as_str(),
TAB_TITLE_MAX_CHARS,
)
})
.collect()
}
fn render_tabs_from_titles<'a>(&self, titles: &'a [String]) -> Vec<RenderTab<'a>> {
titles
.iter()
.enumerate()
.map(|(index, title)| RenderTab {
number: index + 1,
title: title.as_str(),
active: index == self.active_session,
})
.collect()
}
fn tab_hit_at_mouse_position(&self) -> Option<TabChromeHit> {
let tab_titles = self.tab_titles();
let tabs = self.render_tabs_from_titles(&tab_titles);
self.renderer
.tab_hit_at_position(self.mouse_pos.x as f32, self.mouse_pos.y as f32, &tabs)
}
pub fn handle_user_event(&mut self, event: UserEvent) -> Option<AppRequest> {
match event {
UserEvent::PtyOutput { session_id, data } => {
self.push_pty_output(session_id, data);
}
UserEvent::PtyClosed { session_id } => {
let index = self.session_index_by_id(session_id)?;
info!(
"tab {} closed: {}i {}w {}r {}d render={}ms",
session_id,
self.input_events,
self.bytes_written,
self.bytes_received,
self.redraw_count,
self.accum_render_time.as_millis()
);
if self.sessions.len() <= 1 {
return Some(AppRequest::CloseWindow);
}
self.sessions.remove(index);
if index < self.active_session {
self.active_session -= 1;
} else if self.active_session >= self.sessions.len() {
self.active_session = self.sessions.len() - 1;
}
self.on_active_session_changed();
}
UserEvent::NewWindow => {}
#[cfg(feature = "agent-harness")]
UserEvent::AgentCommand(command) => return self.handle_agent_command(&command),
UserEvent::ReloadConfig => {}
}
None
}
pub fn handle_about_to_wait(&mut self) -> ControlFlow {
self.flush_pty_pending();
const IDLE_MS: u64 = 3000;
let blink_ms = self.config.cursor.blink_interval_ms.max(100);
if self.at_bottom() {
let idle = self
.active_session()
.cursor_last_activity
.elapsed()
.as_millis() as u64;
if !self.config.cursor.blink {
if !self.active_session().cursor_visible
|| self.active_session().cursor_blink_active
{
let session = self.active_session_mut();
session.cursor_visible = true;
session.cursor_blink_active = false;
self.needs_redraw = true;
}
} else if self.active_session().cursor_blink_active && idle >= IDLE_MS {
let session = self.active_session_mut();
session.cursor_blink_active = false;
session.cursor_visible = true;
self.needs_redraw = true;
} else if self.active_session().cursor_blink_active
&& self
.active_session()
.cursor_blink_timer
.elapsed()
.as_millis() as u64
>= blink_ms
{
let session = self.active_session_mut();
session.cursor_visible = !session.cursor_visible;
session.cursor_blink_timer = Instant::now();
self.needs_redraw = true;
self.request_redraw();
}
}
if self.needs_redraw {
self.request_redraw();
ControlFlow::Poll
} else if self.config.cursor.blink
&& self.at_bottom()
&& self.active_session().cursor_blink_active
{
let next_blink =
self.active_session().cursor_blink_timer + Duration::from_millis(blink_ms);
ControlFlow::WaitUntil(next_blink)
} else {
ControlFlow::Wait
}
}
fn render_frame(&mut self) {
self.flush_pty_pending();
let at_bottom = self.at_bottom();
let cursor_row = if at_bottom {
self.active_session().terminal.grid.cursor_row()
} else {
usize::MAX
};
let cursor_col = if at_bottom {
self.active_session().terminal.grid.cursor_col()
} else {
0
};
let show_cursor = at_bottom
&& self.active_session().cursor_visible
&& self.active_session().terminal.cursor_visible();
trace!(
"cursor row={} col={} vis={}",
cursor_row, cursor_col, show_cursor
);
let render_start = Instant::now();
self.update_ime_cursor_area(cursor_row, cursor_col);
let active_index = self.active_session;
let tab_titles = self.tab_titles();
let tabs = self.render_tabs_from_titles(&tab_titles);
let status_text = session_status_text(&self.sessions[active_index]);
let session = &self.sessions[active_index];
let renderer = &mut self.renderer;
let draw_result = renderer.draw(RenderFrame {
grid: &session.terminal.grid,
scrollback: &session.terminal.scrollback,
viewport_offset: session.viewport_offset,
selection: &session.selection,
cursor: CursorVisual {
row: cursor_row,
col: cursor_col,
visible: show_cursor,
style: session.terminal.cursor_style(),
},
ime_preedit: (!session.ime_preedit.is_empty()).then_some(session.ime_preedit.as_str()),
hovered_hyperlink_id: session.hovered_hyperlink_id,
tabs: &tabs,
hovered_tab: self.hovered_tab,
status_text: status_text.as_deref(),
});
let render_time = render_start.elapsed();
self.accum_render_time += render_time;
let Some(redraw_count) = draw_result else {
self.needs_redraw = true;
self.request_redraw();
return;
};
self.redraw_count = redraw_count;
let frame_time = self.frame_start.elapsed();
self.last_frame_ms = frame_time.as_secs_f64() * 1000.0;
self.accum_frame_time += frame_time;
self.frame_start = Instant::now();
self.active_session_mut().terminal.grid.clear_dirty();
self.needs_redraw = false;
self.frames_rendered += 1;
self.perf_frames += 1;
#[cfg(feature = "agent-harness")]
self.write_agent_state_snapshot(
cursor_row,
cursor_col,
show_cursor,
status_text.as_deref(),
&tab_titles,
);
if self.last_perf_report.elapsed() >= PERF_REPORT_INTERVAL {
let n = self.perf_frames.max(1);
info!(
"perf: {}fps parse={:.2}ms/f render={:.2}ms/f frame={:.2}ms/f dirty={} backlog={}B parse_budget={}B",
1_000_000 / (self.accum_frame_time.as_micros().max(1) as f64 / n as f64) as u64,
self.accum_parse_time.as_secs_f64() * 1000.0 / n as f64,
self.accum_render_time.as_secs_f64() * 1000.0 / n as f64,
self.accum_frame_time.as_secs_f64() * 1000.0 / n as f64,
self.active_session().terminal.grid.dirty_count(),
self.active_session().pty_pending_bytes,
self.active_session().parse_budget_bytes,
);
self.last_perf_report = Instant::now();
self.accum_parse_time = Duration::ZERO;
self.accum_render_time = Duration::ZERO;
self.accum_frame_time = Duration::ZERO;
self.perf_frames = 0;
}
}
#[cfg(feature = "agent-harness")]
fn write_agent_state_snapshot(
&mut self,
cursor_row: usize,
cursor_col: usize,
cursor_visible: bool,
status_text: Option<&str>,
tab_titles: &[String],
) {
let Some(path) = self.agent_state_path.clone() else {
return;
};
let body = self.agent_state_json(
cursor_row,
cursor_col,
cursor_visible,
status_text,
tab_titles,
);
if self.last_agent_state_json.as_deref() == Some(body.as_str()) {
return;
}
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
&& let Err(error) = fs::create_dir_all(parent)
{
warn!("agent state mkdir failed for {}: {error}", parent.display());
return;
}
if let Err(error) = fs::write(&path, body.as_bytes()) {
warn!("agent state write failed for {}: {error}", path.display());
return;
}
self.last_agent_state_json = Some(body);
}
#[cfg(feature = "agent-harness")]
fn agent_state_json(
&self,
cursor_row: usize,
cursor_col: usize,
cursor_visible: bool,
status_text: Option<&str>,
tab_titles: &[String],
) -> String {
let session = self.active_session();
let rows = visible_row_texts(session);
let tabs = tab_titles
.iter()
.enumerate()
.map(|(index, title)| {
serde_json::json!({
"index": index,
"number": index + 1,
"title": title,
"active": index == self.active_session,
})
})
.collect::<Vec<_>>();
let hovered_hyperlink = session
.terminal
.hyperlink_uri(session.hovered_hyperlink_id)
.unwrap_or("");
let value = serde_json::json!({
"app": "Panasyn",
"profile": "agent",
"active_tab": self.active_session,
"tabs": tabs,
"grid": {
"cols": session.terminal.grid.cols(),
"rows": session.terminal.grid.rows(),
},
"cursor": {
"row": cursor_row,
"col": cursor_col,
"visible": cursor_visible,
},
"viewport_offset": session.viewport_offset,
"scrollback_lines": session.terminal.scrollback.len(),
"selection_active": session.selection.active,
"selection_dragging": session.selection_dragging,
"status": status_text.unwrap_or(""),
"hovered_hyperlink": hovered_hyperlink,
"visible_rows": rows,
});
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
}
fn update_ime_cursor_area(&mut self, row: usize, col: usize) {
if row == usize::MAX {
return;
}
let x = self.renderer.padding_x() + col as f32 * self.renderer.cell_width;
let y = self.renderer.padding_y() + row as f32 * self.renderer.cell_height;
let width = self.renderer.cell_width.max(1.0) as u32;
let height = self.renderer.cell_height.max(1.0) as u32;
let area = (x.round() as i32, y.round() as i32, width, height);
if self.last_ime_cursor_area == Some(area) {
return;
}
self.last_ime_cursor_area = Some(area);
self.window.set_ime_cursor_area(
PhysicalPosition::new(x as f64, y as f64),
PhysicalSize::new(width, height),
);
}
}
#[cfg(feature = "agent-harness")]
fn agent_smoke_output() -> Vec<u8> {
let mut out = Vec::with_capacity(4096);
out.extend_from_slice(b"\x1b]2;agent-smoke\x07");
out.extend_from_slice(b"agent-ui-command-ok\r\n");
out.extend_from_slice("agent-unicode: café 👍🏽 你好\r\n".as_bytes());
append_agent_count(&mut out, "agent-count-", 160);
out.extend_from_slice(b"agent-ui-smoke-end\r\n");
out
}
#[cfg(feature = "agent-harness")]
fn agent_burst_output() -> Vec<u8> {
let mut out = Vec::with_capacity(32 * 1024);
for i in 1..=1200 {
out.extend_from_slice(format!("agent-log-{i:04} ok\r\n").as_bytes());
}
out.extend_from_slice(b"agent-burst-end\r\n");
out
}
#[cfg(feature = "agent-harness")]
fn agent_scrollback_output() -> Vec<u8> {
let mut out = Vec::with_capacity(2048);
append_agent_count(&mut out, "", 160);
out
}
#[cfg(feature = "agent-harness")]
fn append_agent_count(out: &mut Vec<u8>, prefix: &str, count: usize) {
for i in 1..=count {
out.extend_from_slice(format!("{prefix}{i:03}\r\n").as_bytes());
}
}
fn session_status_text(session: &TerminalSession) -> Option<String> {
search_status_text(&session.search).or_else(|| {
session
.status_message
.as_deref()
.map(|message| truncate_title(message, SEARCH_STATUS_MAX_CHARS))
})
}
fn search_status_text(search: &SearchState) -> Option<String> {
if !search.active {
return None;
}
let query = search.query.trim();
if query.is_empty() {
Some("Search".to_string())
} else if search.last_had_match {
Some(format!(
"Find: {}",
truncate_title(query, SEARCH_STATUS_MAX_CHARS)
))
} else {
Some(format!(
"No match: {}",
truncate_title(query, SEARCH_STATUS_MAX_CHARS)
))
}
}
fn find_search_match_in_session(
session: &TerminalSession,
query: &str,
current: Option<SearchMatch>,
direction: SearchDirection,
) -> Option<SearchMatch> {
if query.is_empty() {
return None;
}
let total_rows = total_absolute_rows(session);
if total_rows == 0 {
return None;
}
match direction {
SearchDirection::Next => {
let start_row = current
.map(|search_match| search_match.absolute_row)
.unwrap_or_else(|| viewport_top_absolute(session).min(total_rows - 1));
let start_byte = current
.map(|search_match| search_match.end_byte)
.unwrap_or(0);
for step in 0..total_rows {
let row = (start_row + step) % total_rows;
let min_byte = if step == 0 { start_byte } else { 0 };
if let Some(cells) = absolute_row_cells(session, row)
&& let Some(search_match) =
find_match_in_cells(cells, row, query, min_byte, SearchDirection::Next)
{
return Some(search_match);
}
}
}
SearchDirection::Previous => {
let start_row = current
.map(|search_match| search_match.absolute_row)
.unwrap_or_else(|| {
let top = viewport_top_absolute(session);
top.saturating_add(session.terminal.grid.rows().saturating_sub(1))
.min(total_rows - 1)
});
let max_byte = current
.map(|search_match| search_match.start_byte)
.unwrap_or(usize::MAX);
for step in 0..total_rows {
let row = (start_row + total_rows - (step % total_rows)) % total_rows;
let limit = if step == 0 { max_byte } else { usize::MAX };
if let Some(cells) = absolute_row_cells(session, row)
&& let Some(search_match) =
find_match_in_cells(cells, row, query, limit, SearchDirection::Previous)
{
return Some(search_match);
}
}
}
}
None
}
fn find_match_in_cells(
cells: &[crate::terminal::Cell],
absolute_row: usize,
query: &str,
byte_bound: usize,
direction: SearchDirection,
) -> Option<SearchMatch> {
let (text, offsets) = row_text_and_offsets(cells);
if text.is_empty() {
return None;
}
let (start_byte, end_byte) = match direction {
SearchDirection::Next => {
text[byte_bound.min(text.len())..]
.find(query)
.map(|relative| {
let start = byte_bound.min(text.len()) + relative;
(start, start + query.len())
})?
}
SearchDirection::Previous => {
let limit = byte_bound.min(text.len());
let mut found = None;
for (start, _) in text[..limit].match_indices(query) {
found = Some((start, start + query.len()));
}
found?
}
};
search_match_from_byte_range(cells, &offsets, absolute_row, start_byte, end_byte)
}
fn search_match_from_byte_range(
cells: &[crate::terminal::Cell],
offsets: &[usize],
absolute_row: usize,
start_byte: usize,
end_byte: usize,
) -> Option<SearchMatch> {
let row_len = offsets.len().saturating_sub(1);
if row_len == 0 || end_byte <= start_byte {
return None;
}
let content_end = row_len.min(cells.len());
let start_col = byte_start_to_cell_col(offsets, start_byte, content_end);
let end_exclusive = byte_end_to_cell_col(offsets, end_byte, content_end);
let end_col = end_exclusive.saturating_sub(1).min(content_end - 1);
let start_col = base_cell_col(cells, start_col);
let end_col = cluster_end_col(cells, base_cell_col(cells, end_col), content_end);
Some(SearchMatch {
absolute_row,
start_col,
end_col,
start_byte,
end_byte,
})
}
fn byte_start_to_cell_col(offsets: &[usize], byte: usize, row_len: usize) -> usize {
let col = offsets
.partition_point(|offset| *offset <= byte)
.saturating_sub(1);
col.min(row_len.saturating_sub(1))
}
fn byte_end_to_cell_col(offsets: &[usize], byte: usize, row_len: usize) -> usize {
let col = offsets.partition_point(|offset| *offset < byte);
col.clamp(1, row_len)
}
fn row_text_and_offsets(cells: &[crate::terminal::Cell]) -> (String, Vec<usize>) {
let content_end = cells
.iter()
.rposition(|cell| !is_default_padding_cell(cell))
.map(|idx| idx + 1)
.unwrap_or(0);
let mut text = String::with_capacity(cells.len());
let mut offsets = Vec::with_capacity(content_end + 1);
offsets.push(0);
for cell in &cells[..content_end] {
cell.push_text(&mut text);
offsets.push(text.len());
}
(text, offsets)
}
#[cfg(feature = "agent-harness")]
fn agent_state_path(enabled: bool) -> Option<PathBuf> {
if !enabled {
return None;
}
if let Some(path) = std::env::var_os("PANASYN_AGENT_STATE_FILE") {
return Some(PathBuf::from(path));
}
if let Some(resource) = app_bundle_resource("agent-state-path")
&& let Ok(path) = std::fs::read_to_string(resource)
{
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
Some(std::env::temp_dir().join(format!("panasyn-agent-{}.json", std::process::id())))
}
#[cfg(feature = "agent-harness")]
fn app_bundle_resource(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos = exe.parent()?;
if macos.file_name()? != "MacOS" {
return None;
}
let contents = macos.parent()?;
if contents.file_name()? != "Contents" {
return None;
}
Some(contents.join("Resources").join(name))
}
#[cfg(feature = "agent-harness")]
fn visible_row_texts(session: &TerminalSession) -> Vec<String> {
(0..session.terminal.grid.rows())
.filter_map(|row| viewport_row_cells_for_session(session, row))
.map(|cells| row_text_and_offsets(cells).0)
.collect()
}
fn viewport_row_cells_for_session(
session: &TerminalSession,
row: usize,
) -> Option<&[crate::terminal::Cell]> {
if row >= session.terminal.grid.rows() {
return None;
}
let sb_visible = session
.viewport_offset
.min(session.terminal.scrollback.len());
if row < sb_visible {
let idx = session.terminal.scrollback.len() - sb_visible + row;
session.terminal.scrollback.row_cells(idx)
} else {
let grid_row = row - sb_visible;
(grid_row < session.terminal.grid.rows()).then(|| session.terminal.grid.row_cells(grid_row))
}
}
fn total_absolute_rows(session: &TerminalSession) -> usize {
session.terminal.scrollback.len() + session.terminal.grid.rows()
}
fn viewport_top_absolute(session: &TerminalSession) -> usize {
session.terminal.scrollback.len().saturating_sub(
session
.viewport_offset
.min(session.terminal.scrollback.len()),
)
}
fn absolute_row_cells(
session: &TerminalSession,
absolute_row: usize,
) -> Option<&[crate::terminal::Cell]> {
let scrollback_len = session.terminal.scrollback.len();
if absolute_row < scrollback_len {
session.terminal.scrollback.row_cells(absolute_row)
} else {
let grid_row = absolute_row - scrollback_len;
(grid_row < session.terminal.grid.rows()).then(|| session.terminal.grid.row_cells(grid_row))
}
}
fn cell_distance(a: (usize, usize), b: (usize, usize)) -> usize {
a.0.abs_diff(b.0).max(a.1.abs_diff(b.1))
}
fn line_span_in_cells(cells: &[crate::terminal::Cell]) -> Option<(usize, usize)> {
let end = content_end_col(cells)?;
Some((0, end - 1))
}
fn word_span_in_cells(cells: &[crate::terminal::Cell], col: usize) -> Option<(usize, usize)> {
let content_end = content_end_col(cells)?;
if col >= content_end {
return None;
}
let base = base_cell_col(cells, col.min(content_end - 1));
let kind = selection_cell_kind(cells.get(base)?);
if kind == SelectionCellKind::Blank {
return None;
}
let mut start = base;
let mut end = cluster_end_col(cells, base, content_end);
if kind == SelectionCellKind::Word {
while start > 0 {
let prev = previous_base_col(cells, start - 1);
if selection_cell_kind(&cells[prev]) != kind {
break;
}
start = prev;
}
while end + 1 < content_end {
let next = base_cell_col(cells, end + 1);
if selection_cell_kind(&cells[next]) != kind {
break;
}
end = cluster_end_col(cells, next, content_end);
}
}
Some((start, end))
}
fn content_end_col(cells: &[crate::terminal::Cell]) -> Option<usize> {
cells
.iter()
.rposition(|cell| !is_default_padding_cell(cell))
.map(|idx| idx + 1)
}
fn base_cell_col(cells: &[crate::terminal::Cell], mut col: usize) -> usize {
col = col.min(cells.len().saturating_sub(1));
while col > 0 && cells.get(col).is_some_and(|cell| cell.continuation) {
col -= 1;
}
col
}
fn previous_base_col(cells: &[crate::terminal::Cell], col: usize) -> usize {
base_cell_col(cells, col)
}
fn cluster_end_col(cells: &[crate::terminal::Cell], base: usize, content_end: usize) -> usize {
let mut end = base.min(content_end.saturating_sub(1));
while end + 1 < content_end && cells[end + 1].continuation {
end += 1;
}
end
}
fn selection_cell_kind(cell: &crate::terminal::Cell) -> SelectionCellKind {
if cell.continuation || is_default_padding_cell(cell) {
return SelectionCellKind::Blank;
}
let text = cell.text();
if text.trim().is_empty() {
return SelectionCellKind::Blank;
}
if text.chars().any(is_word_selection_char) {
SelectionCellKind::Word
} else {
SelectionCellKind::Other
}
}
fn is_word_selection_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | '~')
}
fn truncate_title(value: &str, max_chars: usize) -> String {
let mut chars = value.chars();
let mut out: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
out.push_str("...");
}
out
}
fn clean_title_for_chrome(value: &str, fallback: &str, max_chars: usize) -> String {
let cleaned = value
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>();
let normalized = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
truncate_title(fallback, max_chars)
} else {
truncate_title(&normalized, max_chars)
}
}
fn shell_display_name(shell: &str) -> String {
Path::new(shell)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.trim().is_empty())
.unwrap_or("shell")
.trim_start_matches('-')
.to_string()
}
fn alt_screen_wheel_action(lines: f32, wheel_multiplier: f32) -> Option<(KeyAction, usize)> {
if !lines.is_finite() || lines.abs() < 1.0 {
return None;
}
let multiplier = if wheel_multiplier.is_finite() {
wheel_multiplier.max(1.0)
} else {
1.0
};
let count = (lines.abs() * multiplier).round().clamp(1.0, 64.0) as usize;
let action = if lines > 0.0 {
KeyAction::ArrowUp
} else {
KeyAction::ArrowDown
};
Some((action, count))
}
fn allocate_terminal_session_id() -> TerminalSessionId {
NEXT_TERMINAL_SESSION_ID.fetch_add(1, Ordering::Relaxed)
}
fn platform_top_padding_lines() -> f32 {
#[cfg(target_os = "macos")]
{
2.8
}
#[cfg(not(target_os = "macos"))]
{
1.0
}
}
fn push_mouse_utf8(out: &mut Vec<u8>, value: usize) {
let value = value.min(0x10FFFF);
let Some(c) = char::from_u32(value as u32) else {
return;
};
let mut buf = [0; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
fn mouse_report_cb(code: u8, modifiers: ModifiersState) -> u8 {
let mut cb = code;
if modifiers.shift_key() {
cb |= 4;
}
if modifiers.alt_key() {
cb |= 8;
}
if modifiers.control_key() {
cb |= 16;
}
cb
}
fn encode_mouse_report(
encoding: MouseEncoding,
cb: u8,
pressed: bool,
x: usize,
y: usize,
) -> Option<Vec<u8>> {
match encoding {
MouseEncoding::Sgr | MouseEncoding::SgrPixels => {
let suffix = if pressed { 'M' } else { 'm' };
Some(format!("\x1b[<{};{};{}{}", cb, x, y, suffix).into_bytes())
}
MouseEncoding::Urxvt => Some(format!("\x1b[{};{};{}M", cb + 32, x, y).into_bytes()),
MouseEncoding::Utf8 => {
let mut bytes = vec![0x1b, b'[', b'M'];
push_mouse_utf8(&mut bytes, cb as usize + 32);
push_mouse_utf8(&mut bytes, x + 32);
push_mouse_utf8(&mut bytes, y + 32);
Some(bytes)
}
MouseEncoding::Normal if x <= 223 && y <= 223 => {
Some(vec![0x1b, b'[', b'M', cb + 32, x as u8 + 32, y as u8 + 32])
}
MouseEncoding::Normal => None,
}
}
fn is_default_padding_cell(cell: &crate::terminal::Cell) -> bool {
!cell.continuation
&& cell.c == ' '
&& cell.extra.is_none()
&& cell.fg == 0
&& cell.bg == 0
&& cell.attrs == 0
&& cell.hyperlink_id == 0
}
impl Drop for App {
fn drop(&mut self) {
info!(
"stop: {}i {}w {}r {}d parse={}ms render={}ms",
self.input_events,
self.bytes_written,
self.bytes_received,
self.redraw_count,
self.accum_parse_time.as_millis(),
self.accum_render_time.as_millis()
);
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "agent-harness")]
use super::{AgentCommand, agent_smoke_output};
use super::{
App, SearchDirection, alt_screen_wheel_action, clean_title_for_chrome, encode_mouse_report,
find_match_in_cells, line_span_in_cells, mouse_report_cb, push_mouse_utf8,
row_text_and_offsets, search_match_from_byte_range, shell_display_name, truncate_title,
word_span_in_cells,
};
use crate::input::keyboard::KeyAction;
use crate::terminal::{Cell, MouseEncoding};
use winit::keyboard::ModifiersState;
fn ascii_cells(text: &str) -> Vec<Cell> {
text.chars().map(Cell::new).collect()
}
#[test]
fn osc8_uri_open_allowlist_blocks_control_and_script_schemes() {
assert!(App::is_openable_uri("https://example.com/docs"));
assert!(App::is_openable_uri("mailto:hello@example.com"));
assert!(App::is_openable_uri("ssh://host.example"));
assert!(!App::is_openable_uri("file:///etc/passwd"));
assert!(!App::is_openable_uri("javascript:alert(1)"));
assert!(!App::is_openable_uri("https://example.com/\nnext"));
assert!(!App::is_openable_uri("not-a-uri"));
}
#[cfg(feature = "agent-harness")]
#[test]
fn agent_command_parser_is_allowlisted() {
assert_eq!(AgentCommand::parse("smoke"), Some(AgentCommand::Smoke));
assert_eq!(AgentCommand::parse(" unicode "), Some(AgentCommand::Smoke));
assert_eq!(AgentCommand::parse("burst"), Some(AgentCommand::Burst));
assert_eq!(AgentCommand::parse("new-tab"), Some(AgentCommand::NewTab));
assert_eq!(
AgentCommand::parse("previous-tab"),
Some(AgentCommand::PreviousTab)
);
assert_eq!(AgentCommand::parse("rm -rf /"), None);
}
#[cfg(feature = "agent-harness")]
#[test]
fn agent_smoke_output_has_stable_markers() {
let output = String::from_utf8(agent_smoke_output()).unwrap();
assert!(output.contains("agent-ui-command-ok\r\n"));
assert!(output.contains("agent-unicode: café 👍🏽 你好\r\n"));
assert!(output.contains("agent-count-160\r\n"));
assert!(output.contains("agent-ui-smoke-end\r\n"));
}
#[test]
fn word_selection_expands_path_like_words() {
let cells = ascii_cells("src/main.rs other");
assert_eq!(word_span_in_cells(&cells, 4), Some((0, 10)));
}
#[test]
fn word_selection_expands_from_wide_continuation() {
let ni = Cell::new('你');
let ni_cont = Cell::continuation_of(&ni);
let hao = Cell::new('好');
let hao_cont = Cell::continuation_of(&hao);
let cells = vec![ni, ni_cont, hao, hao_cont, Cell::new(' ')];
assert_eq!(word_span_in_cells(&cells, 1), Some((0, 3)));
}
#[test]
fn word_selection_keeps_emoji_cluster_single() {
let mut emoji = Cell::new('👍');
emoji.append_to_cluster('🏽');
let c1 = Cell::continuation_of(&emoji);
let c2 = Cell::continuation_of(&emoji);
let c3 = Cell::continuation_of(&emoji);
let cells = vec![emoji, c1, c2, c3, Cell::new('x')];
assert_eq!(word_span_in_cells(&cells, 2), Some((0, 3)));
}
#[test]
fn line_selection_ignores_trailing_padding() {
let cells = ascii_cells("hello ");
assert_eq!(line_span_in_cells(&cells), Some((0, 4)));
}
#[test]
fn search_match_expands_cjk_cells_to_full_cell_width() {
let ni = Cell::new('你');
let ni_cont = Cell::continuation_of(&ni);
let hao = Cell::new('好');
let hao_cont = Cell::continuation_of(&hao);
let cells = vec![ni, ni_cont, hao, hao_cont, Cell::blank()];
let (text, offsets) = row_text_and_offsets(&cells);
let start = text.find("好").unwrap();
let search_match =
search_match_from_byte_range(&cells, &offsets, 7, start, start + "好".len()).unwrap();
assert_eq!(search_match.absolute_row, 7);
assert_eq!((search_match.start_col, search_match.end_col), (2, 3));
}
#[test]
fn search_match_expands_partial_emoji_to_whole_cluster() {
let mut emoji = Cell::new('👍');
emoji.append_to_cluster('🏽');
let cells = vec![
Cell::new('a'),
emoji.clone(),
Cell::continuation_of(&emoji),
Cell::continuation_of(&emoji),
Cell::continuation_of(&emoji),
Cell::new('z'),
];
let search_match = find_match_in_cells(&cells, 2, "👍", 0, SearchDirection::Next).unwrap();
assert_eq!((search_match.start_col, search_match.end_col), (1, 4));
}
#[test]
fn search_previous_uses_match_before_bound() {
let cells = ascii_cells("alpha beta alpha");
let search_match =
find_match_in_cells(&cells, 0, "alpha", usize::MAX, SearchDirection::Previous).unwrap();
assert_eq!((search_match.start_col, search_match.end_col), (11, 15));
}
#[test]
fn mouse_utf8_encoding_handles_large_coordinates() {
let mut bytes = Vec::new();
push_mouse_utf8(&mut bytes, 256);
assert_eq!(String::from_utf8(bytes).unwrap(), "\u{100}");
}
#[test]
fn normal_mouse_encoding_bounds_coordinates() {
assert_eq!(
encode_mouse_report(MouseEncoding::Normal, 0, true, 1, 1).unwrap(),
b"\x1b[M !!"
);
assert!(encode_mouse_report(MouseEncoding::Normal, 0, true, 224, 1).is_none());
assert!(encode_mouse_report(MouseEncoding::Normal, 0, true, 1, 224).is_none());
}
#[test]
fn mouse_report_cb_encodes_xterm_modifier_bits() {
assert_eq!(mouse_report_cb(0, ModifiersState::SHIFT), 4);
assert_eq!(mouse_report_cb(1, ModifiersState::ALT), 9);
assert_eq!(mouse_report_cb(2, ModifiersState::CONTROL), 18);
assert_eq!(
mouse_report_cb(
64,
ModifiersState::SHIFT | ModifiersState::ALT | ModifiersState::CONTROL
),
92
);
}
#[test]
fn sgr_mouse_encoding_distinguishes_press_release_and_wheel() {
assert_eq!(
String::from_utf8(encode_mouse_report(MouseEncoding::Sgr, 0, true, 12, 3).unwrap())
.unwrap(),
"\x1b[<0;12;3M"
);
assert_eq!(
String::from_utf8(encode_mouse_report(MouseEncoding::Sgr, 0, false, 12, 3).unwrap())
.unwrap(),
"\x1b[<0;12;3m"
);
assert_eq!(
String::from_utf8(encode_mouse_report(MouseEncoding::Sgr, 64, true, 2, 9).unwrap())
.unwrap(),
"\x1b[<64;2;9M"
);
}
#[test]
fn sgr_pixels_mouse_encoding_uses_pixel_coordinates() {
assert_eq!(
String::from_utf8(
encode_mouse_report(MouseEncoding::SgrPixels, 32, true, 240, 88).unwrap()
)
.unwrap(),
"\x1b[<32;240;88M"
);
assert_eq!(
String::from_utf8(
encode_mouse_report(MouseEncoding::SgrPixels, 32, false, 240, 88).unwrap()
)
.unwrap(),
"\x1b[<32;240;88m"
);
}
#[test]
fn urxvt_mouse_encoding_uses_decimal_coordinates() {
assert_eq!(
String::from_utf8(
encode_mouse_report(MouseEncoding::Urxvt, 32, true, 240, 88).unwrap()
)
.unwrap(),
"\x1b[64;240;88M"
);
}
#[test]
fn utf8_mouse_encoding_allows_extended_coordinates() {
let bytes = encode_mouse_report(MouseEncoding::Utf8, 32, true, 240, 88).unwrap();
assert!(bytes.starts_with(b"\x1b[M"));
assert_eq!(std::str::from_utf8(&bytes[3..]).unwrap(), "@\u{110}x");
}
#[test]
fn alt_screen_wheel_maps_to_bounded_arrow_repeats() {
assert_eq!(
alt_screen_wheel_action(2.0, 4.0),
Some((KeyAction::ArrowUp, 8))
);
assert_eq!(
alt_screen_wheel_action(-1.0, 3.0),
Some((KeyAction::ArrowDown, 3))
);
assert_eq!(
alt_screen_wheel_action(-1000.0, 4.0),
Some((KeyAction::ArrowDown, 64))
);
assert_eq!(alt_screen_wheel_action(0.4, 4.0), None);
}
#[test]
fn hovered_link_title_is_bounded() {
let long = "a".repeat(200);
let title = truncate_title(&long, 40);
assert_eq!(title.chars().count(), 43);
assert!(title.ends_with("..."));
}
#[test]
fn tab_chrome_titles_are_clean_and_have_shell_fallbacks() {
assert_eq!(shell_display_name("/bin/zsh"), "zsh");
assert_eq!(shell_display_name("-bash"), "bash");
assert_eq!(shell_display_name(""), "shell");
assert_eq!(
clean_title_for_chrome(" vim\u{7}\nmain.rs ", "zsh", 20),
"vim main.rs"
);
assert_eq!(clean_title_for_chrome("", "zsh", 20), "zsh");
}
#[test]
fn tab_shortcut_modifiers_cover_macos_and_linux_conventions() {
assert!(App::is_tab_create_close_modifier(ModifiersState::SUPER));
assert!(App::is_tab_create_close_modifier(
ModifiersState::CONTROL | ModifiersState::SHIFT
));
assert!(!App::is_tab_create_close_modifier(
ModifiersState::SUPER | ModifiersState::SHIFT
));
assert!(App::is_ctrl_page_tab_modifier(ModifiersState::CONTROL));
assert!(!App::is_ctrl_page_tab_modifier(
ModifiersState::CONTROL | ModifiersState::SHIFT
));
}
#[test]
fn tab_close_active_index_transitions_are_stable() {
assert_eq!(App::active_index_after_tab_close(1, 0, 3), Some(0));
assert_eq!(App::active_index_after_tab_close(0, 2, 3), Some(0));
assert_eq!(App::active_index_after_tab_close(1, 1, 3), Some(1));
assert_eq!(App::active_index_after_tab_close(2, 2, 3), Some(1));
assert_eq!(App::active_index_after_tab_close(0, 0, 1), None);
}
#[test]
fn new_window_modifier_covers_macos_and_linux_conventions() {
assert!(App::is_new_window_modifier(ModifiersState::SUPER));
assert!(App::is_new_window_modifier(
ModifiersState::CONTROL | ModifiersState::SHIFT
));
assert!(!App::is_new_window_modifier(
ModifiersState::SUPER | ModifiersState::SHIFT
));
assert!(!App::is_new_window_modifier(ModifiersState::CONTROL));
}
}