use std::cell::RefCell;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use libghostty_vt::render::{CellIterator, CursorViewport, RowIterator};
use libghostty_vt::style::{RgbColor, Underline};
use libghostty_vt::terminal::ScrollViewport;
use libghostty_vt::{RenderState, Terminal, TerminalOptions};
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
const SCROLLBACK_LINES: usize = 5000;
#[derive(Clone, Default)]
pub struct RenderCell {
pub text: String,
pub fg: Option<RgbColor>,
pub bg: Option<RgbColor>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub inverse: bool,
}
#[derive(Clone)]
pub struct RenderGrid {
pub rows: u16,
pub cols: u16,
pub cells: Vec<RenderCell>,
pub default_fg: RgbColor,
pub default_bg: RgbColor,
pub cursor: Option<(u16, u16)>,
pub ansi_palette: [Option<RgbColor>; 16],
}
impl RenderGrid {
pub fn cell(&self, row: u16, col: u16) -> Option<&RenderCell> {
if row >= self.rows || col >= self.cols {
return None;
}
self.cells
.get(row as usize * self.cols as usize + col as usize)
}
}
pub fn resolve_launcher(workspace: &std::path::Path, id: &str, default_exe: &str) -> String {
let path = workspace
.join(".mnml")
.join("integrations")
.join(format!("{id}.toml"));
let Ok(text) = std::fs::read_to_string(&path) else {
return default_exe.to_string();
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("launcher") {
let rest = rest.trim_start();
let Some(rest) = rest.strip_prefix('=') else {
continue;
};
let rest = rest.trim();
if let Some(inner) = rest.strip_prefix('"')
&& let Some(end) = inner.find('"')
{
let val = &inner[..end];
if !val.is_empty() {
let ctx = crate::launcher_template::TemplateContext::workspace_only(
workspace.to_path_buf(),
);
return crate::launcher_template::expand(val, &ctx);
}
}
}
}
default_exe.to_string()
}
#[derive(Debug, Clone)]
pub struct BinaryProfile {
pub label: String,
pub exe: String,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: Vec<(String, String)>,
pub session_id: Option<String>,
pub integration_id: Option<String>,
}
fn default_shell() -> String {
#[cfg(windows)]
{
"bash".to_string()
}
#[cfg(not(windows))]
{
"/bin/sh".to_string()
}
}
static TERMINAL_LABEL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
pub fn set_terminal_label(label: String) {
let _ = TERMINAL_LABEL.set(label);
}
fn terminal_label() -> &'static str {
TERMINAL_LABEL
.get()
.map(String::as_str)
.unwrap_or("terminal")
}
impl BinaryProfile {
pub fn shell(cwd: Option<PathBuf>) -> Self {
let exe = std::env::var("SHELL").unwrap_or_else(|_| default_shell());
let name = exe
.rsplit(['/', '\\'])
.next()
.unwrap_or("shell")
.to_string();
BinaryProfile {
label: format!("{} ({name})", terminal_label()),
exe,
args: Vec::new(),
cwd,
env: Vec::new(),
session_id: None,
integration_id: None,
}
}
pub fn claude_code(workspace: PathBuf) -> Self {
let sid = crate::ai::gen_session_id();
let mut args = vec!["--session-id".to_string(), sid.clone()];
let brief = workspace.join(".mnml").join("CLAUDE.md");
if let Ok(text) = std::fs::read_to_string(&brief)
&& !text.trim().is_empty()
{
args.push("--append-system-prompt".to_string());
args.push(text);
}
let exe = resolve_launcher(&workspace, "claude_code", "claude");
BinaryProfile {
label: "Claude Code".to_string(),
exe,
args,
cwd: Some(workspace),
env: Vec::new(),
session_id: Some(sid),
integration_id: Some("claude_code".to_string()),
}
}
pub fn claude_code_with_prompt(workspace: PathBuf, initial: String) -> Self {
let mut p = Self::claude_code(workspace);
p.args.push(initial);
p
}
pub fn claude_code_resume(workspace: PathBuf, session_id: String) -> Self {
let exe = resolve_launcher(&workspace, "claude_code", "claude");
BinaryProfile {
label: "Claude Code (resumed)".to_string(),
exe,
args: vec!["--resume".to_string(), session_id.clone()],
cwd: Some(workspace),
env: Vec::new(),
session_id: Some(session_id),
integration_id: Some("claude_code".to_string()),
}
}
pub fn task(name: &str, cmdline: &str, cwd: PathBuf) -> Self {
let shell = std::env::var("SHELL").unwrap_or_else(|_| default_shell());
BinaryProfile {
label: name.to_string(),
exe: shell,
args: vec!["-c".to_string(), cmdline.to_string()],
cwd: Some(cwd),
env: Vec::new(),
session_id: None,
integration_id: None,
}
}
pub fn codex(workspace: PathBuf) -> Self {
let exe = resolve_launcher(&workspace, "codex", "codex");
BinaryProfile {
label: "Codex".to_string(),
exe,
args: Vec::new(),
cwd: Some(workspace),
env: Vec::new(),
session_id: None,
integration_id: Some("codex".to_string()),
}
}
pub fn mixr(workspace: PathBuf, args: Vec<String>) -> Self {
BinaryProfile {
label: "mixr".to_string(),
exe: "mixr".to_string(),
args,
cwd: Some(workspace),
env: Vec::new(),
session_id: None,
integration_id: Some("mixr".to_string()),
}
}
pub fn with_integration(mut self, id: impl Into<String>) -> Self {
self.integration_id = Some(id.into());
self
}
}
pub struct PtySession {
pub profile: BinaryProfile,
pub display_name: Option<String>,
term: Terminal<'static, 'static>,
render_state: RefCell<RenderState<'static>>,
rx: Receiver<Vec<u8>>,
responses: Rc<RefCell<Vec<u8>>>,
writer: Box<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
reader: Option<JoinHandle<()>>,
child: Box<dyn Child + Send + Sync>,
exited: Arc<Mutex<bool>>,
exit_code: Option<i32>,
last_size: (u16, u16),
pub bytes_seen: Arc<AtomicU64>,
pub bytes_seen_on_focus: u64,
pub last_output_at: Option<std::time::Instant>,
pub last_bytes_snapshot: u64,
pub accent_color: Option<String>,
render_cache: RefCell<Option<RenderCache>>,
derived_cache: RefCell<DerivedCache>,
}
#[derive(Default)]
struct DerivedCache {
summary: Option<(u64, Option<String>)>,
summary_lines: Option<(u64, usize, Vec<String>)>, tab_label: Option<(u64, u64, String)>, claude_thinking: Option<(u64, bool)>,
codex_thinking: Option<(u64, bool)>,
}
struct RenderCache {
bytes_at_snapshot: u64,
size: (u16, u16),
snapshot_at: std::time::Instant,
grid: Rc<RenderGrid>,
}
const UNFOCUSED_MIN_INTERVAL_MS: u128 = 60;
impl PtySession {
pub fn spawn(profile: BinaryProfile, rows: u16, cols: u16) -> Result<Self, String> {
let (rows, cols) = (rows.max(4), cols.max(20));
let pair = native_pty_system()
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("openpty: {e}"))?;
let mut cmd = CommandBuilder::new(&profile.exe);
for a in &profile.args {
cmd.arg(a);
}
if let Some(cwd) = &profile.cwd {
cmd.cwd(cwd);
}
if let Some(dirs) = terminfo_search_dirs() {
cmd.env("TERMINFO_DIRS", dirs);
}
cmd.env("MNML_PANE", "1");
for (k, v) in &profile.env {
cmd.env(k, v);
}
if is_shell_profile(&profile.exe) {
for (k, v) in crate::shell_prompt::theme_env_vars("mnml") {
cmd.env(k, v);
}
}
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("spawn {}: {e} — is it on PATH?", profile.exe))?;
drop(pair.slave);
let mut term = Terminal::new(TerminalOptions {
cols,
rows,
max_scrollback: SCROLLBACK_LINES,
})
.map_err(|e| format!("ghostty terminal: {e:?}"))?;
let responses = Rc::new(RefCell::new(Vec::new()));
{
let sink = Rc::clone(&responses);
term.on_pty_write(move |data| {
sink.borrow_mut().extend_from_slice(data);
})
.map_err(|e| format!("ghostty on_pty_write: {e:?}"))?;
}
let render_state =
RefCell::new(RenderState::new().map_err(|e| format!("ghostty render state: {e:?}"))?);
let exited = Arc::new(Mutex::new(false));
let bytes_seen = Arc::new(AtomicU64::new(0));
let (tx, rx) = channel::<Vec<u8>>();
let mut reader_handle = pair
.master
.try_clone_reader()
.map_err(|e| format!("clone pty reader: {e}"))?;
let r_exited = Arc::clone(&exited);
let r_bytes = Arc::clone(&bytes_seen);
let reader = std::thread::Builder::new()
.name(format!("mnml-pty-{}", profile.exe))
.spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader_handle.read(&mut buf) {
Ok(0) | Err(_) => {
if let Ok(mut e) = r_exited.lock() {
*e = true;
}
return;
}
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
return; }
r_bytes.fetch_add(n as u64, Ordering::Relaxed);
}
}
}
})
.map_err(|e| format!("spawn pty reader thread: {e}"))?;
let writer = pair
.master
.take_writer()
.map_err(|e| format!("take pty writer: {e}"))?;
Ok(PtySession {
profile,
display_name: None,
term,
render_state,
rx,
responses,
writer,
master: pair.master,
reader: Some(reader),
child,
exited,
exit_code: None,
last_size: (rows, cols),
bytes_seen,
bytes_seen_on_focus: 0,
last_output_at: None,
last_bytes_snapshot: 0,
accent_color: None,
render_cache: RefCell::new(None),
derived_cache: RefCell::new(DerivedCache::default()),
})
}
pub fn pump(&mut self) {
let mut wrote = false;
while let Ok(chunk) = self.rx.try_recv() {
let _ = self.term.vt_write(&chunk);
wrote = true;
}
if wrote {
let mut out = self.responses.borrow_mut();
if !out.is_empty() {
let _ = self.writer.write_all(&out);
let _ = self.writer.flush();
out.clear();
}
}
if self.exit_code.is_none()
&& self.exited.lock().map(|e| *e).unwrap_or(false)
&& let Ok(Some(status)) = self.child.try_wait()
{
self.exit_code = Some(status.exit_code() as i32);
}
}
pub fn exit_code(&self) -> Option<i32> {
self.exit_code
}
pub fn is_exited_success(&self) -> bool {
self.exit_code == Some(0)
}
pub fn render_grid(&self, is_focused: bool) -> Rc<RenderGrid> {
let now_bytes = self.bytes_processed();
let size = self.last_size;
let now = std::time::Instant::now();
{
let cache = self.render_cache.borrow();
if let Some(c) = cache.as_ref() {
if c.bytes_at_snapshot == now_bytes && c.size == size {
return Rc::clone(&c.grid);
}
if !is_focused
&& c.size == size
&& now.duration_since(c.snapshot_at).as_millis() < UNFOCUSED_MIN_INTERVAL_MS
{
return Rc::clone(&c.grid);
}
}
}
let prior = self.render_cache.borrow();
let prior_ref = prior.as_ref().map(|c| c.grid.as_ref());
let grid = snapshot_grid(&self.term, &mut self.render_state.borrow_mut(), prior_ref);
drop(prior);
let grid_rc = Rc::new(grid);
*self.render_cache.borrow_mut() = Some(RenderCache {
bytes_at_snapshot: now_bytes,
size,
snapshot_at: now,
grid: Rc::clone(&grid_rc),
});
grid_rc
}
pub fn mark_seen(&mut self) {
self.bytes_seen_on_focus = self.bytes_processed();
}
pub fn unread_bytes(&self) -> u64 {
self.bytes_processed()
.saturating_sub(self.bytes_seen_on_focus)
}
pub fn tick_activity(&mut self) {
let now_bytes = self.bytes_processed();
if now_bytes > self.last_bytes_snapshot {
self.last_bytes_snapshot = now_bytes;
self.last_output_at = Some(std::time::Instant::now());
}
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let (rows, cols) = (rows.max(4), cols.max(20));
if self.last_size == (rows, cols) {
return;
}
self.last_size = (rows, cols);
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
let _ = self.term.resize(cols, rows, 0, 0);
}
pub fn write_bytes(&mut self, bytes: &[u8]) {
let _ = self.writer.write_all(bytes);
let _ = self.writer.flush();
}
pub fn is_mouse_tracking(&self) -> bool {
self.term.is_mouse_tracking().unwrap_or(false)
}
pub fn write_sgr_mouse_report(&mut self, button_code: u32, col: u16, row: u16, pressed: bool) {
let final_byte = if pressed { 'M' } else { 'm' };
let bytes = format!("\x1b[<{button_code};{col};{row}{final_byte}");
self.write_bytes(bytes.as_bytes());
}
pub fn scroll_history(&mut self, delta: isize) {
self.term.scroll_viewport(ScrollViewport::Delta(-delta));
}
pub fn scroll_to_top(&mut self) {
self.term.scroll_viewport(ScrollViewport::Top);
}
pub fn scroll_to_bottom(&mut self) {
self.term.scroll_viewport(ScrollViewport::Bottom);
}
pub fn is_exited(&self) -> bool {
self.exited.lock().map(|e| *e).unwrap_or(true)
}
pub fn bytes_processed(&self) -> u64 {
self.bytes_seen.load(Ordering::Relaxed)
}
pub fn pid(&self) -> Option<u32> {
self.child.process_id()
}
pub fn title(&self) -> String {
let base = self.tab_label();
if !self.is_exited() {
return base;
}
let glyph = if self.is_exited_success() {
"✓"
} else {
"✗"
};
format!("{base} {glyph}")
}
pub fn tab_label(&self) -> String {
self.tab_label_with_prefixes(&[])
}
pub fn tab_label_with_prefixes(&self, prefixes: &[String]) -> String {
let prefix_hash = {
let mut h: u64 = 0xcbf29ce484222325;
for p in prefixes {
for b in p.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h ^= 0xff;
}
h
};
let now_bytes = self.bytes_processed();
{
let cache = self.derived_cache.borrow();
if let Some((b, h, s)) = &cache.tab_label
&& *b == now_bytes
&& *h == prefix_hash
{
return s.clone();
}
}
let osc = self.term.title().map(|s| s.to_string()).unwrap_or_default();
let screen_text = if self.display_name.is_none() && !prefixes.is_empty() {
let grid = self.render_grid(false);
Some(grid_to_text(&grid))
} else {
None
};
let ticket = screen_text.and_then(|t| scan_for_ticket(&t, prefixes));
let label = if let Some(t) = ticket {
t
} else {
resolve_tab_label(self.display_name.as_deref(), &osc, &self.profile.label)
};
self.derived_cache.borrow_mut().tab_label = Some((now_bytes, prefix_hash, label.clone()));
label
}
pub fn current_spinner_glyph(&self) -> Option<char> {
let now_bytes = self.bytes_processed();
let cached = {
let cache = self.derived_cache.borrow();
match cache.claude_thinking {
Some((b, v)) if b == now_bytes => Some(v),
_ => None,
}
};
let thinking = match cached {
Some(v) => v,
None => {
let v = is_claude_thinking(&self.render_grid(false));
self.derived_cache.borrow_mut().claude_thinking = Some((now_bytes, v));
v
}
};
if !thinking {
return None;
}
const CYCLE_MS: u128 = 110;
const CLAUDE_FRAMES: &[char] = &[
'\u{F1E10}', '\u{F1E11}', '\u{F1E10}',
'\u{F1E12}', '\u{F1E13}', '\u{F1E14}', '\u{F1E13}',
'\u{F1E12}',
];
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let start = START.get_or_init(std::time::Instant::now);
let ms = std::time::Instant::now().duration_since(*start).as_millis();
let idx = (ms / CYCLE_MS) as usize % CLAUDE_FRAMES.len();
Some(CLAUDE_FRAMES[idx])
}
pub fn is_codex_thinking(&self) -> bool {
let now_bytes = self.bytes_processed();
{
let cache = self.derived_cache.borrow();
if let Some((b, v)) = cache.codex_thinking
&& b == now_bytes
{
return v;
}
}
let v = detect_codex_thinking(&self.render_grid(false));
self.derived_cache.borrow_mut().codex_thinking = Some((now_bytes, v));
v
}
pub fn session_summary(&self) -> Option<String> {
let now_bytes = self.bytes_processed();
{
let cache = self.derived_cache.borrow();
if let Some((b, v)) = &cache.summary
&& *b == now_bytes
{
return v.clone();
}
}
let v = summarize_grid(&self.render_grid(false));
self.derived_cache.borrow_mut().summary = Some((now_bytes, v.clone()));
v
}
pub fn session_summary_lines(&self, max: usize) -> Vec<String> {
let now_bytes = self.bytes_processed();
{
let cache = self.derived_cache.borrow();
if let Some((b, m, lines)) = &cache.summary_lines
&& *b == now_bytes
&& *m == max
{
return lines.clone();
}
}
let lines = summarize_grid_lines(&self.render_grid(false), max);
self.derived_cache.borrow_mut().summary_lines = Some((now_bytes, max, lines.clone()));
lines
}
}
fn row_to_string(grid: &RenderGrid, row: u16) -> String {
let mut line = String::new();
for col in 0..grid.cols {
if let Some(c) = grid.cell(row, col) {
if c.text.is_empty() {
line.push(' ');
} else {
line.push_str(&c.text);
}
}
}
line
}
fn summarize_grid(grid: &RenderGrid) -> Option<String> {
let mut activity: Option<String> = None;
let mut fallback: Option<String> = None;
for row in (0..grid.rows).rev() {
let line = row_to_string(grid, row);
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if is_chrome_line(trimmed) {
continue;
}
if is_footer_chip(trimmed) {
continue;
}
if is_input_prompt(trimmed) {
continue;
}
if activity.is_none() && looks_like_activity_line(trimmed) {
let cleaned = strip_leading_spinner_chars(trimmed).trim().to_string();
if !cleaned.is_empty() {
activity = Some(cleaned);
}
}
if fallback.is_none() {
let cleaned = strip_leading_spinner_chars(trimmed).trim().to_string();
if cleaned.chars().count() >= 3 {
fallback = Some(cleaned);
}
}
if activity.is_some() {
break;
}
}
activity.or(fallback)
}
fn summarize_grid_lines(grid: &RenderGrid, max: usize) -> Vec<String> {
if max == 0 {
return Vec::new();
}
let mut dim_hits: Vec<(u16, String)> = Vec::new();
let mut plain_hits: Vec<(u16, String)> = Vec::new();
for row in (0..grid.rows).rev() {
let line = row_to_string(grid, row);
let trimmed = line.trim();
if trimmed.is_empty()
|| is_chrome_line(trimmed)
|| is_footer_chip(trimmed)
|| is_input_prompt(trimmed)
|| is_worked_completion(trimmed)
{
continue;
}
let cleaned = strip_leading_spinner_chars(trimmed).trim().to_string();
if cleaned.chars().count() < 3 {
continue;
}
if is_dim_row(grid, row) {
dim_hits.push((row, cleaned));
} else {
plain_hits.push((row, cleaned));
}
}
let mut out: Vec<String> = Vec::with_capacity(max);
let mut seen_rows: std::collections::HashSet<u16> = std::collections::HashSet::new();
for (row, text) in dim_hits.into_iter().chain(plain_hits) {
if !seen_rows.insert(row) {
continue;
}
if out.last().map(|s| s == &text).unwrap_or(false) {
continue;
}
out.push(text);
if out.len() >= max {
break;
}
}
out
}
fn is_chrome_line(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
if chars.is_empty() {
return true;
}
let first = chars[0];
if !first.is_alphanumeric() && chars.iter().all(|&c| c == first || c.is_whitespace()) {
return true;
}
false
}
fn is_worked_completion(s: &str) -> bool {
let cleaned = strip_leading_spinner_chars(s).trim();
if !cleaned.starts_with("Worked for ") && !cleaned.starts_with("worked for ") {
return false;
}
cleaned.chars().count() < 30
}
fn is_dim_row(grid: &RenderGrid, row: u16) -> bool {
let default_bright =
grid.default_fg.r as u32 + grid.default_fg.g as u32 + grid.default_fg.b as u32;
if default_bright == 0 {
return false;
}
let threshold = (default_bright * 3) / 5;
let mut total = 0u32;
let mut dim = 0u32;
for col in 0..grid.cols {
let Some(cell) = grid.cell(row, col) else {
continue;
};
if cell.text.is_empty() || cell.text.chars().all(|c| c.is_whitespace()) {
continue;
}
total += 1;
let Some(fg) = cell.fg else {
continue;
};
let b = fg.r as u32 + fg.g as u32 + fg.b as u32;
if b < threshold {
dim += 1;
}
}
total >= 4 && dim * 5 >= total * 3
}
fn is_footer_chip(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
const MARKERS: &[&str] = &[
"auto mode",
"manual mode",
"plan mode",
"shift+tab to cycle",
"shift+tab to change",
"for agents",
"for approval",
"for planning",
"for accept",
"for accept edits",
"for tools",
"for interrupt",
"esc to interrupt",
"esc to close",
"esc to cancel",
"tab to amend",
"for compact",
"context left until auto-compact",
"context left",
"shortcuts",
"mcp server needs authentication",
"mcp servers need authentication",
"run /mcp",
];
MARKERS.iter().any(|m| lower.contains(m))
}
fn is_input_prompt(s: &str) -> bool {
let first = match s.chars().next() {
Some(c) => c,
None => return false,
};
matches!(first, '>' | ')' | '❯')
}
fn looks_like_activity_line(s: &str) -> bool {
if s.contains('…') || s.contains("...") {
return true;
}
if s.chars().count() > 60 {
return false;
}
let bytes = s.as_bytes();
if let Some(pos) = s.find(" for ") {
let mut i = pos + 5;
let mut saw_digit = false;
while i < bytes.len() && bytes[i].is_ascii_digit() {
saw_digit = true;
i += 1;
}
if saw_digit && i < bytes.len() && matches!(bytes[i], b's' | b'm' | b'h') {
return true;
}
}
false
}
pub fn sgr_mouse_button_code(button: ratatui::crossterm::event::MouseButton) -> u32 {
use ratatui::crossterm::event::MouseButton;
match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
}
}
pub fn sgr_mouse_mod_bits(mods: ratatui::crossterm::event::KeyModifiers) -> u32 {
use ratatui::crossterm::event::KeyModifiers;
let mut bits = 0;
if mods.contains(KeyModifiers::SHIFT) {
bits |= 4;
}
if mods.contains(KeyModifiers::ALT) {
bits |= 8;
}
if mods.contains(KeyModifiers::CONTROL) {
bits |= 16;
}
bits
}
fn theme_color_to_rgb(c: ratatui::style::Color) -> RgbColor {
if let ratatui::style::Color::Rgb(r, g, b) = c {
RgbColor { r, g, b }
} else {
RgbColor {
r: 0xff,
g: 0xff,
b: 0xff,
}
}
}
fn snapshot_grid<'a>(
term: &Terminal<'a, 'a>,
rs: &mut RenderState<'a>,
prior: Option<&RenderGrid>,
) -> RenderGrid {
let cols = term.cols().unwrap_or(0);
let t = crate::ui::theme::cur();
let (default_fg, default_bg) = (theme_color_to_rgb(t.fg), theme_color_to_rgb(t.bg_dark));
let mut grid = RenderGrid {
rows: 0,
cols,
cells: Vec::new(),
default_fg,
default_bg,
cursor: None,
ansi_palette: [None; 16],
};
let Ok(snapshot) = rs.update(term) else {
return grid;
};
if let Ok(colors) = snapshot.colors() {
grid.default_fg = colors.foreground;
grid.default_bg = colors.background;
for (i, slot) in grid.ansi_palette.iter_mut().enumerate() {
*slot = Some(colors.palette[i]);
}
}
if snapshot.cursor_visible().unwrap_or(false)
&& let Ok(Some(CursorViewport { x, y, .. })) = snapshot.cursor_viewport()
{
grid.cursor = Some((x, y));
}
let global_dirty = snapshot.dirty().ok();
if let Some(prior) = prior
&& matches!(global_dirty, Some(libghostty_vt::render::Dirty::Clean))
&& prior.cols == cols
{
let _ = snapshot.set_dirty(libghostty_vt::render::Dirty::Clean);
return prior.clone();
}
let use_row_selective = prior.is_some()
&& matches!(global_dirty, Some(libghostty_vt::render::Dirty::Partial))
&& prior.map(|p| p.cols == cols).unwrap_or(false);
let prior_cells: &[RenderCell] = prior.map(|p| p.cells.as_slice()).unwrap_or(&[]);
let mut row_idx: u16 = 0;
if let (Ok(mut rows_h), Ok(mut cells_h)) = (RowIterator::new(), CellIterator::new())
&& let Ok(mut row_iter) = rows_h.update(&snapshot)
{
while let Some(row) = row_iter.next() {
if use_row_selective
&& let Ok(false) = row.dirty()
&& let Some(prior_grid) = prior
&& row_idx < prior_grid.rows
{
let start = row_idx as usize * cols as usize;
let end = start + cols as usize;
if end <= prior_cells.len() {
grid.cells.extend_from_slice(&prior_cells[start..end]);
grid.rows += 1;
row_idx += 1;
let _ = row.set_dirty(false);
continue;
}
}
let mut row_cells: Vec<RenderCell> = Vec::with_capacity(cols as usize);
if let Ok(mut cell_iter) = cells_h.update(&row) {
while let Some(cell) = cell_iter.next() {
let wide = cell.raw_cell().ok().and_then(|c| c.wide().ok());
if matches!(
wide,
Some(libghostty_vt::screen::CellWide::SpacerTail)
| Some(libghostty_vt::screen::CellWide::SpacerHead)
) {
row_cells.push(RenderCell::default());
continue;
}
let text: String = cell
.graphemes()
.map(|g| g.into_iter().collect())
.unwrap_or_default();
let st = cell.style().ok();
row_cells.push(RenderCell {
text,
fg: cell.fg_color().ok().flatten(),
bg: cell.bg_color().ok().flatten(),
bold: st.as_ref().map(|s| s.bold).unwrap_or(false),
italic: st.as_ref().map(|s| s.italic).unwrap_or(false),
underline: st
.as_ref()
.map(|s| s.underline != Underline::None)
.unwrap_or(false),
inverse: st.as_ref().map(|s| s.inverse).unwrap_or(false),
});
}
}
row_cells.resize(cols as usize, RenderCell::default());
grid.cells.extend(row_cells);
grid.rows += 1;
row_idx += 1;
let _ = row.set_dirty(false);
}
}
let _ = snapshot.set_dirty(libghostty_vt::render::Dirty::Clean);
grid
}
fn grid_to_text(grid: &RenderGrid) -> String {
let mut text = String::with_capacity((grid.rows as usize) * (grid.cols as usize + 1));
for r in 0..grid.rows {
for c in 0..grid.cols {
match grid.cell(r, c) {
Some(cell) if !cell.text.is_empty() => text.push_str(&cell.text),
_ => text.push(' '),
}
}
text.push('\n');
}
text
}
pub(crate) fn scan_for_ticket(text: &str, prefixes: &[String]) -> Option<String> {
if prefixes.is_empty() {
return None;
}
let mut best: Option<(usize, String)> = None;
for prefix in prefixes {
if prefix.is_empty() {
continue;
}
let bytes = text.as_bytes();
let pbytes = prefix.as_bytes();
let mut i = 0;
while i + pbytes.len() <= bytes.len() {
if &bytes[i..i + pbytes.len()] == pbytes {
let mut j = i + pbytes.len();
let start_digits = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > start_digits {
let token = format!("{prefix}{}", &text[start_digits..j]);
if best.as_ref().map(|(p, _)| i > *p).unwrap_or(true) {
best = Some((i, token));
}
i = j;
continue;
}
}
i += 1;
}
}
best.map(|(_, t)| t)
}
fn terminfo_search_dirs() -> Option<String> {
let mut dirs: Vec<String> = Vec::new();
#[cfg(target_os = "macos")]
{
let ghostty = "/Applications/Ghostty.app/Contents/Resources/terminfo";
if std::path::Path::new(ghostty).is_dir() {
dirs.push(ghostty.to_string());
}
}
for extra in ["/usr/local/share/terminfo", "/opt/homebrew/share/terminfo"] {
if std::path::Path::new(extra).is_dir() {
dirs.push(extra.to_string());
}
}
let inherited = std::env::var("TERMINFO_DIRS").unwrap_or_default();
if !inherited.is_empty() {
dirs.push(inherited);
} else {
for def in ["/usr/share/terminfo", "/etc/terminfo", "/lib/terminfo"] {
if std::path::Path::new(def).is_dir() {
dirs.push(def.to_string());
}
}
}
if dirs.is_empty() {
None
} else {
Some(dirs.join(":"))
}
}
pub(crate) fn resolve_tab_label(
display_name: Option<&str>,
osc_title: &str,
profile_label: &str,
) -> String {
for cand in [display_name, Some(osc_title)].into_iter().flatten() {
let cleaned = strip_leading_spinner_chars(cand.trim());
if !cleaned.is_empty() {
return cleaned.to_string();
}
}
profile_label.to_string()
}
pub(crate) fn strip_leading_spinner_chars(s: &str) -> &str {
let cutoff = s
.char_indices()
.find(|(_, c)| c.is_alphanumeric() || matches!(*c, '(' | '[' | '<' | '"' | '\''))
.map(|(i, _)| i)
.unwrap_or(s.len());
s[cutoff..].trim_start()
}
fn is_shell_profile(exe: &str) -> bool {
let base = std::path::Path::new(exe)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(exe);
matches!(
base,
"sh" | "bash" | "zsh" | "fish" | "dash" | "ksh" | "tcsh"
)
}
fn detect_codex_thinking(grid: &RenderGrid) -> bool {
let scan_start = grid.rows.saturating_sub(4);
for row in scan_start..grid.rows {
let mut line = String::new();
for col in 0..grid.cols {
if let Some(c) = grid.cell(row, col) {
line.push_str(&c.text);
}
}
if !line.contains('•') {
continue;
}
if line.contains("Working") {
return true;
}
let bytes = line.as_bytes();
for i in 0..bytes.len() {
if !bytes[i].is_ascii_digit() {
continue;
}
let mut j = i;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > i + 3 || j == i {
continue;
}
if j < bytes.len() && matches!(bytes[j], b's' | b'm' | b'h') {
return true;
}
}
}
false
}
fn is_claude_thinking(grid: &RenderGrid) -> bool {
const CLAUDE_SPINNER_CHARS: &[char] = &['·', '✢', '✳', '✱', '✶', '✻', '✽', '❋'];
for row in (0..grid.rows).rev() {
let mut line = String::new();
for col in 0..grid.cols {
if let Some(c) = grid.cell(row, col) {
line.push_str(&c.text);
}
}
if !line.contains('…') && !line.contains("...") {
continue;
}
let Some(first) = line.chars().find(|c| !c.is_whitespace()) else {
continue;
};
if CLAUDE_SPINNER_CHARS.contains(&first) {
return true;
}
}
false
}
#[cfg(test)]
fn detect_spinner_glyph(grid: &RenderGrid) -> Option<char> {
const SPINNER_CHARS: &[char] = &[
'·', '✢', '✳', '✱', '✶', '✦', '✧', '⋆', '✽', '✻', '❋', '✿', '✺', '✷', '✸', '✹', '❉', '❅',
'◐', '◓', '◑', '◒',
];
for row in (0..grid.rows).rev() {
let mut line = String::new();
for col in 0..grid.cols {
if let Some(c) = grid.cell(row, col) {
line.push_str(&c.text);
}
}
let Some(glyph) = line.chars().find(|c| SPINNER_CHARS.contains(c)) else {
continue;
};
if line.contains('…') || line.contains("...") {
return Some(glyph);
}
}
None
}
impl Drop for PtySession {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.reader.take();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_leading_spinner_chars_scrubs_osc_prefix() {
assert_eq!(strip_leading_spinner_chars("✻ Claude Code"), "Claude Code");
assert_eq!(strip_leading_spinner_chars("✽ Claude Code"), "Claude Code");
assert_eq!(strip_leading_spinner_chars("• Codex"), "Codex");
assert_eq!(strip_leading_spinner_chars("Claude Code"), "Claude Code");
assert_eq!(strip_leading_spinner_chars("✻ ✽ Claude"), "Claude");
assert_eq!(
strip_leading_spinner_chars("Working ✻ 12s"),
"Working ✻ 12s"
);
}
#[test]
fn resolve_tab_label_scrubs_leading_spinner_from_osc() {
assert_eq!(
resolve_tab_label(None, "✻ Claude Code", "claude code"),
"Claude Code"
);
}
#[test]
fn resolve_tab_label_prefers_name_then_osc_then_profile() {
assert_eq!(resolve_tab_label(Some("mine"), "osc", "Claude"), "mine");
assert_eq!(
resolve_tab_label(None, "Claude · refactor", "Claude"),
"Claude · refactor"
);
assert_eq!(resolve_tab_label(None, "", "Claude"), "Claude");
assert_eq!(resolve_tab_label(None, " ", "Codex"), "Codex");
assert_eq!(resolve_tab_label(Some(" "), "osc", "Codex"), "osc");
}
fn p(s: &str) -> String {
s.to_string()
}
#[test]
fn scan_for_ticket_empty_prefixes_returns_none() {
assert_eq!(scan_for_ticket("TE-1234 mentioned here", &[]), None);
}
#[test]
fn scan_for_ticket_no_match_returns_none() {
let prefixes = [p("TE-"), p("MIX-")];
assert_eq!(
scan_for_ticket("nothing ticket-shaped here", &prefixes),
None
);
assert_eq!(scan_for_ticket("we use TE- for tickets", &prefixes), None);
}
#[test]
fn scan_for_ticket_single_match() {
let prefixes = [p("TE-")];
assert_eq!(
scan_for_ticket("we just shipped TE-1234 yesterday", &prefixes),
Some("TE-1234".to_string())
);
}
#[test]
fn scan_for_ticket_multiple_matches_returns_last_in_text() {
let prefixes = [p("TE-")];
let txt =
"TE-100 was an early one\nthen later TE-9999 came along\nand most recent TE-12345 wins";
assert_eq!(
scan_for_ticket(txt, &prefixes),
Some("TE-12345".to_string())
);
}
#[test]
fn scan_for_ticket_multiple_prefixes_returns_globally_rightmost() {
let prefixes = [p("TE-"), p("MIX-"), p("PROJ-")];
let txt = "earlier we discussed PROJ-77 then MIX-123 then TE-5";
assert_eq!(scan_for_ticket(txt, &prefixes), Some("TE-5".to_string()));
}
#[test]
fn scan_for_ticket_ignores_empty_prefix_strings() {
let prefixes = [p(""), p("TE-")];
assert_eq!(
scan_for_ticket("see TE-1 for details", &prefixes),
Some("TE-1".to_string())
);
}
#[test]
fn scan_for_ticket_handles_prefix_at_end_without_digits() {
let prefixes = [p("TE-")];
assert_eq!(scan_for_ticket("incomplete TE-", &prefixes), None);
}
#[test]
fn scan_for_ticket_handles_digits_with_non_digit_after() {
let prefixes = [p("TE-")];
assert_eq!(
scan_for_ticket("see TE-1234. it's done", &prefixes),
Some("TE-1234".to_string())
);
}
#[test]
fn scan_for_ticket_does_not_include_letters_in_digit_run() {
let prefixes = [p("TE-")];
assert_eq!(
scan_for_ticket("misformed TE-1234x reference", &prefixes),
Some("TE-1234".to_string())
);
}
fn test_grid(rows: u16, cols: u16, chunks: &[&[u8]]) -> RenderGrid {
let mut term = Terminal::new(TerminalOptions {
cols,
rows,
max_scrollback: 0,
})
.unwrap();
for c in chunks {
let _ = term.vt_write(c);
}
let mut rs = RenderState::new().unwrap();
snapshot_grid(&term, &mut rs, None)
}
#[test]
fn grid_to_text_round_trip() {
let grid = test_grid(
10,
60,
&[
b"first line\r\n",
b"mentioned TE-42 in passing\r\n",
b"then TE-99 came up\r\n",
],
);
let text = grid_to_text(&grid);
let prefixes = [p("TE-")];
assert_eq!(scan_for_ticket(&text, &prefixes), Some("TE-99".to_string()));
}
#[test]
fn detect_spinner_glyph_finds_claude_spinner() {
let grid = test_grid(
6,
60,
&[
b"idle output line\r\n",
"✽ Wandering… (3s · esc to interrupt)\r\n".as_bytes(),
],
);
assert_eq!(detect_spinner_glyph(&grid), Some('✽'));
}
#[test]
fn detect_spinner_glyph_none_without_a_spinner() {
let grid = test_grid(6, 60, &[b"just some normal output\r\nno spinner here\r\n"]);
assert!(detect_spinner_glyph(&grid).is_none());
let grid2 = test_grid(6, 60, &["✽ a starred heading\r\n".as_bytes()]);
assert!(detect_spinner_glyph(&grid2).is_none());
}
#[test]
fn shell_profile_uses_env_shell() {
let p = BinaryProfile::shell(None);
assert!(!p.exe.is_empty());
assert!(p.label.starts_with("terminal ("));
assert!(p.args.is_empty());
}
#[test]
fn resolve_launcher_returns_default_when_no_manifest() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
resolve_launcher(dir.path(), "claude_code", "claude"),
"claude"
);
}
#[test]
fn resolve_launcher_reads_override_from_manifest() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml/integrations")).unwrap();
std::fs::write(
dir.path().join(".mnml/integrations/claude_code.toml"),
"launcher = \"./bin/multi.sh\"\n",
)
.unwrap();
assert_eq!(
resolve_launcher(dir.path(), "claude_code", "claude"),
"./bin/multi.sh"
);
}
#[test]
fn resolve_launcher_empty_string_falls_back_to_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml/integrations")).unwrap();
std::fs::write(
dir.path().join(".mnml/integrations/claude_code.toml"),
"launcher = \"\"\n",
)
.unwrap();
assert_eq!(
resolve_launcher(dir.path(), "claude_code", "claude"),
"claude"
);
}
#[test]
fn resolve_launcher_expands_workspace_template() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml/integrations")).unwrap();
std::fs::write(
dir.path().join(".mnml/integrations/claude_code.toml"),
"launcher = \"{{workspace}}/bin/multi.sh\"\n",
)
.unwrap();
let expected = format!("{}/bin/multi.sh", dir.path().display());
assert_eq!(
resolve_launcher(dir.path(), "claude_code", "claude"),
expected
);
}
#[test]
fn resolve_launcher_expands_workspace_name_token() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml/integrations")).unwrap();
std::fs::write(
dir.path().join(".mnml/integrations/claude_code.toml"),
"launcher = \"echo-{{workspace_name}}\"\n",
)
.unwrap();
let name = dir
.path()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(
resolve_launcher(dir.path(), "claude_code", "claude"),
format!("echo-{name}")
);
}
#[test]
fn resolve_launcher_unknown_template_token_stays_literal() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml/integrations")).unwrap();
std::fs::write(
dir.path().join(".mnml/integrations/claude_code.toml"),
"launcher = \"{{workspce}}/bin/multi.sh\"\n",
)
.unwrap();
assert_eq!(
resolve_launcher(dir.path(), "claude_code", "claude"),
"{{workspce}}/bin/multi.sh"
);
}
#[test]
fn resolve_launcher_survives_comments_and_whitespace() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml/integrations")).unwrap();
std::fs::write(
dir.path().join(".mnml/integrations/codex.toml"),
"# comment\n\n launcher = \"wrap.sh\" \n",
)
.unwrap();
assert_eq!(resolve_launcher(dir.path(), "codex", "codex"), "wrap.sh");
}
#[test]
fn claude_profile_injects_claude_md_when_present() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".mnml")).unwrap();
std::fs::write(dir.path().join(".mnml/CLAUDE.md"), "# brief\nhello mnml").unwrap();
let p = BinaryProfile::claude_code(dir.path().to_path_buf());
assert_eq!(p.exe, "claude");
let i = p
.args
.iter()
.position(|a| a == "--append-system-prompt")
.expect("flag");
assert!(p.args[i + 1].contains("hello mnml"));
let dir2 = tempfile::tempdir().unwrap();
let p2 = BinaryProfile::claude_code(dir2.path().to_path_buf());
assert!(!p2.args.iter().any(|a| a == "--append-system-prompt"));
}
#[test]
fn is_footer_chip_matches_claude_persistent_footer() {
assert!(is_footer_chip(
"auto mode on (shift+tab to cycle) · ↵ for agents"
));
assert!(is_footer_chip("↵ for agents"));
assert!(is_footer_chip("? for shortcuts"));
assert!(is_footer_chip("Context left until auto-compact: 43%"));
assert!(is_footer_chip("Shift+Tab to change mode"));
assert!(is_footer_chip("Esc to cancel · Tab to amend"));
assert!(is_footer_chip("Tab to amend"));
assert!(is_footer_chip("manual mode on"));
assert!(is_footer_chip("plan mode on (shift+tab to cycle)"));
assert!(is_footer_chip(
"⚠ 1 MCP server needs authentication · run /mcp"
));
assert!(is_footer_chip("run /mcp"));
assert!(!is_footer_chip("Sautéed for 27s"));
assert!(!is_footer_chip("● I'll pull up TE-1234 from Jira."));
assert!(!is_footer_chip("2. Yes, and don't ask again for plugin"));
}
#[test]
fn is_worked_completion_matches_claudes_done_chip() {
assert!(is_worked_completion("✻ Worked for 30s"));
assert!(is_worked_completion("· Worked for 5s"));
assert!(is_worked_completion("Worked for 3m 15s"));
assert!(!is_worked_completion(
"Worked for the last 5 years at Company X on payments infrastructure"
));
assert!(!is_worked_completion("✻ Sautéed for 27s"));
}
#[test]
fn is_input_prompt_matches_composer_lines() {
assert!(is_input_prompt("> "));
assert!(is_input_prompt("> tell me about TE-1234"));
assert!(is_input_prompt(") Look for the ordering-channel feature"));
assert!(is_input_prompt("❯ do the thing"));
assert!(!is_input_prompt("Sautéed for 27s"));
}
#[test]
fn looks_like_activity_line_matches_claudes_status_shape() {
assert!(looks_like_activity_line("✻ Sautéed for 27s"));
assert!(looks_like_activity_line("· Reading files…"));
assert!(looks_like_activity_line("✳ Working…"));
assert!(!looks_like_activity_line(
"A long paragraph of body text that happens to say Baked for 5s in the middle of a sentence"
));
assert!(!looks_like_activity_line("auto mode on"));
}
#[test]
fn spawns_a_short_shell_command_and_reaps() {
let mut prof = BinaryProfile::shell(None);
prof.exe = "/bin/sh".to_string();
prof.args = vec!["-c".to_string(), "true".to_string()];
let Ok(s) = PtySession::spawn(prof, 24, 80) else {
return;
};
for _ in 0..50 {
if s.is_exited() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
drop(s);
}
}