mod block_layout_state;
mod pending_paste;
mod presentation_mutation_generation;
mod presentation_observation_state;
mod prompt_editor_state;
mod redraw_sync_generation;
mod renderer_delivery_id;
#[cfg(test)]
mod terminal_generation_tests;
mod terminal_history_generation;
mod terminal_runtime_state;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::io::{self, BufWriter, Write};
use std::sync::{Arc, Mutex, MutexGuard, atomic as path_std_sync_atomic};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use std::{sync as path_std_sync, time as path_std_time};
use base64::engine as path_base64_engine;
use crossterm::cursor as path_crossterm_cursor;
const PROMPT_INPUT_MAX_HEIGHT_PERCENT: usize = 33;
const INPUT_HISTORY_MAX_ENTRIES: usize = 1000;
const INPUT_HISTORY_MAX_BYTES: usize = 16 * 1024 * 1024;
const STALL_WARNING_INTERVAL: Duration = Duration::from_secs(5);
static STALL_WARNING_LIMITER: Mutex<StallWarningLimiter> =
Mutex::new(StallWarningLimiter { last: None });
#[derive(Clone, Copy)]
struct InputHistoryLimits {
max_entries: usize,
max_bytes: usize,
}
struct StallWarningLimiter {
last: Option<std::time::Instant>,
}
impl StallWarningLimiter {
fn admit(&mut self, now: std::time::Instant) -> bool {
if self
.last
.is_some_and(|last| now.duration_since(last) < STALL_WARNING_INTERVAL)
{
return false;
}
self.last = Some(now);
true
}
}
fn admit_stall_warning() -> bool {
STALL_WARNING_LIMITER
.lock()
.expect("stall warning mutex poisoned")
.admit(path_std_time::Instant::now())
}
use block_layout_state::BlockLayoutState;
use crossterm::cursor::{MoveToColumn, MoveUp, SetCursorStyle};
use crossterm::event::{
self, DisableMouseCapture, Event as CtEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::style::Print;
use crossterm::{QueueableCommand, terminal};
use presentation_observation_state::{
CapturedPresentationObservations, PresentationObservationState,
};
pub use presentation_observation_state::{
OpaquePresentationFact, PresentationInvalidation, PresentationObservationKey,
};
use prompt_editor_state::PromptEditorState;
use redraw_sync_generation::RedrawSyncGeneration;
pub use renderer_delivery_id::RendererDeliveryId;
pub use tau_term_screen::{
Align, BlockId, Cell, CellRow, Color, PriorityLine, PriorityLineAlignment,
PriorityLinePriority, PriorityLineTruncation, Span, Style, StyledBlock, StyledText,
TwoLineElision, sanitize_hyperlink_target,
};
use tau_term_screen::{
Screen, display_width, emit_styled_cells, layout_block, layout_lines, next_grapheme_boundary,
previous_grapheme_boundary, truncate_to_width,
};
use terminal_history_generation::TerminalHistoryGeneration;
use terminal_runtime_state::TerminalRuntimeState;
use unicode_segmentation::UnicodeSegmentation;
type NamedActionHandler = fn(&Term) -> Option<Event>;
const NAMED_ACTIONS: &[(&str, NamedActionHandler)] = &[
("accept-completion", Term::accept_completion_event),
("backtab", Term::backtab_action),
("clear-prompt", Term::clear_prompt_action),
(
"clear-or-cancel-prompt",
Term::clear_or_cancel_prompt_action,
),
("cursor-down", Term::cycle_or_move_down),
("cursor-end", Term::move_cursor_end_action),
("cursor-left", Term::move_cursor_left_action),
("cursor-right", Term::move_cursor_right_action),
("cursor-start", Term::move_cursor_start_action),
("cursor-up", Term::cycle_or_move_up),
("delete-backward", Term::delete_backward_action),
("delete-forward", Term::delete_forward_action),
("dismiss-completion", Term::dismiss_completion_event),
("escape", Term::escape_action),
("kill-to-start", Term::kill_to_start_action),
("kill-word-left", Term::kill_word_left_action),
("move-down", Term::move_cursor_down_action),
("move-up", Term::move_cursor_up_action),
("prompt-eof", Term::prompt_eof_action),
(
"select-completion-next",
Term::select_completion_next_action,
),
(
"select-completion-previous",
Term::select_completion_previous_action,
),
];
fn named_action_handler(action: &str) -> Option<NamedActionHandler> {
NAMED_ACTIONS
.iter()
.find_map(|(name, handler)| (*name == action).then_some(*handler))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CursorShape {
Bar,
Block,
}
impl CursorShape {
fn crossterm_style(self) -> crossterm::cursor::SetCursorStyle {
match self {
Self::Bar => path_crossterm_cursor::SetCursorStyle::SteadyBar,
Self::Block => path_crossterm_cursor::SetCursorStyle::SteadyBlock,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalOptions {
pub cursor_shape: CursorShape,
pub mouse: bool,
}
impl Default for TerminalOptions {
fn default() -> Self {
Self {
cursor_shape: CursorShape::Bar,
mouse: true,
}
}
}
#[derive(Clone, Debug)]
pub struct Candidate {
pub label: String,
pub description: String,
pub replacement: String,
pub cursor: usize,
pub acceptance: Option<CompletionAcceptance>,
}
#[derive(Clone, Debug)]
pub struct CompletionAcceptance {
pub replacement: String,
pub cursor: usize,
}
pub trait CompletionSource: Send + Sync {
fn candidates(&self, buffer: &str, cursor: usize) -> Vec<Candidate>;
}
impl<F> CompletionSource for F
where
F: Fn(&str, usize) -> Vec<Candidate> + Send + Sync,
{
fn candidates(&self, buffer: &str, cursor: usize) -> Vec<Candidate> {
(self)(buffer, cursor)
}
}
#[derive(Clone, Debug)]
pub struct CompletionView {
pub candidates: Vec<Candidate>,
pub selected: Option<usize>,
}
#[derive(Clone)]
struct PromptSnapshot {
buffer: String,
cursor: usize,
}
#[derive(Clone)]
struct PromptDraft {
buffer: String,
cursor: usize,
undo: Vec<PromptSnapshot>,
redo: Vec<PromptSnapshot>,
}
impl PromptDraft {
fn submitted(buffer: String) -> Self {
let cursor = buffer.len();
Self {
buffer,
cursor,
undo: Vec::new(),
redo: Vec::new(),
}
}
}
struct HistoryNavEntry {
draft: PromptDraft,
source_index: Option<usize>,
}
struct HistoryNav {
entries: Vec<HistoryNavEntry>,
index: usize,
}
struct CompletionMenu {
candidates: Vec<Candidate>,
selected: Option<usize>,
original_buffer: String,
original_cursor: usize,
}
struct SharedState {
layout: BlockLayoutState,
editor: PromptEditorState,
terminal: TerminalRuntimeState,
presentation_observations: PresentationObservationState,
#[cfg(test)]
presentation_failure_test_records: Vec<(&'static str, u128, usize, u64)>,
#[cfg(any(test, feature = "history-retention-test-support"))]
input_history_limit_override: Option<InputHistoryLimits>,
}
impl SharedState {
fn new(width: usize, height: usize, left_prompt: StyledText) -> Self {
Self {
layout: BlockLayoutState::new(),
editor: PromptEditorState::new(left_prompt),
terminal: TerminalRuntimeState::new(width, height),
presentation_observations: PresentationObservationState::new(),
#[cfg(test)]
presentation_failure_test_records: Vec::new(),
#[cfg(any(test, feature = "history-retention-test-support"))]
input_history_limit_override: None,
}
}
fn advance_completion_generation(&mut self) {
self.editor.completion_generation = self.editor.completion_generation.wrapping_add(1);
}
fn alloc_id(&mut self) -> BlockId {
let id = BlockId(self.layout.next_id);
self.layout.next_id += 1;
id
}
fn mark_history_dirty_from(&mut self, entry: usize) {
self.layout.history_generation.advance();
self.layout.history_dirty_from = Some(
self.layout
.history_dirty_from
.map_or(entry, |dirty| dirty.min(entry)),
);
}
fn add_history_ref(&mut self, id: BlockId) {
*self.layout.history_refs.entry(id).or_insert(0) += 1;
}
fn append_history(&mut self, id: BlockId) {
let appended_at = self.layout.history.len();
self.layout.history.push(id);
self.add_history_ref(id);
self.mark_history_dirty_from(appended_at);
}
fn remove_history_refs(&mut self, id: BlockId, count: usize) {
if count == 0 {
return;
}
if let Some(existing) = self.layout.history_refs.get_mut(&id) {
if *existing <= count {
self.layout.history_refs.remove(&id);
} else {
*existing -= count;
}
}
}
fn rebuild_history_refs(&mut self) {
self.layout.history_refs.clear();
for &id in &self.layout.history {
*self.layout.history_refs.entry(id).or_insert(0) += 1;
}
self.mark_history_dirty_from(0);
}
fn block_in_history(&self, id: BlockId) -> bool {
self.layout.history_refs.contains_key(&id)
}
fn block_is_visible(&self, id: BlockId) -> bool {
self.block_in_history(id)
|| self.layout.above_active.contains(&id)
|| self.layout.above_sticky.contains(&id)
|| self.layout.suggestions.contains(&id)
|| self.layout.below.contains(&id)
}
fn remove_block(&mut self, id: BlockId, observe_delta: bool) -> (bool, Option<StyledBlock>) {
let presentation_changed = observe_delta && self.block_is_visible(id);
let removed_block = self.layout.blocks.remove(&id);
let existed = removed_block.is_some();
let debug_id = self.layout.block_debug_ids.remove(&id);
if self.block_in_history(id) {
#[cfg(test)]
{
self.layout.history_removal_scan_entries += self.layout.history.len();
}
let removal = remove_all_from_zone(&mut self.layout.history, id);
let indexed_refs = self
.layout
.history_refs
.get(&id)
.copied()
.expect("history membership index must contain referenced block");
debug_assert_eq!(
removal.count, indexed_refs,
"history membership index must exactly count duplicate references"
);
self.remove_history_refs(id, removal.count);
self.mark_history_dirty_from(
removal
.first_index
.expect("history membership index must imply one matching entry"),
);
}
remove_all_from_zone(&mut self.layout.above_active, id);
remove_all_from_zone(&mut self.layout.above_sticky, id);
remove_all_from_zone(&mut self.layout.suggestions, id);
remove_all_from_zone(&mut self.layout.below, id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, ?debug_id, existed, "remove block");
(presentation_changed, removed_block)
}
fn current_snapshot(&self) -> PromptSnapshot {
PromptSnapshot {
buffer: self.editor.buffer.clone(),
cursor: self.editor.cursor,
}
}
fn current_draft(&self) -> PromptDraft {
PromptDraft {
buffer: self.editor.buffer.clone(),
cursor: self.editor.cursor,
undo: self.editor.current_undo.clone(),
redo: self.editor.current_redo.clone(),
}
}
fn limit_input_history(&mut self) {
let limits = self.input_history_limits();
let recalled_source = self.editor.last_submitted_recalled_source;
let mut retained_source = None;
let mut original_entries = 0;
let mut retained_entries = 0;
self.editor.input_history.retain(|draft| {
let retain = !draft.buffer.is_empty() && draft.buffer.len() <= limits.max_bytes;
if retain && recalled_source == Some(original_entries) {
retained_source = Some(retained_entries);
}
original_entries += 1;
retained_entries += usize::from(retain);
retain
});
if self.editor.input_history.len() != original_entries {
self.editor.history_nav = None;
}
self.editor.last_submitted_recalled_source = retained_source;
let mut retained_bytes = 0;
let mut retained_start = self.editor.input_history.len();
for (retained_entries, (index, draft)) in self
.editor
.input_history
.iter()
.enumerate()
.rev()
.enumerate()
{
if retained_entries == limits.max_entries
|| draft.buffer.len() > limits.max_bytes - retained_bytes
{
break;
}
retained_bytes += draft.buffer.len();
retained_start = index;
}
if retained_start == 0 {
return;
}
self.editor.input_history.drain(..retained_start);
self.editor.history_nav = None;
self.editor.last_submitted_recalled_source = self
.editor
.last_submitted_recalled_source
.and_then(|index| index.checked_sub(retained_start));
}
fn input_history_limits(&self) -> InputHistoryLimits {
#[cfg(any(test, feature = "history-retention-test-support"))]
if let Some(limits) = self.input_history_limit_override {
return limits;
}
InputHistoryLimits {
max_entries: INPUT_HISTORY_MAX_ENTRIES,
max_bytes: INPUT_HISTORY_MAX_BYTES,
}
}
fn take_submitted_draft(&mut self) -> PromptDraft {
PromptDraft {
buffer: self.editor.buffer.clone(),
cursor: self.editor.cursor,
undo: std::mem::take(&mut self.editor.current_undo),
redo: std::mem::take(&mut self.editor.current_redo),
}
}
fn load_draft(&mut self, draft: PromptDraft) {
self.editor.buffer = draft.buffer;
self.editor.current_undo = draft.undo;
self.editor.current_redo = draft.redo;
self.editor.cursor = draft.cursor.min(self.editor.buffer.len());
self.ensure_input_cursor_visible();
}
fn record_undo(&mut self) {
self.editor.current_undo.push(self.current_snapshot());
self.editor.current_redo.clear();
}
fn sync_buffer_to_history_nav(&mut self) {
let draft = self.current_draft();
if let Some(nav) = self.editor.history_nav.as_mut() {
nav.entries[nav.index].draft = draft.clone();
if let Some(source_index) = nav.entries[nav.index].source_index
&& let Some(source) = self.editor.input_history.get_mut(source_index)
{
*source = draft;
}
}
}
fn visual_cursor_position(&self) -> (usize, usize) {
let width = self.terminal.width.max(1);
let left_cols = self.editor.left_prompt.char_count();
buffer_position_for_byte(&self.editor.buffer, self.editor.cursor, width, left_cols)
}
fn last_visual_row(&self) -> usize {
let width = self.terminal.width.max(1);
let left_cols = self.editor.left_prompt.char_count();
let (max_row, _) = buffer_end_position(&self.editor.buffer, width, left_cols);
max_row
}
fn cursor_byte_at(&self, target_row: usize, target_col: usize) -> usize {
let width = self.terminal.width.max(1);
let left_cols = self.editor.left_prompt.char_count();
byte_offset_for_buffer_position(
&self.editor.buffer,
target_row,
target_col,
width,
left_cols,
)
}
fn vertical_target_col(&mut self) -> usize {
if let Some(col) = self.editor.sticky_col {
return col;
}
let (_, col) = self.visual_cursor_position();
self.editor.sticky_col = Some(col);
col
}
fn write_cursor(&mut self, new_cursor: usize) {
self.editor.cursor = new_cursor;
self.editor.sticky_col = None;
self.ensure_input_cursor_visible();
}
fn write_cursor_keep_sticky(&mut self, new_cursor: usize) {
self.editor.cursor = new_cursor;
self.ensure_input_cursor_visible();
}
fn input_visible_rows(&self) -> usize {
let total_rows = self.last_visual_row() + 1;
let cap_rows = prompt_input_max_rows(self.terminal.height);
let indicator_rows = prompt_scroll_indicator_rows(
self.editor.show_prompt_scroll_indicator,
!self.editor.buffer.is_empty(),
total_rows,
cap_rows,
);
prompt_editable_rows(total_rows, cap_rows, indicator_rows)
}
fn ensure_input_cursor_visible(&mut self) {
let (cursor_row, _) = self.visual_cursor_position();
let total_rows = self.last_visual_row() + 1;
let visible_rows = self.input_visible_rows();
self.editor.input_viewport_start = viewport_start_with_cursor(
self.editor.input_viewport_start,
cursor_row,
total_rows,
visible_rows,
);
}
fn push_current_as_history_entry(&mut self, enforce_limit: bool) -> bool {
if self.editor.buffer.is_empty() {
if enforce_limit {
self.limit_input_history();
}
return false;
}
let draft = self.take_submitted_draft();
self.editor.input_history.push(draft);
if enforce_limit {
self.limit_input_history();
}
self.editor.buffer.clear();
self.write_cursor(0);
true
}
fn undo(&mut self) -> bool {
let Some(snapshot) = self.editor.current_undo.pop() else {
return false;
};
self.editor.current_redo.push(self.current_snapshot());
self.editor.buffer = snapshot.buffer;
self.write_cursor(snapshot.cursor.min(self.editor.buffer.len()));
self.sync_buffer_to_history_nav();
true
}
fn redo(&mut self) -> bool {
let Some(snapshot) = self.editor.current_redo.pop() else {
return false;
};
self.editor.current_undo.push(self.current_snapshot());
self.editor.buffer = snapshot.buffer;
self.write_cursor(snapshot.cursor.min(self.editor.buffer.len()));
self.sync_buffer_to_history_nav();
true
}
fn cycle_completion(&mut self, delta: isize) -> bool {
let (new_buffer, new_cursor) = {
let Some(menu) = self.editor.completion.as_mut() else {
return false;
};
let len = menu.candidates.len();
if len == 0 {
return false;
}
let new_selected = match menu.selected {
None => Some(if 0 < delta { 0 } else { len - 1 }),
Some(0) if delta < 0 => None,
Some(i) => Some((i as isize + delta).rem_euclid(len as isize) as usize),
};
menu.selected = new_selected;
match new_selected {
None => (menu.original_buffer.clone(), menu.original_cursor),
Some(idx) => {
let candidate = &menu.candidates[idx];
let buf = candidate.replacement.clone();
let cursor = candidate.cursor;
(buf, cursor)
}
}
};
self.editor.buffer = new_buffer;
self.write_cursor(new_cursor);
self.advance_completion_generation();
true
}
fn dismiss_completion(&mut self) -> bool {
let Some(menu) = self.editor.completion.take() else {
return false;
};
if menu.selected.is_some() {
self.editor.buffer = menu.original_buffer;
self.write_cursor(menu.original_cursor);
}
self.advance_completion_generation();
true
}
fn accept_completion(&mut self) -> bool {
let Some(menu) = self.editor.completion.take() else {
return false;
};
let Some(selected) = menu.selected else {
self.editor.completion = Some(menu);
return false;
};
if let Some(acceptance) = &menu.candidates[selected].acceptance {
self.editor.buffer.clone_from(&acceptance.replacement);
self.write_cursor(acceptance.cursor);
}
self.advance_completion_generation();
true
}
fn step_history(&mut self, delta: isize) -> bool {
let target_col = self.vertical_target_col();
if self.editor.history_nav.is_none() {
if 0 < delta {
return self.push_current_as_history_entry(true);
}
return self.enter_history_nav(target_col);
}
self.advance_history_nav(delta, target_col)
}
fn enter_history_nav(&mut self, target_col: usize) -> bool {
if self.editor.input_history.is_empty() {
return false;
}
let mut entries: Vec<_> = self
.editor
.input_history
.iter()
.cloned()
.enumerate()
.map(|(source_index, draft)| HistoryNavEntry {
draft,
source_index: Some(source_index),
})
.collect();
entries.push(HistoryNavEntry {
draft: self.current_draft(),
source_index: None,
});
let index = entries.len() - 2;
self.load_draft(entries[index].draft.clone());
let new_cursor = self.cursor_byte_at(self.last_visual_row(), target_col);
self.write_cursor_keep_sticky(new_cursor);
self.editor.history_nav = Some(HistoryNav { entries, index });
true
}
fn recall_prompt_before_current(&mut self, text: String) {
let previous = self.current_draft();
let previous_source = self.editor.history_nav.as_ref().and_then(|nav| {
nav.entries
.get(nav.index)
.and_then(|entry| entry.source_index)
});
let mut entries: Vec<_> = self
.editor
.input_history
.iter()
.cloned()
.enumerate()
.map(|(source_index, draft)| HistoryNavEntry {
draft,
source_index: Some(source_index),
})
.collect();
entries.push(HistoryNavEntry {
draft: PromptDraft::submitted(text),
source_index: None,
});
entries.push(HistoryNavEntry {
draft: previous,
source_index: previous_source,
});
let index = entries.len() - 2;
self.load_draft(entries[index].draft.clone());
self.write_cursor(self.editor.buffer.len());
self.editor.history_nav = Some(HistoryNav { entries, index });
self.editor.completion = None;
}
fn advance_history_nav(&mut self, delta: isize, target_col: usize) -> bool {
let current = self.current_draft();
let nav = self
.editor
.history_nav
.as_mut()
.expect("caller checked Some");
let new_index = nav.index as isize + delta;
if new_index < 0 {
return false;
}
if new_index >= nav.entries.len() as isize {
let wip = nav.entries.last().map(|entry| entry.draft.clone());
self.editor.history_nav = None;
if let Some(wip) = wip {
self.load_draft(wip);
}
return self.push_current_as_history_entry(true);
}
nav.entries[nav.index].draft = current.clone();
if let Some(source_index) = nav.entries[nav.index].source_index
&& let Some(source) = self.editor.input_history.get_mut(source_index)
{
*source = current;
}
nav.index = new_index as usize;
let new_draft = nav.entries[nav.index].draft.clone();
self.load_draft(new_draft);
let target_row = if delta < 0 { self.last_visual_row() } else { 0 };
let new_cursor = self.cursor_byte_at(target_row, target_col);
self.write_cursor_keep_sticky(new_cursor);
true
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum KeyBinding {
Ctrl(char),
CtrlShift(char),
Meta(char),
CtrlKey(KeyCode),
Key(KeyCode),
}
fn parse_plain_key_code(input: &str) -> Option<KeyCode> {
match input.to_ascii_lowercase().as_str() {
"backspace" => Some(KeyCode::Backspace),
"backtab" | "shift-tab" => Some(KeyCode::BackTab),
"delete" | "del" => Some(KeyCode::Delete),
"down" => Some(KeyCode::Down),
"end" => Some(KeyCode::End),
"enter" => Some(KeyCode::Enter),
"esc" | "escape" => Some(KeyCode::Esc),
"home" => Some(KeyCode::Home),
"left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right),
"tab" => Some(KeyCode::Tab),
"up" => Some(KeyCode::Up),
_ => None,
}
}
fn parse_key_binding(input: &str) -> Option<KeyBinding> {
let input = input.trim_matches('`');
if let Some(code) = parse_plain_key_code(input) {
return Some(KeyBinding::Key(code));
}
if let Some(rest) = input.strip_prefix("M-") {
let mut chars = rest.chars();
let ch = chars.next()?;
return (chars.next().is_none() && ch.is_ascii()).then_some(KeyBinding::Meta(ch));
}
let rest = input
.strip_prefix("C-")
.or_else(|| input.strip_prefix("c-"))?;
match rest.to_ascii_lowercase().as_str() {
"enter" => return Some(KeyBinding::CtrlKey(KeyCode::Enter)),
"up" => return Some(KeyBinding::CtrlKey(KeyCode::Up)),
"down" => return Some(KeyBinding::CtrlKey(KeyCode::Down)),
_ => {}
}
let mut chars = rest.chars();
let ch = chars.next()?;
if chars.next().is_some() {
return None;
}
if ch.is_ascii_uppercase() {
Some(KeyBinding::CtrlShift(ch.to_ascii_lowercase()))
} else {
Some(KeyBinding::Ctrl(ch.to_ascii_lowercase()))
}
}
fn key_binding_for_event(key: KeyEvent, ctrl: bool) -> Option<KeyBinding> {
let modifiers = key.modifiers;
let plain = modifiers.is_empty();
let ctrl_only = modifiers == KeyModifiers::CONTROL;
match key.code {
KeyCode::Char(ch) if modifiers == KeyModifiers::ALT => Some(KeyBinding::Meta(ch)),
KeyCode::Char(ch)
if ctrl
&& ch.is_ascii_alphabetic()
&& (modifiers.contains(KeyModifiers::SHIFT) || ch.is_ascii_uppercase()) =>
{
Some(KeyBinding::CtrlShift(ch.to_ascii_lowercase()))
}
KeyCode::Char(ch) if ctrl => Some(KeyBinding::Ctrl(ch.to_ascii_lowercase())),
KeyCode::Char(ch @ '\u{1}'..='\u{1a}') => {
let letter = (b'a' + ch as u8 - 1) as char;
Some(KeyBinding::Ctrl(letter))
}
KeyCode::Enter if ctrl_only => Some(KeyBinding::CtrlKey(KeyCode::Enter)),
KeyCode::Up | KeyCode::Down if ctrl_only => Some(KeyBinding::CtrlKey(key.code)),
KeyCode::BackTab => Some(KeyBinding::Key(KeyCode::BackTab)),
KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Down
| KeyCode::End
| KeyCode::Enter
| KeyCode::Esc
| KeyCode::Home
| KeyCode::Left
| KeyCode::Right
| KeyCode::Tab
| KeyCode::Up
if plain =>
{
Some(KeyBinding::Key(key.code))
}
_ => None,
}
}
pub enum Event {
PasteUpload {
id: u64,
text: Arc<str>,
},
PasteCancelled {
id: u64,
},
Line(String),
Eof,
CancelPrompt,
Resize { width: u16, height: u16 },
FocusChanged { focused: bool },
BufferChanged,
CompletionRefresh,
CompletionAccept,
BackTab,
Escape,
Binding(String),
Notice(String),
ExternalEditor,
}
#[derive(Default)]
struct ZoneRemoval {
count: usize,
first_index: Option<usize>,
}
fn remove_all_from_zone(zone: &mut Vec<BlockId>, id: BlockId) -> ZoneRemoval {
let mut removal = ZoneRemoval::default();
let mut index = 0;
zone.retain(|&candidate| {
let current_index = index;
index += 1;
if candidate == id {
removal.count += 1;
removal.first_index.get_or_insert(current_index);
false
} else {
true
}
});
removal
}
#[derive(Clone, Debug, Default)]
pub struct OutputSnapshot {
blocks: HashMap<BlockId, StyledBlock>,
block_debug_ids: HashMap<BlockId, String>,
next_id: u64,
history: Vec<BlockId>,
above_active: Vec<BlockId>,
above_sticky: Vec<BlockId>,
suggestions: Vec<BlockId>,
below: Vec<BlockId>,
}
impl OutputSnapshot {
pub fn block_count(&self) -> usize {
self.blocks.len()
}
pub fn suggestion_ids(&self) -> &[BlockId] {
&self.suggestions
}
pub fn new_block(
&mut self,
debug_id: impl Into<String>,
block: impl Into<StyledBlock>,
) -> BlockId {
let id = BlockId(self.next_id);
self.next_id = self.next_id.saturating_add(1);
self.blocks.insert(id, block.into());
self.block_debug_ids.insert(id, debug_id.into());
id
}
pub fn set_block(&mut self, id: BlockId, block: impl Into<StyledBlock>) {
self.blocks.insert(id, block.into());
self.block_debug_ids
.entry(id)
.or_insert_with(|| format!("set-block-{}", id.0));
}
pub fn remove_block(&mut self, id: BlockId) {
self.blocks.remove(&id);
self.block_debug_ids.remove(&id);
remove_all_from_zone(&mut self.history, id);
remove_all_from_zone(&mut self.above_active, id);
remove_all_from_zone(&mut self.above_sticky, id);
remove_all_from_zone(&mut self.suggestions, id);
remove_all_from_zone(&mut self.below, id);
}
pub fn push_history(&mut self, id: BlockId) {
self.history.push(id);
}
pub fn push_above_active(&mut self, id: BlockId) {
if !self.above_active.contains(&id) {
self.above_active.push(id);
}
}
pub fn push_above_active_before_any<I>(&mut self, id: BlockId, anchors: I)
where
I: IntoIterator<Item = BlockId>,
{
let anchors = anchors.into_iter().collect::<HashSet<_>>();
self.above_active.retain(|active_id| *active_id != id);
let insert_at = self
.above_active
.iter()
.position(|active_id| anchors.contains(active_id))
.unwrap_or(self.above_active.len());
self.above_active.insert(insert_at, id);
}
pub fn push_above_sticky(&mut self, id: BlockId) {
if !self.above_sticky.contains(&id) {
self.above_sticky.push(id);
}
}
pub fn remove_above_sticky(&mut self, id: BlockId) {
self.above_sticky.retain(|block_id| *block_id != id);
}
pub fn push_below(&mut self, id: BlockId) {
if !self.below.contains(&id) {
self.below.push(id);
}
}
pub fn print_output(
&mut self,
debug_id: impl Into<String>,
block: impl Into<StyledBlock>,
) -> BlockId {
let id = self.new_block(debug_id, block);
self.push_history(id);
id
}
}
#[derive(Clone)]
pub struct TermHandle {
state: Arc<Mutex<SharedState>>,
output_transaction: Arc<Mutex<()>>,
sync_condvar: Arc<std::sync::Condvar>,
redraw: tau_blocking_notify_channel::Sender,
input_tx: path_std_sync::mpsc::Sender<InputMessage>,
output_snapshot_count: Arc<path_std_sync::atomic::AtomicU64>,
output_snapshot_take_count: Arc<path_std_sync::atomic::AtomicU64>,
#[cfg(feature = "redraw-test-counter")]
redraw_request_count: Arc<path_std_sync::atomic::AtomicU64>,
#[cfg(test)]
retirement_probe_count: Arc<path_std_sync_atomic::AtomicU64>,
}
thread_local! {
static HELD_OUTPUT_TRANSACTIONS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new());
#[cfg(test)]
static HELD_SHARED_STATES: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new());
static RETIRED_STYLED_BLOCKS: RefCell<HashMap<usize, Vec<RetiredStyledBlocks>>> =
RefCell::new(HashMap::new());
}
#[cfg(test)]
struct SharedStateGuard<'a> {
guard: MutexGuard<'a, SharedState>,
key: usize,
}
#[cfg(test)]
impl std::ops::Deref for SharedStateGuard<'_> {
type Target = SharedState;
fn deref(&self) -> &Self::Target {
&self.guard
}
}
#[cfg(test)]
impl std::ops::DerefMut for SharedStateGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.guard
}
}
#[cfg(test)]
impl Drop for SharedStateGuard<'_> {
fn drop(&mut self) {
HELD_SHARED_STATES.with(|held| {
let mut held = held.borrow_mut();
let depth = held
.get_mut(&self.key)
.expect("shared terminal state lock depth missing");
*depth -= 1;
if *depth == 0 {
held.remove(&self.key);
}
});
}
}
#[cfg(not(test))]
type SharedStateGuard<'a> = MutexGuard<'a, SharedState>;
struct OutputTransactionDepthGuard {
key: usize,
}
impl Drop for OutputTransactionDepthGuard {
fn drop(&mut self) {
HELD_OUTPUT_TRANSACTIONS.with(|held| {
let mut held = held.borrow_mut();
let depth = held
.get_mut(&self.key)
.expect("output transaction depth missing");
*depth -= 1;
if *depth == 0 {
held.remove(&self.key);
}
});
}
}
struct OutputTransactionGuard<'a> {
guard: Option<MutexGuard<'a, ()>>,
depth: Option<OutputTransactionDepthGuard>,
key: usize,
acquired_at: std::time::Instant,
#[cfg(test)]
state: Arc<Mutex<SharedState>>,
#[cfg(test)]
retirement_probe_count: Arc<path_std_sync_atomic::AtomicU64>,
}
enum RetiredStyledBlocks {
One(StyledBlock),
Snapshot(HashMap<BlockId, StyledBlock>),
}
impl Drop for OutputTransactionGuard<'_> {
fn drop(&mut self) {
let held = self.acquired_at.elapsed();
if Duration::from_millis(500) <= held && admit_stall_warning() {
tracing::warn!(
target: "tau_cli_term_raw::frontend_progress",
hold_ms = held.as_millis(),
"terminal output transaction stalled"
);
}
let retired = RETIRED_STYLED_BLOCKS
.with(|retired| retired.borrow_mut().remove(&self.key).unwrap_or_default());
drop(self.depth.take());
drop(self.guard.take());
for retired in retired {
#[cfg(test)]
{
assert!(
!HELD_OUTPUT_TRANSACTIONS.with(|held| held.borrow().contains_key(&self.key)),
"styled block retirement must follow output transaction release"
);
assert!(
!HELD_SHARED_STATES.with(|held| {
held.borrow()
.contains_key(&(Arc::as_ptr(&self.state) as usize))
}),
"styled block retirement must follow shared terminal state release"
);
self.retirement_probe_count
.fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
}
match retired {
RetiredStyledBlocks::One(block) => drop(block),
RetiredStyledBlocks::Snapshot(blocks) => drop(blocks),
}
}
}
}
#[must_use = "redraw suppression ends immediately when the guard is dropped"]
pub struct RedrawSuppressionGuard {
handle: TermHandle,
}
impl RedrawSuppressionGuard {
fn new(handle: &TermHandle) -> Self {
{
let mut st = handle.lock();
st.terminal.redraw_suppression = st.terminal.redraw_suppression.saturating_add(1);
}
Self {
handle: handle.clone(),
}
}
}
impl Drop for RedrawSuppressionGuard {
fn drop(&mut self) {
let notify = {
let mut st = self.handle.lock();
st.terminal.redraw_suppression = st.terminal.redraw_suppression.saturating_sub(1);
if st.terminal.redraw_suppression == 0 && st.terminal.redraw_dirty_while_suppressed {
st.terminal.redraw_dirty_while_suppressed = false;
true
} else {
false
}
};
if notify {
self.handle.release_redraw_notification();
}
}
}
impl TermHandle {
fn lock(&self) -> SharedStateGuard<'_> {
let guard = self.state.lock().expect("term state mutex poisoned");
#[cfg(test)]
{
let key = Arc::as_ptr(&self.state) as usize;
HELD_SHARED_STATES.with(|held| {
*held.borrow_mut().entry(key).or_insert(0) += 1;
});
SharedStateGuard { guard, key }
}
#[cfg(not(test))]
{
guard
}
}
fn output_transaction_key(&self) -> usize {
Arc::as_ptr(&self.output_transaction) as usize
}
fn output_transaction_is_held(&self) -> bool {
let key = self.output_transaction_key();
HELD_OUTPUT_TRANSACTIONS.with(|held| held.borrow().contains_key(&key))
}
fn mark_output_transaction_held(&self) -> OutputTransactionDepthGuard {
let key = self.output_transaction_key();
HELD_OUTPUT_TRANSACTIONS.with(|held| {
let mut held = held.borrow_mut();
*held.entry(key).or_insert(0) += 1;
});
OutputTransactionDepthGuard { key }
}
fn retire_styled_blocks(&self, retired: RetiredStyledBlocks) {
let key = self.output_transaction_key();
debug_assert!(self.output_transaction_is_held());
RETIRED_STYLED_BLOCKS.with(|retirements| {
retirements
.borrow_mut()
.entry(key)
.or_default()
.push(retired);
});
}
#[cfg(test)]
fn retirement_probe_count(&self) -> u64 {
self.retirement_probe_count
.load(path_std_sync_atomic::Ordering::Relaxed)
}
fn output_transaction_barrier(&self) -> Option<OutputTransactionGuard<'_>> {
if self.output_transaction_is_held() {
return None;
}
let waiting_at = path_std_time::Instant::now();
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
"terminal output transaction acquisition started"
);
let guard = self
.output_transaction
.lock()
.expect("term output transaction mutex poisoned");
let waited = waiting_at.elapsed();
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
wait_us = waited.as_micros(),
"terminal output transaction acquired"
);
if Duration::from_millis(500) <= waited && admit_stall_warning() {
tracing::warn!(
target: "tau_cli_term_raw::frontend_progress",
wait_ms = waited.as_millis(),
"terminal output transaction acquisition stalled"
);
}
let depth = self.mark_output_transaction_held();
Some(OutputTransactionGuard {
guard: Some(guard),
depth: Some(depth),
key: self.output_transaction_key(),
acquired_at: path_std_time::Instant::now(),
#[cfg(test)]
state: Arc::clone(&self.state),
#[cfg(test)]
retirement_probe_count: Arc::clone(&self.retirement_probe_count),
})
}
fn request_redraw_locked(st: &mut SharedState) -> bool {
if st.terminal.redraw_suppression == 0 {
true
} else {
st.terminal.redraw_dirty_while_suppressed = true;
false
}
}
fn notify_redraw(&self) {
let notify = {
let mut st = self.lock();
Self::request_redraw_locked(&mut st)
};
if notify {
self.release_redraw_notification();
}
}
fn release_redraw_notification(&self) {
#[cfg(feature = "redraw-test-counter")]
self.redraw_request_count
.fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
self.redraw.notify();
}
pub fn request_input_shutdown(&self) {
self.lock().terminal.input_shutdown = true;
let _ = self.input_tx.send(InputMessage::Shutdown);
}
pub fn request_completion_refresh(&self) {
let _ = self.input_tx.send(InputMessage::RefreshCompletion);
}
pub fn completion_refresh_generation(&self) -> u64 {
self.lock().editor.completion_generation
}
pub fn request_completion_refresh_if_generation(&self, generation: u64) {
let _ = self
.input_tx
.send(InputMessage::RefreshCompletionIfGeneration(generation));
}
pub fn with_redraw_suppressed<R>(&self, f: impl FnOnce() -> R) -> R {
let _guard = RedrawSuppressionGuard::new(self);
f()
}
pub fn suppress_redraws(&self) -> RedrawSuppressionGuard {
RedrawSuppressionGuard::new(self)
}
pub fn with_output_transaction<R>(&self, f: impl FnOnce() -> R) -> R {
let _transaction = self.output_transaction_barrier();
f()
}
pub fn redraw(&self) {
self.notify_redraw();
}
pub fn observe_presentation_mutation(
&self,
delivery_id: RendererDeliveryId,
fact: OpaquePresentationFact,
) -> bool {
self.observe_presentation_mutation_enabled(delivery_id, fact)
}
fn observe_presentation_mutation_enabled(
&self,
delivery_id: RendererDeliveryId,
fact: OpaquePresentationFact,
) -> bool {
let observed_at = path_std_time::Instant::now();
let (notify, capture_suppressed) = {
let mut st = self.lock();
st.presentation_observations
.register(delivery_id, fact, observed_at);
(
Self::request_redraw_locked(&mut st),
st.terminal.redraw_suppression != 0,
)
};
if notify {
self.release_redraw_notification();
}
capture_suppressed
}
#[cfg(test)]
fn observe_presentation_mutation_for_test(
&self,
delivery_id: RendererDeliveryId,
fact: OpaquePresentationFact,
) -> bool {
self.observe_presentation_mutation_enabled(delivery_id, fact)
}
#[cfg(feature = "redraw-test-counter")]
pub fn redraw_request_count(&self) -> u64 {
self.redraw_request_count
.load(path_std_sync_atomic::Ordering::Relaxed)
}
pub fn clear_output(&self) {
self.replace_output_snapshot(OutputSnapshot::default());
}
pub fn output_snapshot(&self) -> OutputSnapshot {
self.output_snapshot_count
.fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
let _transaction = self.output_transaction_barrier();
let st = self.lock();
OutputSnapshot {
blocks: st.layout.blocks.clone(),
block_debug_ids: st.layout.block_debug_ids.clone(),
next_id: st.layout.next_id,
history: st.layout.history.clone(),
above_active: st.layout.above_active.clone(),
above_sticky: st.layout.above_sticky.clone(),
suggestions: st.layout.suggestions.clone(),
below: st.layout.below.clone(),
}
}
pub fn output_snapshot_count(&self) -> u64 {
self.output_snapshot_count
.load(path_std_sync_atomic::Ordering::Relaxed)
}
pub fn take_output_snapshot(&self) -> OutputSnapshot {
self.output_snapshot_take_count
.fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
OutputSnapshot {
blocks: std::mem::take(&mut st.layout.blocks),
block_debug_ids: std::mem::take(&mut st.layout.block_debug_ids),
next_id: st.layout.next_id,
history: std::mem::take(&mut st.layout.history),
above_active: std::mem::take(&mut st.layout.above_active),
above_sticky: std::mem::take(&mut st.layout.above_sticky),
suggestions: std::mem::take(&mut st.layout.suggestions),
below: std::mem::take(&mut st.layout.below),
}
}
pub fn output_snapshot_take_count(&self) -> u64 {
self.output_snapshot_take_count
.load(path_std_sync_atomic::Ordering::Relaxed)
}
pub fn replace_output_snapshot(&self, snapshot: OutputSnapshot) {
self.replace_output_snapshot_inner(snapshot, true, true);
}
pub fn replace_output_snapshot_quiet(&self, snapshot: OutputSnapshot) {
self.replace_output_snapshot_inner(snapshot, false, false);
}
fn replace_output_snapshot_inner(
&self,
snapshot: OutputSnapshot,
invalidate_screen: bool,
notify: bool,
) {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
let retired_blocks = std::mem::replace(&mut st.layout.blocks, snapshot.blocks);
st.layout.block_debug_ids = snapshot.block_debug_ids;
st.layout.next_id = st.layout.next_id.max(snapshot.next_id);
st.layout.history = snapshot.history;
st.rebuild_history_refs();
st.layout.above_active = snapshot.above_active;
st.layout.above_sticky = snapshot.above_sticky;
st.layout.suggestions = snapshot.suggestions;
st.layout.below = snapshot.below;
if invalidate_screen {
st.terminal.invalidate_screen = true;
}
let notify = notify && Self::request_redraw_locked(&mut st);
drop(st);
if !retired_blocks.is_empty() {
self.retire_styled_blocks(RetiredStyledBlocks::Snapshot(retired_blocks));
}
if notify {
self.release_redraw_notification();
}
}
pub fn invalidate_screen(&self) {
let _transaction = self.output_transaction_barrier();
self.lock().terminal.invalidate_screen = true;
self.notify_redraw();
}
pub fn size(&self) -> (usize, usize) {
let st = self.lock();
(st.terminal.width, st.terminal.height)
}
pub fn height(&self) -> usize {
self.lock().terminal.height
}
pub fn full_render_count(&self) -> u64 {
self.lock().terminal.full_render_count
}
pub fn redraw_history_size(&self) -> usize {
self.lock().terminal.redraw_history_size
}
pub fn set_redraw_history_size(&self, redraw_history_size: usize) {
self.lock().terminal.redraw_history_size = redraw_history_size;
}
pub fn redraw_sync(&self) {
let mut st = self.lock();
if st.terminal.output_failure.is_some() {
return;
}
st.terminal.sync_requested.advance();
let target = st.terminal.sync_requested;
drop(st);
self.redraw.notify();
let st = self.state.lock().expect("term state mutex poisoned");
let _st = self
.sync_condvar
.wait_while(st, |s| s.terminal.sync_completed < target)
.expect("term state mutex poisoned");
}
pub fn new_block(&self, debug_id: impl Into<String>, block: impl Into<StyledBlock>) -> BlockId {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
let id = st.alloc_id();
let debug_id = debug_id.into();
let block = block.into();
let content_empty = block.is_empty();
let retired_block = st.layout.blocks.insert(id, block);
st.layout.block_debug_ids.insert(id, debug_id.clone());
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, debug_id, content_empty, "new block");
drop(st);
if let Some(retired_block) = retired_block {
self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
}
id
}
pub fn set_block(&self, id: BlockId, block: impl Into<StyledBlock>) {
self.set_block_inner(id, block, false);
}
pub fn set_block_with_presentation_delta(
&self,
id: BlockId,
block: impl Into<StyledBlock>,
) -> bool {
self.set_block_inner(id, block, true)
}
fn set_block_inner(
&self,
id: BlockId,
block: impl Into<StyledBlock>,
observe_delta: bool,
) -> bool {
let _transaction = self.output_transaction_barrier();
let block = block.into();
let content_empty = block.is_empty();
let mut st = self.lock();
let affects_history = st.block_in_history(id);
let changed = observe_delta && st.layout.blocks.get(&id) != Some(&block);
let presentation_changed = changed && st.block_is_visible(id);
let retired_block = st.layout.blocks.insert(id, block);
st.layout
.block_debug_ids
.entry(id)
.or_insert_with(|| format!("set-block-{}", id.0));
if affects_history {
st.mark_history_dirty_from(0);
}
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, content_empty, "set block");
drop(st);
if let Some(retired_block) = retired_block {
self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
}
presentation_changed
}
pub fn remove_block(&self, id: BlockId) {
self.remove_block_inner(id, false);
}
pub fn remove_block_with_presentation_delta(&self, id: BlockId) -> bool {
self.remove_block_inner(id, true)
}
fn remove_block_inner(&self, id: BlockId, observe_delta: bool) -> bool {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
let (presentation_changed, retired_block) = st.remove_block(id, observe_delta);
drop(st);
if let Some(retired_block) = retired_block {
self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
}
presentation_changed
}
pub fn push_history(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
st.append_history(id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "history", "push block zone");
}
pub fn push_above_active(&self, id: BlockId) {
self.push_above_active_inner(id);
}
pub fn push_above_active_with_presentation_delta(&self, id: BlockId) -> bool {
self.push_above_active_inner(id)
}
fn push_above_active_inner(&self, id: BlockId) -> bool {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
if !st.layout.above_active.contains(&id) {
st.layout.above_active.push(id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_active", "push block zone");
true
} else {
false
}
}
pub fn push_above_active_before_any<I>(&self, id: BlockId, anchors: I)
where
I: IntoIterator<Item = BlockId>,
{
self.push_above_active_before_any_inner(id, anchors, false);
}
pub fn push_above_active_before_any_with_presentation_delta<I>(
&self,
id: BlockId,
anchors: I,
) -> bool
where
I: IntoIterator<Item = BlockId>,
{
self.push_above_active_before_any_inner(id, anchors, true)
}
fn push_above_active_before_any_inner<I>(
&self,
id: BlockId,
anchors: I,
observe_delta: bool,
) -> bool
where
I: IntoIterator<Item = BlockId>,
{
let _transaction = self.output_transaction_barrier();
let anchors = anchors.into_iter().collect::<HashSet<_>>();
let mut st = self.lock();
let previous = observe_delta.then(|| st.layout.above_active.clone());
st.layout.above_active.retain(|&x| x != id);
let insert_at = st
.layout
.above_active
.iter()
.position(|active_id| anchors.contains(active_id))
.unwrap_or(st.layout.above_active.len());
st.layout.above_active.insert(insert_at, id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_active", "insert block zone");
previous.is_some_and(|previous| st.layout.above_active != previous)
}
pub fn remove_above_active(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
self.lock().layout.above_active.retain(|&x| x != id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_active", "remove block zone");
}
pub fn push_above_sticky(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
if !st.layout.above_sticky.contains(&id) {
st.layout.above_sticky.push(id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_sticky", "push block zone");
}
}
pub fn remove_above_sticky(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
self.lock().layout.above_sticky.retain(|&x| x != id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_sticky", "remove block zone");
}
pub fn push_suggestions(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
if !st.layout.suggestions.contains(&id) {
st.layout.suggestions.push(id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "suggestions", "push block zone");
}
}
pub fn remove_suggestions(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
self.lock().layout.suggestions.retain(|&x| x != id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "suggestions", "remove block zone");
}
pub fn push_below(&self, id: BlockId) {
self.push_below_inner(id);
}
pub fn push_below_with_presentation_delta(&self, id: BlockId) -> bool {
self.push_below_inner(id)
}
fn push_below_inner(&self, id: BlockId) -> bool {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
if !st.layout.below.contains(&id) {
st.layout.below.push(id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "below", "push block zone");
true
} else {
false
}
}
pub fn remove_below(&self, id: BlockId) {
let _transaction = self.output_transaction_barrier();
self.lock().layout.below.retain(|&x| x != id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "below", "remove block zone");
}
pub fn print_output(
&self,
debug_id: impl Into<String>,
block: impl Into<StyledBlock>,
) -> BlockId {
let _transaction = self.output_transaction_barrier();
let mut st = self.lock();
let id = st.alloc_id();
let debug_id = debug_id.into();
let block = block.into();
let content_empty = block.is_empty();
let retired_block = st.layout.blocks.insert(id, block);
st.layout.block_debug_ids.insert(id, debug_id.clone());
st.append_history(id);
tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, debug_id, content_empty, zone = "history", "print output");
let notify = Self::request_redraw_locked(&mut st);
drop(st);
if let Some(retired_block) = retired_block {
self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
}
if notify {
self.release_redraw_notification();
}
id
}
pub fn set_left_prompt(&self, text: impl Into<StyledText>) {
let mut st = self.lock();
st.editor.left_prompt = text.into();
st.ensure_input_cursor_visible();
}
pub fn get_buffer(&self) -> String {
self.lock().editor.buffer.clone()
}
pub fn enable_paste_uploads(&self, threshold: usize) {
self.lock().editor.paste_upload_threshold = Some(threshold);
}
pub fn finish_paste_upload(&self, id: u64, result: Result<String, String>) {
let _ = self
.input_tx
.send(InputMessage::Raw(RawEvent::PasteUploadFinished {
id,
result,
}));
}
pub fn get_cursor(&self) -> usize {
self.lock().editor.cursor
}
pub fn get_buffer_revision(&self) -> u64 {
self.lock().editor.revision
}
pub fn last_submitted_buffer_revision(&self) -> Option<u64> {
self.lock().editor.last_submitted_revision
}
pub fn set_buffer(&self, text: String, cursor: usize) {
let mut st = self.lock();
if st.editor.pending_paste.is_some() {
return;
}
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
let new_cursor = clamp_cursor_to_grapheme_boundary(&text, cursor);
st.editor.buffer = text;
let abandoned_history_nav = st.editor.history_nav.take().is_some();
st.editor.completion = None;
st.editor.current_undo.clear();
st.editor.current_redo.clear();
st.write_cursor(new_cursor);
if abandoned_history_nav {
st.limit_input_history();
}
}
pub fn set_buffer_if_revision(
&self,
expected_revision: u64,
text: String,
cursor: usize,
) -> bool {
let mut st = self.lock();
if st.editor.pending_paste.is_some() || st.editor.revision != expected_revision {
return false;
}
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
let new_cursor = clamp_cursor_to_grapheme_boundary(&text, cursor);
st.editor.buffer = text;
let abandoned_history_nav = st.editor.history_nav.take().is_some();
st.editor.completion = None;
st.editor.current_undo.clear();
st.editor.current_redo.clear();
st.write_cursor(new_cursor);
if abandoned_history_nav {
st.limit_input_history();
}
true
}
pub fn recall_prompt_before_current(&self, text: String) {
let mut st = self.lock();
if st.editor.pending_paste.is_some() {
return;
}
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
st.recall_prompt_before_current(text);
}
pub fn set_buffer_preserving_undo(&self, text: String, cursor: usize) {
let mut st = self.lock();
if st.editor.pending_paste.is_some() {
return;
}
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
let new_cursor = clamp_cursor_to_grapheme_boundary(&text, cursor);
st.editor.buffer = text;
let abandoned_history_nav = st.editor.history_nav.take().is_some();
st.editor.completion = None;
st.editor.current_redo.clear();
st.write_cursor(new_cursor);
if abandoned_history_nav {
st.limit_input_history();
}
}
pub fn completion_state(&self) -> Option<CompletionView> {
let st = self.lock();
st.editor.completion.as_ref().map(|c| CompletionView {
candidates: c.candidates.clone(),
selected: c.selected,
})
}
pub fn set_right_prompt(&self, text: impl Into<StyledText>) {
self.lock().editor.right_prompt = text.into();
}
pub fn set_input_placeholder(&self, text: impl Into<StyledText>) {
self.lock().editor.input_placeholder = text.into();
}
pub fn set_prompt_scroll_indicator(&self, enabled: bool) {
let mut st = self.lock();
st.editor.show_prompt_scroll_indicator = enabled;
st.ensure_input_cursor_visible();
}
pub fn print_terminal_bell(&self) {
self.queue_terminal_side_effect("\x07");
}
pub fn print_osc1337_set_user_var(&self, name: &str, value: &str, in_tmux: bool) {
if let Err(error) = validate_osc1337_name(name) {
tracing::warn!(
target: "tau_cli_term_raw::terminal_side_effect",
name_len = name.len(),
error,
"skipping invalid OSC 1337 SetUserVar side effect"
);
return;
}
let encoded = {
use base64::Engine as _;
path_base64_engine::general_purpose::STANDARD.encode(value.as_bytes())
};
let sequence = if in_tmux {
format!("\x1bPtmux;\x1b\x1b]1337;SetUserVar={name}={encoded}\x07\x1b\\")
} else {
format!("\x1b]1337;SetUserVar={name}={encoded}\x07")
};
self.queue_terminal_side_effect(sequence);
}
fn queue_terminal_side_effect(&self, sequence: impl Into<String>) {
let notify = {
let mut st = self.lock();
st.terminal.pending_raw.push(sequence.into());
Self::request_redraw_locked(&mut st)
};
if notify {
self.release_redraw_notification();
}
}
}
fn validate_osc1337_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("name must not be empty");
}
if name.len() > 128 {
return Err("name must be at most 128 bytes");
}
if !name
.bytes()
.all(|b| (0x20..=0x7e).contains(&b) && b != b'=')
{
return Err("name must be printable ASCII without '='");
}
Ok(())
}
pub enum RawEvent {
PasteUploadFinished {
id: u64,
result: Result<String, String>,
},
Key(KeyEvent),
Resize(u16, u16),
FocusChanged {
focused: bool,
},
Paste(String),
CompletionRefresh,
CompletionRefreshIfGeneration(u64),
}
enum InputMessage {
Raw(RawEvent),
RealRaw(RawEvent),
Shutdown,
RefreshCompletion,
RefreshCompletionIfGeneration(u64),
RealError(io::Error),
}
fn spawn_real_reader_if_needed(
in_flight: &Arc<path_std_sync_atomic::AtomicBool>,
tx: path_std_sync::mpsc::Sender<InputMessage>,
read: impl FnOnce() -> io::Result<RawEvent> + Send + 'static,
) -> bool {
if in_flight
.compare_exchange(
false,
true,
path_std_sync_atomic::Ordering::AcqRel,
path_std_sync_atomic::Ordering::Acquire,
)
.is_err()
{
return false;
}
thread::spawn(move || {
let message = match read() {
Ok(raw) => InputMessage::RealRaw(raw),
Err(error) => InputMessage::RealError(error),
};
let _ = tx.send(message);
});
true
}
fn finish_real_reader(in_flight: &path_std_sync_atomic::AtomicBool) {
in_flight.store(false, path_std_sync_atomic::Ordering::Release);
}
#[derive(Clone, Debug)]
struct OutputFailure {
kind: io::ErrorKind,
message: String,
}
impl OutputFailure {
fn new(error: io::Error) -> Self {
Self {
kind: error.kind(),
message: error.to_string(),
}
}
fn io_error(&self) -> io::Error {
io::Error::new(self.kind, self.clone())
}
}
impl std::fmt::Display for OutputFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "terminal output failed: {}", self.message)
}
}
impl std::error::Error for OutputFailure {}
#[must_use]
pub fn is_output_failure(error: &io::Error) -> bool {
error
.get_ref()
.is_some_and(|source| source.is::<OutputFailure>())
}
pub struct Term {
handle: TermHandle,
input_rx: path_std_sync::mpsc::Receiver<InputMessage>,
real_read_in_flight: Arc<path_std_sync_atomic::AtomicBool>,
redraw_thread: Option<JoinHandle<()>>,
owns_raw_mode: bool,
terminal_options: TerminalOptions,
completion_source: Option<Box<dyn CompletionSource>>,
bindings: HashMap<KeyBinding, String>,
defer_submitted_input_history_limit: bool,
}
impl std::ops::Deref for Term {
type Target = TermHandle;
fn deref(&self) -> &TermHandle {
&self.handle
}
}
impl Term {
pub fn new(
left_prompt: impl Into<StyledText>,
terminal_options: TerminalOptions,
) -> io::Result<(Self, TermHandle)> {
let (width, height) = term_size();
let state = Arc::new(Mutex::new(SharedState::new(
width,
height,
left_prompt.into(),
)));
let (redraw_tx, redraw_rx) = tau_blocking_notify_channel::channel();
let sync_condvar = Arc::new(path_std_sync::Condvar::new());
let (input_tx, input_rx) = path_std_sync::mpsc::channel();
terminal::enable_raw_mode()?;
if let Err(error) = initialize_terminal_features(
&mut io::stdout(),
terminal_options.cursor_shape,
terminal_options,
) {
let _ = terminal::disable_raw_mode();
return Err(error);
}
let redraw_state = Arc::clone(&state);
let redraw_writer: Box<dyn Write + Send> = Box::new(io::stdout());
let redraw_sync_cv = Arc::clone(&sync_condvar);
let redraw_input_tx = input_tx.clone();
let redraw_thread = thread::spawn(move || {
redraw_loop(
redraw_state,
redraw_rx,
redraw_writer,
redraw_input_tx,
&redraw_sync_cv,
);
});
let handle = TermHandle {
state,
output_transaction: Arc::new(Mutex::new(())),
sync_condvar,
redraw: redraw_tx,
input_tx,
output_snapshot_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
output_snapshot_take_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
#[cfg(feature = "redraw-test-counter")]
redraw_request_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
#[cfg(test)]
retirement_probe_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
};
handle.release_redraw_notification();
Ok((
Self {
handle: handle.clone(),
input_rx,
real_read_in_flight: Arc::new(path_std_sync_atomic::AtomicBool::new(false)),
redraw_thread: Some(redraw_thread),
owns_raw_mode: true,
terminal_options,
completion_source: None,
bindings: HashMap::new(),
defer_submitted_input_history_limit: false,
},
handle,
))
}
pub fn new_virtual(
width: usize,
height: usize,
left_prompt: impl Into<StyledText>,
output: Box<dyn Write + Send>,
cursor_shape: CursorShape,
) -> (Self, TermHandle, path_std_sync::mpsc::Sender<RawEvent>) {
let state = Arc::new(Mutex::new(SharedState::new(
width,
height,
left_prompt.into(),
)));
let (redraw_tx, redraw_rx) = tau_blocking_notify_channel::channel();
let sync_condvar = Arc::new(path_std_sync::Condvar::new());
let (input_tx, input_rx) = path_std_sync::mpsc::channel();
let redraw_state = Arc::clone(&state);
let redraw_sync_cv = Arc::clone(&sync_condvar);
let redraw_input_tx = input_tx.clone();
let redraw_thread = thread::spawn(move || {
redraw_loop(
redraw_state,
redraw_rx,
output,
redraw_input_tx,
&redraw_sync_cv,
);
});
let (term_input_tx, term_input_rx) = path_std_sync::mpsc::channel();
let virtual_input_tx = input_tx.clone();
thread::spawn(move || {
while let Ok(raw) = term_input_rx.recv() {
if virtual_input_tx.send(InputMessage::Raw(raw)).is_err() {
break;
}
}
let _ = virtual_input_tx.send(InputMessage::Shutdown);
});
let handle = TermHandle {
state,
output_transaction: Arc::new(Mutex::new(())),
sync_condvar,
redraw: redraw_tx,
input_tx,
output_snapshot_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
output_snapshot_take_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
#[cfg(feature = "redraw-test-counter")]
redraw_request_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
#[cfg(test)]
retirement_probe_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
};
handle.release_redraw_notification();
let term = Self {
handle: handle.clone(),
input_rx,
real_read_in_flight: Arc::new(path_std_sync_atomic::AtomicBool::new(false)),
redraw_thread: Some(redraw_thread),
owns_raw_mode: false,
terminal_options: TerminalOptions {
cursor_shape,
..TerminalOptions::default()
},
completion_source: None,
bindings: HashMap::new(),
defer_submitted_input_history_limit: false,
};
(term, handle, term_input_tx)
}
pub fn handle(&self) -> &TermHandle {
&self.handle
}
pub fn defer_submitted_input_history_limit(&mut self) {
self.defer_submitted_input_history_limit = true;
}
#[cfg(feature = "history-retention-test-support")]
#[doc(hidden)]
pub fn set_input_history_max_bytes_for_test(&mut self, max_bytes: usize) {
self.handle.lock().input_history_limit_override = Some(InputHistoryLimits {
max_entries: INPUT_HISTORY_MAX_ENTRIES,
max_bytes,
});
}
pub fn finalize_submitted_input_history(&mut self) {
let mut st = self.handle.lock();
st.limit_input_history();
st.editor.last_submitted_input_retained = st.editor.input_history.last().is_some();
}
pub fn get_next_event(&self) -> io::Result<Event> {
loop {
let raw = match self.next_raw()? {
Some(ev) => ev,
None => return Ok(Event::Eof),
};
match raw {
RawEvent::Key(key) => {
{
let mut st = self.handle.lock();
if let Some(paste) = &st.editor.pending_paste {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
let id = paste.id;
st.editor.pending_paste = None;
return Ok(Event::PasteCancelled { id });
}
if key.code == KeyCode::Enter && paste.failed {
let text = paste.text.clone();
st.editor.next_paste_id += 1;
let id = st.editor.next_paste_id;
st.editor.pending_paste = Some(pending_paste::PendingPaste {
id,
text: text.clone(),
failed: false,
});
return Ok(Event::PasteUpload { id, text });
}
continue;
}
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
}
if let Some(event) = self.handle_key(key)? {
self.handle.redraw();
return Ok(event);
}
self.handle.redraw();
}
RawEvent::Resize(w, h) => {
let (width, height) = {
let mut st = self.handle.lock();
let width = effective_resize_dimension(w, st.terminal.width);
let height = effective_resize_dimension(h, st.terminal.height);
st.terminal.width = width;
st.terminal.height = height;
st.ensure_input_cursor_visible();
(width, height)
};
self.handle.redraw();
return Ok(Event::Resize {
width: size_event_dimension(width),
height: size_event_dimension(height),
});
}
RawEvent::FocusChanged { focused } => {
return Ok(Event::FocusChanged { focused });
}
RawEvent::Paste(text) => {
if text.is_empty() {
self.handle.redraw();
continue;
}
let text = normalize_paste_text(text);
{
let mut st = self.handle.lock();
if st.editor.pending_paste.is_some() {
return Ok(Event::Notice(
"Paste busy; wait or press Ctrl-C to discard it before pasting again.".to_owned(),
));
}
if st
.editor
.paste_upload_threshold
.is_some_and(|limit| text.len() >= limit)
{
st.editor.next_paste_id += 1;
let id = st.editor.next_paste_id;
let text: Arc<str> = text.into();
st.editor.pending_paste = Some(pending_paste::PendingPaste {
id,
text: text.clone(),
failed: false,
});
return Ok(Event::PasteUpload { id, text });
}
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.insert_str(cursor, &text);
st.write_cursor(cursor + text.len());
st.sync_buffer_to_history_nav();
}
self.refresh_completion();
self.handle.redraw();
return Ok(Event::BufferChanged);
}
RawEvent::PasteUploadFinished { id, result } => {
let mut st = self.handle.lock();
let Some(paste) = st
.editor
.pending_paste
.as_mut()
.filter(|paste| paste.id == id)
else {
continue;
};
match result {
Err(error) => {
paste.failed = true;
return Ok(Event::Notice(format!(
"Paste upload failed: {error}. Enter retries; Ctrl-C discards. Draft unchanged."
)));
}
Ok(reference) => {
st.editor.pending_paste = None;
st.editor.revision = st.editor.revision.wrapping_add(1);
st.advance_completion_generation();
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.insert_str(cursor, &reference);
st.write_cursor(cursor + reference.len());
st.sync_buffer_to_history_nav();
}
}
drop(st);
self.refresh_completion();
self.handle.redraw();
return Ok(Event::BufferChanged);
}
RawEvent::CompletionRefresh => {
if self.handle.lock().editor.pending_paste.is_some() {
continue;
}
self.refresh_completion();
self.handle.redraw();
return Ok(Event::CompletionRefresh);
}
RawEvent::CompletionRefreshIfGeneration(generation) => {
let eligible = {
let st = self.handle.lock();
st.editor.pending_paste.is_none()
&& st.editor.completion_generation == generation
&& st
.editor
.completion
.as_ref()
.is_none_or(|menu| menu.selected.is_none())
};
if !eligible {
continue;
}
self.refresh_completion();
self.handle.redraw();
return Ok(Event::CompletionRefresh);
}
}
}
}
fn next_raw(&self) -> io::Result<Option<RawEvent>> {
{
let st = self.handle.lock();
if let Some(error) = &st.terminal.output_failure {
return Err(error.io_error());
}
if st.terminal.input_shutdown {
return Ok(None);
}
}
if self.owns_raw_mode {
spawn_real_reader_if_needed(
&self.real_read_in_flight,
self.handle.input_tx.clone(),
|| read_real_raw_event(event::read, raw_term_size),
);
}
let message = match self.input_rx.recv() {
Ok(message) => message,
Err(_) => return Ok(None),
};
{
let st = self.handle.lock();
if let Some(error) = &st.terminal.output_failure {
return Err(error.io_error());
}
if st.terminal.input_shutdown {
return Ok(None);
}
}
match message {
InputMessage::Raw(raw) => Ok(Some(raw)),
InputMessage::RealRaw(raw) => {
finish_real_reader(&self.real_read_in_flight);
Ok(Some(raw))
}
InputMessage::Shutdown => {
self.handle.lock().terminal.input_shutdown = true;
Ok(None)
}
InputMessage::RefreshCompletion => Ok(Some(RawEvent::CompletionRefresh)),
InputMessage::RefreshCompletionIfGeneration(generation) => {
Ok(Some(RawEvent::CompletionRefreshIfGeneration(generation)))
}
InputMessage::RealError(error) => {
finish_real_reader(&self.real_read_in_flight);
Err(error)
}
}
}
pub fn set_completion_source(&mut self, source: Option<Box<dyn CompletionSource>>) {
self.completion_source = source;
let mut st = self.handle.lock();
st.editor.completion = None;
}
pub fn set_bindings(&mut self, bindings: impl IntoIterator<Item = (String, String)>) {
self.bindings = bindings
.into_iter()
.filter_map(|(raw_key, action)| {
let parsed = parse_key_binding(&raw_key);
tracing::trace!(
target: "tau_cli_term_raw::input",
raw_key,
?parsed,
action,
"configured prompt binding"
);
parsed.map(|key| (key, action))
})
.collect();
}
pub fn seed_input_history(&mut self, history: impl IntoIterator<Item = String>) {
let mut st = self.handle.lock();
st.editor.input_history.extend(
history
.into_iter()
.filter(|buffer| !buffer.is_empty())
.map(PromptDraft::submitted),
);
st.limit_input_history();
st.editor.history_nav = None;
st.editor.last_submitted_input_retained = false;
}
pub fn replace_last_submitted_input(&mut self, text: String) {
let mut st = self.handle.lock();
let recalled_source = st.editor.last_submitted_recalled_source;
if let Some(index) = recalled_source
&& let Some(source) = st.editor.input_history.get_mut(index)
{
*source = PromptDraft::submitted(text.clone());
}
if st.editor.last_submitted_input_retained {
if let Some(last) = st.editor.input_history.last_mut() {
*last = PromptDraft::submitted(text.clone());
}
} else if !text.is_empty() {
st.editor
.input_history
.push(PromptDraft::submitted(text.clone()));
}
if !self.defer_submitted_input_history_limit {
st.limit_input_history();
}
st.editor.last_submitted_input_retained = st
.editor
.input_history
.last()
.is_some_and(|draft| draft.buffer == text);
st.editor.history_nav = None;
st.editor.completion = None;
}
fn refresh_completion(&self) {
let Some(source) = self.completion_source.as_deref() else {
return;
};
let (buffer, cursor) = {
let st = self.handle.lock();
(st.editor.buffer.clone(), st.editor.cursor)
};
let candidates = source
.candidates(&buffer, cursor)
.into_iter()
.filter(|candidate| {
candidate.cursor
== clamp_cursor_to_grapheme_boundary(&candidate.replacement, candidate.cursor)
&& candidate.acceptance.as_ref().is_none_or(|acceptance| {
acceptance.cursor
== clamp_cursor_to_grapheme_boundary(
&acceptance.replacement,
acceptance.cursor,
)
})
})
.collect::<Vec<_>>();
let mut st = self.handle.lock();
if candidates.is_empty() {
st.editor.completion = None;
} else {
st.editor.completion = Some(CompletionMenu {
candidates,
selected: None,
original_buffer: buffer,
original_cursor: cursor,
});
}
}
pub fn pause_for_external(&self) -> io::Result<()> {
if !self.owns_raw_mode {
return Ok(());
}
self.pause_for_external_with_release(|| {
let mut stdout = io::stdout();
write_external_pause_features(&mut stdout, self.terminal_options)?;
terminal::disable_raw_mode()?;
crossterm::execute!(
io::stdout(),
crossterm::style::ResetColor,
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
)?;
Ok(())
})
}
fn pause_for_external_with_release(
&self,
release_terminal: impl FnOnce() -> io::Result<()>,
) -> io::Result<()> {
{
let mut st = self.handle.lock();
st.terminal.external_paused = true;
}
self.handle.redraw_sync();
if let Err(error) = release_terminal() {
let _ = self.resume_after_external();
return Err(error);
}
Ok(())
}
pub fn resume_after_external(&self) -> io::Result<()> {
if !self.owns_raw_mode {
self.finish_external_resume();
return Ok(());
}
let result = (|| -> io::Result<()> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
write_external_resume_features(
&mut stdout,
self.terminal_options.cursor_shape,
self.terminal_options,
)?;
crossterm::execute!(
io::stdout(),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0)
)?;
Ok(())
})();
self.finish_external_resume();
result
}
fn finish_external_resume(&self) {
let (width, height) = term_size();
{
let mut st = self.handle.lock();
st.terminal.width = width;
st.terminal.height = height;
st.ensure_input_cursor_visible();
st.terminal.external_paused = false;
st.terminal.invalidate_screen = true;
}
self.handle.redraw();
}
pub fn record_prompt_undo(&self) {
let mut st = self.handle.lock();
st.record_undo();
}
pub fn trigger_insert_newline(&self) -> Event {
self.insert_newline()
}
pub fn trigger_submit_or_accept_completion(&self) -> Event {
self.submit_or_accept_completion()
}
pub fn dismiss_completion_menu(&self) -> bool {
let mut st = self.handle.lock();
st.dismiss_completion()
}
pub fn trigger_history_step(&self, delta: isize) {
let mut st = self.handle.lock();
st.editor.completion = None;
st.step_history(delta);
}
pub fn trigger_undo(&self) -> bool {
let mut st = self.handle.lock();
st.editor.completion = None;
st.undo()
}
pub fn trigger_redo(&self) -> bool {
let mut st = self.handle.lock();
st.editor.completion = None;
st.redo()
}
fn step_history_event(&self, delta: isize) -> io::Result<Option<Event>> {
self.trigger_history_step(delta);
Ok(Some(Event::BufferChanged))
}
fn binding_action(&self, binding: &Option<KeyBinding>) -> Option<String> {
binding
.as_ref()
.and_then(|key| self.bindings.get(key))
.cloned()
}
fn handle_completion_key(
&self,
key: KeyEvent,
ctrl: bool,
shift: bool,
alt: bool,
) -> Option<Event> {
match key.code {
KeyCode::Tab => {
let mut st = self.handle.lock();
st.cycle_completion(1).then_some(Event::BufferChanged)
}
KeyCode::BackTab | KeyCode::Up => {
let mut st = self.handle.lock();
st.cycle_completion(-1).then_some(Event::BufferChanged)
}
KeyCode::Down => {
let mut st = self.handle.lock();
st.cycle_completion(1).then_some(Event::BufferChanged)
}
KeyCode::Esc => {
let mut st = self.handle.lock();
st.dismiss_completion().then_some(Event::BufferChanged)
}
KeyCode::Enter if ctrl || (!shift && !alt) => self.accept_completion_event(),
_ => None,
}
}
fn move_cursor_left(&self) -> bool {
let mut st = self.handle.lock();
if st.editor.cursor == 0 {
return false;
}
let prev = prev_char_boundary(&st.editor.buffer, st.editor.cursor);
st.write_cursor(prev);
true
}
fn move_cursor_right(&self) -> bool {
let mut st = self.handle.lock();
if st.editor.buffer.len() <= st.editor.cursor {
return false;
}
let next = next_char_boundary(&st.editor.buffer, st.editor.cursor);
st.write_cursor(next);
true
}
fn move_cursor_start(&self) -> bool {
let mut st = self.handle.lock();
if st.editor.cursor == 0 {
return false;
}
st.write_cursor(0);
true
}
fn move_cursor_end(&self) -> bool {
let mut st = self.handle.lock();
let len = st.editor.buffer.len();
if st.editor.cursor == len {
return false;
}
st.write_cursor(len);
true
}
fn delete_backward(&self) -> bool {
let changed = {
let mut st = self.handle.lock();
if st.editor.cursor == 0 {
return false;
}
st.record_undo();
let prev = prev_char_boundary(&st.editor.buffer, st.editor.cursor);
let cursor = st.editor.cursor;
st.editor.buffer.drain(prev..cursor);
st.write_cursor(prev);
st.sync_buffer_to_history_nav();
true
};
self.refresh_completion();
changed
}
fn delete_forward(&self) -> bool {
let changed = {
let mut st = self.handle.lock();
if st.editor.buffer.len() <= st.editor.cursor {
return false;
}
st.record_undo();
let cursor = st.editor.cursor;
let next = next_char_boundary(&st.editor.buffer, cursor);
st.editor.buffer.drain(cursor..next);
st.write_cursor(cursor);
st.sync_buffer_to_history_nav();
true
};
self.refresh_completion();
changed
}
fn clear_prompt(&self) -> bool {
let changed = {
let mut st = self.handle.lock();
if st.editor.buffer.is_empty() {
return false;
}
st.editor.ctrl_c_cancel_armed = false;
st.record_undo();
st.editor.buffer.clear();
let abandoned_history_nav = st.editor.history_nav.take().is_some();
st.editor.completion = None;
st.write_cursor(0);
if abandoned_history_nav {
st.limit_input_history();
}
true
};
self.refresh_completion();
changed
}
fn clear_or_cancel_prompt(&self) -> Event {
let mut st = self.handle.lock();
if st.editor.buffer.is_empty() {
if st.editor.ctrl_c_cancel_armed {
st.editor.ctrl_c_cancel_armed = false;
return Event::CancelPrompt;
}
st.editor.ctrl_c_cancel_armed = true;
return Event::Notice(
"Press Ctrl-C again to cancel the current response; use Ctrl-D to exit".to_owned(),
);
}
st.editor.ctrl_c_cancel_armed = false;
st.record_undo();
st.editor.buffer.clear();
let abandoned_history_nav = st.editor.history_nav.take().is_some();
st.editor.completion = None;
st.write_cursor(0);
if abandoned_history_nav {
st.limit_input_history();
}
drop(st);
self.refresh_completion();
Event::BufferChanged
}
fn kill_to_start(&self) -> bool {
let changed = {
let mut st = self.handle.lock();
if st.editor.cursor == 0 {
return false;
}
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.drain(..cursor);
st.write_cursor(0);
st.sync_buffer_to_history_nav();
true
};
self.refresh_completion();
changed
}
fn kill_word_left(&self) -> bool {
let changed = {
let mut st = self.handle.lock();
if st.editor.cursor == 0 {
return false;
}
let new_end = word_left_boundary(&st.editor.buffer, st.editor.cursor);
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.drain(new_end..cursor);
st.write_cursor(new_end);
st.sync_buffer_to_history_nav();
true
};
self.refresh_completion();
changed
}
fn move_cursor_vertical_event(&self, delta: isize) -> Option<Event> {
let mut st = self.handle.lock();
let target_col = st.vertical_target_col();
if let Some(new_cursor) = move_cursor_vertical(&st, delta, target_col) {
st.write_cursor_keep_sticky(new_cursor);
return Some(Event::BufferChanged);
}
None
}
fn cycle_or_move_up(&self) -> Option<Event> {
let mut st = self.handle.lock();
if st.cycle_completion(-1) {
return Some(Event::BufferChanged);
}
let target_col = st.vertical_target_col();
if let Some(new_cursor) = move_cursor_vertical(&st, -1, target_col) {
st.write_cursor_keep_sticky(new_cursor);
return Some(Event::BufferChanged);
}
if st.step_history(-1) {
return Some(Event::BufferChanged);
}
None
}
fn cycle_or_move_down(&self) -> Option<Event> {
let mut st = self.handle.lock();
if st.cycle_completion(1) {
return Some(Event::BufferChanged);
}
let target_col = st.vertical_target_col();
if let Some(new_cursor) = move_cursor_vertical(&st, 1, target_col) {
st.write_cursor_keep_sticky(new_cursor);
return Some(Event::BufferChanged);
}
if st.step_history(1) {
return Some(Event::BufferChanged);
}
None
}
fn cycle_completion_event(&self, delta: isize) -> Option<Event> {
let mut st = self.handle.lock();
st.cycle_completion(delta).then_some(Event::BufferChanged)
}
fn dismiss_completion_event(&self) -> Option<Event> {
let mut st = self.handle.lock();
st.dismiss_completion().then_some(Event::BufferChanged)
}
fn accept_completion_event(&self) -> Option<Event> {
let accepted = {
let mut st = self.handle.lock();
st.accept_completion()
};
if !accepted {
return None;
}
self.refresh_completion();
Some(Event::CompletionAccept)
}
pub fn is_named_action(action: &str) -> bool {
named_action_handler(action).is_some()
}
pub fn trigger_named_action(&self, action: &str) -> Option<Event> {
named_action_handler(action).and_then(|handler| handler(self))
}
fn backtab_action(&self) -> Option<Event> {
Some(Event::BackTab)
}
fn clear_prompt_action(&self) -> Option<Event> {
self.clear_prompt().then_some(Event::BufferChanged)
}
fn clear_or_cancel_prompt_action(&self) -> Option<Event> {
Some(self.clear_or_cancel_prompt())
}
fn move_cursor_end_action(&self) -> Option<Event> {
self.move_cursor_end().then_some(Event::BufferChanged)
}
fn move_cursor_left_action(&self) -> Option<Event> {
self.move_cursor_left().then_some(Event::BufferChanged)
}
fn move_cursor_right_action(&self) -> Option<Event> {
self.move_cursor_right().then_some(Event::BufferChanged)
}
fn move_cursor_start_action(&self) -> Option<Event> {
self.move_cursor_start().then_some(Event::BufferChanged)
}
fn delete_backward_action(&self) -> Option<Event> {
self.delete_backward().then_some(Event::BufferChanged)
}
fn delete_forward_action(&self) -> Option<Event> {
self.delete_forward().then_some(Event::BufferChanged)
}
fn escape_action(&self) -> Option<Event> {
Some(Event::Escape)
}
fn kill_to_start_action(&self) -> Option<Event> {
self.kill_to_start().then_some(Event::BufferChanged)
}
fn kill_word_left_action(&self) -> Option<Event> {
self.kill_word_left().then_some(Event::BufferChanged)
}
fn move_cursor_down_action(&self) -> Option<Event> {
self.move_cursor_vertical_event(1)
}
fn move_cursor_up_action(&self) -> Option<Event> {
self.move_cursor_vertical_event(-1)
}
fn prompt_eof_action(&self) -> Option<Event> {
let is_empty = self.handle.lock().editor.buffer.is_empty();
is_empty.then_some(Event::Eof)
}
fn select_completion_next_action(&self) -> Option<Event> {
self.cycle_completion_event(1)
}
fn select_completion_previous_action(&self) -> Option<Event> {
self.cycle_completion_event(-1)
}
fn insert_newline(&self) -> Event {
{
let mut st = self.handle.lock();
st.editor.completion = None;
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.insert(cursor, '\n');
st.write_cursor(cursor + 1);
st.sync_buffer_to_history_nav();
}
self.refresh_completion();
Event::BufferChanged
}
fn submit_or_accept_completion(&self) -> Event {
if self.accept_completion_event().is_some() {
return Event::CompletionAccept;
}
let started = path_std_time::Instant::now();
let line = {
let mut st = self.handle.lock();
st.editor.completion = None;
st.editor.last_submitted_recalled_source =
st.editor.history_nav.as_ref().and_then(|nav| {
nav.entries
.get(nav.index)
.and_then(|entry| entry.source_index)
});
st.editor.history_nav = None;
let line = st.editor.buffer.clone();
st.push_current_as_history_entry(!self.defer_submitted_input_history_limit);
st.editor.last_submitted_input_retained = st
.editor
.input_history
.last()
.is_some_and(|draft| draft.buffer == line);
st.editor.last_submitted_revision = Some(st.editor.revision);
line
};
tracing::trace!(
target: "tau_cli::prompt_submission",
stage = "raw_submit_clear",
prompt_bytes = line.len(),
stage_us = started.elapsed().as_micros(),
"content-free prompt submission stage"
);
Event::Line(line)
}
fn handle_enter_key(&self, ctrl: bool, shift: bool, alt: bool) -> Event {
if shift || alt {
return self.insert_newline();
}
if ctrl {
self.submit_or_accept_completion()
} else {
self.insert_newline()
}
}
fn write_cursor_start_raw(&self) {
let mut st = self.handle.lock();
st.write_cursor(0);
}
fn write_cursor_end_raw(&self) {
let mut st = self.handle.lock();
let len = st.editor.buffer.len();
st.write_cursor(len);
}
fn kill_to_start_raw_event(&self) -> Event {
{
let mut st = self.handle.lock();
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.drain(..cursor);
st.write_cursor(0);
st.sync_buffer_to_history_nav();
}
self.refresh_completion();
Event::BufferChanged
}
fn handle_ctrl_c_key(&self) -> Event {
let mut st = self.handle.lock();
if st.editor.buffer.is_empty() {
if st.editor.ctrl_c_cancel_armed {
st.editor.ctrl_c_cancel_armed = false;
return Event::CancelPrompt;
}
st.editor.ctrl_c_cancel_armed = true;
return Event::Notice(
"Press Ctrl-C again to cancel the current response; use Ctrl-D to exit".to_owned(),
);
}
st.editor.ctrl_c_cancel_armed = false;
st.record_undo();
st.editor.buffer.clear();
let abandoned_history_nav = st.editor.history_nav.take().is_some();
st.editor.completion = None;
st.write_cursor(0);
if abandoned_history_nav {
st.limit_input_history();
}
Event::BufferChanged
}
fn handle_control_char_key(&self, ch: char) -> io::Result<Option<Event>> {
match ch {
'd' => {
let is_empty = self
.state
.lock()
.expect("term state mutex poisoned")
.editor
.buffer
.is_empty();
Ok(is_empty.then_some(Event::Eof))
}
'c' => Ok(Some(self.handle_ctrl_c_key())),
'u' => Ok(Some(self.kill_to_start_raw_event())),
'w' => Ok(self.kill_word_left().then_some(Event::BufferChanged)),
'a' => {
self.write_cursor_start_raw();
Ok(None)
}
'e' => {
self.write_cursor_end_raw();
Ok(None)
}
'o' | 'g' => Ok(Some(Event::ExternalEditor)),
'j' => self.step_history_event(1),
'k' => self.step_history_event(-1),
_ => Ok(None),
}
}
fn insert_char_event(&self, ch: char) -> Event {
{
let mut st = self.handle.lock();
st.record_undo();
let cursor = st.editor.cursor;
st.editor.buffer.insert(cursor, ch);
st.write_cursor(cursor + ch.len_utf8());
st.sync_buffer_to_history_nav();
}
self.refresh_completion();
Event::BufferChanged
}
fn handle_plain_edit_key(&self, code: KeyCode) -> Option<Event> {
match code {
KeyCode::Backspace => self.delete_backward().then_some(Event::BufferChanged),
KeyCode::Delete => self.delete_forward().then_some(Event::BufferChanged),
_ => None,
}
}
fn handle_plain_cursor_key(&self, code: KeyCode) {
match code {
KeyCode::Left => {
self.move_cursor_left();
}
KeyCode::Right => {
self.move_cursor_right();
}
KeyCode::Home => {
self.write_cursor_start_raw();
}
KeyCode::End => {
self.write_cursor_end_raw();
}
_ => {}
}
}
fn handle_vertical_key(&self, code: KeyCode, ctrl: bool) -> io::Result<Option<Event>> {
match (code, ctrl) {
(KeyCode::Up, true) => self.step_history_event(-1),
(KeyCode::Down, true) => self.step_history_event(1),
(KeyCode::Up, false) => Ok(self.cycle_or_move_up()),
(KeyCode::Down, false) => Ok(self.cycle_or_move_down()),
_ => Ok(None),
}
}
fn handle_unbound_key(
&self,
key: KeyEvent,
ctrl: bool,
shift: bool,
alt: bool,
) -> io::Result<Option<Event>> {
match key.code {
KeyCode::Enter => Ok(Some(self.handle_enter_key(ctrl, shift, alt))),
KeyCode::Char(ch) if ctrl => self.handle_control_char_key(ch),
KeyCode::Char(ch) => Ok(Some(self.insert_char_event(ch))),
KeyCode::Backspace | KeyCode::Delete => Ok(self.handle_plain_edit_key(key.code)),
KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => {
self.handle_plain_cursor_key(key.code);
Ok(None)
}
KeyCode::Up | KeyCode::Down => self.handle_vertical_key(key.code, ctrl),
KeyCode::BackTab => Ok(Some(Event::BackTab)),
KeyCode::Esc => Ok(Some(Event::Escape)),
_ => Ok(None),
}
}
fn handle_key(&self, key: KeyEvent) -> io::Result<Option<Event>> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let binding = key_binding_for_event(key, ctrl);
tracing::trace!(
target: "tau_cli_term_raw::input",
?key,
ctrl,
shift,
alt,
?binding,
binding_count = self.bindings.len(),
"handling key event"
);
let ctrl_c = matches!(key.code, KeyCode::Char('c')) && ctrl;
if !ctrl_c {
self.handle.lock().editor.ctrl_c_cancel_armed = false;
}
if let Some(event) = self.handle_completion_key(key, ctrl, shift, alt) {
return Ok(Some(event));
}
if let Some(action) = self.binding_action(&binding) {
tracing::trace!(
target: "tau_cli_term_raw::input",
?binding,
action,
"matched configured binding"
);
return Ok(Some(Event::Binding(action)));
}
self.handle_unbound_key(key, ctrl, shift, alt)
}
}
impl Term {
fn shutdown(&mut self) {
{
let mut st = self.handle.lock();
st.terminal.shutdown = true;
}
self.handle.release_redraw_notification();
if let Some(handle) = self.redraw_thread.take() {
let _ = handle.join();
}
}
}
fn word_left_boundary(buffer: &str, cursor: usize) -> usize {
let before_cursor = &buffer[..cursor];
let trimmed_end = before_cursor.trim_end_matches(char::is_whitespace).len();
before_cursor[..trimmed_end]
.char_indices()
.rev()
.find_map(|(index, ch)| ch.is_whitespace().then_some(index + ch.len_utf8()))
.unwrap_or(0)
}
fn read_real_raw_event(
mut read: impl FnMut() -> io::Result<CtEvent>,
mut term_size: impl FnMut() -> io::Result<(u16, u16)>,
) -> io::Result<RawEvent> {
loop {
let raw = read()?;
tracing::trace!(
target: "tau_cli_term_raw::input",
kind = ?std::mem::discriminant(&raw),
"terminal raw input event"
);
match raw {
CtEvent::Key(key) => {
if key.kind == KeyEventKind::Release {
continue;
}
return Ok(RawEvent::Key(key));
}
CtEvent::Resize(w, h) => {
let (actual_w, actual_h) = term_size().unwrap_or((0, 0));
return Ok(RawEvent::Resize(
resample_resize_dimension(w, actual_w),
resample_resize_dimension(h, actual_h),
));
}
CtEvent::FocusGained => return Ok(RawEvent::FocusChanged { focused: true }),
CtEvent::FocusLost => return Ok(RawEvent::FocusChanged { focused: false }),
CtEvent::Paste(text) => return Ok(RawEvent::Paste(text)),
_ => {}
}
}
}
fn write_external_pause_features(
writer: &mut impl Write,
terminal_options: TerminalOptions,
) -> io::Result<()> {
if !terminal_options.mouse {
crossterm::execute!(writer, DisableMouseCapture)?;
}
crossterm::execute!(
writer,
PopKeyboardEnhancementFlags,
crossterm::event::DisableFocusChange,
crossterm::event::DisableBracketedPaste,
SetCursorStyle::DefaultUserShape,
)
}
fn write_external_resume_features(
writer: &mut impl Write,
cursor_shape: CursorShape,
terminal_options: TerminalOptions,
) -> io::Result<()> {
if !terminal_options.mouse {
crossterm::execute!(writer, DisableMouseCapture)?;
}
crossterm::execute!(
writer,
crossterm::event::EnableBracketedPaste,
crossterm::event::EnableFocusChange,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES),
cursor_shape.crossterm_style()
)
}
fn initialize_terminal_features(
writer: &mut impl Write,
cursor_shape: CursorShape,
terminal_options: TerminalOptions,
) -> io::Result<()> {
if let Err(error) = write_external_resume_features(writer, cursor_shape, terminal_options) {
let _ = write_external_pause_features(writer, terminal_options);
return Err(error);
}
Ok(())
}
impl Drop for Term {
fn drop(&mut self) {
self.shutdown();
if self.should_write_drop_terminal_cleanup() {
let _ = write_drop_terminal_cleanup(&mut io::stdout(), self.terminal_options);
let _ = terminal::disable_raw_mode();
}
}
}
impl Term {
fn should_write_drop_terminal_cleanup(&self) -> bool {
self.owns_raw_mode && !self.handle.lock().terminal.external_paused
}
}
fn write_drop_terminal_cleanup(
writer: &mut impl Write,
terminal_options: TerminalOptions,
) -> io::Result<()> {
write_external_pause_features(writer, terminal_options)
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum LineSource {
Block {
id: BlockId,
debug_id: String,
wrapped_row: usize,
},
Input {
wrapped_row: usize,
},
InputScrollIndicator,
}
fn layout_id_list(
ids: &[BlockId],
blocks: &HashMap<BlockId, StyledBlock>,
block_debug_ids: &HashMap<BlockId, String>,
width: usize,
out: &mut Vec<CellRow>,
sources: &mut Vec<LineSource>,
) {
for id in ids {
if let Some(block) = blocks.get(id) {
if block.is_empty() {
continue;
}
let lines = layout_block(block, width);
for (wrapped_row, line) in lines.into_iter().enumerate() {
sources.push(LineSource::Block {
id: *id,
debug_id: block_debug_ids
.get(id)
.cloned()
.unwrap_or_else(|| "<unknown>".to_owned()),
wrapped_row,
});
out.push(line.into());
}
}
}
}
struct HistoryLayoutCache {
width: usize,
generation: TerminalHistoryGeneration,
previous_generation: TerminalHistoryGeneration,
appended_from_line: Option<usize>,
entry_line_offsets: Vec<usize>,
lines: Vec<CellRow>,
sources: Vec<LineSource>,
}
impl Default for HistoryLayoutCache {
fn default() -> Self {
Self {
width: 0,
generation: TerminalHistoryGeneration::default(),
previous_generation: TerminalHistoryGeneration::default(),
appended_from_line: None,
entry_line_offsets: vec![0],
lines: Vec::new(),
sources: Vec::new(),
}
}
}
impl HistoryLayoutCache {
fn refresh(&mut self, st: &mut SharedState) -> usize {
if self.width == st.terminal.width && self.generation == st.layout.history_generation {
return 0;
}
let previous_generation = self.generation;
let previous_entry_count = self.entry_line_offsets.len().saturating_sub(1);
let width_changed = self.width != st.terminal.width;
let requested_dirty_from = st.layout.history_dirty_from.take().unwrap_or(0);
let can_reuse_prefix = !width_changed
&& requested_dirty_from <= previous_entry_count
&& requested_dirty_from <= st.layout.history.len();
let dirty_from = if can_reuse_prefix {
requested_dirty_from
} else {
0
};
let line_start = self
.entry_line_offsets
.get(dirty_from)
.copied()
.unwrap_or(0);
let append_only = can_reuse_prefix
&& dirty_from == previous_entry_count
&& previous_entry_count <= st.layout.history.len();
self.lines.truncate(line_start);
self.sources.truncate(line_start);
self.entry_line_offsets.truncate(dirty_from + 1);
for id in &st.layout.history[dirty_from..] {
layout_id_list(
std::slice::from_ref(id),
&st.layout.blocks,
&st.layout.block_debug_ids,
st.terminal.width,
&mut self.lines,
&mut self.sources,
);
self.entry_line_offsets.push(self.lines.len());
}
self.width = st.terminal.width;
self.previous_generation = previous_generation;
self.generation = st.layout.history_generation;
self.appended_from_line = append_only.then_some(line_start);
st.layout.history.len().saturating_sub(dirty_from)
}
fn rebuild(st: &SharedState) -> Self {
let mut cache = Self {
width: st.terminal.width,
generation: st.layout.history_generation,
..Self::default()
};
for id in &st.layout.history {
layout_id_list(
std::slice::from_ref(id),
&st.layout.blocks,
&st.layout.block_debug_ids,
st.terminal.width,
&mut cache.lines,
&mut cache.sources,
);
cache.entry_line_offsets.push(cache.lines.len());
}
cache
}
}
struct TailLayout {
lines: Vec<CellRow>,
sources: Vec<LineSource>,
active_height: usize,
cursor_row: usize,
cursor_col: usize,
}
impl TailLayout {
fn fixed_height(&self) -> usize {
self.lines.len().saturating_sub(self.active_height)
}
}
struct LayoutAll {
all_lines: Vec<CellRow>,
line_sources: Vec<LineSource>,
log_end: usize,
history_generation: TerminalHistoryGeneration,
history_width: usize,
history_height: usize,
cursor_row: usize,
cursor_col: usize,
}
struct ViewPlan {
viewport_start: usize,
rubber_height: usize,
render_lines: Vec<CellRow>,
cursor_row: usize,
}
impl ViewPlan {
fn visible_start(&self, _height: usize) -> usize {
self.viewport_start.min(self.render_lines.len())
}
fn visible_lines(&self, height: usize) -> &[CellRow] {
let start = self.visible_start(height);
let end = (start + height).min(self.render_lines.len());
&self.render_lines[start..end]
}
fn cursor_in_visible(&self, height: usize) -> usize {
self.cursor_row.saturating_sub(self.visible_start(height))
}
}
struct PlanMetrics {
viewport_start: usize,
rubber_height: usize,
render_len: usize,
cursor_row: usize,
}
#[derive(Default)]
struct TerminalModel {
viewport_start: usize,
rubber_height: usize,
history_generation: TerminalHistoryGeneration,
history_width: usize,
history_height: usize,
active_height: usize,
known_lines: Vec<CellRow>,
known_sources: Vec<LineSource>,
}
impl TerminalModel {
fn desired_viewport_start(layout: &LayoutAll, height: usize) -> usize {
layout.all_lines.len().saturating_sub(height)
}
fn history_cache_matches(&self, history: &HistoryLayoutCache) -> bool {
self.history_generation == history.generation
&& self.history_width == history.width
&& history.lines.len() <= self.known_lines.len()
&& history.sources.len() <= self.known_sources.len()
}
fn history_append_matches(&self, history: &HistoryLayoutCache) -> bool {
self.history_generation == history.previous_generation
&& self.history_width == history.width
&& history.appended_from_line == Some(self.history_height)
&& self.active_height == 0
}
fn hidden_prefix_changed(&self, layout: &LayoutAll) -> bool {
hidden_lines_changed(
&self.known_lines,
&layout.all_lines[..layout.log_end],
self.viewport_start.min(layout.log_end),
)
}
fn changed_hidden_line(&self, layout: &LayoutAll) -> Option<usize> {
changed_line_in_range(
&self.known_lines,
&layout.all_lines[..layout.log_end],
0..self.viewport_start.min(layout.log_end),
)
}
fn build_plan(layout: &LayoutAll, viewport_start: usize, rubber_height: usize) -> ViewPlan {
let mut render_lines = Vec::with_capacity(layout.all_lines.len() + rubber_height);
render_lines.extend_from_slice(&layout.all_lines[..layout.log_end]);
render_lines
.extend(std::iter::repeat_with(|| CellRow::new(Vec::new())).take(rubber_height));
render_lines.extend_from_slice(&layout.all_lines[layout.log_end..]);
let cursor_row = if layout.log_end <= layout.cursor_row {
layout.cursor_row + rubber_height
} else {
layout.cursor_row
};
ViewPlan {
viewport_start,
rubber_height,
render_lines,
cursor_row,
}
}
fn full_redraw_plan(layout: &LayoutAll, height: usize) -> ViewPlan {
let plan = Self::build_plan(layout, Self::desired_viewport_start(layout, height), 0);
Self::keep_cursor_visible(plan, height)
}
#[cfg(test)]
fn bottom_aligned_plan(layout: &LayoutAll, height: usize) -> ViewPlan {
let mut plan = Self::build_plan(layout, Self::desired_viewport_start(layout, height), 0);
plan.viewport_start = plan.visible_start(height);
plan
}
fn keep_cursor_visible(mut plan: ViewPlan, height: usize) -> ViewPlan {
let height = height.max(1);
let bottom_start = plan.visible_start(height);
let viewport_start = viewport_start_with_cursor(
bottom_start,
plan.cursor_row,
plan.render_lines.len(),
height,
);
if viewport_start < bottom_start {
let viewport_end = (viewport_start + height).min(plan.render_lines.len());
plan.render_lines.truncate(viewport_end);
}
plan.viewport_start = plan.visible_start(height);
plan
}
fn plan_metrics(
&self,
log_height: usize,
fixed_height: usize,
cursor_row: usize,
height: usize,
) -> PlanMetrics {
let height = height.max(1);
let viewport_start = self.viewport_start.min(log_height);
let mut rubber_height = self.rubber_height;
if fixed_height < height {
let occupied = log_height.saturating_sub(viewport_start) + rubber_height + fixed_height;
if occupied < height {
if 0 < self.viewport_start || 0 < rubber_height {
rubber_height += height - occupied;
}
} else if height < occupied {
let overflow = occupied - height;
let consume_rubber = rubber_height.min(overflow);
rubber_height -= consume_rubber;
}
} else {
rubber_height = 0;
}
let render_len = log_height + rubber_height + fixed_height;
let cursor_row = if log_height <= cursor_row {
cursor_row + rubber_height
} else {
cursor_row
};
let bottom_start = render_len.saturating_sub(height);
let visible_start =
viewport_start_with_cursor(bottom_start, cursor_row, render_len, height);
let render_len = if visible_start < bottom_start {
(visible_start + height).min(render_len)
} else {
render_len
};
PlanMetrics {
viewport_start: render_len.saturating_sub(height),
rubber_height,
render_len,
cursor_row,
}
}
fn plan_view(&self, layout: &LayoutAll, height: usize) -> ViewPlan {
let fixed_height = layout.all_lines.len().saturating_sub(layout.log_end);
let metrics = self.plan_metrics(layout.log_end, fixed_height, layout.cursor_row, height);
let mut plan = Self::build_plan(layout, metrics.viewport_start, metrics.rubber_height);
plan.cursor_row = metrics.cursor_row;
plan.render_lines.truncate(metrics.render_len);
plan.viewport_start = metrics.viewport_start;
plan
}
fn apply_fast_plan(
&mut self,
history: &HistoryLayoutCache,
tail: &TailLayout,
metrics: &PlanMetrics,
) {
self.viewport_start = metrics.viewport_start;
self.rubber_height = metrics.rubber_height;
self.history_generation = history.generation;
self.history_width = history.width;
self.known_lines.truncate(self.history_height);
self.known_sources.truncate(self.history_height);
self.known_lines
.extend_from_slice(&history.lines[self.history_height..]);
self.known_sources
.extend_from_slice(&history.sources[self.history_height..]);
self.history_height = history.lines.len();
self.active_height = tail.active_height;
self.known_lines
.extend_from_slice(&tail.lines[..tail.active_height]);
self.known_sources
.extend_from_slice(&tail.sources[..tail.active_height]);
}
fn reset_to_layout(&mut self, layout: &LayoutAll, viewport_start: usize, rubber_height: usize) {
self.viewport_start = viewport_start;
self.rubber_height = rubber_height;
self.history_generation = layout.history_generation;
self.history_width = layout.history_width;
self.history_height = layout.history_height;
self.active_height = layout.log_end.saturating_sub(layout.history_height);
self.known_lines = layout.all_lines[..layout.log_end].to_vec();
self.known_sources = layout.line_sources[..layout.log_end].to_vec();
}
}
fn prompt_input_max_rows(terminal_height: usize) -> usize {
(terminal_height.max(1) * PROMPT_INPUT_MAX_HEIGHT_PERCENT / 100).max(1)
}
fn prompt_scroll_indicator_rows(
show_indicator: bool,
buffer_non_empty: bool,
total_rows: usize,
cap_rows: usize,
) -> usize {
usize::from(show_indicator && buffer_non_empty && 2 <= cap_rows && cap_rows < total_rows)
}
fn prompt_editable_rows(total_rows: usize, cap_rows: usize, indicator_rows: usize) -> usize {
cap_rows
.saturating_sub(indicator_rows)
.max(1)
.min(total_rows.max(1))
}
fn prompt_scroll_indicator_text(
start: usize,
visible_rows: usize,
total_rows: usize,
width: usize,
) -> String {
let end = (start + visible_rows).min(total_rows);
let hidden_above = start;
let hidden_below = total_rows.saturating_sub(end);
let full = format!(
"↕ prompt rows {}-{}/{} ↑{} ↓{}",
start + 1,
end,
total_rows,
hidden_above,
hidden_below
);
if display_width(&full) <= width {
return full;
}
let compact = format!("↕ ↑{} ↓{}", hidden_above, hidden_below);
if display_width(&compact) <= width {
return compact;
}
truncate_to_width("↕", width)
}
fn layout_tail(st: &SharedState, history_height: usize) -> TailLayout {
let width = st.terminal.width;
let mut lines: Vec<CellRow> = Vec::new();
let mut sources: Vec<LineSource> = Vec::new();
layout_id_list(
&st.layout.above_active,
&st.layout.blocks,
&st.layout.block_debug_ids,
width,
&mut lines,
&mut sources,
);
let active_height = lines.len();
layout_id_list(
&st.layout.above_sticky,
&st.layout.blocks,
&st.layout.block_debug_ids,
width,
&mut lines,
&mut sources,
);
let above_end = history_height + lines.len();
let mut input_content = st.editor.left_prompt.clone();
if st.editor.buffer.is_empty() {
for span in st.editor.input_placeholder.spans() {
input_content.push(span.clone());
}
} else {
input_content.push(Span::plain(&st.editor.buffer));
}
let mut input_lines = layout_lines()
.content(&input_content)
.width(width)
.preserve_last_newline(true)
.call();
let left_cols = st.editor.left_prompt.char_count();
let (buffer_cursor_row, cursor_col) =
buffer_position_for_byte(&st.editor.buffer, st.editor.cursor, width, left_cols);
while input_lines.len() <= buffer_cursor_row {
input_lines.push(Vec::new());
}
if !st.editor.right_prompt.is_empty() && !input_lines.is_empty() {
let first_line = &input_lines[0];
let right_cells = st.editor.right_prompt.to_cells();
let first_cols: usize = first_line.iter().map(|c| c.col_width()).sum();
let right_cols: usize = right_cells.iter().map(|c| c.col_width()).sum();
let needed = first_cols + 1 + right_cols;
if needed <= width && input_lines.len() == 1 {
let padding = width - first_cols - right_cols;
let mut padded = first_line.clone();
padded.extend(std::iter::repeat_n(Cell::plain(' '), padding));
padded.extend(right_cells);
input_lines[0] = padded;
}
}
let input_total_rows = input_lines.len().max(1);
let cap_rows = prompt_input_max_rows(st.terminal.height);
let indicator_rows = prompt_scroll_indicator_rows(
st.editor.show_prompt_scroll_indicator,
!st.editor.buffer.is_empty(),
input_total_rows,
cap_rows,
);
let visible_input_rows = prompt_editable_rows(input_total_rows, cap_rows, indicator_rows);
let viewport_start = viewport_start_with_cursor(
st.editor.input_viewport_start,
buffer_cursor_row,
input_total_rows,
visible_input_rows,
);
let cursor_row = above_end + indicator_rows + buffer_cursor_row.saturating_sub(viewport_start);
if indicator_rows == 1 {
let indicator = prompt_scroll_indicator_text(
viewport_start,
visible_input_rows,
input_total_rows,
width,
);
sources.push(LineSource::InputScrollIndicator);
lines.push(StyledText::from(indicator).to_cells().into());
}
let viewport_end = (viewport_start + visible_input_rows).min(input_lines.len());
for (wrapped_row, line) in input_lines
.into_iter()
.enumerate()
.skip(viewport_start)
.take(viewport_end.saturating_sub(viewport_start))
{
sources.push(LineSource::Input { wrapped_row });
lines.push(line.into());
}
layout_id_list(
&st.layout.suggestions,
&st.layout.blocks,
&st.layout.block_debug_ids,
width,
&mut lines,
&mut sources,
);
layout_id_list(
&st.layout.below,
&st.layout.blocks,
&st.layout.block_debug_ids,
width,
&mut lines,
&mut sources,
);
TailLayout {
lines,
sources,
active_height,
cursor_row,
cursor_col,
}
}
fn layout_all_from_cached_history(history: &HistoryLayoutCache, tail: TailLayout) -> LayoutAll {
let log_end = history.lines.len() + tail.active_height;
let cursor_row = tail.cursor_row;
let cursor_col = tail.cursor_col;
let mut all_lines = Vec::with_capacity(history.lines.len() + tail.lines.len());
all_lines.extend_from_slice(&history.lines);
all_lines.extend(tail.lines);
let mut line_sources = Vec::with_capacity(history.sources.len() + tail.sources.len());
line_sources.extend_from_slice(&history.sources);
line_sources.extend(tail.sources);
LayoutAll {
all_lines,
line_sources,
log_end,
history_generation: history.generation,
history_width: history.width,
history_height: history.lines.len(),
cursor_row,
cursor_col,
}
}
fn layout_all(st: &SharedState) -> LayoutAll {
let history = HistoryLayoutCache::rebuild(st);
let tail = layout_tail(st, history.lines.len());
layout_all_from_cached_history(&history, tail)
}
fn visible_lines_from_parts(
history_lines: &[CellRow],
tail: &TailLayout,
metrics: &PlanMetrics,
) -> Vec<CellRow> {
render_rows_from(history_lines, tail, metrics, metrics.viewport_start)
}
fn render_rows_from(
history_lines: &[CellRow],
tail: &TailLayout,
metrics: &PlanMetrics,
start: usize,
) -> Vec<CellRow> {
let history_height = history_lines.len();
let log_height = history_height + tail.active_height;
let fixed_start = log_height + metrics.rubber_height;
let mut rows = Vec::with_capacity(metrics.render_len.saturating_sub(start));
for idx in start..metrics.render_len {
if idx < history_height {
rows.push(
history_lines
.get(idx)
.expect("requested history row should exist")
.clone(),
);
} else if idx < log_height {
rows.push(
tail.lines
.get(idx - history_height)
.expect("requested active row should exist")
.clone(),
);
} else if idx < fixed_start {
rows.push(CellRow::new(Vec::new()));
} else {
rows.push(
tail.lines
.get(tail.active_height + idx - fixed_start)
.expect("requested fixed row should exist")
.clone(),
);
}
}
rows
}
fn scrolling_suffix(
history_lines: &[CellRow],
tail: &TailLayout,
metrics: &PlanMetrics,
terminal_model: &TerminalModel,
) -> Vec<CellRow> {
render_rows_from(history_lines, tail, metrics, terminal_model.viewport_start)
}
enum RenderFrame {
Fast {
tail: TailLayout,
metrics: PlanMetrics,
},
Full {
layout: LayoutAll,
},
}
struct RedrawPass {
width: usize,
height: usize,
size_changed: bool,
force_full: bool,
sync_gen: RedrawSyncGeneration,
pending_raw: Vec<String>,
redraw_history_size: usize,
frame: RenderFrame,
presentation_observations: Option<CapturedPresentationObservations>,
}
struct FullRenderMark {
reason: &'static str,
prev_visible_start: usize,
visible_start: usize,
height: usize,
changed_line: Option<usize>,
previous_source: Option<LineSource>,
}
struct FullRenderMarkInput {
reason: &'static str,
changed_line: Option<usize>,
previous_source: Option<LineSource>,
}
fn redraw_loop(
state: Arc<Mutex<SharedState>>,
notify_rx: tau_blocking_notify_channel::Receiver,
writer: Box<dyn Write + Send>,
input_tx: path_std_sync::mpsc::Sender<InputMessage>,
sync_condvar: &std::sync::Condvar,
) {
let mut writer = BufWriter::new(writer);
let (w, h) = {
let st = state.lock().expect("term state mutex poisoned");
(st.terminal.width, st.terminal.height)
};
let mut screen = Screen::new(w);
let mut prev_width = w;
let mut prev_height = h;
let mut history_cache = HistoryLayoutCache::default();
let mut terminal_model = TerminalModel::default();
loop {
if render_shutdown_if_requested(
&state,
&mut writer,
&mut screen,
&terminal_model,
prev_width,
sync_condvar,
) {
break;
}
if !wait_for_redraw_or_sync(&state, ¬ify_rx) {
break;
}
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
"redraw prepare started"
);
let pass = match prepare_redraw_pass(
&state,
&mut history_cache,
&terminal_model,
prev_width,
prev_height,
sync_condvar,
) {
Some(pass) => pass,
None => continue,
};
let write_started = path_std_time::Instant::now();
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
"terminal write started"
);
let render_result = render_redraw_pass(
&state,
&mut writer,
&mut screen,
&history_cache,
&mut terminal_model,
&pass,
);
let write_elapsed = write_started.elapsed();
if let Err(error) = render_result {
trace_failed_presentation_observations(&state, &pass, "write", write_elapsed, &error);
fail_terminal_output(&state, &input_tx, sync_condvar, error);
discard_failed_output(writer);
return;
}
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
write_us = write_elapsed.as_micros(),
"terminal write finished; flush started"
);
let flush_started = path_std_time::Instant::now();
let output_result = writer.flush();
let flush_elapsed = flush_started.elapsed();
if let Err(error) = output_result {
trace_failed_presentation_observations(&state, &pass, "flush", flush_elapsed, &error);
fail_terminal_output(&state, &input_tx, sync_condvar, error);
discard_failed_output(writer);
return;
}
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
write_us = write_elapsed.as_micros(),
flush_us = flush_elapsed.as_micros(),
"terminal write and flush finished"
);
trace_flushed_presentation_observations(&state, &pass);
if (Duration::from_millis(500) <= write_elapsed
|| Duration::from_millis(500) <= flush_elapsed)
&& admit_stall_warning()
{
tracing::warn!(
target: "tau_cli_term_raw::frontend_progress",
write_ms = write_elapsed.as_millis(),
flush_ms = flush_elapsed.as_millis(),
"terminal output stalled"
);
}
prev_width = pass.width;
prev_height = pass.height;
complete_redraw_sync(&state, pass.sync_gen, sync_condvar);
}
}
fn discard_failed_output(writer: BufWriter<Box<dyn Write + Send>>) {
let _ = writer.into_parts();
}
fn render_shutdown_if_requested(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &TerminalModel,
prev_width: usize,
sync_condvar: &std::sync::Condvar,
) -> bool {
let mut st = state.lock().expect("term state mutex poisoned");
if !st.terminal.shutdown {
return false;
}
if st.terminal.external_paused {
st.terminal.sync_completed = st.terminal.sync_requested;
drop(st);
sync_condvar.notify_all();
return true;
}
let layout = layout_all(&st);
let height = st.terminal.height.max(1);
let plan = terminal_model.plan_view(&layout, height);
let visible = plan.visible_lines(height);
let cursor_in_visible = plan.cursor_in_visible(height);
drop(st);
screen.set_width(prev_width);
let _ = screen.update(writer, visible, (cursor_in_visible, layout.cursor_col));
let below = plan.render_lines.len().saturating_sub(plan.cursor_row + 1);
for _ in 0..=below {
let _ = writer.queue(crossterm::style::Print("\r\n"));
}
let _ = writer.flush();
{
let mut st = state.lock().expect("term state mutex poisoned");
st.terminal.sync_completed = st.terminal.sync_requested;
}
sync_condvar.notify_all();
true
}
fn wait_for_redraw_or_sync(
state: &Arc<Mutex<SharedState>>,
notify_rx: &tau_blocking_notify_channel::Receiver,
) -> bool {
let trace_enabled = tracing::enabled!(
target: "tau_cli_term_raw::frontend_progress",
tracing::Level::TRACE
);
let lock_started = trace_enabled.then(path_std_time::Instant::now);
let st = state.lock().expect("term state mutex poisoned");
if let Some(lock_started) = lock_started {
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
lock_wait_us = lock_started.elapsed().as_micros(),
stage = "notification_check",
"terminal shared state acquired"
);
}
if st.terminal.sync_completed < st.terminal.sync_requested {
return true;
}
drop(st);
let notification_started = trace_enabled.then(path_std_time::Instant::now);
let result = notify_rx.recv().is_ok();
if let Some(notification_started) = notification_started {
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
notification_wait_us = notification_started.elapsed().as_micros(),
"redraw notification wait finished"
);
}
result
}
fn prepare_redraw_pass(
state: &Arc<Mutex<SharedState>>,
history_cache: &mut HistoryLayoutCache,
terminal_model: &TerminalModel,
prev_width: usize,
prev_height: usize,
sync_condvar: &std::sync::Condvar,
) -> Option<RedrawPass> {
let trace_enabled = tracing::enabled!(
target: "tau_cli_term_raw::frontend_progress",
tracing::Level::TRACE
);
let lock_started = trace_enabled.then(path_std_time::Instant::now);
let mut st = state.lock().expect("term state mutex poisoned");
if let Some(lock_started) = lock_started {
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
lock_wait_us = lock_started.elapsed().as_micros(),
stage = "redraw_prepare",
"terminal shared state acquired"
);
}
if st.terminal.redraw_suppression != 0 {
st.terminal.redraw_dirty_while_suppressed = true;
st.terminal.sync_completed = st.terminal.sync_requested;
sync_condvar.notify_all();
return None;
}
if st.terminal.external_paused {
st.terminal.sync_completed = st.terminal.sync_requested;
sync_condvar.notify_all();
return None;
}
let width = st.terminal.width;
let height = st.terminal.height.max(1);
let size_changed = prev_width != width || prev_height != height;
let force_full = std::mem::take(&mut st.terminal.invalidate_screen);
let sync_gen = st.terminal.sync_requested;
let pending_raw = std::mem::take(&mut st.terminal.pending_raw);
let redraw_history_size = st.terminal.redraw_history_size;
let presentation_observations =
(!st.presentation_observations.is_empty()).then(|| st.presentation_observations.capture());
let preparation_started = trace_enabled.then(path_std_time::Instant::now);
history_cache.refresh(&mut st);
let tail = layout_tail(&st, history_cache.lines.len());
let log_height = history_cache.lines.len() + tail.active_height;
let fixed_height = tail.fixed_height();
let metrics = terminal_model.plan_metrics(log_height, fixed_height, tail.cursor_row, height);
let can_fast = !size_changed
&& !force_full
&& ((terminal_model.history_cache_matches(history_cache)
&& metrics.viewport_start == terminal_model.viewport_start)
|| (terminal_model.history_append_matches(history_cache)
&& terminal_model.viewport_start <= metrics.viewport_start))
&& metrics.viewport_start <= history_cache.lines.len();
let frame = if can_fast {
RenderFrame::Fast { tail, metrics }
} else {
RenderFrame::Full {
layout: layout_all_from_cached_history(history_cache, tail),
}
};
let pass = RedrawPass {
width,
height,
size_changed,
force_full,
sync_gen,
pending_raw,
redraw_history_size,
frame,
presentation_observations,
};
if let Some(preparation_started) = preparation_started {
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
preparation_us = preparation_started.elapsed().as_micros(),
"redraw layout prepared"
);
}
Some(pass)
}
fn trace_flushed_presentation_observations(_state: &Arc<Mutex<SharedState>>, pass: &RedrawPass) {
let Some(observations) = &pass.presentation_observations else {
return;
};
let flushed_at = path_std_time::Instant::now();
for fact in &observations.facts {
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
delivery_id = fact.delivery_id.get(),
fact = fact.fact,
mutation_generation = fact.generation.get(),
frame_generation = observations.generation.get(),
mutation_to_flush_us = flushed_at.duration_since(fact.observed_at).as_micros(),
"selected presentation mutation frame written and flushed"
);
}
if observations.omitted != 0 {
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
frame_generation = observations.generation.get(),
omitted = observations.omitted,
"selected presentation flush observations omitted"
);
}
#[cfg(test)]
_state
.lock()
.expect("term state mutex poisoned")
.presentation_observations
.record_success_for_test(observations);
}
fn trace_failed_presentation_observations(
_state: &Arc<Mutex<SharedState>>,
pass: &RedrawPass,
stage: &'static str,
stage_elapsed: Duration,
error: &io::Error,
) {
let Some(observations) = &pass.presentation_observations else {
return;
};
#[cfg(test)]
_state
.lock()
.expect("term state mutex poisoned")
.presentation_failure_test_records
.push((
stage,
stage_elapsed.as_micros(),
observations.facts.len(),
observations.omitted,
));
tracing::trace!(
target: "tau_cli_term_raw::frontend_progress",
stage,
stage_us = stage_elapsed.as_micros(),
frame_generation = observations.generation.get(),
indeterminate_facts = observations.facts.len(),
omitted = observations.omitted,
error_kind = ?error.kind(),
"selected presentation redraw pass failed or is indeterminate"
);
}
fn render_redraw_pass(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
history_cache: &HistoryLayoutCache,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
) -> io::Result<()> {
for seq in &pass.pending_raw {
writer.write_all(seq.as_bytes())?;
}
if pass.force_full {
screen.invalidate();
}
match &pass.frame {
RenderFrame::Fast { tail, metrics } => {
render_fast_frame(
writer,
screen,
history_cache,
terminal_model,
pass,
tail,
metrics,
)?;
}
RenderFrame::Full { layout } => {
render_full_frame(state, writer, screen, terminal_model, pass, layout)?;
}
}
Ok(())
}
fn render_fast_frame(
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
history_cache: &HistoryLayoutCache,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
tail: &TailLayout,
metrics: &PlanMetrics,
) -> io::Result<()> {
screen.set_width(pass.width);
if terminal_model.viewport_start < metrics.viewport_start {
let previous_viewport_start = terminal_model.viewport_start;
let suffix = scrolling_suffix(&history_cache.lines, tail, metrics, terminal_model);
let cursor_row = metrics.cursor_row.saturating_sub(previous_viewport_start);
screen.render_scrolling(
writer,
&suffix,
0,
pass.height,
(cursor_row, tail.cursor_col),
)?;
} else {
let visible = visible_lines_from_parts(&history_cache.lines, tail, metrics);
let cursor_in_visible = metrics.cursor_row.saturating_sub(metrics.viewport_start);
screen.update(writer, &visible, (cursor_in_visible, tail.cursor_col))?;
}
terminal_model.apply_fast_plan(history_cache, tail, metrics);
Ok(())
}
fn render_full_frame(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
) -> io::Result<()> {
if pass.size_changed || pass.force_full {
let reason = if pass.size_changed {
"size_changed"
} else {
"force_full"
};
render_marked_full_frame(
state,
writer,
screen,
terminal_model,
pass,
layout,
FullRenderMarkInput {
reason,
changed_line: None,
previous_source: None,
},
)?;
return Ok(());
}
render_incremental_or_scroll_frame(state, writer, screen, terminal_model, pass, layout)
}
fn render_incremental_or_scroll_frame(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
) -> io::Result<()> {
screen.set_width(pass.width);
let hidden_prefix_changed = terminal_model.hidden_prefix_changed(layout);
let incremental_plan = terminal_model.plan_view(layout, pass.height);
let incremental_visible_start = incremental_plan.viewport_start;
if incremental_visible_start < terminal_model.viewport_start {
render_viewport_moved_up_frame(state, writer, screen, terminal_model, pass, layout)
} else if hidden_prefix_changed {
render_hidden_prefix_changed_frame(state, writer, screen, terminal_model, pass, layout)
} else if terminal_model.viewport_start < incremental_visible_start {
render_scrolling_frame(
writer,
screen,
terminal_model,
pass,
layout,
incremental_plan,
)
} else {
render_diff_frame(
writer,
screen,
terminal_model,
pass,
layout,
incremental_plan,
)
}
}
fn render_marked_full_frame(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
mark_input: FullRenderMarkInput,
) -> io::Result<()> {
let plan = TerminalModel::full_redraw_plan(layout, pass.height);
let mark = FullRenderMark {
reason: mark_input.reason,
prev_visible_start: terminal_model.viewport_start,
visible_start: plan.viewport_start,
height: pass.height,
changed_line: mark_input.changed_line,
previous_source: mark_input.previous_source,
};
mark_full_render(state, layout, mark);
full_render(
writer,
screen,
layout,
&plan,
pass.width,
pass.height,
pass.redraw_history_size,
)?;
reset_model_after_rendered_full_frame(terminal_model, pass, layout, plan);
Ok(())
}
fn render_viewport_moved_up_frame(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
) -> io::Result<()> {
render_marked_full_frame(
state,
writer,
screen,
terminal_model,
pass,
layout,
FullRenderMarkInput {
reason: "viewport_moved_up",
changed_line: None,
previous_source: None,
},
)
}
fn render_hidden_prefix_changed_frame(
state: &Arc<Mutex<SharedState>>,
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
) -> io::Result<()> {
let changed_line = terminal_model.changed_hidden_line(layout);
let previous_source = changed_line
.and_then(|idx| terminal_model.known_sources.get(idx))
.cloned();
render_marked_full_frame(
state,
writer,
screen,
terminal_model,
pass,
layout,
FullRenderMarkInput {
reason: "hidden_prefix_changed",
changed_line,
previous_source,
},
)
}
fn render_scrolling_frame(
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
plan: ViewPlan,
) -> io::Result<()> {
screen.render_scrolling(
writer,
&plan.render_lines,
terminal_model.viewport_start,
pass.height,
(plan.cursor_row, layout.cursor_col),
)?;
terminal_model.reset_to_layout(layout, plan.viewport_start, plan.rubber_height);
Ok(())
}
fn render_diff_frame(
writer: &mut BufWriter<Box<dyn Write + Send>>,
screen: &mut Screen,
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
plan: ViewPlan,
) -> io::Result<()> {
let visible = plan.visible_lines(pass.height);
let cursor_in_visible = plan.cursor_in_visible(pass.height);
screen.update(writer, visible, (cursor_in_visible, layout.cursor_col))?;
terminal_model.reset_to_layout(layout, plan.viewport_start, plan.rubber_height);
Ok(())
}
fn reset_model_after_rendered_full_frame(
terminal_model: &mut TerminalModel,
pass: &RedrawPass,
layout: &LayoutAll,
plan: ViewPlan,
) {
let viewport_start =
full_render_effective_viewport_start(layout, &plan, pass.height, pass.redraw_history_size);
terminal_model.reset_to_layout(layout, viewport_start, plan.rubber_height);
}
fn complete_redraw_sync(
state: &Arc<Mutex<SharedState>>,
sync_gen: RedrawSyncGeneration,
sync_condvar: &std::sync::Condvar,
) {
{
let mut st = state.lock().expect("term state mutex poisoned");
st.terminal.sync_completed = st.terminal.sync_completed.max(sync_gen);
}
sync_condvar.notify_all();
}
fn fail_terminal_output(
state: &Arc<Mutex<SharedState>>,
input_tx: &path_std_sync::mpsc::Sender<InputMessage>,
sync_condvar: &std::sync::Condvar,
error: io::Error,
) {
let mut st = state.lock().expect("term state mutex poisoned");
if st.terminal.output_failure.is_none() {
tracing::error!(
target: "tau_cli_term_raw::redraw",
error = %error,
"terminal output failed; stopping attachment renderer"
);
st.terminal.output_failure = Some(OutputFailure::new(error));
}
st.terminal.input_shutdown = true;
st.terminal.sync_completed = st.terminal.sync_requested;
drop(st);
sync_condvar.notify_all();
let _ = input_tx.send(InputMessage::Shutdown);
}
fn changed_line_in_range(
prev_all_lines: &[CellRow],
all_lines: &[CellRow],
range: std::ops::Range<usize>,
) -> Option<usize> {
range
.into_iter()
.find(|idx| prev_all_lines.get(*idx) != all_lines.get(*idx))
}
fn mark_full_render(state: &Arc<Mutex<SharedState>>, layout: &LayoutAll, mark: FullRenderMark) {
let full_render_count = {
let mut st = state.lock().expect("term state mutex poisoned");
st.terminal.full_render_count += 1;
st.terminal.full_render_count
};
let current_source = mark
.changed_line
.and_then(|idx| layout.line_sources.get(idx))
.cloned();
let previous = describe_line_source(mark.previous_source.as_ref());
let current = describe_line_source(current_source.as_ref());
tracing::info!(
target: "tau_cli_term_raw::redraw",
full_render_count,
reason = mark.reason,
prev_visible_start = mark.prev_visible_start,
visible_start = mark.visible_start,
height = mark.height,
total_lines = layout.all_lines.len(),
changed_line = mark.changed_line,
previous_source = ?mark.previous_source,
current_source = ?current_source,
"full redraw caused by {}: {previous} -> {current}", mark.reason
);
tracing::trace!(
target: "tau_cli_term_raw::redraw",
full_render_count,
reason = mark.reason,
prev_visible_start = mark.prev_visible_start,
visible_start = mark.visible_start,
height = mark.height,
total_lines = layout.all_lines.len(),
changed_line = mark.changed_line,
previous_source = ?mark.previous_source,
current_source = ?current_source,
"full render"
);
}
fn describe_line_source(source: Option<&LineSource>) -> String {
match source {
Some(LineSource::Block {
id,
debug_id,
wrapped_row,
}) => format!("block {:?} `{}` row {}", id, debug_id, wrapped_row),
Some(LineSource::Input { wrapped_row }) => format!("input row {wrapped_row}"),
Some(LineSource::InputScrollIndicator) => "input scroll indicator".to_owned(),
None => "<missing>".to_owned(),
}
}
fn viewport_start_with_cursor(
viewport_start: usize,
cursor_row: usize,
total_rows: usize,
height: usize,
) -> usize {
let height = height.max(1);
let max_start = total_rows.saturating_sub(height);
let mut start = viewport_start.min(max_start);
if cursor_row < start {
start = cursor_row;
} else if start + height <= cursor_row {
start = (cursor_row + 1).saturating_sub(height);
}
start.min(max_start)
}
fn hidden_lines_changed(
prev_all_lines: &[CellRow],
all_lines: &[CellRow],
prev_visible_start: usize,
) -> bool {
(0..prev_visible_start).any(|idx| prev_all_lines.get(idx) != all_lines.get(idx))
}
fn full_render_replay_start(
layout: &LayoutAll,
plan: &ViewPlan,
redraw_history_size: usize,
) -> usize {
let total = plan.render_lines.len();
let log_end = layout.log_end.min(total);
log_end.saturating_sub(redraw_history_size)
}
fn full_render_effective_viewport_start(
layout: &LayoutAll,
plan: &ViewPlan,
height: usize,
redraw_history_size: usize,
) -> usize {
let replay_start = full_render_replay_start(layout, plan, redraw_history_size);
let replay_len = plan.render_lines.len().saturating_sub(replay_start);
if height < replay_len {
plan.render_lines.len().saturating_sub(height)
} else {
replay_start
}
}
fn full_render(
stdout: &mut impl Write,
screen: &mut Screen,
layout: &LayoutAll,
plan: &ViewPlan,
width: usize,
height: usize,
redraw_history_size: usize,
) -> io::Result<()> {
screen.set_width(width);
let all_lines = &plan.render_lines;
let replay_start = full_render_replay_start(layout, plan, redraw_history_size);
let replay_lines = &all_lines[replay_start..];
let replay_total = replay_lines.len();
let effective_viewport_start =
full_render_effective_viewport_start(layout, plan, height, redraw_history_size);
with_synchronized_update(stdout, |stdout| {
stdout.queue(Print("\x1b[2J\x1b[H\x1b[3J\x1b[?7l"))?;
for (i, line) in replay_lines.iter().enumerate() {
if 0 < i {
stdout.queue(Print("\r\n"))?;
}
emit_styled_cells(stdout, line)?;
}
stdout.queue(Print("\x1b[?7h"))?;
let current_screen_row = if height <= replay_total {
height - 1
} else {
replay_total.saturating_sub(1)
};
let cursor_screen_row = plan.cursor_row.saturating_sub(effective_viewport_start);
let up = current_screen_row.saturating_sub(cursor_screen_row);
if 0 < up {
stdout.queue(MoveUp(up as u16))?;
}
stdout.queue(MoveToColumn(layout.cursor_col as u16))?;
Ok(())
})?;
let visible_end = (effective_viewport_start + height).min(plan.render_lines.len());
let visible_lines = plan.render_lines[effective_viewport_start..visible_end].to_vec();
let cursor_in_visible = plan.cursor_row.saturating_sub(effective_viewport_start);
screen.reset_to(visible_lines, cursor_in_visible, layout.cursor_col);
Ok(())
}
fn with_synchronized_update<W, F>(writer: &mut W, body: F) -> io::Result<()>
where
W: Write,
F: FnOnce(&mut W) -> io::Result<()>,
{
writer.queue(terminal::BeginSynchronizedUpdate)?;
let body_result = body(writer);
let end_result = writer.queue(terminal::EndSynchronizedUpdate).map(|_| ());
body_result.and(end_result)
}
fn move_cursor_vertical(st: &SharedState, delta: isize, target_col: usize) -> Option<usize> {
let width = st.terminal.width.max(1);
let left_cols = st.editor.left_prompt.char_count();
let (current_row, _) =
buffer_position_for_byte(&st.editor.buffer, st.editor.cursor, width, left_cols);
let target_row = current_row as isize + delta;
if target_row < 0 {
return None;
}
let target_row = target_row as usize;
let (max_row, _) = buffer_end_position(&st.editor.buffer, width, left_cols);
if max_row < target_row {
return None;
}
Some(byte_offset_for_buffer_position(
&st.editor.buffer,
target_row,
target_col,
width,
left_cols,
))
}
fn term_size() -> (usize, usize) {
raw_term_size()
.map(|(w, h)| (usize::from(w).max(1), usize::from(h).max(1)))
.unwrap_or((80, 24))
}
fn raw_term_size() -> io::Result<(u16, u16)> {
terminal::size()
}
fn resample_resize_dimension(reported: u16, actual: u16) -> u16 {
if 0 < reported { reported } else { actual }
}
fn effective_resize_dimension(reported: u16, fallback: usize) -> usize {
let reported = usize::from(reported);
if 0 < reported {
reported
} else {
fallback.max(1)
}
}
fn size_event_dimension(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
fn normalize_paste_text(text: String) -> String {
if !text.contains('\r') {
return text;
}
let mut normalized = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\r' {
if chars.peek() == Some(&'\n') {
chars.next();
}
normalized.push('\n');
} else {
normalized.push(ch);
}
}
normalized
}
fn is_prompt_line_break(grapheme: &str) -> bool {
matches!(grapheme, "\n" | "\r\n" | "\r")
}
fn initial_buffer_position(initial_cols: usize, width: usize) -> (usize, usize) {
let width = width.max(1);
(initial_cols / width, initial_cols % width)
}
fn buffer_position_for_byte(
s: &str,
byte_pos: usize,
width: usize,
initial_cols: usize,
) -> (usize, usize) {
let width = width.max(1);
let mut pos = initial_buffer_position(initial_cols, width);
let mut pending_exact_wrap = false;
for (byte, grapheme) in UnicodeSegmentation::grapheme_indices(s, true) {
if byte_pos <= byte || byte_pos < byte + grapheme.len() {
break;
}
advance_prompt_cursor_position(
&mut pos.0,
&mut pos.1,
&mut pending_exact_wrap,
grapheme,
width,
);
}
pos
}
fn advance_prompt_cursor_position(
row: &mut usize,
col: &mut usize,
pending_exact_wrap: &mut bool,
grapheme: &str,
width: usize,
) {
let width = width.max(1);
if is_prompt_line_break(grapheme) {
if *pending_exact_wrap {
*pending_exact_wrap = false;
} else {
*row += 1;
*col = 0;
}
return;
}
*pending_exact_wrap = false;
let grapheme_width = display_width(grapheme);
if 0 < *col && width < *col + grapheme_width {
*row += 1;
*col = 0;
}
*col += grapheme_width;
if width <= *col {
*row += *col / width;
*col %= width;
*pending_exact_wrap = grapheme_width != 0 && *col == 0;
}
}
fn buffer_end_position(s: &str, width: usize, initial_cols: usize) -> (usize, usize) {
buffer_position_for_byte(s, s.len(), width, initial_cols)
}
fn byte_offset_for_buffer_position(
s: &str,
target_row: usize,
target_col: usize,
width: usize,
initial_cols: usize,
) -> usize {
let mut row_col = initial_buffer_position(initial_cols, width);
let mut pending_exact_wrap = false;
for (byte, grapheme) in UnicodeSegmentation::grapheme_indices(s, true) {
let (row, col) = row_col;
if target_row < row || (target_row == row && target_col <= col) {
return byte;
}
if is_prompt_line_break(grapheme) && !pending_exact_wrap && target_row == row {
return byte;
}
let mut next = row_col;
let mut next_pending_exact_wrap = pending_exact_wrap;
advance_prompt_cursor_position(
&mut next.0,
&mut next.1,
&mut next_pending_exact_wrap,
grapheme,
width,
);
if !is_prompt_line_break(grapheme)
&& (target_row < next.0 || (target_row == next.0 && target_col <= next.1))
{
return byte + grapheme.len();
}
row_col = next;
pending_exact_wrap = next_pending_exact_wrap;
}
s.len()
}
fn clamp_cursor_to_grapheme_boundary(s: &str, cursor: usize) -> usize {
let cursor = cursor.min(s.len());
if cursor == s.len() {
return cursor;
}
let mut boundary = 0;
for (idx, _) in UnicodeSegmentation::grapheme_indices(s, true) {
if cursor < idx {
break;
}
boundary = idx;
}
boundary
}
fn prev_char_boundary(s: &str, pos: usize) -> usize {
previous_grapheme_boundary(s, pos)
}
fn next_char_boundary(s: &str, pos: usize) -> usize {
next_grapheme_boundary(s, pos)
}
#[cfg(test)]
mod tests;