use std::io::{self, Write};
use compact_str::CompactString;
use crossterm::ExecutableCommand;
use crossterm::cursor::MoveTo;
use crossterm::style::Color;
use crossterm::terminal::{Clear, ClearType};
pub enum BackendWriter {
#[cfg_attr(test, allow(dead_code))]
Tty(std::fs::File),
#[cfg_attr(test, allow(dead_code))]
Stdout(std::io::Stdout),
}
impl std::io::Write for BackendWriter {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
match self {
BackendWriter::Tty(f) => f.write(b),
BackendWriter::Stdout(s) => s.write(b),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
BackendWriter::Tty(f) => f.flush(),
BackendWriter::Stdout(s) => s.flush(),
}
}
}
fn build_tui_terminal()
-> Option<ratatui::Terminal<ratatui::backend::CrosstermBackend<BackendWriter>>> {
#[cfg(test)]
{
None
}
#[cfg(not(test))]
{
let writer = match crate::ui::terminal::open_tty_for_write() {
Some(f) => BackendWriter::Tty(f),
None => BackendWriter::Stdout(std::io::stdout()),
};
ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(writer)).ok()
}
}
#[derive(Clone)]
pub struct LineEntry {
pub text: CompactString,
pub color: Color,
}
#[derive(Clone)]
enum SourceBlock {
Plain { text: String, color: Color },
Markdown {
src: String,
base_color: Color,
handle: bool,
},
Raw { rows: Vec<LineEntry> },
}
#[derive(Clone)]
struct Block {
src: SourceBlock,
rows: usize,
}
pub const MAX_INPUT_VISIBLE_LINES: usize = 8;
pub const ALERT_FRAME_ROWS: u16 = 2;
pub const CHAT_FRAME_ROWS: u16 = 2;
const PANEL_AUTO_MIN_COLS: u16 = 152;
const TERMINAL_MODE_REASSERT: &[u8] = b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?2004h";
const MODE_REASSERT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
fn mode_reassert_payload(
last: Option<std::time::Instant>,
now: std::time::Instant,
) -> Option<&'static [u8]> {
let due = match last {
None => true,
Some(t) => now.saturating_duration_since(t) >= MODE_REASSERT_INTERVAL,
};
if due {
Some(TERMINAL_MODE_REASSERT)
} else {
None
}
}
#[cfg(feature = "experimental-ui-terminal-tab")]
fn format_terminal_title(state: crate::ui::avatar::AvatarState, tool_name: Option<&str>) -> String {
use crate::ui::avatar::AvatarState;
let sanitize = |s: &str| -> String {
s.chars()
.filter(|c| !c.is_control() && *c != '\u{0007}' && *c != '\u{001b}' && *c != '\u{009c}')
.take(64)
.collect()
};
match state {
AvatarState::Idle | AvatarState::Done => "● dirge".to_string(),
AvatarState::Thinking => "● dirge: thinking".to_string(),
AvatarState::Speaking => "● dirge: responding".to_string(),
AvatarState::Reading | AvatarState::Writing | AvatarState::Bash => {
if let Some(name) = tool_name {
let clean = sanitize(name);
if clean.is_empty() {
"◌ dirge: working".to_string()
} else {
format!("◌ dirge: {}", clean)
}
} else {
"◌ dirge: working".to_string()
}
}
AvatarState::Alert => "✗ dirge: needs input".to_string(),
AvatarState::Error => "✗ dirge: ERROR".to_string(),
}
}
#[cfg(feature = "experimental-ui-terminal-tab")]
fn osc_set_title(title: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(title.len() + 5);
out.extend_from_slice(b"\x1b]0;");
out.extend_from_slice(title.as_bytes());
out.extend_from_slice(b"\x1b\\");
out
}
#[cfg(feature = "experimental-ui-terminal-tab")]
#[allow(dead_code)]
fn osc_reset_title() -> Vec<u8> {
b"\x1b]0;\x1b\\".to_vec()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PanelMode {
Auto,
On,
Off,
Debug,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PaneVisibility {
pub left: bool,
pub right: bool,
}
pub fn parse_display_spec(spec: &str) -> Result<PaneVisibility, String> {
let mut vis = PaneVisibility {
left: false,
right: false,
};
let mut saw_token = false;
for tok in spec.split(['|', ',', ' ', '\t']).filter(|t| !t.is_empty()) {
saw_token = true;
match tok.to_ascii_lowercase().as_str() {
"left" => vis.left = true,
"right" => vis.right = true,
"main" => {}
other => {
return Err(format!(
"unknown pane '{other}' (use left, main, and/or right, e.g. /display left|main|right)"
));
}
}
}
if !saw_token {
return Err(
"usage: /display <panes> where panes are left|main|right (e.g. /display main|right)"
.to_string(),
);
}
Ok(vis)
}
pub use crate::ui::panel_data::{LeftPanelInfo, PanelData, SubagentStatusRow};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SelectionRange {
pub start: (usize, usize),
pub end: (usize, usize),
}
fn is_word_char(ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
pub fn normalize_selection_range(a: (usize, usize), b: (usize, usize)) -> SelectionRange {
if (a.0, a.1) <= (b.0, b.1) {
SelectionRange { start: a, end: b }
} else {
SelectionRange { start: b, end: a }
}
}
pub struct ChatSnapshot {
pub name: String,
buffer: Vec<LineEntry>,
source: Vec<Block>,
streaming: bool,
open_rows: usize,
partial: CompactString,
partial_color: Color,
scroll_offset: usize,
lines: u16,
col: u16,
selection_active: bool,
selection_start: Option<(usize, usize)>,
selection_end: Option<(usize, usize)>,
}
pub struct Renderer {
lines: u16,
col: u16,
spinner_tick: bool,
needs_paint: bool,
last_paint: Option<std::time::Instant>,
last_mode_reassert: Option<std::time::Instant>,
eviction_generation: u64,
#[cfg(test)]
test_cols: Option<u16>,
buffer: Vec<LineEntry>,
source: Vec<Block>,
streaming: bool,
open_rows: usize,
partial: CompactString,
partial_color: Color,
scroll_offset: usize,
chats: Vec<ChatSnapshot>,
active_chat: usize,
input_rows: u16,
pub selection_active: bool,
pub selection_start: Option<(usize, usize)>,
pub selection_end: Option<(usize, usize)>,
pub last_click: Option<(std::time::Instant, u16, u16)>,
pub suppress_next_mouseup: bool,
left_panel_mode: PanelMode,
right_panel_mode: PanelMode,
panel_data: PanelData,
subagent_status: Vec<SubagentStatusRow>,
left_panel_info: LeftPanelInfo,
#[cfg(feature = "dap")]
debug_panel_data: Option<crate::dap::types::DebugPanelData>,
alert_overlay: Option<Vec<(String, Color)>>,
picker_overlay: Option<crate::ui::picker::PickerOverlay>,
rewind_overlay: Option<crate::ui::picker::PickerOverlay>,
alert_title: String,
avatar_state: crate::ui::avatar::AvatarState,
avatar_tick: bool,
tui_terminal: Option<ratatui::Terminal<ratatui::backend::CrosstermBackend<BackendWriter>>>,
cached_input_rows: Vec<String>,
cached_input_cursor_row: u16,
cached_input_cursor_col: u16,
cached_status: String,
cached_is_running: bool,
cached_completion_preview: String,
cached_input_ghost: String,
cached_chat_rect: Option<ratatui::layout::Rect>,
pub(crate) modified_offset: usize,
last_modified_len: Option<usize>,
pub(crate) cached_modified_rect: Option<ratatui::layout::Rect>,
#[cfg(feature = "experimental-ui-terminal-tab")]
cached_terminal_title: String,
#[cfg(feature = "experimental-ui-terminal-tab")]
last_tool_name: Option<String>,
}
impl Renderer {
pub fn new() -> io::Result<Self> {
let tui_terminal = build_tui_terminal();
Ok(Renderer {
lines: 0,
col: 0,
spinner_tick: false,
needs_paint: false,
last_paint: None,
last_mode_reassert: None,
eviction_generation: 0,
#[cfg(test)]
test_cols: None,
buffer: Vec::new(),
source: Vec::new(),
streaming: false,
open_rows: 0,
partial: CompactString::new(""),
partial_color: Color::White,
scroll_offset: 0,
chats: vec![ChatSnapshot::empty("main")],
active_chat: 0,
input_rows: 1,
selection_active: false,
selection_start: None,
selection_end: None,
last_click: None,
suppress_next_mouseup: false,
left_panel_mode: PanelMode::Auto,
right_panel_mode: PanelMode::Auto,
panel_data: PanelData::default(),
subagent_status: Vec::new(),
left_panel_info: LeftPanelInfo::default(),
#[cfg(feature = "dap")]
debug_panel_data: None,
alert_overlay: None,
picker_overlay: None,
rewind_overlay: None,
alert_title: String::new(),
avatar_state: crate::ui::avatar::AvatarState::Idle,
avatar_tick: false,
tui_terminal,
cached_input_rows: vec![String::new()],
cached_input_cursor_row: 0,
cached_input_cursor_col: 0,
cached_status: String::new(),
cached_is_running: false,
cached_completion_preview: String::new(),
cached_input_ghost: String::new(),
cached_chat_rect: None,
modified_offset: 0,
last_modified_len: None,
cached_modified_rect: None,
#[cfg(feature = "experimental-ui-terminal-tab")]
cached_terminal_title: String::new(),
#[cfg(feature = "experimental-ui-terminal-tab")]
last_tool_name: None,
})
}
pub(crate) fn tui_redraw(&mut self) -> io::Result<()> {
use crate::ui::avatar;
use crate::ui::tui::bottom::{AvatarSpec, BottomBody};
use crate::ui::tui::scene::{Scene, render_frame};
let now = std::time::Instant::now();
if let Some(bytes) = mode_reassert_payload(self.last_mode_reassert, now) {
if let Some(mut tty) = crate::ui::terminal::open_tty_for_write() {
let _ = tty.write_all(bytes);
let _ = tty.flush();
}
self.last_mode_reassert = Some(now);
}
let max_offset = self.buffer.len().saturating_sub(self.visible_lines());
if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
#[cfg(feature = "experimental-ui-terminal-tab")]
let new_title = {
let tool = self.last_tool_name.as_deref();
format_terminal_title(self.avatar_state, tool)
};
let show_left_panel = self.left_panel_visible();
let show_right_panel = self.right_panel_visible();
let frame_color = crate::ui::theme::header();
let Self {
buffer,
scroll_offset,
input_rows,
panel_data,
left_panel_info,
subagent_status,
alert_overlay,
picker_overlay,
alert_title,
avatar_state,
avatar_tick,
cached_input_rows,
cached_input_cursor_row,
cached_input_cursor_col,
cached_status,
cached_is_running,
cached_completion_preview,
cached_input_ghost,
cached_chat_rect,
modified_offset,
cached_modified_rect,
tui_terminal,
selection_active,
selection_start,
selection_end,
right_panel_mode,
..
} = self;
let Some(terminal) = tui_terminal.as_mut() else {
return Ok(());
};
if let Some(last) = self.last_paint {
let elapsed = last.elapsed();
if elapsed < std::time::Duration::from_millis(8) {
return Ok(());
}
}
let face = avatar::art(*avatar_state, *avatar_tick);
let avatar_color = crate::ui::tui::chat::crossterm_to_ratatui(avatar::color(*avatar_state));
let avatar = Some(AvatarSpec {
face,
color: avatar_color,
});
let body = if let Some(lines) = alert_overlay.as_ref() {
BottomBody::Overlay {
title: alert_title.as_str(),
lines: lines.as_slice(),
}
} else {
let completion_extra = if cached_completion_preview.is_empty() {
0
} else {
1
};
let window = (*input_rows as usize)
.saturating_sub(completion_extra)
.max(1);
let offset = editor_scroll_offset(
cached_input_rows.len(),
*cached_input_cursor_row as usize,
window,
);
BottomBody::Editor {
rows: &cached_input_rows[offset..],
cursor_row: cached_input_cursor_row.saturating_sub(offset as u16),
cursor_col: *cached_input_cursor_col,
is_running: *cached_is_running,
completion_preview: cached_completion_preview.as_str(),
ghost: cached_input_ghost.as_str(),
}
};
let (cols_q, rows_q) = crate::ui::terminal::tty_size();
let effective_input_rows = if let Some(lines) = alert_overlay.as_ref() {
let probe = crate::ui::tui::layout::Layout::with_panels(
cols_q,
rows_q,
1,
show_left_panel,
show_right_panel,
);
let wrapped =
crate::ui::tui::bottom::overlay_wrapped_row_count(lines, probe.input_box.width);
let ceiling = (rows_q as i32 - 9).max(1) as u16;
(wrapped as u16).clamp(1, ceiling)
} else {
*input_rows
};
let layout_now = crate::ui::tui::layout::Layout::with_panels(
cols_q,
rows_q,
effective_input_rows,
show_left_panel,
show_right_panel,
);
let chat_rect_now = layout_now.chat;
*cached_chat_rect = Some(chat_rect_now);
let modified_rect_now = if show_right_panel && layout_now.right_panel.width >= 16 {
crate::ui::tui::panels::compute_modified_rect(panel_data, layout_now.right_panel)
} else {
None
};
*cached_modified_rect = modified_rect_now;
if let Some(r) = modified_rect_now {
let inner_rows = (r.height as usize).saturating_sub(2);
let head_rows = inner_rows.saturating_sub(1).max(1);
let total = panel_data.modified.len();
let max_off = total.saturating_sub(head_rows);
if *modified_offset > max_off {
*modified_offset = max_off;
}
} else {
*modified_offset = 0;
}
let chat_selection = if *selection_active {
match (*selection_start, *selection_end) {
(Some(s), Some(e)) => Some(normalize_selection_range(s, e)),
_ => None,
}
} else {
None
};
let scene = Scene {
chat_buffer: buffer,
scroll_offset: *scroll_offset,
input_rows: effective_input_rows,
chat_selection,
panel_data,
modified_offset: *modified_offset,
left_info: left_panel_info,
subagents: subagent_status,
avatar,
body,
status: cached_status.as_str(),
show_left_panel,
show_right_panel,
frame_color,
background: crate::ui::theme::background(),
picker: picker_overlay.as_ref(),
right_panel_mode: *right_panel_mode,
#[cfg(feature = "dap")]
debug_panel_data: self.debug_panel_data.as_ref(),
};
use crossterm::ExecutableCommand as _;
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
let _ = terminal.backend_mut().execute(BeginSynchronizedUpdate);
let draw_result = terminal.draw(|f| render_frame(&scene, f)).map(|_| ());
let _ = terminal.backend_mut().execute(EndSynchronizedUpdate);
draw_result?;
self.last_paint = Some(std::time::Instant::now());
self.needs_paint = false;
#[cfg(feature = "experimental-ui-terminal-tab")]
{
if new_title != self.cached_terminal_title {
self.cached_terminal_title.clone_from(&new_title);
let osc = osc_set_title(&new_title);
let _ = terminal.backend_mut().write_all(&osc);
}
}
Ok(())
}
pub fn add_chat(&mut self, name: impl Into<String>) -> usize {
self.chats.push(ChatSnapshot::empty(name.into()));
self.needs_paint = true;
self.chats.len() - 1
}
pub fn switch_chat(&mut self, idx: usize) {
if idx == self.active_chat || idx >= self.chats.len() {
return;
}
self.save_active();
self.active_chat = idx;
self.load_active();
self.needs_paint = true;
}
#[allow(dead_code)]
pub fn next_chat(&mut self) {
if self.chats.len() <= 1 {
return;
}
let next = if self.active_chat + 1 >= self.chats.len() {
0
} else {
self.active_chat + 1
};
self.switch_chat(next);
}
#[allow(dead_code)]
pub fn prev_chat(&mut self) {
if self.chats.len() <= 1 {
return;
}
let prev = if self.active_chat == 0 {
self.chats.len() - 1
} else {
self.active_chat - 1
};
self.switch_chat(prev);
}
pub fn remove_chat(&mut self, idx: usize) {
if self.chats.len() <= 1 || idx >= self.chats.len() {
return;
}
self.chats.remove(idx);
if idx < self.active_chat {
self.active_chat -= 1;
} else if idx == self.active_chat && self.active_chat >= self.chats.len() {
self.active_chat = 0;
}
self.needs_paint = true;
}
pub fn active_chat(&self) -> usize {
self.active_chat
}
pub fn chat_count(&self) -> usize {
self.chats.len()
}
pub fn chat_names(&self) -> Vec<String> {
self.chats.iter().map(|c| c.name.clone()).collect()
}
fn save_active(&mut self) {
let slot = &mut self.chats[self.active_chat];
slot.buffer = std::mem::take(&mut self.buffer);
slot.source = std::mem::take(&mut self.source);
slot.streaming = self.streaming;
slot.open_rows = self.open_rows;
slot.partial = std::mem::take(&mut self.partial);
slot.partial_color = self.partial_color;
slot.scroll_offset = self.scroll_offset;
slot.lines = self.lines;
slot.col = self.col;
slot.selection_active = self.selection_active;
slot.selection_start = self.selection_start;
slot.selection_end = self.selection_end;
}
fn load_active(&mut self) {
let slot = &mut self.chats[self.active_chat];
self.buffer = std::mem::take(&mut slot.buffer);
self.source = std::mem::take(&mut slot.source);
self.streaming = slot.streaming;
self.open_rows = slot.open_rows;
self.partial = std::mem::take(&mut slot.partial);
self.partial_color = slot.partial_color;
self.scroll_offset = slot.scroll_offset;
self.lines = slot.lines;
self.col = slot.col;
self.selection_active = slot.selection_active;
self.selection_start = slot.selection_start;
self.selection_end = slot.selection_end;
}
}
impl ChatSnapshot {
fn empty(name: impl Into<String>) -> Self {
Self {
name: name.into(),
buffer: Vec::new(),
source: Vec::new(),
streaming: false,
open_rows: 0,
partial: CompactString::new(""),
partial_color: Color::White,
scroll_offset: 0,
lines: 0,
col: 0,
selection_active: false,
selection_start: None,
selection_end: None,
}
}
}
impl Renderer {
#[allow(dead_code)]
fn _ov2_phase_a_anchor() {}
pub fn write_line_to_chat(&mut self, idx: usize, text: &str, color: Color) -> io::Result<()> {
if idx == self.active_chat {
return self.write_line(text, color);
}
if let Some(slot) = self.chats.get_mut(idx) {
for line in text.split('\n') {
slot.buffer.push(LineEntry {
text: CompactString::from(line),
color,
});
slot.lines = slot.lines.saturating_add(1);
}
}
Ok(())
}
pub fn set_avatar_state(&mut self, state: crate::ui::avatar::AvatarState) {
if self.avatar_state != state {
self.avatar_state = state;
self.needs_paint = true;
}
}
#[cfg(feature = "experimental-ui-terminal-tab")]
pub fn set_last_tool_name(&mut self, name: &str) {
self.last_tool_name = if name.is_empty() {
None
} else {
Some(name.to_string())
};
}
pub fn set_panel_mode(&mut self, mode: PanelMode) {
self.left_panel_mode = mode;
self.right_panel_mode = mode;
}
pub fn set_right_panel_mode(&mut self, mode: PanelMode) {
self.right_panel_mode = mode;
}
pub fn set_pane_visibility(&mut self, vis: PaneVisibility) {
self.left_panel_mode = if vis.left {
PanelMode::On
} else {
PanelMode::Off
};
self.right_panel_mode = if vis.right {
PanelMode::On
} else {
PanelMode::Off
};
}
pub fn left_panel_mode(&self) -> PanelMode {
self.left_panel_mode
}
pub fn right_panel_mode(&self) -> PanelMode {
self.right_panel_mode
}
pub fn set_subagent_status(&mut self, rows: Vec<SubagentStatusRow>) {
self.subagent_status = rows;
}
pub fn set_left_panel_info(&mut self, info: LeftPanelInfo) {
self.left_panel_info = info;
}
#[cfg(feature = "dap")]
pub fn set_debug_panel_data(&mut self, data: Option<crate::dap::types::DebugPanelData>) {
let was_active = self.debug_panel_data.is_some();
self.debug_panel_data = data;
if !was_active && self.debug_panel_data.is_some() {
self.right_panel_mode = PanelMode::Debug;
}
}
pub fn set_alert_overlay(&mut self, rows: Vec<(String, Color)>) {
self.alert_overlay = Some(rows);
if self.alert_title.is_empty() {
self.alert_title = "[ALERT]".to_string();
}
self.last_paint = None;
self.needs_paint = true;
}
pub fn clear_alert_overlay(&mut self) {
self.alert_overlay = None;
self.alert_title.clear();
self.last_paint = None;
self.needs_paint = true;
}
pub fn set_rewind_overlay(&mut self, overlay: Option<crate::ui::picker::PickerOverlay>) {
self.rewind_overlay = overlay;
self.needs_paint = true;
}
pub fn set_panel_data(&mut self, data: PanelData) {
let new_len = data.modified.len();
if let Some(prev) = self.last_modified_len
&& new_len > prev
{
self.modified_offset = 0;
}
self.last_modified_len = Some(new_len);
self.panel_data = data;
}
pub fn panel_modified_scroll(&mut self, delta: isize, visible_rows: usize) -> bool {
let total = self.panel_data.modified.len();
if total <= visible_rows {
let was = self.modified_offset;
self.modified_offset = 0;
return was != 0;
}
let max_off = total.saturating_sub(visible_rows);
let prev = self.modified_offset as isize;
let next = (prev + delta).clamp(0, max_off as isize);
let next = next as usize;
let changed = next != self.modified_offset;
self.modified_offset = next;
if changed {
self.needs_paint = true;
}
changed
}
fn side_panel_visible(&self, mode: PanelMode) -> bool {
let (cols, _) = self.terminal_size();
match mode {
PanelMode::Off => false,
PanelMode::On => self.content_indent() >= 15,
PanelMode::Auto => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15,
PanelMode::Debug => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15,
}
}
pub fn left_panel_visible(&self) -> bool {
self.side_panel_visible(self.left_panel_mode)
}
pub fn right_panel_visible(&self) -> bool {
self.side_panel_visible(self.right_panel_mode)
}
fn terminal_size(&self) -> (u16, u16) {
#[cfg(test)]
if let Some(cols) = self.test_cols {
return (cols, 24);
}
crate::ui::terminal::tty_size()
}
#[cfg(test)]
pub(crate) fn set_test_cols(&mut self, cols: u16) {
self.test_cols = Some(cols);
}
fn max_line_width(&self) -> usize {
self.content_width()
}
pub fn input_wrap_w(&self) -> usize {
self.content_width().saturating_sub(3).max(1)
}
pub fn line_width(&self) -> usize {
let (cols, _) = self.terminal_size();
cols.saturating_sub(2) as usize
}
pub fn content_width(&self) -> usize {
self.line_width().min(120)
}
pub fn content_indent(&self) -> usize {
let band = self.line_width();
let target = self.content_width();
band.saturating_sub(target) / 2
}
pub fn buffer_len(&self) -> usize {
self.buffer.len()
}
pub fn eviction_generation(&self) -> u64 {
self.eviction_generation
}
#[allow(dead_code)]
pub fn buffer_lines(&self) -> Vec<&str> {
self.buffer.iter().map(|e| e.text.as_str()).collect()
}
pub fn replace_from(&mut self, start: usize, lines: Vec<LineEntry>) {
self.commit_partial();
let old_len = self.buffer.len();
let start = start.min(old_len);
let mut acc = 0usize;
let mut keep_blocks = 0usize;
for b in &self.source {
if acc + b.rows <= start {
acc += b.rows;
keep_blocks += 1;
} else {
break;
}
}
let carry: Vec<LineEntry> = self.buffer[acc..start].to_vec();
self.source.truncate(keep_blocks);
if !carry.is_empty() {
let rows = carry.len();
self.source.push(Block {
src: SourceBlock::Raw { rows: carry },
rows,
});
}
if !lines.is_empty() {
self.source.push(Block {
src: SourceBlock::Raw {
rows: lines.clone(),
},
rows: lines.len(),
});
}
self.streaming = false;
self.open_rows = 0;
self.buffer.truncate(start);
self.buffer.extend(lines);
let new_len = self.buffer.len();
self.lines = new_len as u16;
self.col = 0;
self.partial.clear();
let visible = self.visible_lines();
let max_offset = new_len.saturating_sub(visible);
if self.scroll_offset > 0 {
let delta = new_len as isize - old_len as isize;
let new_offset = (self.scroll_offset as isize + delta).max(0) as usize;
self.scroll_offset = new_offset.min(max_offset);
} else if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
self.needs_paint = true;
}
pub fn visible_lines(&self) -> usize {
let (_, rows) = self.terminal_size();
rows.saturating_sub(self.input_rows + 1 + ALERT_FRAME_ROWS + CHAT_FRAME_ROWS) as usize
}
pub fn buffer_pos_at(&self, row: u16, col: u16) -> Option<(usize, usize)> {
let line_idx = self.buffer_line_at_row(row)?;
let entry = self.buffer.get(line_idx)?;
let clean = crate::ui::ansi::strip_ansi(&entry.text);
let chat_x = self
.cached_chat_rect
.map(|r| r.x)
.unwrap_or(self.content_indent() as u16);
let display_col = if col < chat_x {
0
} else {
(col - chat_x) as usize
};
let char_col = display_col_to_char_index(&clean, display_col);
Some((line_idx, char_col))
}
pub fn buffer_line_at_row(&self, row: u16) -> Option<usize> {
let total = self.buffer.len();
if total == 0 {
return None;
}
let (chat_y, visible) = if let Some(rect) = self.cached_chat_rect {
(rect.y, rect.height as usize)
} else {
let (_, rows) = self.terminal_size();
let v = rows.saturating_sub(self.input_rows + 1 + ALERT_FRAME_ROWS + CHAT_FRAME_ROWS)
as usize;
(1, v)
};
if visible == 0 {
return None;
}
let chat_row = row.checked_sub(chat_y)? as usize;
if chat_row >= visible {
return None;
}
let start = if self.scroll_offset == 0 {
total.saturating_sub(visible)
} else {
total.saturating_sub(self.scroll_offset + visible)
};
let start = start.min(total.saturating_sub(visible));
let idx = start + chat_row;
if idx < total { Some(idx) } else { None }
}
#[allow(dead_code)]
pub fn chat_rect(&self) -> Option<ratatui::layout::Rect> {
self.cached_chat_rect
}
#[cfg(test)]
pub fn set_chat_rect_for_test(&mut self, rect: ratatui::layout::Rect) {
self.cached_chat_rect = Some(rect);
}
pub fn word_bounds_at(&self, pos: (usize, usize)) -> Option<((usize, usize), (usize, usize))> {
let (line, ch) = pos;
let entry = self.buffer.get(line)?;
let chars: Vec<char> = crate::ui::ansi::strip_ansi(&entry.text).chars().collect();
if chars.is_empty() {
return None;
}
let i = ch.min(chars.len() - 1);
if !is_word_char(chars[i]) {
return None;
}
let mut start = i;
while start > 0 && is_word_char(chars[start - 1]) {
start -= 1;
}
let mut end = i;
while end + 1 < chars.len() && is_word_char(chars[end + 1]) {
end += 1;
}
Some(((line, start), (line, end + 1)))
}
pub fn clear_selection(&mut self) {
self.selection_active = false;
self.selection_start = None;
self.selection_end = None;
self.needs_paint = true;
}
pub fn selected_text(&self) -> Option<String> {
let (start, end) = match (self.selection_start, self.selection_end) {
(Some(s), Some(e)) if (s.0, s.1) <= (e.0, e.1) => (s, e),
(Some(s), Some(e)) => (e, s),
_ => return None,
};
let row_clean = |i: usize| -> Option<Vec<char>> {
self.buffer
.get(i)
.map(|e| crate::ui::ansi::strip_ansi(&e.text).chars().collect())
};
let mut result = String::new();
if start.0 == end.0 {
if let Some(chars) = row_clean(start.0) {
let lo = start.1.min(chars.len());
let hi = end.1.min(chars.len());
if lo < hi {
result.extend(&chars[lo..hi]);
}
}
} else {
let start_chars = row_clean(start.0);
let start_content: String = match &start_chars {
Some(chars) => {
let lo = start.1.min(chars.len());
chars[lo..].iter().collect()
}
None => String::new(),
};
let mut prev_ended_ws = start_chars
.as_ref()
.and_then(|chars| chars.last())
.is_some_and(|c| c.is_whitespace());
result.push_str(&start_content);
for i in (start.0 + 1)..end.0 {
let content: String = match row_clean(i) {
Some(chars) => chars.into_iter().collect(),
None => String::new(),
};
if !prev_ended_ws {
result.push('\n');
}
prev_ended_ws = content.chars().next_back().is_some_and(char::is_whitespace);
result.push_str(&content);
}
if !prev_ended_ws {
result.push('\n');
}
if let Some(chars) = row_clean(end.0) {
let hi = end.1.min(chars.len());
result.extend(&chars[..hi]);
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn wrap_line(&self, line: &str, max_width: usize) -> Vec<CompactString> {
crate::ui::wrap::soft_wrap(line, max_width, "")
.into_iter()
.map(CompactString::new)
.collect()
}
fn commit_partial(&mut self) {
if !self.partial.is_empty() {
let max_width = self.max_line_width();
let c = self.partial_color;
let text = self.partial.to_string();
for chunk in self.wrap_line(&self.partial, max_width) {
self.push_buffer_line(LineEntry {
text: chunk,
color: c,
});
}
self.partial.clear();
let block = SourceBlock::Plain { text, color: c };
let rows = self.render_block(&block).len();
self.source.push(Block { src: block, rows });
self.enforce_cap();
}
}
fn render_block(&self, block: &SourceBlock) -> Vec<LineEntry> {
match block {
SourceBlock::Plain { text, color } => {
self.wrap_line(text, self.max_line_width())
.into_iter()
.map(|t| LineEntry {
text: t,
color: *color,
})
.collect()
}
SourceBlock::Markdown {
src,
base_color,
handle,
} => {
let w = if *handle {
self.content_width().saturating_sub(9)
} else {
self.content_width()
};
let mut styled = crate::ui::markdown::markdown_to_styled(src, w, *base_color);
if *handle && !styled.is_empty() {
styled[0].text = CompactString::from(format!("<dirge> {}", styled[0].text));
}
styled
}
SourceBlock::Raw { rows } => rows.clone(),
}
}
fn append_source_block(&mut self, block: SourceBlock) {
self.commit_stream();
let rendered = self.render_block(&block);
let rows = rendered.len();
for row in rendered {
self.push_buffer_line(row);
}
self.source.push(Block { src: block, rows });
self.enforce_cap();
}
pub fn stream(&mut self, src: &str, base_color: Color, handle: bool) {
self.commit_partial();
let block = SourceBlock::Markdown {
src: src.to_string(),
base_color,
handle,
};
let rows = self.render_block(&block);
let start =
self.buffer
.len()
.saturating_sub(if self.streaming { self.open_rows } else { 0 });
let old_len = self.buffer.len();
self.buffer.truncate(start);
self.buffer.extend(rows.iter().cloned());
let entry = Block {
src: block,
rows: rows.len(),
};
if self.streaming {
if let Some(last) = self.source.last_mut() {
*last = entry;
} else {
self.source.push(entry);
}
} else {
self.source.push(entry);
self.streaming = true;
}
self.open_rows = rows.len();
self.lines = self.buffer.len() as u16;
self.col = 0;
self.partial.clear();
let new_len = self.buffer.len();
self.anchor_after_resize_delta(old_len, new_len);
self.needs_paint = true;
}
pub fn commit_stream(&mut self) {
if self.streaming {
self.streaming = false;
self.open_rows = 0;
self.enforce_cap();
}
}
fn anchor_after_resize_delta(&mut self, old_len: usize, new_len: usize) {
let visible = self.visible_lines();
let max_offset = new_len.saturating_sub(visible);
if self.scroll_offset > 0 {
let delta = new_len as isize - old_len as isize;
let new_offset = (self.scroll_offset as isize + delta).max(0) as usize;
self.scroll_offset = new_offset.min(max_offset);
} else if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
}
fn enforce_cap(&mut self) {
const MAX_SCROLLBACK: usize = 20_000;
if self.buffer.len() <= MAX_SCROLLBACK {
return;
}
let mut dropped_rows = 0usize;
loop {
if self.buffer.len() - dropped_rows.min(self.buffer.len()) <= MAX_SCROLLBACK {
break;
}
let sealed = if self.streaming {
self.source.len().saturating_sub(1)
} else {
self.source.len()
};
if sealed == 0 {
break;
}
dropped_rows += self.source[0].rows;
self.source.remove(0);
}
if dropped_rows == 0 {
return;
}
let dropped_rows = dropped_rows.min(self.buffer.len());
self.buffer.drain(..dropped_rows);
self.eviction_generation = self.eviction_generation.wrapping_add(1);
if let Some(s) = self.selection_start.as_mut() {
s.0 = s.0.saturating_sub(dropped_rows);
}
if let Some(e) = self.selection_end.as_mut() {
e.0 = e.0.saturating_sub(dropped_rows);
}
let visible = self.visible_lines();
let max_offset = self.buffer.len().saturating_sub(visible);
if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
}
pub fn rebuild(&mut self) {
let old_len = self.buffer.len();
let mut blocks = std::mem::take(&mut self.source);
let mut buffer = Vec::new();
let mut open_rows = 0usize;
let last = blocks.len().saturating_sub(1);
for (i, block) in blocks.iter_mut().enumerate() {
let rows = self.render_block(&block.src);
block.rows = rows.len();
if self.streaming && i == last {
open_rows = rows.len();
}
buffer.extend(rows);
}
self.source = blocks;
self.buffer = buffer;
self.open_rows = if self.streaming { open_rows } else { 0 };
self.lines = self.buffer.len() as u16;
let new_len = self.buffer.len();
self.anchor_after_resize_delta(old_len, new_len);
self.enforce_cap();
self.needs_paint = true;
}
fn push_buffer_line(&mut self, entry: LineEntry) {
self.buffer.push(entry);
if self.scroll_offset > 0 {
let visible = self.visible_lines();
let max_offset = self.buffer.len().saturating_sub(visible);
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
}
if self.scroll_offset == 0 {
self.needs_paint = true;
}
}
pub fn is_scrolling(&self) -> bool {
self.scroll_offset > 0
}
pub fn scroll_line_up(&mut self) {
let visible = self.visible_lines();
let max_offset = self.buffer.len().saturating_sub(visible);
if self.scroll_offset < max_offset {
self.scroll_offset += 1;
}
self.needs_paint = true;
}
pub fn scroll_line_down(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
self.needs_paint = true;
}
pub fn is_scrolled_up(&self) -> bool {
self.scroll_offset > 0
}
pub fn scroll_page_up(&mut self) {
let visible = self.visible_lines();
let page = visible.saturating_sub(2).max(1);
let max_offset = self.buffer.len().saturating_sub(visible);
self.scroll_offset = (self.scroll_offset + page).min(max_offset);
self.needs_paint = true;
}
pub fn scroll_page_down(&mut self) {
let visible = self.visible_lines();
let page = visible.saturating_sub(2).max(1);
if self.scroll_offset <= page {
self.scroll_offset = 0;
} else {
self.scroll_offset = self.scroll_offset.saturating_sub(page);
}
self.needs_paint = true;
}
pub fn scroll_to_top(&mut self) {
let visible = self.visible_lines();
self.scroll_offset = self.buffer.len().saturating_sub(visible);
self.needs_paint = true;
}
pub fn scroll_to_bottom(&mut self) -> io::Result<()> {
self.scroll_offset = 0;
self.sync_to_buffer()
}
fn sync_to_buffer(&mut self) -> io::Result<()> {
self.commit_partial();
self.col = 0;
self.lines = self.buffer.len() as u16;
self.render_viewport()
}
pub fn render_viewport(&mut self) -> io::Result<()> {
self.needs_paint = true;
Ok(())
}
pub fn write_line(&mut self, text: &str, color: Color) -> io::Result<()> {
self.commit_partial();
self.append_source_block(SourceBlock::Plain {
text: text.to_string(),
color,
});
Ok(())
}
pub fn write_line_raw(&mut self, text: &str, color: Color) -> io::Result<()> {
self.commit_partial();
self.commit_stream();
let rows: Vec<LineEntry> = text
.split('\n')
.map(|l| LineEntry {
text: CompactString::from(l),
color,
})
.collect();
let n = rows.len();
for row in &rows {
self.push_buffer_line(row.clone());
}
self.source.push(Block {
src: SourceBlock::Raw { rows },
rows: n,
});
self.enforce_cap();
Ok(())
}
pub fn write(&mut self, text: &str, color: Color) -> io::Result<()> {
if text.is_empty() {
return Ok(());
}
let max_width = self.max_line_width();
if max_width == 0 {
return Ok(());
}
let parts: Vec<&str> = text.split('\n').collect();
let last = parts.len() - 1;
for (i, segment) in parts.iter().enumerate() {
if i < last {
let len_before = self.buffer.len();
self.commit_partial();
let had_content = len_before < self.buffer.len();
if !segment.is_empty() {
self.partial_color = color;
self.partial.push_str(segment);
self.commit_partial();
} else if !had_content {
self.push_buffer_line(LineEntry {
text: CompactString::new(""),
color,
});
}
self.col = 0;
} else if !segment.is_empty() {
let chars: Vec<char> = segment.chars().collect();
let mut idx = 0;
while idx < chars.len() {
let avail = max_width.saturating_sub(self.col as usize);
if avail == 0 {
self.commit_partial();
self.col = 0;
continue;
}
let end = (idx + avail).min(chars.len());
let chunk: String = chars[idx..end].iter().collect();
self.partial_color = color;
self.partial.push_str(&chunk);
self.col = self.col.saturating_add(chunk.chars().count() as u16);
idx = end;
if idx < chars.len() {
self.commit_partial();
self.col = 0;
}
}
}
}
if self.scroll_offset == 0 {
self.needs_paint = true;
}
Ok(())
}
pub fn clear_content(&mut self) -> io::Result<()> {
self.buffer.clear();
self.source.clear();
self.streaming = false;
self.open_rows = 0;
self.partial.clear();
self.scroll_offset = 0;
self.clear_selection();
let mut stdout = io::stdout();
stdout.execute(Clear(ClearType::All))?;
stdout.execute(MoveTo(0, 0))?;
stdout.flush()?;
self.lines = 0;
self.col = 0;
Ok(())
}
fn cache_bottom(
&mut self,
editor: &crate::ui::input::InputEditor,
status: &str,
is_running: bool,
) {
let prev_status = self.cached_status.clone();
let prev_running = self.cached_is_running;
let prev_rows = self.cached_input_rows.clone();
let prev_cursor = (self.cached_input_cursor_row, self.cached_input_cursor_col);
let prev_ghost = self.cached_input_ghost.clone();
let prev_preview = self.cached_completion_preview.clone();
let prev_picker = self.picker_overlay.is_some();
let (display_buf, cursor_byte) = if editor.is_in_search() {
editor.search_display()
} else {
editor.display()
};
let full = display_buf.as_str();
let cursor_byte = cursor_byte.min(full.len());
let wrap_w = self.content_width().saturating_sub(3).max(1);
let (rows, cursor_row, cursor_col) = wrap_editor(full, cursor_byte, wrap_w);
let total_rows = rows.len() as u16;
self.cached_input_rows = rows;
self.cached_input_cursor_row = cursor_row;
self.cached_input_cursor_col = cursor_col;
#[cfg(feature = "slash-completion")]
{
self.cached_input_ghost = if cursor_byte == full.len() {
crate::ui::slash::ghost_suffix(full).unwrap_or_default()
} else {
String::new()
};
}
#[cfg(not(feature = "slash-completion"))]
{
self.cached_input_ghost = String::new();
}
self.cached_status = status.to_string();
self.cached_is_running = is_running;
self.input_rows = total_rows.clamp(1, MAX_INPUT_VISIBLE_LINES as u16);
#[cfg(feature = "slash-completion")]
{
self.cached_completion_preview =
crate::ui::slash::format_completion_preview(editor.completion.as_ref(), wrap_w);
}
#[cfg(not(feature = "slash-completion"))]
{
self.cached_completion_preview = String::new();
}
let completion_extra: u16 = if self.cached_completion_preview.is_empty() {
0
} else {
1
};
self.input_rows = (total_rows + completion_extra).clamp(1, MAX_INPUT_VISIBLE_LINES as u16);
if is_running {
self.spinner_tick = !self.spinner_tick;
self.avatar_tick = !self.avatar_tick;
}
self.picker_overlay = editor
.picker
.as_ref()
.filter(|p| p.active)
.map(|p| p.overlay())
.or_else(|| self.rewind_overlay.clone());
if prev_status != self.cached_status
|| prev_running != self.cached_is_running
|| prev_rows != self.cached_input_rows
|| prev_cursor != (self.cached_input_cursor_row, self.cached_input_cursor_col)
|| prev_ghost != self.cached_input_ghost
|| prev_preview != self.cached_completion_preview
|| prev_picker != self.picker_overlay.is_some()
{
self.needs_paint = true;
}
}
pub fn draw_bottom(
&mut self,
editor: &crate::ui::input::InputEditor,
status: &str,
is_running: bool,
) -> io::Result<()> {
self.cache_bottom(editor, status, is_running);
Ok(())
}
pub fn set_bottom(
&mut self,
editor: &crate::ui::input::InputEditor,
status: &str,
is_running: bool,
) {
self.cache_bottom(editor, status, is_running);
}
pub fn request_repaint(&mut self) {
self.needs_paint = true;
}
pub fn needs_paint(&self) -> bool {
self.needs_paint
}
pub fn flush(&mut self) -> io::Result<()> {
if self.needs_paint {
self.tui_redraw()
} else {
Ok(())
}
}
#[cfg(unix)]
pub fn set_needs_repaint(&mut self) {
self.needs_paint = true;
}
#[cfg(unix)]
pub fn reset_tui(&mut self) {
self.tui_terminal = build_tui_terminal();
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VisualRow {
pub logical_line: usize,
pub char_start: usize,
pub char_end: usize,
}
#[allow(dead_code)]
pub(crate) fn wrap_input(
display_lines: &[String],
cursor_line_idx: usize,
cursor_display_col: usize,
wrap_width: usize,
) -> (Vec<VisualRow>, usize, usize) {
let wrap_width = wrap_width.max(1);
let mut rows: Vec<VisualRow> = Vec::new();
let mut cursor_visual_row = 0usize;
let mut cursor_visual_col = 0usize;
for (li, line) in display_lines.iter().enumerate() {
use unicode_width::UnicodeWidthStr;
let char_count = line.chars().count();
let display_width = UnicodeWidthStr::width(line.as_str());
let row_count = if char_count == 0 {
1
} else {
char_count.div_ceil(wrap_width)
};
let base = rows.len();
let mut emitted = row_count;
if li == cursor_line_idx {
let col = cursor_display_col;
let (vr, vc) = if col > 0 && col == display_width && col % wrap_width == 0 {
(col / wrap_width - 1, wrap_width)
} else {
(col / wrap_width, col % wrap_width)
};
cursor_visual_row = base + vr;
cursor_visual_col = vc;
if vr + 1 > emitted {
emitted = vr + 1;
}
}
for r in 0..emitted {
let cs = (r * wrap_width).min(char_count);
let ce = ((r + 1) * wrap_width).min(char_count);
rows.push(VisualRow {
logical_line: li,
char_start: cs,
char_end: ce,
});
}
}
(rows, cursor_visual_row, cursor_visual_col)
}
pub(crate) fn display_col_to_char_index(s: &str, display_col: usize) -> usize {
use unicode_width::UnicodeWidthChar;
let mut acc = 0usize;
for (char_idx, ch) in s.chars().enumerate() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if acc >= display_col {
return char_idx;
}
if acc + w > display_col {
return char_idx;
}
acc += w;
}
s.chars().count()
}
pub(crate) fn wrap_editor(
full: &str,
cursor_byte: usize,
wrap_w: usize,
) -> (Vec<String>, u16, u16) {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let wrap_w = wrap_w.max(1);
let mut rows: Vec<String> = Vec::new();
let mut cursor_row: u16 = 0;
let mut cursor_col: u16 = 0;
let cursor_byte = cursor_byte.min(full.len());
let mut byte_idx: usize = 0;
for logical in full.split('\n') {
let logical_start = byte_idx;
let _logical_end = logical_start + logical.len();
let mut cur = String::new();
let mut cur_w: usize = 0;
let mut local_byte: usize = 0;
for ch in logical.chars() {
let w = ch.width().unwrap_or(0);
if cur_w + w > wrap_w && !cur.is_empty() {
let break_at = cur.rfind([' ', '\t']);
match break_at {
Some(ws_idx) => {
let prefix: String = cur[..ws_idx].to_string();
let suffix: String = cur[ws_idx..].trim_start().to_string();
let row_start = logical_start + local_byte - cur.len();
let row_end = row_start + prefix.len();
rows.push(prefix);
if cursor_byte >= row_start && cursor_byte <= row_end {
cursor_row = rows.len() as u16 - 1;
cursor_col =
UnicodeWidthStr::width(&full[row_start..cursor_byte.min(row_end)])
as u16;
}
cur = suffix;
cur_w = UnicodeWidthStr::width(cur.as_str());
}
None => {
let row_start = logical_start + local_byte - cur.len();
let row_end = row_start + cur.len();
rows.push(std::mem::take(&mut cur));
if cursor_byte >= row_start && cursor_byte <= row_end {
cursor_row = rows.len() as u16 - 1;
cursor_col =
UnicodeWidthStr::width(&full[row_start..cursor_byte.min(row_end)])
as u16;
}
cur_w = 0;
}
}
}
cur.push(ch);
cur_w += w;
local_byte += ch.len_utf8();
}
let row_start = logical_start + local_byte - cur.len();
let row_end = logical_start + local_byte;
rows.push(cur);
if cursor_byte >= row_start && cursor_byte <= row_end {
cursor_row = rows.len() as u16 - 1;
cursor_col = UnicodeWidthStr::width(&full[row_start..cursor_byte.min(row_end)]) as u16;
}
byte_idx += logical.len() + 1;
}
if rows.is_empty() {
rows.push(String::new());
}
(rows, cursor_row, cursor_col)
}
pub(crate) fn editor_scroll_offset(total_rows: usize, cursor_row: usize, window: usize) -> usize {
if window == 0 || total_rows <= window {
return 0;
}
let max_offset = total_rows - window;
cursor_row.saturating_sub(window - 1).min(max_offset)
}
#[allow(dead_code)]
fn left_truncate(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
return s.to_string();
}
if max <= 1 {
return "…".to_string();
}
let start = chars.len() - (max - 1);
let mut out = String::with_capacity(max);
out.push('…');
out.extend(&chars[start..]);
out
}
pub fn copy_to_clipboard(text: &str) {
let cmds: &[(&str, &[&str])] = &[
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
("pbcopy", &[]),
("clip.exe", &[]),
];
for &(cmd, args) in cmds {
if let Ok(mut child) = std::process::Command::new(cmd)
.args(args)
.stdin(std::process::Stdio::piped())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
let _ = stdin.flush();
}
const CLIP_WAIT_LIMIT: std::time::Duration = std::time::Duration::from_millis(2000);
let poll_interval = std::time::Duration::from_millis(25);
let deadline = std::time::Instant::now() + CLIP_WAIT_LIMIT;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break;
}
std::thread::sleep(poll_interval);
}
Err(_) => break,
}
}
return;
}
}
}
#[cfg(test)]
#[path = "renderer_tests.rs"]
mod tests;