pub mod cells;
pub mod pty;
use std::borrow::Cow;
use std::path::PathBuf;
use std::process::ExitStatus;
use alacritty_terminal::event::Event as TermEvent;
use alacritty_terminal::event_loop::Msg;
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::{Column, Point, Side};
use alacritty_terminal::selection::{Selection, SelectionType};
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{point_to_viewport, viewport_to_point, Term, TermMode};
use alacritty_terminal::vte::ansi::{CursorShape, Rgb};
use anyhow::Result;
use ratatui::buffer::CellDiffOption;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders};
use ratatui::Frame;
use tokio::sync::mpsc;
pub use pty::{GridSize, TabId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabKind {
Shell,
Claude,
}
impl TabKind {
fn label(self) -> &'static str {
match self {
Self::Shell => "shell",
Self::Claude => "claude",
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum TabEffect {
None,
Redraw,
CopyToClipboard(String),
Exited,
}
pub struct TerminalTab {
id: TabId,
pub kind: TabKind,
pub opened_in: PathBuf,
handle: Option<pty::PtyHandle>,
pub title: Option<String>,
pub exit_status: Option<ExitStatus>,
size: GridSize,
}
impl TerminalTab {
pub fn spawn(
id: TabId,
kind: TabKind,
opened_in: PathBuf,
size: GridSize,
tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
) -> Result<Self> {
let program = match kind {
TabKind::Shell => None,
TabKind::Claude => {
let exe = std::env::current_exe().map_or_else(
|_| "omni-dev".to_string(),
|p| p.to_string_lossy().into_owned(),
);
Some((
exe,
vec![
"claude-wrap".to_string(),
"--".to_string(),
"claude".to_string(),
],
))
}
};
let request = pty::SpawnRequest {
tab: id,
program,
cwd: opened_in,
size,
extra_env: Vec::new(),
};
Self::from_request(kind, request, tx)
}
pub(crate) fn from_request(
kind: TabKind,
request: pty::SpawnRequest,
tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
) -> Result<Self> {
let handle = pty::spawn(&request, tx)?;
Ok(Self {
id: request.tab,
kind,
opened_in: request.cwd,
handle: Some(handle),
title: None,
exit_status: None,
size: request.size,
})
}
pub fn id(&self) -> TabId {
self.id
}
pub fn is_alive(&self) -> bool {
self.handle.is_some() && self.exit_status.is_none()
}
pub fn handle_event(&mut self, event: TermEvent) -> TabEffect {
match event {
TermEvent::PtyWrite(reply) => {
self.write_input(reply.into_bytes());
TabEffect::None
}
TermEvent::ClipboardStore(_, text) => TabEffect::CopyToClipboard(text),
TermEvent::ClipboardLoad(..) => TabEffect::None,
TermEvent::ColorRequest(index, format) => {
self.write_input(format(default_palette_rgb(index)).into_bytes());
TabEffect::None
}
TermEvent::TextAreaSizeRequest(format) => {
self.write_input(format(self.size.window_size()).into_bytes());
TabEffect::None
}
TermEvent::Title(title) => {
self.title = Some(title);
TabEffect::Redraw
}
TermEvent::ResetTitle => {
self.title = None;
TabEffect::Redraw
}
TermEvent::ChildExit(status) => {
self.exit_status = Some(status);
TabEffect::Exited
}
TermEvent::Exit => TabEffect::Exited,
TermEvent::Wakeup => TabEffect::Redraw,
TermEvent::Bell | TermEvent::MouseCursorDirty | TermEvent::CursorBlinkingChange => {
TabEffect::None
}
}
}
pub fn write_input(&self, bytes: Vec<u8>) {
if bytes.is_empty() {
return;
}
if let Some(handle) = &self.handle {
let _ = handle.sender.send(Msg::Input(Cow::Owned(bytes)));
}
}
pub fn resize(&mut self, size: GridSize) {
if size == self.size || size.cols < 2 || size.lines < 1 {
return;
}
self.size = size;
if let Some(handle) = &self.handle {
handle.term.lock().resize(size);
let _ = handle.sender.send(Msg::Resize(size.window_size()));
}
}
pub fn scroll(&self, scroll: Scroll) {
if let Some(handle) = &self.handle {
handle.term.lock().scroll_display(scroll);
}
}
pub fn mode(&self) -> TermMode {
self.handle
.as_ref()
.map(|h| *h.term.lock().mode())
.unwrap_or_default()
}
pub fn selection_to_string(&self) -> Option<String> {
self.handle
.as_ref()
.and_then(|h| h.term.lock().selection_to_string())
}
pub fn selection_start(&self, col: u16, line: u16, ty: SelectionType) {
let Some(handle) = &self.handle else {
return;
};
let mut term = handle.term.lock();
let point = viewport_point(&term, col, line);
term.selection = Some(Selection::new(ty, point, Side::Left));
}
pub fn selection_update(&self, col: u16, line: u16) {
let Some(handle) = &self.handle else {
return;
};
let mut term = handle.term.lock();
let point = viewport_point(&term, col, line);
if let Some(selection) = term.selection.as_mut() {
selection.update(point, Side::Right);
}
}
pub fn clear_selection(&self) {
if let Some(handle) = &self.handle {
handle.term.lock().selection = None;
}
}
pub fn shutdown(&mut self) {
let Some(mut handle) = self.handle.take() else {
return;
};
let _ = handle.sender.send(Msg::Shutdown);
if let Some(thread) = handle.take_thread() {
tokio::task::spawn_blocking(move || {
let _ = thread.join();
});
}
}
pub fn draw(&self, frame: &mut Frame<'_>, area: Rect, focused: bool) {
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(self.pane_title());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 2 || inner.height < 1 {
return;
}
let Some(handle) = &self.handle else {
return;
};
let term = handle.term.lock();
let content = term.renderable_content();
let colors = content.colors;
let selection = content.selection;
let display_offset = content.display_offset;
let buf = frame.buffer_mut();
for indexed in content.display_iter {
let Some(view_point) = point_to_viewport(display_offset, indexed.point) else {
continue;
};
let (col, row) = (view_point.column.0, view_point.line);
if col >= usize::from(inner.width) || row >= usize::from(inner.height) {
continue;
}
let x = inner.x + col as u16;
let y = inner.y + row as u16;
let Some(out) = buf.cell_mut((x, y)) else {
continue;
};
let cell = indexed.cell;
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
out.set_diff_option(CellDiffOption::Skip);
continue;
}
let mut style = cells::cell_style(cell, colors);
if selection.is_some_and(|range| range.contains(indexed.point)) {
style = style.add_modifier(Modifier::REVERSED);
}
let symbol = if cell.flags.contains(Flags::HIDDEN)
|| cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER)
|| cell.c == '\t'
{
" ".to_string()
} else {
let mut s = String::with_capacity(4);
s.push(cell.c);
for zw in cell.zerowidth().into_iter().flatten() {
s.push(*zw);
}
s
};
out.set_diff_option(CellDiffOption::None);
out.set_symbol(&symbol);
out.set_style(style);
}
if content.cursor.shape != CursorShape::Hidden {
if let Some(cursor) = point_to_viewport(display_offset, content.cursor.point) {
let (col, row) = (cursor.column.0, cursor.line);
if col < usize::from(inner.width) && row < usize::from(inner.height) {
let x = inner.x + col as u16;
let y = inner.y + row as u16;
if focused {
frame.set_cursor_position((x, y));
} else if let Some(out) = frame.buffer_mut().cell_mut((x, y)) {
out.set_style(out.style().add_modifier(Modifier::REVERSED | Modifier::DIM));
}
}
}
}
}
pub fn strip_label(&self) -> String {
let name = self.worktree_name();
let label = match &self.title {
Some(t) if !t.is_empty() => format!("{}·{t}", self.kind.label()),
_ => format!("{}·{name}", self.kind.label()),
};
truncate_middle(&label, 24)
}
fn worktree_name(&self) -> String {
self.opened_in.file_name().map_or_else(
|| self.opened_in.display().to_string(),
|n| n.to_string_lossy().into_owned(),
)
}
fn pane_title(&self) -> String {
let name = self.worktree_name();
let mut title = match &self.title {
Some(t) if !t.is_empty() => format!(" {} · {name} · {t} ", self.kind.label()),
_ => format!(" {} · {name} ", self.kind.label()),
};
if let Some(status) = self.exit_status {
title.push_str(&match status.code() {
Some(code) => format!("[exited {code}] "),
None => "[exited by signal] ".to_string(),
});
} else if let Some(handle) = &self.handle {
let offset = handle.term.lock().grid().display_offset();
if offset > 0 {
title.push_str(&format!("[scrollback -{offset}] "));
}
}
title
}
}
fn truncate_middle(text: &str, max: usize) -> String {
let count = text.chars().count();
if count <= max || max < 3 {
return text.to_string();
}
let keep = max - 1; let head = keep.div_ceil(2);
let tail = keep - head;
let mut out: String = text.chars().take(head).collect();
out.push('…');
out.extend(text.chars().skip(count - tail));
out
}
fn viewport_point<T>(term: &Term<T>, col: u16, line: u16) -> Point {
let col = usize::from(col).min(term.columns().saturating_sub(1));
let line = usize::from(line).min(term.screen_lines().saturating_sub(1));
viewport_to_point(term.grid().display_offset(), Point::new(line, Column(col)))
}
fn default_palette_rgb(index: usize) -> Rgb {
const BASE: [(u8, u8, u8); 16] = [
(0, 0, 0),
(205, 0, 0),
(0, 205, 0),
(205, 205, 0),
(0, 0, 238),
(205, 0, 205),
(0, 205, 205),
(229, 229, 229),
(127, 127, 127),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(92, 92, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
let (r, g, b) = match index {
i if i < 16 => BASE[i],
i if (16..232).contains(&i) => {
let i = i - 16;
let level = |v: usize| if v == 0 { 0 } else { (55 + 40 * v) as u8 };
(level(i / 36), level((i / 6) % 6), level(i % 6))
}
i if (232..256).contains(&i) => {
let v = (8 + 10 * (i - 232)) as u8;
(v, v, v)
}
256 | 258 => (229, 229, 229),
_ => (0, 0, 0),
};
Rgb { r, g, b }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::index::Line;
use alacritty_terminal::term::Config;
use super::*;
#[test]
fn resize_keeps_a_selection_on_rows_only_change_and_clears_it_on_column_change() {
let mut term = Term::new(
Config::default(),
&GridSize { cols: 20, lines: 5 },
VoidListener,
);
let start = Point::new(Line(1), Column(2));
let mut selection = Selection::new(SelectionType::Simple, start, Side::Left);
selection.update(Point::new(Line(1), Column(6)), Side::Right);
term.selection = Some(selection);
term.resize(GridSize { cols: 20, lines: 8 });
assert!(
term.selection.is_some(),
"rows-only resize must keep the selection"
);
term.resize(GridSize { cols: 30, lines: 8 });
assert!(
term.selection.is_none(),
"a column change must clear the selection"
);
}
fn buffer_text(terminal: &ratatui::Terminal<ratatui::backend::TestBackend>) -> String {
terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[cfg(unix)]
fn sh_tab(script: &str, tx: mpsc::UnboundedSender<(TabId, TermEvent)>) -> TerminalTab {
let request = pty::SpawnRequest {
tab: 7,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
)),
cwd: std::env::temp_dir(),
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
TerminalTab::from_request(TabKind::Shell, request, tx).unwrap()
}
#[cfg(unix)]
async fn pump(
tab: &mut TerminalTab,
rx: &mut mpsc::UnboundedReceiver<(TabId, TermEvent)>,
mut done: impl FnMut(&TabEffect, &TerminalTab) -> bool,
) -> Vec<TabEffect> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let mut effects = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Some((_, event))) => {
let effect = tab.handle_event(event);
let finished = done(&effect, tab);
effects.push(effect);
if finished {
return effects;
}
}
_ => return effects,
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_live_tab_renders_its_grid_title_and_cursor_then_reports_exit() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (tx, mut rx) = mpsc::unbounded_channel();
let script = "printf 'hi 你好\\n'; printf '\\033]2;tab-title\\a'; \
printf '\\033]10;?\\a\\033[18t'; sleep 1";
let mut tab = sh_tab(script, tx);
assert_eq!(tab.id(), 7);
assert!(tab.is_alive());
assert!(tab.selection_to_string().is_none());
assert!(!tab.mode().contains(TermMode::ALT_SCREEN));
pump(&mut tab, &mut rx, |_, t| {
t.title.as_deref() == Some("tab-title")
})
.await;
assert_eq!(tab.title.as_deref(), Some("tab-title"));
tab.resize(GridSize { cols: 60, lines: 8 });
tab.resize(GridSize { cols: 60, lines: 8 }); tab.resize(GridSize { cols: 1, lines: 0 });
pump(&mut tab, &mut rx, |e, _| *e == TabEffect::Redraw).await;
let mut terminal = Terminal::new(TestBackend::new(64, 10)).unwrap();
terminal
.draw(|frame| tab.draw(frame, frame.area(), true))
.unwrap();
let text = buffer_text(&terminal);
assert!(text.contains("hi 你"), "grid text was: {text}");
assert!(text.contains("好"), "grid text was: {text}");
assert!(text.contains("shell"), "pane title names the kind");
assert!(
text.contains("tab-title"),
"pane title carries the child's title"
);
terminal
.draw(|frame| tab.draw(frame, frame.area(), false))
.unwrap();
terminal
.draw(|frame| tab.draw(frame, Rect::new(0, 0, 2, 2), true))
.unwrap();
tab.scroll(Scroll::PageUp);
tab.scroll(Scroll::Bottom);
tab.write_input(Vec::new()); tab.write_input(b"\n".to_vec());
tab.selection_start(0, 0, SelectionType::Simple);
tab.selection_update(1, 0);
assert_eq!(tab.selection_to_string().as_deref(), Some("hi"));
terminal
.draw(|frame| tab.draw(frame, frame.area(), true))
.unwrap();
tab.selection_start(1, 0, SelectionType::Semantic);
assert_eq!(tab.selection_to_string().as_deref(), Some("hi"));
tab.selection_start(0, 0, SelectionType::Lines);
assert!(tab
.selection_to_string()
.is_some_and(|s| s.starts_with("hi 你好")));
tab.selection_update(500, 500); tab.clear_selection();
assert!(tab.selection_to_string().is_none());
tab.selection_update(1, 0); assert!(tab.selection_to_string().is_none());
let effects = pump(&mut tab, &mut rx, |e, _| *e == TabEffect::Exited).await;
assert!(effects.contains(&TabEffect::Exited));
assert!(tab.exit_status.is_some());
assert!(!tab.is_alive());
terminal
.draw(|frame| tab.draw(frame, frame.area(), false))
.unwrap();
assert!(buffer_text(&terminal).contains("[exited 0]"));
tab.shutdown();
tab.shutdown(); assert!(!tab.is_alive());
assert_eq!(tab.mode(), TermMode::default());
assert!(tab.selection_to_string().is_none());
tab.write_input(b"x".to_vec());
tab.scroll(Scroll::PageUp);
tab.selection_start(0, 0, SelectionType::Simple);
tab.selection_update(1, 1);
tab.clear_selection();
tab.resize(GridSize { cols: 20, lines: 5 });
terminal
.draw(|frame| tab.draw(frame, frame.area(), true))
.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn synthetic_events_map_to_the_documented_effects() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut tab = sh_tab("sleep 1", tx);
assert_eq!(
tab.handle_event(TermEvent::ClipboardStore(
alacritty_terminal::term::ClipboardType::Clipboard,
"copied".to_string()
)),
TabEffect::CopyToClipboard("copied".to_string())
);
assert_eq!(
tab.handle_event(TermEvent::Title("t".to_string())),
TabEffect::Redraw
);
assert_eq!(tab.handle_event(TermEvent::ResetTitle), TabEffect::Redraw);
assert_eq!(tab.title, None);
assert_eq!(tab.handle_event(TermEvent::Bell), TabEffect::None);
assert_eq!(
tab.handle_event(TermEvent::MouseCursorDirty),
TabEffect::None
);
assert_eq!(
tab.handle_event(TermEvent::CursorBlinkingChange),
TabEffect::None
);
assert_eq!(tab.handle_event(TermEvent::Wakeup), TabEffect::Redraw);
assert_eq!(
tab.handle_event(TermEvent::ClipboardLoad(
alacritty_terminal::term::ClipboardType::Clipboard,
std::sync::Arc::new(|s: &str| s.to_string())
)),
TabEffect::None
);
assert_eq!(
tab.handle_event(TermEvent::PtyWrite("\x1b[1;1R".to_string())),
TabEffect::None
);
assert_eq!(tab.handle_event(TermEvent::Exit), TabEffect::Exited);
pump(&mut tab, &mut rx, |e, _| *e == TabEffect::Exited).await;
tab.shutdown();
}
#[test]
fn truncate_middle_elides_only_when_it_has_to_and_never_splits_a_char() {
assert_eq!(truncate_middle("short", 24), "short");
assert_eq!(truncate_middle("exactly-ten", 11), "exactly-ten");
let long = "shell·issue-1585-worktrees-ui-phase-4";
let out = truncate_middle(long, 20);
assert_eq!(out.chars().count(), 20);
assert!(out.contains('…'));
assert!(out.starts_with("shell·"), "the head survives: {out}");
assert!(out.ends_with('4'), "the tail survives: {out}");
let cjk = "你好你好你好你好你好你好";
assert_eq!(truncate_middle(cjk, 5).chars().count(), 5);
assert_eq!(truncate_middle("abcdef", 2), "abcdef");
}
#[cfg(unix)]
#[tokio::test]
async fn strip_label_names_the_kind_and_worktree_then_the_childs_title() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut tab = sh_tab("printf '\\033]2;my-title\\a'; sleep 1", tx);
assert!(tab.strip_label().starts_with("shell·"));
pump(&mut tab, &mut rx, |_, t| {
t.title.as_deref() == Some("my-title")
})
.await;
assert_eq!(tab.strip_label(), "shell·my-title");
tab.shutdown();
}
#[test]
fn default_palette_covers_named_cube_and_greyscale_ranges() {
assert_eq!(default_palette_rgb(1), Rgb { r: 205, g: 0, b: 0 });
assert_eq!(default_palette_rgb(16), Rgb { r: 0, g: 0, b: 0 });
assert_eq!(
default_palette_rgb(231),
Rgb {
r: 255,
g: 255,
b: 255
}
);
assert_eq!(default_palette_rgb(232), Rgb { r: 8, g: 8, b: 8 });
assert_eq!(
default_palette_rgb(255),
Rgb {
r: 238,
g: 238,
b: 238
}
);
}
#[test]
fn no_pty_content_is_ever_logged() {
let sources = [
("terminal/mod.rs", include_str!("mod.rs")),
("terminal/pty.rs", include_str!("pty.rs")),
("terminal/cells.rs", include_str!("cells.rs")),
("app.rs", include_str!("../app.rs")),
("keys.rs", include_str!("../keys.rs")),
("mouse.rs", include_str!("../mouse.rs")),
("clipboard.rs", include_str!("../clipboard.rs")),
];
let sensitive = [
"PtyWrite",
"selection_to_string",
"ClipboardStore",
"write_input",
"copy_text",
];
for (name, source) in sources {
for (number, line) in source.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue;
}
let logs = code.contains("tracing::")
|| code.contains("println!")
|| code.contains("eprintln!");
assert!(
!(logs && sensitive.iter().any(|s| code.contains(s))),
"{name}:{}: logs PTY/clipboard content: {line}",
number + 1
);
}
}
}
}