Skip to main content

tau_cli_term_raw/
lib.rs

1//! Terminal prompt with async output support.
2//!
3//! Renders directly to the normal terminal buffer (no alternate screen)
4//! so the terminal's native scrollback is preserved. See `README.md`
5//! in this crate for the full rendering strategy.
6//!
7//! Three rendering paths (see `README.md`):
8//! - **Differential update** — common case, diffs visible viewport via
9//!   [`Screen`]
10//! - **Scrolling render** — on overflow, diffs a suffix rebased at the prior
11//!   viewport and renders in order; `\r\n` at the bottom pushes content into
12//!   scrollback without materializing older hidden history
13//! - **Full render** — on resize/invalidation, clears screen + scrollback and
14//!   replays the capped log/history suffix plus fixed tail without rubber
15
16mod block_layout_state;
17mod presentation_mutation_generation;
18mod presentation_observation_state;
19mod prompt_editor_state;
20mod redraw_sync_generation;
21mod renderer_delivery_id;
22#[cfg(test)]
23mod terminal_generation_tests;
24mod terminal_history_generation;
25mod terminal_runtime_state;
26
27use std::cell::RefCell;
28use std::collections::{HashMap, HashSet};
29use std::io::{self, BufWriter, Write};
30use std::sync::{Arc, Mutex, MutexGuard, atomic as path_std_sync_atomic};
31use std::thread::{self, JoinHandle};
32use std::time::Duration;
33use std::{sync as path_std_sync, time as path_std_time};
34
35use base64::engine as path_base64_engine;
36use crossterm::cursor as path_crossterm_cursor;
37const PROMPT_INPUT_MAX_HEIGHT_PERCENT: usize = 33;
38/// Maximum number of nonempty drafts retained for one terminal attachment.
39const INPUT_HISTORY_MAX_ENTRIES: usize = 1000;
40/// Maximum primary UTF-8 text retained for one terminal attachment's drafts.
41const INPUT_HISTORY_MAX_BYTES: usize = 16 * 1024 * 1024;
42const STALL_WARNING_INTERVAL: Duration = Duration::from_secs(5);
43static STALL_WARNING_LIMITER: Mutex<StallWarningLimiter> =
44    Mutex::new(StallWarningLimiter { last: None });
45
46/// Independent raw-input history retention limits for one attachment.
47#[derive(Clone, Copy)]
48struct InputHistoryLimits {
49    /// Maximum retained nonempty drafts.
50    max_entries: usize,
51    /// Maximum retained primary UTF-8 bytes.
52    max_bytes: usize,
53}
54
55/// Limits repeated slow-stage warnings across terminal operations.
56struct StallWarningLimiter {
57    /// Most recent admitted warning timestamp.
58    last: Option<std::time::Instant>,
59}
60
61impl StallWarningLimiter {
62    /// Admits the first warning in each fixed minimum interval.
63    fn admit(&mut self, now: std::time::Instant) -> bool {
64        if self
65            .last
66            .is_some_and(|last| now.duration_since(last) < STALL_WARNING_INTERVAL)
67        {
68            return false;
69        }
70        self.last = Some(now);
71        true
72    }
73}
74
75fn admit_stall_warning() -> bool {
76    STALL_WARNING_LIMITER
77        .lock()
78        .expect("stall warning mutex poisoned")
79        .admit(path_std_time::Instant::now())
80}
81
82use block_layout_state::BlockLayoutState;
83use crossterm::cursor::{MoveToColumn, MoveUp, SetCursorStyle};
84use crossterm::event::{
85    self, DisableMouseCapture, Event as CtEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
86    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
87};
88use crossterm::style::Print;
89use crossterm::{QueueableCommand, terminal};
90use presentation_observation_state::{
91    CapturedPresentationObservations, PresentationObservationState,
92};
93pub use presentation_observation_state::{
94    OpaquePresentationFact, PresentationInvalidation, PresentationObservationKey,
95};
96use prompt_editor_state::PromptEditorState;
97use redraw_sync_generation::RedrawSyncGeneration;
98pub use renderer_delivery_id::RendererDeliveryId;
99pub use tau_term_screen::{
100    Align, BlockId, Cell, CellRow, Color, PriorityLine, PriorityLineAlignment,
101    PriorityLinePriority, PriorityLineTruncation, Span, Style, StyledBlock, StyledText,
102    TwoLineElision, sanitize_hyperlink_target,
103};
104use tau_term_screen::{
105    Screen, display_width, emit_styled_cells, layout_block, layout_lines, next_grapheme_boundary,
106    previous_grapheme_boundary, truncate_to_width,
107};
108use terminal_history_generation::TerminalHistoryGeneration;
109use terminal_runtime_state::TerminalRuntimeState;
110use unicode_segmentation::UnicodeSegmentation;
111
112type NamedActionHandler = fn(&Term) -> Option<Event>;
113
114// Shared source of truth for raw named actions. Keep this table in sync with
115// built-in keybindings and any user-facing binding documentation when adding or
116// removing actions.
117const NAMED_ACTIONS: &[(&str, NamedActionHandler)] = &[
118    ("accept-completion", Term::accept_completion_event),
119    ("backtab", Term::backtab_action),
120    ("clear-prompt", Term::clear_prompt_action),
121    (
122        "clear-or-cancel-prompt",
123        Term::clear_or_cancel_prompt_action,
124    ),
125    ("cursor-down", Term::cycle_or_move_down),
126    ("cursor-end", Term::move_cursor_end_action),
127    ("cursor-left", Term::move_cursor_left_action),
128    ("cursor-right", Term::move_cursor_right_action),
129    ("cursor-start", Term::move_cursor_start_action),
130    ("cursor-up", Term::cycle_or_move_up),
131    ("delete-backward", Term::delete_backward_action),
132    ("delete-forward", Term::delete_forward_action),
133    ("dismiss-completion", Term::dismiss_completion_event),
134    ("escape", Term::escape_action),
135    ("kill-to-start", Term::kill_to_start_action),
136    ("kill-word-left", Term::kill_word_left_action),
137    ("move-down", Term::move_cursor_down_action),
138    ("move-up", Term::move_cursor_up_action),
139    ("prompt-eof", Term::prompt_eof_action),
140    (
141        "select-completion-next",
142        Term::select_completion_next_action,
143    ),
144    (
145        "select-completion-previous",
146        Term::select_completion_previous_action,
147    ),
148];
149
150fn named_action_handler(action: &str) -> Option<NamedActionHandler> {
151    NAMED_ACTIONS
152        .iter()
153        .find_map(|(name, handler)| (*name == action).then_some(*handler))
154}
155
156/// Cursor shape requested for the prompt while Tau owns raw mode.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum CursorShape {
159    /// Thin vertical cursor bar.
160    Bar,
161    /// Solid block cursor.
162    Block,
163}
164
165impl CursorShape {
166    fn crossterm_style(self) -> crossterm::cursor::SetCursorStyle {
167        match self {
168            Self::Bar => path_crossterm_cursor::SetCursorStyle::SteadyBar,
169            Self::Block => path_crossterm_cursor::SetCursorStyle::SteadyBlock,
170        }
171    }
172}
173
174/// Immutable terminal behavior selected before Tau acquires the terminal.
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub struct TerminalOptions {
177    /// Cursor shape Tau uses while it owns raw terminal input.
178    pub cursor_shape: CursorShape,
179    /// Whether mouse activity remains enabled for the CLI UI.
180    ///
181    /// When false, the raw terminal layer explicitly disables terminal mouse
182    /// reporting while Tau owns the terminal. The terminal then handles mouse
183    /// activity natively instead of sending it to Tau.
184    pub mouse: bool,
185}
186
187impl Default for TerminalOptions {
188    fn default() -> Self {
189        Self {
190            cursor_shape: CursorShape::Bar,
191            mouse: true,
192        }
193    }
194}
195
196/// A single completion candidate surfaced by a [`CompletionSource`].
197#[derive(Clone, Debug)]
198pub struct Candidate {
199    /// Short text shown in the menu's left column.
200    pub label: String,
201    /// Description shown to the right of the label.
202    pub description: String,
203    /// Buffer contents to install when this candidate is selected for preview.
204    pub replacement: String,
205    /// UTF-8 byte offset at which to place the prompt cursor in `replacement`.
206    pub cursor: usize,
207    /// Optional buffer replacement installed only when the user accepts this
208    /// candidate instead of merely previewing it.
209    pub acceptance: Option<CompletionAcceptance>,
210}
211
212/// A candidate's whole-buffer replacement and cursor position at acceptance.
213#[derive(Clone, Debug)]
214pub struct CompletionAcceptance {
215    /// Buffer contents to install when the user accepts the candidate.
216    pub replacement: String,
217    /// UTF-8 byte offset at which to place the prompt cursor in `replacement`.
218    pub cursor: usize,
219}
220
221/// Builds the candidate list for the current buffer.
222///
223/// Called on every buffer mutation (typing, paste, backspace) and on explicit
224/// terminal-owned refresh requests. An empty result closes the completion menu;
225/// a non-empty result opens it (or refreshes it if already open).
226pub trait CompletionSource: Send + Sync {
227    /// Returns whole-buffer completion candidates for `buffer`.
228    ///
229    /// `cursor` is a UTF-8 byte offset into `buffer`, clamped to a grapheme
230    /// boundary by the prompt before this hook is called. The hook runs
231    /// synchronously on the input-event path, so implementations should avoid
232    /// blocking work. Previewing a returned candidate replaces the entire
233    /// prompt buffer with [`Candidate::replacement`] and places the cursor
234    /// at [`Candidate::cursor`]. Accepting it uses
235    /// [`Candidate::acceptance`] when present, otherwise it retains the
236    /// preview. Sources must provide UTF-8 byte offsets on
237    /// extended-grapheme boundaries no larger than their replacement
238    /// lengths; malformed candidates are omitted.
239    fn candidates(&self, buffer: &str, cursor: usize) -> Vec<Candidate>;
240}
241
242impl<F> CompletionSource for F
243where
244    F: Fn(&str, usize) -> Vec<Candidate> + Send + Sync,
245{
246    fn candidates(&self, buffer: &str, cursor: usize) -> Vec<Candidate> {
247        (self)(buffer, cursor)
248    }
249}
250
251/// Read-only snapshot of the completion menu state.
252#[derive(Clone, Debug)]
253pub struct CompletionView {
254    /// Candidates currently displayed in menu order.
255    pub candidates: Vec<Candidate>,
256    /// Candidate currently previewed in the input buffer, if any.
257    pub selected: Option<usize>,
258}
259
260#[derive(Clone)]
261struct PromptSnapshot {
262    buffer: String,
263    cursor: usize,
264}
265
266#[derive(Clone)]
267struct PromptDraft {
268    buffer: String,
269    cursor: usize,
270    undo: Vec<PromptSnapshot>,
271    redo: Vec<PromptSnapshot>,
272}
273
274impl PromptDraft {
275    fn submitted(buffer: String) -> Self {
276        let cursor = buffer.len();
277        Self {
278            buffer,
279            cursor,
280            undo: Vec::new(),
281            redo: Vec::new(),
282        }
283    }
284}
285
286/// One draft navigable through prompt history with its retained-source link.
287struct HistoryNavEntry {
288    /// Draft content and local undo/redo state shown at this navigation slot.
289    draft: PromptDraft,
290    /// Original retained history position, absent for queued and new WIP
291    /// drafts.
292    source_index: Option<usize>,
293}
294
295/// State for input-history navigation. Present only while Up/Down
296/// has recalled a previous line and the user hasn't submitted or
297/// dismissed yet.
298struct HistoryNav {
299    /// Snapshot of retained entries plus queued/WIP drafts. Editing in history
300    /// mode mutates the selected entry's draft and retained source.
301    entries: Vec<HistoryNavEntry>,
302    /// Current position within `entries`.
303    index: usize,
304}
305
306/// State for an open completion menu.
307struct CompletionMenu {
308    candidates: Vec<Candidate>,
309    /// `None` = menu open but no preview (buffer == `original_buffer`);
310    /// `Some(i)` = candidate `i` is previewed in the buffer.
311    selected: Option<usize>,
312    original_buffer: String,
313    original_cursor: usize,
314}
315
316/// Mutable state shared between the input loop, redraw thread, and
317/// any [`TermHandle`] holders.
318struct SharedState {
319    /// Rendered output blocks and their placement around the prompt.
320    layout: BlockLayoutState,
321    /// Prompt contents, editing history, and prompt-local viewport state.
322    editor: PromptEditorState,
323    /// Terminal dimensions, redraw coordination, and lifecycle flags.
324    terminal: TerminalRuntimeState,
325    /// Bounded selected-mutation correlation captured with redraw preparation.
326    presentation_observations: PresentationObservationState,
327    /// Actual redraw-loop failure timing retained for focused test assertions.
328    #[cfg(test)]
329    presentation_failure_test_records: Vec<(&'static str, u128, usize, u64)>,
330    /// Optional small retention limits for focused cross-crate tests.
331    #[cfg(any(test, feature = "history-retention-test-support"))]
332    input_history_limit_override: Option<InputHistoryLimits>,
333}
334
335impl SharedState {
336    fn new(width: usize, height: usize, left_prompt: StyledText) -> Self {
337        Self {
338            layout: BlockLayoutState::new(),
339            editor: PromptEditorState::new(left_prompt),
340            terminal: TerminalRuntimeState::new(width, height),
341            presentation_observations: PresentationObservationState::new(),
342            #[cfg(test)]
343            presentation_failure_test_records: Vec::new(),
344            #[cfg(any(test, feature = "history-retention-test-support"))]
345            input_history_limit_override: None,
346        }
347    }
348
349    /// Invalidates guarded asynchronous completion refreshes from older input
350    /// interactions.
351    fn advance_completion_generation(&mut self) {
352        self.editor.completion_generation = self.editor.completion_generation.wrapping_add(1);
353    }
354
355    fn alloc_id(&mut self) -> BlockId {
356        let id = BlockId(self.layout.next_id);
357        self.layout.next_id += 1;
358        id
359    }
360
361    fn mark_history_dirty_from(&mut self, entry: usize) {
362        self.layout.history_generation.advance();
363        self.layout.history_dirty_from = Some(
364            self.layout
365                .history_dirty_from
366                .map_or(entry, |dirty| dirty.min(entry)),
367        );
368    }
369
370    fn add_history_ref(&mut self, id: BlockId) {
371        *self.layout.history_refs.entry(id).or_insert(0) += 1;
372    }
373
374    fn append_history(&mut self, id: BlockId) {
375        let appended_at = self.layout.history.len();
376        self.layout.history.push(id);
377        self.add_history_ref(id);
378        self.mark_history_dirty_from(appended_at);
379    }
380
381    fn remove_history_refs(&mut self, id: BlockId, count: usize) {
382        if count == 0 {
383            return;
384        }
385        if let Some(existing) = self.layout.history_refs.get_mut(&id) {
386            if *existing <= count {
387                self.layout.history_refs.remove(&id);
388            } else {
389                *existing -= count;
390            }
391        }
392    }
393
394    fn rebuild_history_refs(&mut self) {
395        self.layout.history_refs.clear();
396        for &id in &self.layout.history {
397            *self.layout.history_refs.entry(id).or_insert(0) += 1;
398        }
399        self.mark_history_dirty_from(0);
400    }
401
402    fn block_in_history(&self, id: BlockId) -> bool {
403        self.layout.history_refs.contains_key(&id)
404    }
405
406    /// Returns whether a block contributes to any rendered output zone.
407    fn block_is_visible(&self, id: BlockId) -> bool {
408        self.block_in_history(id)
409            || self.layout.above_active.contains(&id)
410            || self.layout.above_sticky.contains(&id)
411            || self.layout.suggestions.contains(&id)
412            || self.layout.below.contains(&id)
413    }
414
415    /// Removes one block and every one of its rendered-zone references.
416    fn remove_block(&mut self, id: BlockId, observe_delta: bool) -> (bool, Option<StyledBlock>) {
417        let presentation_changed = observe_delta && self.block_is_visible(id);
418        let removed_block = self.layout.blocks.remove(&id);
419        let existed = removed_block.is_some();
420        let debug_id = self.layout.block_debug_ids.remove(&id);
421
422        // `history_refs` is the authoritative membership index. Queued/live
423        // blocks occupy an active zone but not persistent history, so skipping
424        // this scan is both safe and keeps their removal independent of
425        // transcript length.
426        if self.block_in_history(id) {
427            #[cfg(test)]
428            {
429                self.layout.history_removal_scan_entries += self.layout.history.len();
430            }
431            let removal = remove_all_from_zone(&mut self.layout.history, id);
432            let indexed_refs = self
433                .layout
434                .history_refs
435                .get(&id)
436                .copied()
437                .expect("history membership index must contain referenced block");
438            debug_assert_eq!(
439                removal.count, indexed_refs,
440                "history membership index must exactly count duplicate references"
441            );
442            self.remove_history_refs(id, removal.count);
443            self.mark_history_dirty_from(
444                removal
445                    .first_index
446                    .expect("history membership index must imply one matching entry"),
447            );
448        }
449
450        remove_all_from_zone(&mut self.layout.above_active, id);
451        remove_all_from_zone(&mut self.layout.above_sticky, id);
452        remove_all_from_zone(&mut self.layout.suggestions, id);
453        remove_all_from_zone(&mut self.layout.below, id);
454        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, ?debug_id, existed, "remove block");
455        (presentation_changed, removed_block)
456    }
457
458    fn current_snapshot(&self) -> PromptSnapshot {
459        PromptSnapshot {
460            buffer: self.editor.buffer.clone(),
461            cursor: self.editor.cursor,
462        }
463    }
464
465    fn current_draft(&self) -> PromptDraft {
466        PromptDraft {
467            buffer: self.editor.buffer.clone(),
468            cursor: self.editor.cursor,
469            undo: self.editor.current_undo.clone(),
470            redo: self.editor.current_redo.clone(),
471        }
472    }
473
474    /// Evicts the oldest draft prefix until the retained history is a bounded
475    /// newest suffix of nonempty primary prompt text.
476    fn limit_input_history(&mut self) {
477        let limits = self.input_history_limits();
478        let recalled_source = self.editor.last_submitted_recalled_source;
479        let mut retained_source = None;
480        let mut original_entries = 0;
481        let mut retained_entries = 0;
482        self.editor.input_history.retain(|draft| {
483            let retain = !draft.buffer.is_empty() && draft.buffer.len() <= limits.max_bytes;
484            if retain && recalled_source == Some(original_entries) {
485                retained_source = Some(retained_entries);
486            }
487            original_entries += 1;
488            retained_entries += usize::from(retain);
489            retain
490        });
491        if self.editor.input_history.len() != original_entries {
492            self.editor.history_nav = None;
493        }
494        self.editor.last_submitted_recalled_source = retained_source;
495
496        let mut retained_bytes = 0;
497        let mut retained_start = self.editor.input_history.len();
498        for (retained_entries, (index, draft)) in self
499            .editor
500            .input_history
501            .iter()
502            .enumerate()
503            .rev()
504            .enumerate()
505        {
506            if retained_entries == limits.max_entries
507                || draft.buffer.len() > limits.max_bytes - retained_bytes
508            {
509                break;
510            }
511            retained_bytes += draft.buffer.len();
512            retained_start = index;
513        }
514        if retained_start == 0 {
515            return;
516        }
517
518        self.editor.input_history.drain(..retained_start);
519        self.editor.history_nav = None;
520        self.editor.last_submitted_recalled_source = self
521            .editor
522            .last_submitted_recalled_source
523            .and_then(|index| index.checked_sub(retained_start));
524    }
525
526    /// Returns production limits or a focused test's intentionally small pair.
527    fn input_history_limits(&self) -> InputHistoryLimits {
528        #[cfg(any(test, feature = "history-retention-test-support"))]
529        if let Some(limits) = self.input_history_limit_override {
530            return limits;
531        }
532        InputHistoryLimits {
533            max_entries: INPUT_HISTORY_MAX_ENTRIES,
534            max_bytes: INPUT_HISTORY_MAX_BYTES,
535        }
536    }
537
538    /// Takes the current undo state into a submitted history entry.
539    ///
540    /// Submission clears the live prompt, so moving these stacks preserves raw
541    /// history semantics without cloning snapshots that a higher layer may
542    /// immediately replace with a canonical submitted draft.
543    fn take_submitted_draft(&mut self) -> PromptDraft {
544        PromptDraft {
545            buffer: self.editor.buffer.clone(),
546            cursor: self.editor.cursor,
547            undo: std::mem::take(&mut self.editor.current_undo),
548            redo: std::mem::take(&mut self.editor.current_redo),
549        }
550    }
551
552    fn load_draft(&mut self, draft: PromptDraft) {
553        self.editor.buffer = draft.buffer;
554        self.editor.current_undo = draft.undo;
555        self.editor.current_redo = draft.redo;
556        self.editor.cursor = draft.cursor.min(self.editor.buffer.len());
557        self.ensure_input_cursor_visible();
558    }
559
560    fn record_undo(&mut self) {
561        self.editor.current_undo.push(self.current_snapshot());
562        self.editor.current_redo.clear();
563    }
564
565    /// Mirrors edits made to `buffer` and undo state into the live
566    /// history-nav slot so navigating Down then Up returns to the
567    /// user's edited copy. No-op when not navigating history.
568    fn sync_buffer_to_history_nav(&mut self) {
569        let draft = self.current_draft();
570        if let Some(nav) = self.editor.history_nav.as_mut() {
571            nav.entries[nav.index].draft = draft.clone();
572            if let Some(source_index) = nav.entries[nav.index].source_index
573                && let Some(source) = self.editor.input_history.get_mut(source_index)
574            {
575                *source = draft;
576            }
577        }
578    }
579
580    /// Visual `(row, col)` of the cursor against the current buffer.
581    /// Row 0 starts after the left prompt, so `col` on row 0 is offset
582    /// by the prompt width.
583    fn visual_cursor_position(&self) -> (usize, usize) {
584        let width = self.terminal.width.max(1);
585        let left_cols = self.editor.left_prompt.char_count();
586        buffer_position_for_byte(&self.editor.buffer, self.editor.cursor, width, left_cols)
587    }
588
589    /// Last visual row index of the current buffer.
590    fn last_visual_row(&self) -> usize {
591        let width = self.terminal.width.max(1);
592        let left_cols = self.editor.left_prompt.char_count();
593        let (max_row, _) = buffer_end_position(&self.editor.buffer, width, left_cols);
594        max_row
595    }
596
597    /// Byte offset within the current buffer that lands the cursor at
598    /// the given visual `(row, col)`. Clamps to the nearest reachable
599    /// position.
600    fn cursor_byte_at(&self, target_row: usize, target_col: usize) -> usize {
601        let width = self.terminal.width.max(1);
602        let left_cols = self.editor.left_prompt.char_count();
603        byte_offset_for_buffer_position(
604            &self.editor.buffer,
605            target_row,
606            target_col,
607            width,
608            left_cols,
609        )
610    }
611
612    /// Visual column to use for the next vertical motion: returns the
613    /// sticky column if one is set, otherwise captures the current
614    /// cursor's visual column and stores it as sticky.
615    fn vertical_target_col(&mut self) -> usize {
616        if let Some(col) = self.editor.sticky_col {
617            return col;
618        }
619        let (_, col) = self.visual_cursor_position();
620        self.editor.sticky_col = Some(col);
621        col
622    }
623
624    /// Sets the cursor as part of a horizontal motion or edit and
625    /// invalidates the sticky vertical column. All cursor mutations
626    /// outside of vertical motion must go through this — the sticky
627    /// column only stays valid as long as the cursor is moving
628    /// purely up/down.
629    fn write_cursor(&mut self, new_cursor: usize) {
630        self.editor.cursor = new_cursor;
631        self.editor.sticky_col = None;
632        self.ensure_input_cursor_visible();
633    }
634
635    /// Sets the cursor as part of a vertical motion. Preserves the
636    /// sticky column so consecutive vertical moves can replay the
637    /// original column over short or empty rows.
638    fn write_cursor_keep_sticky(&mut self, new_cursor: usize) {
639        self.editor.cursor = new_cursor;
640        self.ensure_input_cursor_visible();
641    }
642
643    fn input_visible_rows(&self) -> usize {
644        let total_rows = self.last_visual_row() + 1;
645        let cap_rows = prompt_input_max_rows(self.terminal.height);
646        let indicator_rows = prompt_scroll_indicator_rows(
647            self.editor.show_prompt_scroll_indicator,
648            !self.editor.buffer.is_empty(),
649            total_rows,
650            cap_rows,
651        );
652        prompt_editable_rows(total_rows, cap_rows, indicator_rows)
653    }
654
655    fn ensure_input_cursor_visible(&mut self) {
656        let (cursor_row, _) = self.visual_cursor_position();
657        let total_rows = self.last_visual_row() + 1;
658        let visible_rows = self.input_visible_rows();
659        self.editor.input_viewport_start = viewport_start_with_cursor(
660            self.editor.input_viewport_start,
661            cursor_row,
662            total_rows,
663            visible_rows,
664        );
665    }
666
667    /// Pushes the current prompt onto input history and resets to a
668    /// fresh empty prompt. An empty prompt does not add an entry, but may
669    /// still enforce retention while leaving history navigation. Clears the
670    /// sticky column via `write_cursor`.
671    fn push_current_as_history_entry(&mut self, enforce_limit: bool) -> bool {
672        if self.editor.buffer.is_empty() {
673            if enforce_limit {
674                self.limit_input_history();
675            }
676            return false;
677        }
678        let draft = self.take_submitted_draft();
679        self.editor.input_history.push(draft);
680        if enforce_limit {
681            self.limit_input_history();
682        }
683        self.editor.buffer.clear();
684        self.write_cursor(0);
685        true
686    }
687
688    fn undo(&mut self) -> bool {
689        let Some(snapshot) = self.editor.current_undo.pop() else {
690            return false;
691        };
692        self.editor.current_redo.push(self.current_snapshot());
693        self.editor.buffer = snapshot.buffer;
694        self.write_cursor(snapshot.cursor.min(self.editor.buffer.len()));
695        self.sync_buffer_to_history_nav();
696        true
697    }
698
699    fn redo(&mut self) -> bool {
700        let Some(snapshot) = self.editor.current_redo.pop() else {
701            return false;
702        };
703        self.editor.current_undo.push(self.current_snapshot());
704        self.editor.buffer = snapshot.buffer;
705        self.write_cursor(snapshot.cursor.min(self.editor.buffer.len()));
706        self.sync_buffer_to_history_nav();
707        true
708    }
709
710    /// Cycles the completion menu selection by `delta` (+1 forward,
711    /// -1 backward) and updates the buffer to preview the new
712    /// selection (or restore `original_buffer` when wrapping past the
713    /// ends to `selected = None`). Returns `true` if a menu was open.
714    fn cycle_completion(&mut self, delta: isize) -> bool {
715        let (new_buffer, new_cursor) = {
716            let Some(menu) = self.editor.completion.as_mut() else {
717                return false;
718            };
719            let len = menu.candidates.len();
720            if len == 0 {
721                return false;
722            }
723            let new_selected = match menu.selected {
724                None => Some(if 0 < delta { 0 } else { len - 1 }),
725                // Up at the first match drops back to "no preview" so
726                // the user sees their original buffer; pressing Up
727                // again wraps to the last match.
728                Some(0) if delta < 0 => None,
729                Some(i) => Some((i as isize + delta).rem_euclid(len as isize) as usize),
730            };
731            menu.selected = new_selected;
732            match new_selected {
733                None => (menu.original_buffer.clone(), menu.original_cursor),
734                Some(idx) => {
735                    let candidate = &menu.candidates[idx];
736                    let buf = candidate.replacement.clone();
737                    let cursor = candidate.cursor;
738                    (buf, cursor)
739                }
740            }
741        };
742        self.editor.buffer = new_buffer;
743        self.write_cursor(new_cursor);
744        self.advance_completion_generation();
745        true
746    }
747
748    /// Closes the completion menu. If a candidate was previewed,
749    /// restores the original buffer; otherwise leaves the buffer
750    /// alone. Returns `true` if a menu was open.
751    fn dismiss_completion(&mut self) -> bool {
752        let Some(menu) = self.editor.completion.take() else {
753            return false;
754        };
755        if menu.selected.is_some() {
756            self.editor.buffer = menu.original_buffer;
757            self.write_cursor(menu.original_cursor);
758        }
759        self.advance_completion_generation();
760        true
761    }
762
763    /// Accepts the currently previewed candidate and closes the menu. An
764    /// acceptance-specific replacement supersedes the preview when present.
765    /// Returns `true` if a candidate was accepted (i.e. the menu had a
766    /// selection).
767    fn accept_completion(&mut self) -> bool {
768        let Some(menu) = self.editor.completion.take() else {
769            return false;
770        };
771        let Some(selected) = menu.selected else {
772            self.editor.completion = Some(menu);
773            return false;
774        };
775        if let Some(acceptance) = &menu.candidates[selected].acceptance {
776            self.editor.buffer.clone_from(&acceptance.replacement);
777            self.write_cursor(acceptance.cursor);
778        }
779        self.advance_completion_generation();
780        true
781    }
782
783    /// Steps history navigation by `delta`. Enters history-nav mode
784    /// from `Editing` when moving backward and history exists. Moving
785    /// forward from a non-empty editing buffer stores it as history
786    /// and opens a fresh empty prompt. Returns `true` if the buffer
787    /// changed.
788    ///
789    /// Cursor placement preserves the visual column so that
790    /// `Up`/`Down` across prompts feels like one continuous text:
791    /// stepping back lands on the previous entry's last visual row,
792    /// stepping forward lands on the next entry's first visual row,
793    /// both at (or clamped to) the column the cursor was on.
794    fn step_history(&mut self, delta: isize) -> bool {
795        let target_col = self.vertical_target_col();
796        if self.editor.history_nav.is_none() {
797            if 0 < delta {
798                return self.push_current_as_history_entry(true);
799            }
800            return self.enter_history_nav(target_col);
801        }
802        self.advance_history_nav(delta, target_col)
803    }
804
805    /// Switches from `Editing` into history-navigation mode at the
806    /// most recent entry, with the cursor placed at the previous
807    /// entry's last visual row at `target_col`.
808    fn enter_history_nav(&mut self, target_col: usize) -> bool {
809        if self.editor.input_history.is_empty() {
810            return false;
811        }
812        let mut entries: Vec<_> = self
813            .editor
814            .input_history
815            .iter()
816            .cloned()
817            .enumerate()
818            .map(|(source_index, draft)| HistoryNavEntry {
819                draft,
820                source_index: Some(source_index),
821            })
822            .collect();
823        entries.push(HistoryNavEntry {
824            draft: self.current_draft(),
825            source_index: None,
826        });
827        // The WIP buffer sits at `entries.last()`; the previous
828        // history entry is one slot before it.
829        let index = entries.len() - 2;
830        self.load_draft(entries[index].draft.clone());
831        let new_cursor = self.cursor_byte_at(self.last_visual_row(), target_col);
832        self.write_cursor_keep_sticky(new_cursor);
833        self.editor.history_nav = Some(HistoryNav { entries, index });
834        true
835    }
836
837    fn recall_prompt_before_current(&mut self, text: String) {
838        let previous = self.current_draft();
839        let previous_source = self.editor.history_nav.as_ref().and_then(|nav| {
840            nav.entries
841                .get(nav.index)
842                .and_then(|entry| entry.source_index)
843        });
844        let mut entries: Vec<_> = self
845            .editor
846            .input_history
847            .iter()
848            .cloned()
849            .enumerate()
850            .map(|(source_index, draft)| HistoryNavEntry {
851                draft,
852                source_index: Some(source_index),
853            })
854            .collect();
855        entries.push(HistoryNavEntry {
856            draft: PromptDraft::submitted(text),
857            source_index: None,
858        });
859        entries.push(HistoryNavEntry {
860            draft: previous,
861            source_index: previous_source,
862        });
863        let index = entries.len() - 2;
864        self.load_draft(entries[index].draft.clone());
865        self.write_cursor(self.editor.buffer.len());
866        self.editor.history_nav = Some(HistoryNav { entries, index });
867        self.editor.completion = None;
868    }
869
870    /// Steps within an already-active history navigation. Going past
871    /// the WIP slot (Down at the latest entry) pushes the WIP buffer
872    /// onto history and returns to a fresh prompt, mirroring Down
873    /// from `Editing`.
874    fn advance_history_nav(&mut self, delta: isize, target_col: usize) -> bool {
875        let current = self.current_draft();
876        let nav = self
877            .editor
878            .history_nav
879            .as_mut()
880            .expect("caller checked Some");
881        let new_index = nav.index as isize + delta;
882        if new_index < 0 {
883            return false;
884        }
885        if new_index >= nav.entries.len() as isize {
886            let wip = nav.entries.last().map(|entry| entry.draft.clone());
887            self.editor.history_nav = None;
888            if let Some(wip) = wip {
889                self.load_draft(wip);
890            }
891            return self.push_current_as_history_entry(true);
892        }
893        nav.entries[nav.index].draft = current.clone();
894        if let Some(source_index) = nav.entries[nav.index].source_index
895            && let Some(source) = self.editor.input_history.get_mut(source_index)
896        {
897            *source = current;
898        }
899        nav.index = new_index as usize;
900        let new_draft = nav.entries[nav.index].draft.clone();
901        self.load_draft(new_draft);
902        let target_row = if delta < 0 { self.last_visual_row() } else { 0 };
903        let new_cursor = self.cursor_byte_at(target_row, target_col);
904        self.write_cursor_keep_sticky(new_cursor);
905        true
906    }
907}
908
909#[derive(Clone, Debug, Eq, Hash, PartialEq)]
910enum KeyBinding {
911    Ctrl(char),
912    CtrlShift(char),
913    Meta(char),
914    CtrlKey(KeyCode),
915    Key(KeyCode),
916}
917
918fn parse_plain_key_code(input: &str) -> Option<KeyCode> {
919    match input.to_ascii_lowercase().as_str() {
920        "backspace" => Some(KeyCode::Backspace),
921        "backtab" | "shift-tab" => Some(KeyCode::BackTab),
922        "delete" | "del" => Some(KeyCode::Delete),
923        "down" => Some(KeyCode::Down),
924        "end" => Some(KeyCode::End),
925        "enter" => Some(KeyCode::Enter),
926        "esc" | "escape" => Some(KeyCode::Esc),
927        "home" => Some(KeyCode::Home),
928        "left" => Some(KeyCode::Left),
929        "right" => Some(KeyCode::Right),
930        "tab" => Some(KeyCode::Tab),
931        "up" => Some(KeyCode::Up),
932        _ => None,
933    }
934}
935
936fn parse_key_binding(input: &str) -> Option<KeyBinding> {
937    let input = input.trim_matches('`');
938    if let Some(code) = parse_plain_key_code(input) {
939        return Some(KeyBinding::Key(code));
940    }
941    if let Some(rest) = input.strip_prefix("M-") {
942        let mut chars = rest.chars();
943        let ch = chars.next()?;
944        return (chars.next().is_none() && ch.is_ascii()).then_some(KeyBinding::Meta(ch));
945    }
946    let rest = input
947        .strip_prefix("C-")
948        .or_else(|| input.strip_prefix("c-"))?;
949    match rest.to_ascii_lowercase().as_str() {
950        "enter" => return Some(KeyBinding::CtrlKey(KeyCode::Enter)),
951        "up" => return Some(KeyBinding::CtrlKey(KeyCode::Up)),
952        "down" => return Some(KeyBinding::CtrlKey(KeyCode::Down)),
953        _ => {}
954    }
955    let mut chars = rest.chars();
956    let ch = chars.next()?;
957    if chars.next().is_some() {
958        return None;
959    }
960    if ch.is_ascii_uppercase() {
961        Some(KeyBinding::CtrlShift(ch.to_ascii_lowercase()))
962    } else {
963        Some(KeyBinding::Ctrl(ch.to_ascii_lowercase()))
964    }
965}
966
967fn key_binding_for_event(key: KeyEvent, ctrl: bool) -> Option<KeyBinding> {
968    let modifiers = key.modifiers;
969    let plain = modifiers.is_empty();
970    let ctrl_only = modifiers == KeyModifiers::CONTROL;
971
972    match key.code {
973        KeyCode::Char(ch) if modifiers == KeyModifiers::ALT => Some(KeyBinding::Meta(ch)),
974        KeyCode::Char(ch)
975            if ctrl
976                && ch.is_ascii_alphabetic()
977                && (modifiers.contains(KeyModifiers::SHIFT) || ch.is_ascii_uppercase()) =>
978        {
979            Some(KeyBinding::CtrlShift(ch.to_ascii_lowercase()))
980        }
981        KeyCode::Char(ch) if ctrl => Some(KeyBinding::Ctrl(ch.to_ascii_lowercase())),
982        KeyCode::Char(ch @ '\u{1}'..='\u{1a}') => {
983            let letter = (b'a' + ch as u8 - 1) as char;
984            Some(KeyBinding::Ctrl(letter))
985        }
986        KeyCode::Enter if ctrl_only => Some(KeyBinding::CtrlKey(KeyCode::Enter)),
987        KeyCode::Up | KeyCode::Down if ctrl_only => Some(KeyBinding::CtrlKey(key.code)),
988        KeyCode::BackTab => Some(KeyBinding::Key(KeyCode::BackTab)),
989        KeyCode::Backspace
990        | KeyCode::Delete
991        | KeyCode::Down
992        | KeyCode::End
993        | KeyCode::Enter
994        | KeyCode::Esc
995        | KeyCode::Home
996        | KeyCode::Left
997        | KeyCode::Right
998        | KeyCode::Tab
999        | KeyCode::Up
1000            if plain =>
1001        {
1002            Some(KeyBinding::Key(key.code))
1003        }
1004        _ => None,
1005    }
1006}
1007/// High-level events surfaced to the downstream event loop.
1008pub enum Event {
1009    /// The user submitted a line with Ctrl-Enter or `submit-prompt`
1010    /// outside the completion menu, or with no candidate selected.
1011    Line(String),
1012    /// The user signalled EOF (Ctrl-D on empty line).
1013    Eof,
1014    /// The user requested prompt cancellation with a second consecutive Ctrl-C.
1015    CancelPrompt,
1016    /// The terminal was resized.
1017    Resize { width: u16, height: u16 },
1018    /// The terminal reported focus gained or lost.
1019    FocusChanged { focused: bool },
1020    /// The input buffer or completion menu state changed. Fires for
1021    /// keystrokes that mutate the buffer and for completion menu
1022    /// open/close/cycle. Caller should re-render anything that
1023    /// depends on either (typically the menu and the prompt itself).
1024    BufferChanged,
1025    /// A background owner requested that the completion menu be recomputed
1026    /// without changing the prompt buffer.
1027    CompletionRefresh,
1028    /// The user pressed Ctrl-Enter with a candidate previewed in the
1029    /// menu. The buffer is now the candidate's accepted replacement and
1030    /// completion has been re-evaluated for that buffer. The caller
1031    /// should re-render the menu area but typically *should not*
1032    /// submit — a second Ctrl-Enter is expected to confirm.
1033    CompletionAccept,
1034    /// The user pressed Shift-Tab outside an open completion menu.
1035    /// Inside a menu it cycles backwards and is consumed internally.
1036    BackTab,
1037    /// The user pressed Escape outside an open completion menu.
1038    Escape,
1039    /// The user activated a configured key binding.
1040    Binding(String),
1041    /// A local prompt notice should be printed above the prompt.
1042    Notice(String),
1043    /// The user requested an external editor (Ctrl-O / Ctrl-G).
1044    /// Caller is expected to call [`Term::pause_for_external`], spawn
1045    /// `$VISUAL`/`$EDITOR`, and call [`Term::resume_after_external`].
1046    ExternalEditor,
1047}
1048
1049/// References removed from one ordered render zone.
1050#[derive(Default)]
1051struct ZoneRemoval {
1052    /// Number of removed references.
1053    count: usize,
1054    /// First entry whose removal changes the remaining suffix.
1055    first_index: Option<usize>,
1056}
1057
1058/// Removes every occurrence of `id` from one rendered zone.
1059fn remove_all_from_zone(zone: &mut Vec<BlockId>, id: BlockId) -> ZoneRemoval {
1060    let mut removal = ZoneRemoval::default();
1061    let mut index = 0;
1062    zone.retain(|&candidate| {
1063        let current_index = index;
1064        index += 1;
1065        if candidate == id {
1066            removal.count += 1;
1067            removal.first_index.get_or_insert(current_index);
1068            false
1069        } else {
1070            true
1071        }
1072    });
1073    removal
1074}
1075
1076/// Snapshot of terminal output zones, excluding prompt input/history state.
1077#[derive(Clone, Debug, Default)]
1078pub struct OutputSnapshot {
1079    blocks: HashMap<BlockId, StyledBlock>,
1080    block_debug_ids: HashMap<BlockId, String>,
1081    /// Next block identity allocated within this presentation model.
1082    next_id: u64,
1083    history: Vec<BlockId>,
1084    above_active: Vec<BlockId>,
1085    above_sticky: Vec<BlockId>,
1086    suggestions: Vec<BlockId>,
1087    below: Vec<BlockId>,
1088}
1089
1090impl OutputSnapshot {
1091    /// Returns the number of blocks retained by this presentation model.
1092    pub fn block_count(&self) -> usize {
1093        self.blocks.len()
1094    }
1095
1096    /// Returns the ordered block ids currently present in the suggestions zone.
1097    pub fn suggestion_ids(&self) -> &[BlockId] {
1098        &self.suggestions
1099    }
1100
1101    /// Allocates and stores a block in this output snapshot.
1102    pub fn new_block(
1103        &mut self,
1104        debug_id: impl Into<String>,
1105        block: impl Into<StyledBlock>,
1106    ) -> BlockId {
1107        let id = BlockId(self.next_id);
1108        self.next_id = self.next_id.saturating_add(1);
1109        self.blocks.insert(id, block.into());
1110        self.block_debug_ids.insert(id, debug_id.into());
1111        id
1112    }
1113
1114    /// Replaces one block in this output snapshot.
1115    pub fn set_block(&mut self, id: BlockId, block: impl Into<StyledBlock>) {
1116        self.blocks.insert(id, block.into());
1117        self.block_debug_ids
1118            .entry(id)
1119            .or_insert_with(|| format!("set-block-{}", id.0));
1120    }
1121
1122    /// Removes one block and all of its zone references.
1123    pub fn remove_block(&mut self, id: BlockId) {
1124        self.blocks.remove(&id);
1125        self.block_debug_ids.remove(&id);
1126        remove_all_from_zone(&mut self.history, id);
1127        remove_all_from_zone(&mut self.above_active, id);
1128        remove_all_from_zone(&mut self.above_sticky, id);
1129        remove_all_from_zone(&mut self.suggestions, id);
1130        remove_all_from_zone(&mut self.below, id);
1131    }
1132
1133    /// Appends a block to snapshot history.
1134    pub fn push_history(&mut self, id: BlockId) {
1135        self.history.push(id);
1136    }
1137
1138    /// Appends a block to the snapshot active zone.
1139    pub fn push_above_active(&mut self, id: BlockId) {
1140        if !self.above_active.contains(&id) {
1141            self.above_active.push(id);
1142        }
1143    }
1144
1145    /// Moves a block before the first matching snapshot active-zone anchor.
1146    pub fn push_above_active_before_any<I>(&mut self, id: BlockId, anchors: I)
1147    where
1148        I: IntoIterator<Item = BlockId>,
1149    {
1150        let anchors = anchors.into_iter().collect::<HashSet<_>>();
1151        self.above_active.retain(|active_id| *active_id != id);
1152        let insert_at = self
1153            .above_active
1154            .iter()
1155            .position(|active_id| anchors.contains(active_id))
1156            .unwrap_or(self.above_active.len());
1157        self.above_active.insert(insert_at, id);
1158    }
1159
1160    /// Appends a block to the snapshot sticky zone.
1161    pub fn push_above_sticky(&mut self, id: BlockId) {
1162        if !self.above_sticky.contains(&id) {
1163            self.above_sticky.push(id);
1164        }
1165    }
1166
1167    /// Removes a block reference from the snapshot sticky zone.
1168    pub fn remove_above_sticky(&mut self, id: BlockId) {
1169        self.above_sticky.retain(|block_id| *block_id != id);
1170    }
1171
1172    /// Appends a block to the snapshot below-prompt zone.
1173    pub fn push_below(&mut self, id: BlockId) {
1174        if !self.below.contains(&id) {
1175            self.below.push(id);
1176        }
1177    }
1178
1179    /// Creates and appends one snapshot history block.
1180    pub fn print_output(
1181        &mut self,
1182        debug_id: impl Into<String>,
1183        block: impl Into<StyledBlock>,
1184    ) -> BlockId {
1185        let id = self.new_block(debug_id, block);
1186        self.push_history(id);
1187        id
1188    }
1189}
1190
1191/// A cloneable handle for mutating prompt zones from any thread.
1192///
1193/// Setters update the shared state but do **not** trigger a redraw.
1194/// Call [`redraw`](TermHandle::redraw) after making all changes.
1195#[derive(Clone)]
1196pub struct TermHandle {
1197    state: Arc<Mutex<SharedState>>,
1198    output_transaction: Arc<Mutex<()>>,
1199    sync_condvar: Arc<std::sync::Condvar>,
1200    redraw: tau_blocking_notify_channel::Sender,
1201    input_tx: path_std_sync::mpsc::Sender<InputMessage>,
1202    /// Number of transcript-sized output snapshot clones requested.
1203    output_snapshot_count: Arc<path_std_sync::atomic::AtomicU64>,
1204    /// Number of transcript-sized output snapshots transferred by ownership.
1205    output_snapshot_take_count: Arc<path_std_sync::atomic::AtomicU64>,
1206    /// Number of asynchronous redraw notifications released by this handle.
1207    #[cfg(feature = "redraw-test-counter")]
1208    redraw_request_count: Arc<path_std_sync::atomic::AtomicU64>,
1209    /// Number of retired styled-block owners observed after lock release.
1210    #[cfg(test)]
1211    retirement_probe_count: Arc<path_std_sync_atomic::AtomicU64>,
1212}
1213
1214thread_local! {
1215    static HELD_OUTPUT_TRANSACTIONS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new());
1216    #[cfg(test)]
1217    static HELD_SHARED_STATES: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new());
1218    static RETIRED_STYLED_BLOCKS: RefCell<HashMap<usize, Vec<RetiredStyledBlocks>>> =
1219        RefCell::new(HashMap::new());
1220}
1221
1222/// Shared terminal state guard with thread-local lock-state tracking.
1223#[cfg(test)]
1224struct SharedStateGuard<'a> {
1225    /// Underlying shared-state mutex guard.
1226    guard: MutexGuard<'a, SharedState>,
1227    /// Process-local identity of the shared state mutex.
1228    key: usize,
1229}
1230
1231#[cfg(test)]
1232impl std::ops::Deref for SharedStateGuard<'_> {
1233    type Target = SharedState;
1234
1235    fn deref(&self) -> &Self::Target {
1236        &self.guard
1237    }
1238}
1239
1240#[cfg(test)]
1241impl std::ops::DerefMut for SharedStateGuard<'_> {
1242    fn deref_mut(&mut self) -> &mut Self::Target {
1243        &mut self.guard
1244    }
1245}
1246
1247#[cfg(test)]
1248impl Drop for SharedStateGuard<'_> {
1249    fn drop(&mut self) {
1250        HELD_SHARED_STATES.with(|held| {
1251            let mut held = held.borrow_mut();
1252            let depth = held
1253                .get_mut(&self.key)
1254                .expect("shared terminal state lock depth missing");
1255            *depth -= 1;
1256            if *depth == 0 {
1257                held.remove(&self.key);
1258            }
1259        });
1260    }
1261}
1262
1263/// Shared terminal state guard without test-only lock-state tracking.
1264#[cfg(not(test))]
1265type SharedStateGuard<'a> = MutexGuard<'a, SharedState>;
1266
1267struct OutputTransactionDepthGuard {
1268    key: usize,
1269}
1270
1271impl Drop for OutputTransactionDepthGuard {
1272    fn drop(&mut self) {
1273        HELD_OUTPUT_TRANSACTIONS.with(|held| {
1274            let mut held = held.borrow_mut();
1275            let depth = held
1276                .get_mut(&self.key)
1277                .expect("output transaction depth missing");
1278            *depth -= 1;
1279            if *depth == 0 {
1280                held.remove(&self.key);
1281            }
1282        });
1283    }
1284}
1285
1286/// Guard that serializes terminal output snapshot mutations.
1287struct OutputTransactionGuard<'a> {
1288    guard: Option<MutexGuard<'a, ()>>,
1289    depth: Option<OutputTransactionDepthGuard>,
1290    key: usize,
1291    /// Monotonic acquisition time used for content-free hold diagnostics.
1292    acquired_at: std::time::Instant,
1293    #[cfg(test)]
1294    state: Arc<Mutex<SharedState>>,
1295    #[cfg(test)]
1296    retirement_probe_count: Arc<path_std_sync_atomic::AtomicU64>,
1297}
1298
1299/// Styled blocks removed from the shared terminal presentation.
1300enum RetiredStyledBlocks {
1301    /// One replaced or explicitly removed block.
1302    One(StyledBlock),
1303    /// Every block displaced by an output snapshot replacement.
1304    Snapshot(HashMap<BlockId, StyledBlock>),
1305}
1306
1307impl Drop for OutputTransactionGuard<'_> {
1308    fn drop(&mut self) {
1309        let held = self.acquired_at.elapsed();
1310        if Duration::from_millis(500) <= held && admit_stall_warning() {
1311            tracing::warn!(
1312                target: "tau_cli_term_raw::frontend_progress",
1313                hold_ms = held.as_millis(),
1314                "terminal output transaction stalled"
1315            );
1316        }
1317
1318        let retired = RETIRED_STYLED_BLOCKS
1319            .with(|retired| retired.borrow_mut().remove(&self.key).unwrap_or_default());
1320        drop(self.depth.take());
1321        drop(self.guard.take());
1322
1323        for retired in retired {
1324            #[cfg(test)]
1325            {
1326                assert!(
1327                    !HELD_OUTPUT_TRANSACTIONS.with(|held| held.borrow().contains_key(&self.key)),
1328                    "styled block retirement must follow output transaction release"
1329                );
1330                assert!(
1331                    !HELD_SHARED_STATES.with(|held| {
1332                        held.borrow()
1333                            .contains_key(&(Arc::as_ptr(&self.state) as usize))
1334                    }),
1335                    "styled block retirement must follow shared terminal state release"
1336                );
1337                self.retirement_probe_count
1338                    .fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
1339            }
1340            match retired {
1341                RetiredStyledBlocks::One(block) => drop(block),
1342                RetiredStyledBlocks::Snapshot(blocks) => drop(blocks),
1343            }
1344        }
1345    }
1346}
1347
1348/// Owned redraw-suppression scope that may span multiple renderer calls.
1349///
1350/// Dropping the guard releases one nesting level and emits at most one
1351/// coalesced redraw notification for all mutations made while it was held.
1352#[must_use = "redraw suppression ends immediately when the guard is dropped"]
1353pub struct RedrawSuppressionGuard {
1354    handle: TermHandle,
1355}
1356
1357impl RedrawSuppressionGuard {
1358    fn new(handle: &TermHandle) -> Self {
1359        {
1360            let mut st = handle.lock();
1361            st.terminal.redraw_suppression = st.terminal.redraw_suppression.saturating_add(1);
1362        }
1363        Self {
1364            handle: handle.clone(),
1365        }
1366    }
1367}
1368
1369impl Drop for RedrawSuppressionGuard {
1370    fn drop(&mut self) {
1371        let notify = {
1372            let mut st = self.handle.lock();
1373            st.terminal.redraw_suppression = st.terminal.redraw_suppression.saturating_sub(1);
1374            if st.terminal.redraw_suppression == 0 && st.terminal.redraw_dirty_while_suppressed {
1375                st.terminal.redraw_dirty_while_suppressed = false;
1376                true
1377            } else {
1378                false
1379            }
1380        };
1381        if notify {
1382            self.handle.release_redraw_notification();
1383        }
1384    }
1385}
1386
1387impl TermHandle {
1388    fn lock(&self) -> SharedStateGuard<'_> {
1389        let guard = self.state.lock().expect("term state mutex poisoned");
1390        #[cfg(test)]
1391        {
1392            let key = Arc::as_ptr(&self.state) as usize;
1393            HELD_SHARED_STATES.with(|held| {
1394                *held.borrow_mut().entry(key).or_insert(0) += 1;
1395            });
1396            SharedStateGuard { guard, key }
1397        }
1398        #[cfg(not(test))]
1399        {
1400            guard
1401        }
1402    }
1403
1404    fn output_transaction_key(&self) -> usize {
1405        Arc::as_ptr(&self.output_transaction) as usize
1406    }
1407
1408    fn output_transaction_is_held(&self) -> bool {
1409        let key = self.output_transaction_key();
1410        HELD_OUTPUT_TRANSACTIONS.with(|held| held.borrow().contains_key(&key))
1411    }
1412
1413    fn mark_output_transaction_held(&self) -> OutputTransactionDepthGuard {
1414        let key = self.output_transaction_key();
1415        HELD_OUTPUT_TRANSACTIONS.with(|held| {
1416            let mut held = held.borrow_mut();
1417            *held.entry(key).or_insert(0) += 1;
1418        });
1419        OutputTransactionDepthGuard { key }
1420    }
1421
1422    /// Retains removed styled blocks until the outer output transaction
1423    /// unlocks.
1424    fn retire_styled_blocks(&self, retired: RetiredStyledBlocks) {
1425        let key = self.output_transaction_key();
1426        // ast-grep-ignore: debug-assert-expression-must-not-mutate
1427        debug_assert!(self.output_transaction_is_held());
1428        RETIRED_STYLED_BLOCKS.with(|retirements| {
1429            retirements
1430                .borrow_mut()
1431                .entry(key)
1432                .or_default()
1433                .push(retired);
1434        });
1435    }
1436
1437    /// Returns how many styled-block owners reached the post-lock drop probe.
1438    #[cfg(test)]
1439    fn retirement_probe_count(&self) -> u64 {
1440        self.retirement_probe_count
1441            .load(path_std_sync_atomic::Ordering::Relaxed)
1442    }
1443
1444    fn output_transaction_barrier(&self) -> Option<OutputTransactionGuard<'_>> {
1445        if self.output_transaction_is_held() {
1446            return None;
1447        }
1448        let waiting_at = path_std_time::Instant::now();
1449        tracing::trace!(
1450            target: "tau_cli_term_raw::frontend_progress",
1451            "terminal output transaction acquisition started"
1452        );
1453        let guard = self
1454            .output_transaction
1455            .lock()
1456            .expect("term output transaction mutex poisoned");
1457        let waited = waiting_at.elapsed();
1458        tracing::trace!(
1459            target: "tau_cli_term_raw::frontend_progress",
1460            wait_us = waited.as_micros(),
1461            "terminal output transaction acquired"
1462        );
1463        if Duration::from_millis(500) <= waited && admit_stall_warning() {
1464            tracing::warn!(
1465                target: "tau_cli_term_raw::frontend_progress",
1466                wait_ms = waited.as_millis(),
1467                "terminal output transaction acquisition stalled"
1468            );
1469        }
1470        let depth = self.mark_output_transaction_held();
1471        Some(OutputTransactionGuard {
1472            guard: Some(guard),
1473            depth: Some(depth),
1474            key: self.output_transaction_key(),
1475            acquired_at: path_std_time::Instant::now(),
1476            #[cfg(test)]
1477            state: Arc::clone(&self.state),
1478            #[cfg(test)]
1479            retirement_probe_count: Arc::clone(&self.retirement_probe_count),
1480        })
1481    }
1482
1483    fn request_redraw_locked(st: &mut SharedState) -> bool {
1484        if st.terminal.redraw_suppression == 0 {
1485            true
1486        } else {
1487            st.terminal.redraw_dirty_while_suppressed = true;
1488            false
1489        }
1490    }
1491
1492    fn notify_redraw(&self) {
1493        let notify = {
1494            let mut st = self.lock();
1495            Self::request_redraw_locked(&mut st)
1496        };
1497        if notify {
1498            self.release_redraw_notification();
1499        }
1500    }
1501
1502    /// Releases one asynchronous redraw notification and records it in tests.
1503    fn release_redraw_notification(&self) {
1504        #[cfg(feature = "redraw-test-counter")]
1505        self.redraw_request_count
1506            .fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
1507        self.redraw.notify();
1508    }
1509
1510    /// Requests that the prompt input loop stop and return EOF.
1511    ///
1512    /// The input loop waits on an internal channel rather than polling, so a
1513    /// shutdown message is sent after the shared flag is set to wake any
1514    /// blocked receiver immediately. Blocking crossterm reads that are
1515    /// already in flight may finish later; their events are ignored after
1516    /// this flag is set.
1517    pub fn request_input_shutdown(&self) {
1518        self.lock().terminal.input_shutdown = true;
1519        let _ = self.input_tx.send(InputMessage::Shutdown);
1520    }
1521
1522    /// Requests a completion-source refresh without changing the prompt buffer.
1523    ///
1524    /// The input owner recomputes the menu and redraws it, so background state
1525    /// updates never run completion code on their own threads.
1526    pub fn request_completion_refresh(&self) {
1527        let _ = self.input_tx.send(InputMessage::RefreshCompletion);
1528    }
1529
1530    /// Returns the generation that a background completion owner must preserve
1531    /// before requesting a guarded menu refresh.
1532    pub fn completion_refresh_generation(&self) -> u64 {
1533        self.lock().editor.completion_generation
1534    }
1535
1536    /// Requests a completion refresh only if the input interaction still
1537    /// matches `generation` and no candidate is currently previewed.
1538    pub fn request_completion_refresh_if_generation(&self, generation: u64) {
1539        let _ = self
1540            .input_tx
1541            .send(InputMessage::RefreshCompletionIfGeneration(generation));
1542    }
1543
1544    /// Run `f` while redraw notifications from this handle are suppressed.
1545    ///
1546    /// Mutations remain visible in shared state, but redraw requests are marked
1547    /// dirty and coalesced into one notification after the outermost nested
1548    /// suppression scope exits. Use this to publish related visible-state
1549    /// changes as one coherent rendered frame.
1550    pub fn with_redraw_suppressed<R>(&self, f: impl FnOnce() -> R) -> R {
1551        let _guard = RedrawSuppressionGuard::new(self);
1552        f()
1553    }
1554
1555    /// Suppress redraw notifications until the returned owned guard is dropped.
1556    ///
1557    /// Prefer [`Self::with_redraw_suppressed`] for one lexical operation. This
1558    /// owned form exists for bounded multi-message publication such as initial
1559    /// attachment catch-up.
1560    pub fn suppress_redraws(&self) -> RedrawSuppressionGuard {
1561        RedrawSuppressionGuard::new(self)
1562    }
1563
1564    /// Run `f` while terminal output snapshot mutations from other threads are
1565    /// blocked.
1566    ///
1567    /// This transaction is intentionally narrower than the shared
1568    /// terminal-state mutex: callers can perform a multi-step snapshot swap
1569    /// using ordinary [`TermHandle`] methods without exposing the temporary
1570    /// snapshot to local output producers that own only a cloned handle.
1571    pub fn with_output_transaction<R>(&self, f: impl FnOnce() -> R) -> R {
1572        let _transaction = self.output_transaction_barrier();
1573        f()
1574    }
1575
1576    /// Triggers a redraw of the terminal.
1577    ///
1578    /// Call this after updating one or more blocks/zones. Multiple
1579    /// calls coalesce into a single repaint.
1580    ///
1581    /// This goes through the differential update path — only the
1582    /// visible viewport is repainted. Use it for any mutation
1583    /// guaranteed to be inside the viewport (input, status chip,
1584    /// streaming live blocks, newly-printed blocks). For mutations
1585    /// to past blocks that may have scrolled into scrollback, use
1586    /// [`invalidate_screen`](Self::invalidate_screen) instead. See
1587    /// `README.md` § "When mutations need a full redraw" for the
1588    /// full rule.
1589    pub fn redraw(&self) {
1590        self.notify_redraw();
1591    }
1592
1593    /// Registers one completed selected-transcript mutation for flush
1594    /// correlation.
1595    ///
1596    /// The caller must establish raw frontend-progress TRACE interest before
1597    /// calling. The delivery identity and caller-owned opaque label remain
1598    /// process-local and content-free. A caller whose typed opaque fact
1599    /// invalidates a visible predecessor must suppress redraw
1600    /// capture across both its presentation mutation and this registration.
1601    /// Returns `true` exactly when redraw capture was suppressed while the
1602    /// registration held shared terminal state; `false` means capture was not
1603    /// suppressed. The return value does not report notification delivery or
1604    /// eventual writer success.
1605    pub fn observe_presentation_mutation(
1606        &self,
1607        delivery_id: RendererDeliveryId,
1608        fact: OpaquePresentationFact,
1609    ) -> bool {
1610        self.observe_presentation_mutation_enabled(delivery_id, fact)
1611    }
1612
1613    /// Registers a fact after the caller has established trace interest.
1614    fn observe_presentation_mutation_enabled(
1615        &self,
1616        delivery_id: RendererDeliveryId,
1617        fact: OpaquePresentationFact,
1618    ) -> bool {
1619        let observed_at = path_std_time::Instant::now();
1620        let (notify, capture_suppressed) = {
1621            let mut st = self.lock();
1622            st.presentation_observations
1623                .register(delivery_id, fact, observed_at);
1624            (
1625                Self::request_redraw_locked(&mut st),
1626                st.terminal.redraw_suppression != 0,
1627            )
1628        };
1629        if notify {
1630            self.release_redraw_notification();
1631        }
1632        capture_suppressed
1633    }
1634
1635    /// Registers a presentation fact without requiring a global test
1636    /// subscriber.
1637    #[cfg(test)]
1638    fn observe_presentation_mutation_for_test(
1639        &self,
1640        delivery_id: RendererDeliveryId,
1641        fact: OpaquePresentationFact,
1642    ) -> bool {
1643        self.observe_presentation_mutation_enabled(delivery_id, fact)
1644    }
1645
1646    /// Returns how many asynchronous redraw notifications this handle released.
1647    ///
1648    /// This excludes synchronous redraw barriers, which directly notify the
1649    /// renderer so callers can wait for their completion.
1650    #[cfg(feature = "redraw-test-counter")]
1651    pub fn redraw_request_count(&self) -> u64 {
1652        self.redraw_request_count
1653            .load(path_std_sync_atomic::Ordering::Relaxed)
1654    }
1655
1656    /// Drops every rendered block from every output zone and forces a
1657    /// full repaint. The prompt, current input buffer, and input-line
1658    /// history are left intact.
1659    pub fn clear_output(&self) {
1660        self.replace_output_snapshot(OutputSnapshot::default());
1661    }
1662
1663    /// Returns a clone of all output blocks/zones, excluding prompt input and
1664    /// prompt-history state.
1665    pub fn output_snapshot(&self) -> OutputSnapshot {
1666        self.output_snapshot_count
1667            .fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
1668        let _transaction = self.output_transaction_barrier();
1669        let st = self.lock();
1670        OutputSnapshot {
1671            blocks: st.layout.blocks.clone(),
1672            block_debug_ids: st.layout.block_debug_ids.clone(),
1673            next_id: st.layout.next_id,
1674            history: st.layout.history.clone(),
1675            above_active: st.layout.above_active.clone(),
1676            above_sticky: st.layout.above_sticky.clone(),
1677            suggestions: st.layout.suggestions.clone(),
1678            below: st.layout.below.clone(),
1679        }
1680    }
1681
1682    /// Returns how many full terminal output snapshots this handle has cloned.
1683    ///
1684    /// This content-free counter supports frontend progress diagnostics and
1685    /// guards hidden-agent rendering against transcript-sized clone
1686    /// regressions.
1687    pub fn output_snapshot_count(&self) -> u64 {
1688        self.output_snapshot_count
1689            .load(path_std_sync_atomic::Ordering::Relaxed)
1690    }
1691
1692    /// Transfers all output blocks and zones out of the visible terminal.
1693    ///
1694    /// The returned snapshot owns the exact map and zone allocations formerly
1695    /// installed in the terminal. Prompt input and prompt history remain in the
1696    /// terminal. Callers must install another snapshot before allowing visible
1697    /// output mutations.
1698    pub fn take_output_snapshot(&self) -> OutputSnapshot {
1699        self.output_snapshot_take_count
1700            .fetch_add(1, path_std_sync_atomic::Ordering::Relaxed);
1701        let _transaction = self.output_transaction_barrier();
1702        let mut st = self.lock();
1703        OutputSnapshot {
1704            blocks: std::mem::take(&mut st.layout.blocks),
1705            block_debug_ids: std::mem::take(&mut st.layout.block_debug_ids),
1706            next_id: st.layout.next_id,
1707            history: std::mem::take(&mut st.layout.history),
1708            above_active: std::mem::take(&mut st.layout.above_active),
1709            above_sticky: std::mem::take(&mut st.layout.above_sticky),
1710            suggestions: std::mem::take(&mut st.layout.suggestions),
1711            below: std::mem::take(&mut st.layout.below),
1712        }
1713    }
1714
1715    /// Returns how many output snapshots this handle has transferred by
1716    /// ownership rather than cloned.
1717    ///
1718    /// This content-free counter distinguishes selection handoffs from
1719    /// transcript-sized clone requests in frontend progress diagnostics.
1720    pub fn output_snapshot_take_count(&self) -> u64 {
1721        self.output_snapshot_take_count
1722            .load(path_std_sync_atomic::Ordering::Relaxed)
1723    }
1724
1725    /// Replaces all output blocks/zones, preserving prompt input and history.
1726    pub fn replace_output_snapshot(&self, snapshot: OutputSnapshot) {
1727        self.replace_output_snapshot_inner(snapshot, true, true);
1728    }
1729
1730    /// Replaces all output blocks/zones without invalidating or redrawing.
1731    /// The caller must ensure the visible terminal still corresponds to the
1732    /// restored snapshot.
1733    pub fn replace_output_snapshot_quiet(&self, snapshot: OutputSnapshot) {
1734        self.replace_output_snapshot_inner(snapshot, false, false);
1735    }
1736
1737    fn replace_output_snapshot_inner(
1738        &self,
1739        snapshot: OutputSnapshot,
1740        invalidate_screen: bool,
1741        notify: bool,
1742    ) {
1743        let _transaction = self.output_transaction_barrier();
1744        let mut st = self.lock();
1745        let retired_blocks = std::mem::replace(&mut st.layout.blocks, snapshot.blocks);
1746        st.layout.block_debug_ids = snapshot.block_debug_ids;
1747        st.layout.next_id = st.layout.next_id.max(snapshot.next_id);
1748        st.layout.history = snapshot.history;
1749        st.rebuild_history_refs();
1750        st.layout.above_active = snapshot.above_active;
1751        st.layout.above_sticky = snapshot.above_sticky;
1752        st.layout.suggestions = snapshot.suggestions;
1753        st.layout.below = snapshot.below;
1754        if invalidate_screen {
1755            st.terminal.invalidate_screen = true;
1756        }
1757        let notify = notify && Self::request_redraw_locked(&mut st);
1758        drop(st);
1759        if !retired_blocks.is_empty() {
1760            self.retire_styled_blocks(RetiredStyledBlocks::Snapshot(retired_blocks));
1761        }
1762        if notify {
1763            self.release_redraw_notification();
1764        }
1765    }
1766
1767    /// Forces the next redraw to take the full-render path: clear
1768    /// the visible screen + scrollback (`\x1b[2J\x1b[H\x1b[3J`)
1769    /// and re-emit the configured suffix of rendered history/log rows plus the
1770    /// fixed tail. Overflow naturally rebuilds recent terminal scrollback, but
1771    /// full-redraw plans intentionally omit rubber.
1772    ///
1773    /// Use this when a mutation affects rows that may already be in
1774    /// terminal scrollback — e.g. toggling visibility of a block from
1775    /// a past turn (`:set show-diff`, `:set show-thinking`). The
1776    /// differential renderer only repaints the visible window, so
1777    /// without invalidation those scrolled-out rows would remain as
1778    /// stale fossils that disagree with current state. See
1779    /// `README.md` § "When mutations need a full redraw".
1780    pub fn invalidate_screen(&self) {
1781        let _transaction = self.output_transaction_barrier();
1782        self.lock().terminal.invalidate_screen = true;
1783        self.notify_redraw();
1784    }
1785
1786    /// Current terminal size tracked by the renderer.
1787    pub fn size(&self) -> (usize, usize) {
1788        let st = self.lock();
1789        (st.terminal.width, st.terminal.height)
1790    }
1791
1792    /// Current terminal height tracked by the renderer.
1793    pub fn height(&self) -> usize {
1794        self.lock().terminal.height
1795    }
1796
1797    /// Number of full renders performed by the redraw thread since
1798    /// terminal creation. Temporary debugging aid for scrollback bugs.
1799    pub fn full_render_count(&self) -> u64 {
1800        self.lock().terminal.full_render_count
1801    }
1802
1803    /// Maximum number of rendered history/log rows replayed during a full
1804    /// redraw. `usize::MAX` preserves the historical unbounded behavior.
1805    pub fn redraw_history_size(&self) -> usize {
1806        self.lock().terminal.redraw_history_size
1807    }
1808
1809    /// Updates the maximum number of rendered history/log rows replayed during
1810    /// full redraw. This method only stores the value; callers decide whether
1811    /// to invalidate the screen immediately.
1812    pub fn set_redraw_history_size(&self, redraw_history_size: usize) {
1813        self.lock().terminal.redraw_history_size = redraw_history_size;
1814    }
1815
1816    /// Triggers a redraw and blocks until the redraw thread has
1817    /// processed it. Uses a generation counter: the caller bumps
1818    /// `sync_requested`, the redraw thread sets `sync_completed`
1819    /// atomically with going idle (right before blocking on recv).
1820    ///
1821    /// After terminal output fail-stop, this returns immediately without
1822    /// requesting or retrying a redraw. The failed attachment can no longer
1823    /// promise that any terminal frame was delivered.
1824    pub fn redraw_sync(&self) {
1825        let mut st = self.lock();
1826        if st.terminal.output_failure.is_some() {
1827            return;
1828        }
1829        st.terminal.sync_requested.advance();
1830        let target = st.terminal.sync_requested;
1831        drop(st);
1832
1833        self.redraw.notify();
1834
1835        let st = self.state.lock().expect("term state mutex poisoned");
1836        let _st = self
1837            .sync_condvar
1838            .wait_while(st, |s| s.terminal.sync_completed < target)
1839            .expect("term state mutex poisoned");
1840    }
1841
1842    // --- Block management ---
1843
1844    /// Allocates a new [`BlockId`] and stores the block.
1845    pub fn new_block(&self, debug_id: impl Into<String>, block: impl Into<StyledBlock>) -> BlockId {
1846        let _transaction = self.output_transaction_barrier();
1847        let mut st = self.lock();
1848        let id = st.alloc_id();
1849        let debug_id = debug_id.into();
1850        let block = block.into();
1851        let content_empty = block.is_empty();
1852        let retired_block = st.layout.blocks.insert(id, block);
1853        st.layout.block_debug_ids.insert(id, debug_id.clone());
1854        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, debug_id, content_empty, "new block");
1855        drop(st);
1856        if let Some(retired_block) = retired_block {
1857            self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
1858        }
1859        id
1860    }
1861
1862    /// Updates the content of an existing block (or inserts it at the given
1863    /// id).
1864    pub fn set_block(&self, id: BlockId, block: impl Into<StyledBlock>) {
1865        self.set_block_inner(id, block, false);
1866    }
1867
1868    /// Updates a block and reports whether an already-rendered reference
1869    /// changed.
1870    pub fn set_block_with_presentation_delta(
1871        &self,
1872        id: BlockId,
1873        block: impl Into<StyledBlock>,
1874    ) -> bool {
1875        self.set_block_inner(id, block, true)
1876    }
1877
1878    /// Applies one block update with optional presentation comparison.
1879    fn set_block_inner(
1880        &self,
1881        id: BlockId,
1882        block: impl Into<StyledBlock>,
1883        observe_delta: bool,
1884    ) -> bool {
1885        let _transaction = self.output_transaction_barrier();
1886        let block = block.into();
1887        let content_empty = block.is_empty();
1888        let mut st = self.lock();
1889        let affects_history = st.block_in_history(id);
1890        let changed = observe_delta && st.layout.blocks.get(&id) != Some(&block);
1891        let presentation_changed = changed && st.block_is_visible(id);
1892        let retired_block = st.layout.blocks.insert(id, block);
1893        st.layout
1894            .block_debug_ids
1895            .entry(id)
1896            .or_insert_with(|| format!("set-block-{}", id.0));
1897        if affects_history {
1898            st.mark_history_dirty_from(0);
1899        }
1900        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, content_empty, "set block");
1901        drop(st);
1902        if let Some(retired_block) = retired_block {
1903            self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
1904        }
1905        presentation_changed
1906    }
1907
1908    /// Removes a block from the central store and every zone that references
1909    /// it.
1910    pub fn remove_block(&self, id: BlockId) {
1911        self.remove_block_inner(id, false);
1912    }
1913
1914    /// Removes a block and reports whether any rendered zone referenced it.
1915    pub fn remove_block_with_presentation_delta(&self, id: BlockId) -> bool {
1916        self.remove_block_inner(id, true)
1917    }
1918
1919    /// Applies one removal with optional presentation inspection.
1920    fn remove_block_inner(&self, id: BlockId, observe_delta: bool) -> bool {
1921        let _transaction = self.output_transaction_barrier();
1922        let mut st = self.lock();
1923        let (presentation_changed, retired_block) = st.remove_block(id, observe_delta);
1924        drop(st);
1925        if let Some(retired_block) = retired_block {
1926            self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
1927        }
1928        presentation_changed
1929    }
1930
1931    // --- Zone lists ---
1932
1933    /// Appends a block id to the history (persistent output).
1934    pub fn push_history(&self, id: BlockId) {
1935        let _transaction = self.output_transaction_barrier();
1936        let mut st = self.lock();
1937        st.append_history(id);
1938        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "history", "push block zone");
1939    }
1940
1941    /// Appends a block id to the above-active zone (if not already
1942    /// present).
1943    pub fn push_above_active(&self, id: BlockId) {
1944        self.push_above_active_inner(id);
1945    }
1946
1947    /// Adds an active block and reports whether its rendered zone changed.
1948    pub fn push_above_active_with_presentation_delta(&self, id: BlockId) -> bool {
1949        self.push_above_active_inner(id)
1950    }
1951
1952    /// Applies one active-zone insertion and reports its exact delta.
1953    fn push_above_active_inner(&self, id: BlockId) -> bool {
1954        let _transaction = self.output_transaction_barrier();
1955        let mut st = self.lock();
1956        if !st.layout.above_active.contains(&id) {
1957            st.layout.above_active.push(id);
1958            tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_active", "push block zone");
1959            true
1960        } else {
1961            false
1962        }
1963    }
1964
1965    /// Inserts a block id into the above-active zone before the first matching
1966    /// anchor block, or appends it when none of the anchors are active.
1967    ///
1968    /// Existing references to `id` are moved rather than duplicated. This keeps
1969    /// callers from rebuilding the whole output snapshot when they need a
1970    /// stable sub-order inside the bottom-anchored live block area.
1971    pub fn push_above_active_before_any<I>(&self, id: BlockId, anchors: I)
1972    where
1973        I: IntoIterator<Item = BlockId>,
1974    {
1975        self.push_above_active_before_any_inner(id, anchors, false);
1976    }
1977
1978    /// Reorders an active block and reports whether the rendered order changed.
1979    pub fn push_above_active_before_any_with_presentation_delta<I>(
1980        &self,
1981        id: BlockId,
1982        anchors: I,
1983    ) -> bool
1984    where
1985        I: IntoIterator<Item = BlockId>,
1986    {
1987        self.push_above_active_before_any_inner(id, anchors, true)
1988    }
1989
1990    /// Applies one active-zone reorder with optional comparison.
1991    fn push_above_active_before_any_inner<I>(
1992        &self,
1993        id: BlockId,
1994        anchors: I,
1995        observe_delta: bool,
1996    ) -> bool
1997    where
1998        I: IntoIterator<Item = BlockId>,
1999    {
2000        let _transaction = self.output_transaction_barrier();
2001        let anchors = anchors.into_iter().collect::<HashSet<_>>();
2002        let mut st = self.lock();
2003        let previous = observe_delta.then(|| st.layout.above_active.clone());
2004        st.layout.above_active.retain(|&x| x != id);
2005        let insert_at = st
2006            .layout
2007            .above_active
2008            .iter()
2009            .position(|active_id| anchors.contains(active_id))
2010            .unwrap_or(st.layout.above_active.len());
2011        st.layout.above_active.insert(insert_at, id);
2012        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_active", "insert block zone");
2013        previous.is_some_and(|previous| st.layout.above_active != previous)
2014    }
2015
2016    /// Removes a block id from the above-active zone.
2017    pub fn remove_above_active(&self, id: BlockId) {
2018        let _transaction = self.output_transaction_barrier();
2019        self.lock().layout.above_active.retain(|&x| x != id);
2020        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_active", "remove block zone");
2021    }
2022
2023    /// Appends a block id to the above-sticky zone (if not already
2024    /// present).
2025    pub fn push_above_sticky(&self, id: BlockId) {
2026        let _transaction = self.output_transaction_barrier();
2027        let mut st = self.lock();
2028        if !st.layout.above_sticky.contains(&id) {
2029            st.layout.above_sticky.push(id);
2030            tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_sticky", "push block zone");
2031        }
2032    }
2033
2034    /// Removes a block id from the above-sticky zone.
2035    pub fn remove_above_sticky(&self, id: BlockId) {
2036        let _transaction = self.output_transaction_barrier();
2037        self.lock().layout.above_sticky.retain(|&x| x != id);
2038        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "above_sticky", "remove block zone");
2039    }
2040
2041    /// Appends a block id to the suggestions zone (if not already
2042    /// present). Rendered between the prompt and below blocks.
2043    pub fn push_suggestions(&self, id: BlockId) {
2044        let _transaction = self.output_transaction_barrier();
2045        let mut st = self.lock();
2046        if !st.layout.suggestions.contains(&id) {
2047            st.layout.suggestions.push(id);
2048            tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "suggestions", "push block zone");
2049        }
2050    }
2051
2052    /// Removes a block id from the suggestions zone.
2053    pub fn remove_suggestions(&self, id: BlockId) {
2054        let _transaction = self.output_transaction_barrier();
2055        self.lock().layout.suggestions.retain(|&x| x != id);
2056        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "suggestions", "remove block zone");
2057    }
2058
2059    /// Appends a block id to the below zone (if not already present).
2060    pub fn push_below(&self, id: BlockId) {
2061        self.push_below_inner(id);
2062    }
2063
2064    /// Adds a below-prompt block and reports whether its rendered zone changed.
2065    pub fn push_below_with_presentation_delta(&self, id: BlockId) -> bool {
2066        self.push_below_inner(id)
2067    }
2068
2069    /// Applies one below-zone insertion and reports its exact delta.
2070    fn push_below_inner(&self, id: BlockId) -> bool {
2071        let _transaction = self.output_transaction_barrier();
2072        let mut st = self.lock();
2073        if !st.layout.below.contains(&id) {
2074            st.layout.below.push(id);
2075            tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "below", "push block zone");
2076            true
2077        } else {
2078            false
2079        }
2080    }
2081
2082    /// Removes a block id from the below zone.
2083    pub fn remove_below(&self, id: BlockId) {
2084        let _transaction = self.output_transaction_barrier();
2085        self.lock().layout.below.retain(|&x| x != id);
2086        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, zone = "below", "remove block zone");
2087    }
2088
2089    // --- Convenience ---
2090
2091    /// Creates a new block and appends it to the history.
2092    /// Triggers a redraw automatically.
2093    pub fn print_output(
2094        &self,
2095        debug_id: impl Into<String>,
2096        block: impl Into<StyledBlock>,
2097    ) -> BlockId {
2098        let _transaction = self.output_transaction_barrier();
2099        let mut st = self.lock();
2100        let id = st.alloc_id();
2101        let debug_id = debug_id.into();
2102        let block = block.into();
2103        let content_empty = block.is_empty();
2104        let retired_block = st.layout.blocks.insert(id, block);
2105        st.layout.block_debug_ids.insert(id, debug_id.clone());
2106        st.append_history(id);
2107        tracing::trace!(target: "tau_cli_term_raw::blocks", ?id, debug_id, content_empty, zone = "history", "print output");
2108        let notify = Self::request_redraw_locked(&mut st);
2109        drop(st);
2110        if let Some(retired_block) = retired_block {
2111            self.retire_styled_blocks(RetiredStyledBlocks::One(retired_block));
2112        }
2113        if notify {
2114            self.release_redraw_notification();
2115        }
2116        id
2117    }
2118
2119    /// Updates the left prompt prefix.
2120    pub fn set_left_prompt(&self, text: impl Into<StyledText>) {
2121        let mut st = self.lock();
2122        st.editor.left_prompt = text.into();
2123        st.ensure_input_cursor_visible();
2124    }
2125
2126    /// Returns a clone of the current input buffer.
2127    pub fn get_buffer(&self) -> String {
2128        self.lock().editor.buffer.clone()
2129    }
2130
2131    /// Returns the current cursor position in bytes.
2132    pub fn get_cursor(&self) -> usize {
2133        self.lock().editor.cursor
2134    }
2135
2136    /// Returns the current monotonic editor revision.
2137    pub fn get_buffer_revision(&self) -> u64 {
2138        self.lock().editor.revision
2139    }
2140
2141    /// Returns the exact editor revision captured after the most recent raw
2142    /// line submission cleared the prompt.
2143    pub fn last_submitted_buffer_revision(&self) -> Option<u64> {
2144        self.lock().editor.last_submitted_revision
2145    }
2146
2147    /// Replaces the input buffer and cursor position. Also clears
2148    /// any active history-navigation, completion menu, and prompt undo
2149    /// state — an external buffer set is treated as a fresh starting
2150    /// point.
2151    pub fn set_buffer(&self, text: String, cursor: usize) {
2152        let mut st = self.lock();
2153        st.editor.revision = st.editor.revision.wrapping_add(1);
2154        st.advance_completion_generation();
2155        let new_cursor = clamp_cursor_to_grapheme_boundary(&text, cursor);
2156        st.editor.buffer = text;
2157        let abandoned_history_nav = st.editor.history_nav.take().is_some();
2158        st.editor.completion = None;
2159        st.editor.current_undo.clear();
2160        st.editor.current_redo.clear();
2161        st.write_cursor(new_cursor);
2162        if abandoned_history_nav {
2163            st.limit_input_history();
2164        }
2165    }
2166
2167    /// Replaces the input buffer only if no raw or external editor mutation has
2168    /// occurred since `expected_revision`.
2169    pub fn set_buffer_if_revision(
2170        &self,
2171        expected_revision: u64,
2172        text: String,
2173        cursor: usize,
2174    ) -> bool {
2175        let mut st = self.lock();
2176        if st.editor.revision != expected_revision {
2177            return false;
2178        }
2179        st.editor.revision = st.editor.revision.wrapping_add(1);
2180        st.advance_completion_generation();
2181        let new_cursor = clamp_cursor_to_grapheme_boundary(&text, cursor);
2182        st.editor.buffer = text;
2183        let abandoned_history_nav = st.editor.history_nav.take().is_some();
2184        st.editor.completion = None;
2185        st.editor.current_undo.clear();
2186        st.editor.current_redo.clear();
2187        st.write_cursor(new_cursor);
2188        if abandoned_history_nav {
2189            st.limit_input_history();
2190        }
2191        true
2192    }
2193
2194    /// Recalls a queued prompt before the current draft, matching
2195    /// prompt-history navigation so pressing Down restores the draft that
2196    /// was present at recall time.
2197    pub fn recall_prompt_before_current(&self, text: String) {
2198        let mut st = self.lock();
2199        st.editor.revision = st.editor.revision.wrapping_add(1);
2200        st.advance_completion_generation();
2201        st.recall_prompt_before_current(text);
2202    }
2203
2204    /// Replaces the input buffer and cursor position without clearing
2205    /// prompt undo history.
2206    ///
2207    /// Use this after the caller has explicitly recorded the current
2208    /// prompt as an undo snapshot before launching an external picker.
2209    /// Active history navigation and completion are still closed because
2210    /// the replacement becomes the new editable draft.
2211    pub fn set_buffer_preserving_undo(&self, text: String, cursor: usize) {
2212        let mut st = self.lock();
2213        st.editor.revision = st.editor.revision.wrapping_add(1);
2214        st.advance_completion_generation();
2215        let new_cursor = clamp_cursor_to_grapheme_boundary(&text, cursor);
2216        st.editor.buffer = text;
2217        let abandoned_history_nav = st.editor.history_nav.take().is_some();
2218        st.editor.completion = None;
2219        st.editor.current_redo.clear();
2220        st.write_cursor(new_cursor);
2221        if abandoned_history_nav {
2222            st.limit_input_history();
2223        }
2224    }
2225
2226    /// Snapshot of the open completion menu, if any. Returns `None`
2227    /// when no menu is showing.
2228    pub fn completion_state(&self) -> Option<CompletionView> {
2229        let st = self.lock();
2230        st.editor.completion.as_ref().map(|c| CompletionView {
2231            candidates: c.candidates.clone(),
2232            selected: c.selected,
2233        })
2234    }
2235
2236    /// Updates the right prompt.
2237    pub fn set_right_prompt(&self, text: impl Into<StyledText>) {
2238        self.lock().editor.right_prompt = text.into();
2239    }
2240
2241    /// Updates the placeholder shown when the input buffer is empty.
2242    pub fn set_input_placeholder(&self, text: impl Into<StyledText>) {
2243        self.lock().editor.input_placeholder = text.into();
2244    }
2245
2246    /// Enables or disables the compact hidden-row indicator for capped prompt
2247    /// input.
2248    pub fn set_prompt_scroll_indicator(&self, enabled: bool) {
2249        let mut st = self.lock();
2250        st.editor.show_prompt_scroll_indicator = enabled;
2251        st.ensure_input_cursor_visible();
2252    }
2253
2254    /// Queues a terminal bell to be written by the redraw thread on its next
2255    /// pass. Goes through the redraw loop so the byte never interleaves with an
2256    /// in-flight frame.
2257    pub fn print_terminal_bell(&self) {
2258        self.queue_terminal_side_effect("\x07");
2259    }
2260
2261    /// Queues an iTerm2 OSC 1337 `SetUserVar` side effect.
2262    ///
2263    /// `name` must be non-empty printable ASCII, must not contain `=`, and must
2264    /// be at most 128 bytes. Invalid names are skipped and logged without
2265    /// echoing the invalid bytes. When `in_tmux` is true, the OSC is wrapped in
2266    /// a tmux passthrough DCS sequence so the outer terminal receives it.
2267    ///
2268    /// `value` is base64-encoded before being written. Invalid `name` values
2269    /// are rejected and logged rather than emitted because OSC names are
2270    /// structural escape-sequence fields.
2271    pub fn print_osc1337_set_user_var(&self, name: &str, value: &str, in_tmux: bool) {
2272        if let Err(error) = validate_osc1337_name(name) {
2273            tracing::warn!(
2274                target: "tau_cli_term_raw::terminal_side_effect",
2275                name_len = name.len(),
2276                error,
2277                "skipping invalid OSC 1337 SetUserVar side effect"
2278            );
2279            return;
2280        }
2281        let encoded = {
2282            use base64::Engine as _;
2283            path_base64_engine::general_purpose::STANDARD.encode(value.as_bytes())
2284        };
2285        let sequence = if in_tmux {
2286            format!("\x1bPtmux;\x1b\x1b]1337;SetUserVar={name}={encoded}\x07\x1b\\")
2287        } else {
2288            format!("\x1b]1337;SetUserVar={name}={encoded}\x07")
2289        };
2290        self.queue_terminal_side_effect(sequence);
2291    }
2292
2293    fn queue_terminal_side_effect(&self, sequence: impl Into<String>) {
2294        let notify = {
2295            let mut st = self.lock();
2296            st.terminal.pending_raw.push(sequence.into());
2297            Self::request_redraw_locked(&mut st)
2298        };
2299        if notify {
2300            self.release_redraw_notification();
2301        }
2302    }
2303}
2304
2305fn validate_osc1337_name(name: &str) -> Result<(), &'static str> {
2306    if name.is_empty() {
2307        return Err("name must not be empty");
2308    }
2309    if name.len() > 128 {
2310        return Err("name must be at most 128 bytes");
2311    }
2312    if !name
2313        .bytes()
2314        .all(|b| (0x20..=0x7e).contains(&b) && b != b'=')
2315    {
2316        return Err("name must be printable ASCII without '='");
2317    }
2318    Ok(())
2319}
2320
2321/// Raw terminal events from crossterm or a virtual test input channel.
2322pub enum RawEvent {
2323    /// A decoded key press from crossterm.
2324    Key(KeyEvent),
2325    /// Terminal resize event with width and height in cells.
2326    Resize(u16, u16),
2327    /// Terminal focus changed.
2328    FocusChanged {
2329        /// True when focus was gained; false when it was lost.
2330        focused: bool,
2331    },
2332    /// One bracketed paste. The whole pasted string is delivered
2333    /// atomically so a multi-line paste doesn't trigger Enter on
2334    /// embedded newlines.
2335    Paste(String),
2336    /// Re-evaluate the completion source without treating it as user input.
2337    CompletionRefresh,
2338    /// Re-evaluate completion only if the captured interaction is still
2339    /// current.
2340    CompletionRefreshIfGeneration(u64),
2341}
2342
2343enum InputMessage {
2344    /// A raw terminal event to process unless sticky shutdown/EOF already won.
2345    Raw(RawEvent),
2346    /// A real-terminal reader result that retires the in-flight reader marker.
2347    RealRaw(RawEvent),
2348    /// Wake the receiver and transition it to sticky EOF.
2349    Shutdown,
2350    /// Wake the input owner to re-evaluate an already-open completion menu.
2351    RefreshCompletion,
2352    /// Wake the input owner for a generation-guarded completion refresh.
2353    RefreshCompletionIfGeneration(u64),
2354    /// A real-terminal reader error that retires the in-flight reader marker.
2355    RealError(io::Error),
2356}
2357
2358/// Starts exactly one real-terminal reader until its result reaches the input
2359/// owner, even if non-input wakeups arrive in the meantime.
2360fn spawn_real_reader_if_needed(
2361    in_flight: &Arc<path_std_sync_atomic::AtomicBool>,
2362    tx: path_std_sync::mpsc::Sender<InputMessage>,
2363    read: impl FnOnce() -> io::Result<RawEvent> + Send + 'static,
2364) -> bool {
2365    if in_flight
2366        .compare_exchange(
2367            false,
2368            true,
2369            path_std_sync_atomic::Ordering::AcqRel,
2370            path_std_sync_atomic::Ordering::Acquire,
2371        )
2372        .is_err()
2373    {
2374        return false;
2375    }
2376    thread::spawn(move || {
2377        let message = match read() {
2378            Ok(raw) => InputMessage::RealRaw(raw),
2379            Err(error) => InputMessage::RealError(error),
2380        };
2381        let _ = tx.send(message);
2382    });
2383    true
2384}
2385
2386/// Retires a consumed real-terminal reader result on its owning input thread.
2387fn finish_real_reader(in_flight: &path_std_sync_atomic::AtomicBool) {
2388    in_flight.store(false, path_std_sync_atomic::Ordering::Release);
2389}
2390
2391/// The first reported output failure that permanently stops one terminal
2392/// attachment.
2393#[derive(Clone, Debug)]
2394struct OutputFailure {
2395    /// Original standard I/O error classification.
2396    kind: io::ErrorKind,
2397    /// Stable error text retained after the originating error is consumed.
2398    message: String,
2399}
2400
2401impl OutputFailure {
2402    /// Captures an output error for later delivery to the input owner.
2403    fn new(error: io::Error) -> Self {
2404        Self {
2405            kind: error.kind(),
2406            message: error.to_string(),
2407        }
2408    }
2409
2410    /// Reconstructs an I/O error carrying the attachment-failure marker.
2411    fn io_error(&self) -> io::Error {
2412        io::Error::new(self.kind, self.clone())
2413    }
2414}
2415
2416impl std::fmt::Display for OutputFailure {
2417    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2418        write!(formatter, "terminal output failed: {}", self.message)
2419    }
2420}
2421
2422impl std::error::Error for OutputFailure {}
2423
2424/// Returns whether an I/O error reports fail-stop of this terminal attachment.
2425#[must_use]
2426pub fn is_output_failure(error: &io::Error) -> bool {
2427    error
2428        .get_ref()
2429        .is_some_and(|source| source.is::<OutputFailure>())
2430}
2431
2432/// The terminal prompt engine.
2433///
2434/// Owns the input event loop. Call [`Term::get_next_event`] in a loop to
2435/// drive it.
2436///
2437/// Real terminals isolate each blocking crossterm read in a short-lived helper
2438/// thread and deliver the result through an internal channel. This lets
2439/// shutdown wake the downstream input loop without timeout polling while still
2440/// avoiding a persistent stdin reader that could race a foreground program such
2441/// as `$EDITOR`.
2442///
2443/// Virtual terminals (tests) use the injected channel branch.
2444pub struct Term {
2445    /// Cloneable handle exposing zone/buffer mutators. `Term` derefs
2446    /// to this so callers can use `term.print_output(...)` etc.
2447    /// without going through an explicit `.handle()` accessor.
2448    handle: TermHandle,
2449    /// Receives raw input, read errors, and shutdown wakeups.
2450    input_rx: path_std_sync::mpsc::Receiver<InputMessage>,
2451    /// Keeps one real-terminal read alive across non-input wakeups.
2452    real_read_in_flight: Arc<path_std_sync_atomic::AtomicBool>,
2453    /// Redraw thread handle — taken and joined on drop.
2454    redraw_thread: Option<JoinHandle<()>>,
2455    /// Whether to disable raw mode on drop (false for virtual terms).
2456    owns_raw_mode: bool,
2457    /// Immutable terminal behavior selected before raw mode was acquired.
2458    terminal_options: TerminalOptions,
2459    /// Plugged in by callers that want completion. When `None`, the
2460    /// completion menu never opens; Tab/Esc are no-ops.
2461    completion_source: Option<Box<dyn CompletionSource>>,
2462    /// Plugged in by callers that want prompt key bindings.
2463    bindings: HashMap<KeyBinding, String>,
2464    /// Whether a high-level owner will finalize each submitted entry before
2465    /// its raw history can evict older entries.
2466    defer_submitted_input_history_limit: bool,
2467}
2468
2469impl std::ops::Deref for Term {
2470    type Target = TermHandle;
2471    fn deref(&self) -> &TermHandle {
2472        &self.handle
2473    }
2474}
2475
2476impl Term {
2477    /// Creates a new terminal prompt.
2478    ///
2479    /// Enters raw mode with `terminal_options` and spawns the redraw thread.
2480    /// Returns the prompt engine and a cloneable [`TermHandle`].
2481    ///
2482    /// # Errors
2483    ///
2484    /// Returns terminal I/O errors from enabling raw mode or terminal input
2485    /// features. If feature setup fails after raw mode was enabled, raw mode is
2486    /// disabled on a best-effort basis before returning the error.
2487    pub fn new(
2488        left_prompt: impl Into<StyledText>,
2489        terminal_options: TerminalOptions,
2490    ) -> io::Result<(Self, TermHandle)> {
2491        let (width, height) = term_size();
2492        let state = Arc::new(Mutex::new(SharedState::new(
2493            width,
2494            height,
2495            left_prompt.into(),
2496        )));
2497
2498        let (redraw_tx, redraw_rx) = tau_blocking_notify_channel::channel();
2499        let sync_condvar = Arc::new(path_std_sync::Condvar::new());
2500        let (input_tx, input_rx) = path_std_sync::mpsc::channel();
2501
2502        terminal::enable_raw_mode()?;
2503        // Opt into bracketed paste so the terminal wraps pasted content
2504        // in `ESC[200~` / `ESC[201~` and crossterm surfaces it as one
2505        // `CtEvent::Paste(String)` instead of a stream of individual
2506        // KeyEvents (which, without bracketed paste, leaked literal
2507        // escape-sequence bytes into the input buffer).
2508        //
2509        // Also push the kitty keyboard protocol's
2510        // `DISAMBIGUATE_ESCAPE_CODES` flag so the terminal sends
2511        // distinct sequences for combos like `Shift+Enter` /
2512        // `Ctrl+Enter` that vanilla terminals collapse into a bare
2513        // `\r`. Terminals that don't implement the protocol silently
2514        // ignore the escape and we keep the legacy behavior.
2515        if let Err(error) = initialize_terminal_features(
2516            &mut io::stdout(),
2517            terminal_options.cursor_shape,
2518            terminal_options,
2519        ) {
2520            let _ = terminal::disable_raw_mode();
2521            return Err(error);
2522        }
2523
2524        let redraw_state = Arc::clone(&state);
2525        let redraw_writer: Box<dyn Write + Send> = Box::new(io::stdout());
2526        let redraw_sync_cv = Arc::clone(&sync_condvar);
2527        let redraw_input_tx = input_tx.clone();
2528        let redraw_thread = thread::spawn(move || {
2529            redraw_loop(
2530                redraw_state,
2531                redraw_rx,
2532                redraw_writer,
2533                redraw_input_tx,
2534                &redraw_sync_cv,
2535            );
2536        });
2537
2538        let handle = TermHandle {
2539            state,
2540            output_transaction: Arc::new(Mutex::new(())),
2541            sync_condvar,
2542            redraw: redraw_tx,
2543            input_tx,
2544            output_snapshot_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2545            output_snapshot_take_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2546            #[cfg(feature = "redraw-test-counter")]
2547            redraw_request_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2548            #[cfg(test)]
2549            retirement_probe_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2550        };
2551
2552        handle.release_redraw_notification();
2553
2554        Ok((
2555            Self {
2556                handle: handle.clone(),
2557                input_rx,
2558                real_read_in_flight: Arc::new(path_std_sync_atomic::AtomicBool::new(false)),
2559                redraw_thread: Some(redraw_thread),
2560                owns_raw_mode: true,
2561                terminal_options,
2562                completion_source: None,
2563                bindings: HashMap::new(),
2564                defer_submitted_input_history_limit: false,
2565            },
2566            handle,
2567        ))
2568    }
2569
2570    /// Creates a virtual terminal for testing.
2571    ///
2572    /// No raw mode, no crossterm input reader. Output goes to the
2573    /// provided writer (e.g. a pipe). Input is injected via the
2574    /// returned `Sender<RawEvent>`. Dropping every returned sender closes
2575    /// virtual input and makes later reads return sticky [`Event::Eof`].
2576    pub fn new_virtual(
2577        width: usize,
2578        height: usize,
2579        left_prompt: impl Into<StyledText>,
2580        output: Box<dyn Write + Send>,
2581        cursor_shape: CursorShape,
2582    ) -> (Self, TermHandle, path_std_sync::mpsc::Sender<RawEvent>) {
2583        let state = Arc::new(Mutex::new(SharedState::new(
2584            width,
2585            height,
2586            left_prompt.into(),
2587        )));
2588
2589        let (redraw_tx, redraw_rx) = tau_blocking_notify_channel::channel();
2590        let sync_condvar = Arc::new(path_std_sync::Condvar::new());
2591        let (input_tx, input_rx) = path_std_sync::mpsc::channel();
2592
2593        let redraw_state = Arc::clone(&state);
2594        let redraw_sync_cv = Arc::clone(&sync_condvar);
2595        let redraw_input_tx = input_tx.clone();
2596        let redraw_thread = thread::spawn(move || {
2597            redraw_loop(
2598                redraw_state,
2599                redraw_rx,
2600                output,
2601                redraw_input_tx,
2602                &redraw_sync_cv,
2603            );
2604        });
2605
2606        let (term_input_tx, term_input_rx) = path_std_sync::mpsc::channel();
2607        let virtual_input_tx = input_tx.clone();
2608        thread::spawn(move || {
2609            while let Ok(raw) = term_input_rx.recv() {
2610                if virtual_input_tx.send(InputMessage::Raw(raw)).is_err() {
2611                    break;
2612                }
2613            }
2614            let _ = virtual_input_tx.send(InputMessage::Shutdown);
2615        });
2616
2617        let handle = TermHandle {
2618            state,
2619            output_transaction: Arc::new(Mutex::new(())),
2620            sync_condvar,
2621            redraw: redraw_tx,
2622            input_tx,
2623            output_snapshot_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2624            output_snapshot_take_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2625            #[cfg(feature = "redraw-test-counter")]
2626            redraw_request_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2627            #[cfg(test)]
2628            retirement_probe_count: Arc::new(path_std_sync_atomic::AtomicU64::new(0)),
2629        };
2630
2631        handle.release_redraw_notification();
2632
2633        let term = Self {
2634            handle: handle.clone(),
2635            input_rx,
2636            real_read_in_flight: Arc::new(path_std_sync_atomic::AtomicBool::new(false)),
2637            redraw_thread: Some(redraw_thread),
2638            owns_raw_mode: false,
2639            terminal_options: TerminalOptions {
2640                cursor_shape,
2641                ..TerminalOptions::default()
2642            },
2643            completion_source: None,
2644            bindings: HashMap::new(),
2645            defer_submitted_input_history_limit: false,
2646        };
2647
2648        (term, handle, term_input_tx)
2649    }
2650
2651    /// Returns a reference to the embedded [`TermHandle`]. Most
2652    /// callers can simply call handle methods through `Term`'s
2653    /// `Deref<Target = TermHandle>` instead.
2654    pub fn handle(&self) -> &TermHandle {
2655        &self.handle
2656    }
2657
2658    /// Defers submitted-input retention until the high-level owner has
2659    /// canonicalized or redacted the entry.
2660    pub fn defer_submitted_input_history_limit(&mut self) {
2661        self.defer_submitted_input_history_limit = true;
2662    }
2663
2664    /// Narrows raw-history bytes for a focused cross-crate test.
2665    #[cfg(feature = "history-retention-test-support")]
2666    #[doc(hidden)]
2667    pub fn set_input_history_max_bytes_for_test(&mut self, max_bytes: usize) {
2668        self.handle.lock().input_history_limit_override = Some(InputHistoryLimits {
2669            max_entries: INPUT_HISTORY_MAX_ENTRIES,
2670            max_bytes,
2671        });
2672    }
2673
2674    /// Applies the raw input-history limit after a deferred submitted entry has
2675    /// reached its final canonical or redacted representation.
2676    pub fn finalize_submitted_input_history(&mut self) {
2677        let mut st = self.handle.lock();
2678        st.limit_input_history();
2679        st.editor.last_submitted_input_retained = st.editor.input_history.last().is_some();
2680    }
2681
2682    /// Blocks until the next meaningful input event.
2683    ///
2684    /// Handles key editing internally (insert, delete, cursor movement)
2685    /// and only surfaces events the downstream cares about. Triggers
2686    /// a redraw before returning so internal state changes are visible.
2687    ///
2688    /// # Errors
2689    ///
2690    /// Returns terminal I/O errors from crossterm reading on real terminals.
2691    /// Both real and virtual terminals return the typed retained output failure
2692    /// after their redraw owner fail-stops. Virtual terminals otherwise return
2693    /// EOF when their injected input channel is disconnected.
2694    pub fn get_next_event(&self) -> io::Result<Event> {
2695        loop {
2696            let raw = match self.next_raw()? {
2697                Some(ev) => ev,
2698                None => return Ok(Event::Eof),
2699            };
2700
2701            match raw {
2702                RawEvent::Key(key) => {
2703                    {
2704                        let mut st = self.handle.lock();
2705                        st.editor.revision = st.editor.revision.wrapping_add(1);
2706                        st.advance_completion_generation();
2707                    }
2708                    if let Some(event) = self.handle_key(key)? {
2709                        self.handle.redraw();
2710                        return Ok(event);
2711                    }
2712                    self.handle.redraw();
2713                }
2714                RawEvent::Resize(w, h) => {
2715                    let (width, height) = {
2716                        let mut st = self.handle.lock();
2717                        let width = effective_resize_dimension(w, st.terminal.width);
2718                        let height = effective_resize_dimension(h, st.terminal.height);
2719                        st.terminal.width = width;
2720                        st.terminal.height = height;
2721                        st.ensure_input_cursor_visible();
2722                        (width, height)
2723                    };
2724                    self.handle.redraw();
2725                    return Ok(Event::Resize {
2726                        width: size_event_dimension(width),
2727                        height: size_event_dimension(height),
2728                    });
2729                }
2730                RawEvent::FocusChanged { focused } => {
2731                    return Ok(Event::FocusChanged { focused });
2732                }
2733                RawEvent::Paste(text) => {
2734                    // Insert the whole paste at the cursor in one go.
2735                    // Going through the per-char path would re-trigger
2736                    // the redraw thread N times and, more importantly,
2737                    // would expose embedded `\n` bytes to the Enter
2738                    // handler and submit the line mid-paste.
2739                    if text.is_empty() {
2740                        self.handle.redraw();
2741                        continue;
2742                    }
2743                    let text = normalize_paste_text(text);
2744                    {
2745                        let mut st = self.handle.lock();
2746                        st.editor.revision = st.editor.revision.wrapping_add(1);
2747                        st.advance_completion_generation();
2748                        st.record_undo();
2749                        let cursor = st.editor.cursor;
2750                        st.editor.buffer.insert_str(cursor, &text);
2751                        st.write_cursor(cursor + text.len());
2752                        st.sync_buffer_to_history_nav();
2753                    }
2754                    self.refresh_completion();
2755                    self.handle.redraw();
2756                    return Ok(Event::BufferChanged);
2757                }
2758                RawEvent::CompletionRefresh => {
2759                    self.refresh_completion();
2760                    self.handle.redraw();
2761                    return Ok(Event::CompletionRefresh);
2762                }
2763                RawEvent::CompletionRefreshIfGeneration(generation) => {
2764                    let eligible = {
2765                        let st = self.handle.lock();
2766                        st.editor.completion_generation == generation
2767                            && st
2768                                .editor
2769                                .completion
2770                                .as_ref()
2771                                .is_none_or(|menu| menu.selected.is_none())
2772                    };
2773                    if !eligible {
2774                        continue;
2775                    }
2776                    self.refresh_completion();
2777                    self.handle.redraw();
2778                    return Ok(Event::CompletionRefresh);
2779                }
2780            }
2781        }
2782    }
2783
2784    /// Reads the next raw event, blocking until one arrives.
2785    ///
2786    /// Real terminals perform blocking crossterm reads in a one-shot helper
2787    /// thread and wait on the same channel used for shutdown wakeups. One
2788    /// helper remains associated with an outstanding read across non-input
2789    /// wakeups, so refreshes cannot start competing stdin readers. A returned
2790    /// input result retires that helper before downstream code handles it; if
2791    /// shutdown wins, a later result is dropped by the shutdown channel path.
2792    fn next_raw(&self) -> io::Result<Option<RawEvent>> {
2793        {
2794            let st = self.handle.lock();
2795            if let Some(error) = &st.terminal.output_failure {
2796                return Err(error.io_error());
2797            }
2798            if st.terminal.input_shutdown {
2799                return Ok(None);
2800            }
2801        }
2802
2803        if self.owns_raw_mode {
2804            spawn_real_reader_if_needed(
2805                &self.real_read_in_flight,
2806                self.handle.input_tx.clone(),
2807                || read_real_raw_event(event::read, raw_term_size),
2808            );
2809        }
2810
2811        let message = match self.input_rx.recv() {
2812            Ok(message) => message,
2813            Err(_) => return Ok(None),
2814        };
2815        {
2816            let st = self.handle.lock();
2817            if let Some(error) = &st.terminal.output_failure {
2818                return Err(error.io_error());
2819            }
2820            if st.terminal.input_shutdown {
2821                return Ok(None);
2822            }
2823        }
2824        match message {
2825            InputMessage::Raw(raw) => Ok(Some(raw)),
2826            InputMessage::RealRaw(raw) => {
2827                finish_real_reader(&self.real_read_in_flight);
2828                Ok(Some(raw))
2829            }
2830            InputMessage::Shutdown => {
2831                self.handle.lock().terminal.input_shutdown = true;
2832                Ok(None)
2833            }
2834            InputMessage::RefreshCompletion => Ok(Some(RawEvent::CompletionRefresh)),
2835            InputMessage::RefreshCompletionIfGeneration(generation) => {
2836                Ok(Some(RawEvent::CompletionRefreshIfGeneration(generation)))
2837            }
2838            InputMessage::RealError(error) => {
2839                finish_real_reader(&self.real_read_in_flight);
2840                Err(error)
2841            }
2842        }
2843    }
2844
2845    /// Plugs in (or replaces) the completion source. Pass `None` to
2846    /// disable completion entirely. Closes the menu if currently open.
2847    pub fn set_completion_source(&mut self, source: Option<Box<dyn CompletionSource>>) {
2848        self.completion_source = source;
2849        let mut st = self.handle.lock();
2850        st.editor.completion = None;
2851    }
2852
2853    /// Configures key bindings surfaced as [`Event::Binding`].
2854    ///
2855    /// Supported key spellings include `Tab`, `BackTab`, `Shift-Tab`, `Enter`,
2856    /// `Esc`, arrow/navigation/editing keys, `C-Enter`, `C-Up`, `C-Down`, and
2857    /// `C-<letter>`, and canonical `M-<ascii-character>` for exact Alt-only
2858    /// character events. Control letters are case-sensitive:
2859    /// `C-b` and shifted `C-B` may have different actions when the terminal
2860    /// reports Shift.
2861    pub fn set_bindings(&mut self, bindings: impl IntoIterator<Item = (String, String)>) {
2862        self.bindings = bindings
2863            .into_iter()
2864            .filter_map(|(raw_key, action)| {
2865                let parsed = parse_key_binding(&raw_key);
2866                tracing::trace!(
2867                    target: "tau_cli_term_raw::input",
2868                    raw_key,
2869                    ?parsed,
2870                    action,
2871                    "configured prompt binding"
2872                );
2873                parsed.map(|key| (key, action))
2874            })
2875            .collect();
2876    }
2877
2878    /// Appends previously submitted prompts to the input history.
2879    ///
2880    /// Intended for startup seeding from persistent history. Empty
2881    /// prompts are ignored, and the active edit buffer is left intact.
2882    pub fn seed_input_history(&mut self, history: impl IntoIterator<Item = String>) {
2883        let mut st = self.handle.lock();
2884        st.editor.input_history.extend(
2885            history
2886                .into_iter()
2887                .filter(|buffer| !buffer.is_empty())
2888                .map(PromptDraft::submitted),
2889        );
2890        st.limit_input_history();
2891        st.editor.history_nav = None;
2892        st.editor.last_submitted_input_retained = false;
2893    }
2894
2895    /// Replaces the most recently submitted input-history entry and any
2896    /// recalled source entry that the submission edited.
2897    ///
2898    /// Higher layers use this after canonicalizing prompt syntax that the raw
2899    /// editor intentionally does not interpret.
2900    pub fn replace_last_submitted_input(&mut self, text: String) {
2901        let mut st = self.handle.lock();
2902        let recalled_source = st.editor.last_submitted_recalled_source;
2903        if let Some(index) = recalled_source
2904            && let Some(source) = st.editor.input_history.get_mut(index)
2905        {
2906            *source = PromptDraft::submitted(text.clone());
2907        }
2908        if st.editor.last_submitted_input_retained {
2909            if let Some(last) = st.editor.input_history.last_mut() {
2910                *last = PromptDraft::submitted(text.clone());
2911            }
2912        } else if !text.is_empty() {
2913            st.editor
2914                .input_history
2915                .push(PromptDraft::submitted(text.clone()));
2916        }
2917        if !self.defer_submitted_input_history_limit {
2918            st.limit_input_history();
2919        }
2920        st.editor.last_submitted_input_retained = st
2921            .editor
2922            .input_history
2923            .last()
2924            .is_some_and(|draft| draft.buffer == text);
2925        st.editor.history_nav = None;
2926        st.editor.completion = None;
2927    }
2928
2929    /// Re-evaluates the completion source against the current buffer
2930    /// and updates the menu state accordingly. Called from buffer
2931    /// mutation paths (typing, paste, backspace, kill-line, etc.).
2932    /// Treats every mutation as committing any prior preview: the
2933    /// new buffer/cursor become the menu's `original_*` so a later
2934    /// Esc returns here, not to a stale earlier state.
2935    fn refresh_completion(&self) {
2936        let Some(source) = self.completion_source.as_deref() else {
2937            return;
2938        };
2939        let (buffer, cursor) = {
2940            let st = self.handle.lock();
2941            (st.editor.buffer.clone(), st.editor.cursor)
2942        };
2943        let candidates = source
2944            .candidates(&buffer, cursor)
2945            .into_iter()
2946            .filter(|candidate| {
2947                candidate.cursor
2948                    == clamp_cursor_to_grapheme_boundary(&candidate.replacement, candidate.cursor)
2949                    && candidate.acceptance.as_ref().is_none_or(|acceptance| {
2950                        acceptance.cursor
2951                            == clamp_cursor_to_grapheme_boundary(
2952                                &acceptance.replacement,
2953                                acceptance.cursor,
2954                            )
2955                    })
2956            })
2957            .collect::<Vec<_>>();
2958        let mut st = self.handle.lock();
2959        if candidates.is_empty() {
2960            st.editor.completion = None;
2961        } else {
2962            st.editor.completion = Some(CompletionMenu {
2963                candidates,
2964                selected: None,
2965                original_buffer: buffer,
2966                original_cursor: cursor,
2967            });
2968        }
2969    }
2970
2971    /// Releases the terminal for an external program (e.g. `$EDITOR`):
2972    /// disables raw mode + bracketed paste, restores the user-configured
2973    /// cursor shape, and clears the screen so the editor starts on a clean
2974    /// canvas.
2975    ///
2976    /// No reader-thread coordination is needed: the one-shot crossterm reader
2977    /// is joined logically by `get_next_event` returning before callers can
2978    /// launch the external program, so no persistent stdin reader remains
2979    /// active while the program owns the terminal.
2980    ///
2981    /// # Errors
2982    ///
2983    /// Returns terminal I/O errors from releasing raw-mode features or clearing
2984    /// the screen. On failure, Tau attempts to roll terminal ownership back via
2985    /// [`Self::resume_after_external`], which also unmutes redraws and
2986    /// invalidates the next frame.
2987    pub fn pause_for_external(&self) -> io::Result<()> {
2988        if !self.owns_raw_mode {
2989            return Ok(());
2990        }
2991        self.pause_for_external_with_release(|| {
2992            let mut stdout = io::stdout();
2993            write_external_pause_features(&mut stdout, self.terminal_options)?;
2994            terminal::disable_raw_mode()?;
2995            crossterm::execute!(
2996                io::stdout(),
2997                crossterm::style::ResetColor,
2998                crossterm::cursor::MoveTo(0, 0),
2999                crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
3000            )?;
3001            Ok(())
3002        })
3003    }
3004
3005    fn pause_for_external_with_release(
3006        &self,
3007        release_terminal: impl FnOnce() -> io::Result<()>,
3008    ) -> io::Result<()> {
3009        {
3010            let mut st = self.handle.lock();
3011            st.terminal.external_paused = true;
3012        }
3013        // Wait until any redraw frame that already passed the paused-state
3014        // check has finished writing before releasing the terminal to an
3015        // external program.
3016        self.handle.redraw_sync();
3017
3018        if let Err(error) = release_terminal() {
3019            let _ = self.resume_after_external();
3020            return Err(error);
3021        }
3022        Ok(())
3023    }
3024
3025    /// Re-acquires raw mode + bracketed paste after an external
3026    /// program. Marks the redraw thread's `Screen` cache stale so the
3027    /// next render repaints from scratch; without this, the cache
3028    /// would diff against what we *thought* was on screen and skip
3029    /// drawing anything since the editor exited.
3030    ///
3031    /// # Errors
3032    ///
3033    /// Returns terminal I/O errors from re-enabling raw-mode features or
3034    /// clearing the screen. Even on failure, the redraw pause is cleared, the
3035    /// tracked terminal size is refreshed, and the next frame is invalidated.
3036    pub fn resume_after_external(&self) -> io::Result<()> {
3037        if !self.owns_raw_mode {
3038            self.finish_external_resume();
3039            return Ok(());
3040        }
3041        let result = (|| -> io::Result<()> {
3042            terminal::enable_raw_mode()?;
3043            let mut stdout = io::stdout();
3044            write_external_resume_features(
3045                &mut stdout,
3046                self.terminal_options.cursor_shape,
3047                self.terminal_options,
3048            )?;
3049            crossterm::execute!(
3050                io::stdout(),
3051                crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
3052                crossterm::cursor::MoveTo(0, 0)
3053            )?;
3054            Ok(())
3055        })();
3056        self.finish_external_resume();
3057        result
3058    }
3059
3060    fn finish_external_resume(&self) {
3061        let (width, height) = term_size();
3062        {
3063            let mut st = self.handle.lock();
3064            st.terminal.width = width;
3065            st.terminal.height = height;
3066            st.ensure_input_cursor_visible();
3067            st.terminal.external_paused = false;
3068            st.terminal.invalidate_screen = true;
3069        }
3070        self.handle.redraw();
3071    }
3072
3073    /// Records the current prompt as an undo snapshot without changing
3074    /// the visible buffer.
3075    ///
3076    /// External pickers call this before releasing the terminal so that
3077    /// a later undo restores the draft that was on screen when the
3078    /// picker opened.
3079    pub fn record_prompt_undo(&self) {
3080        let mut st = self.handle.lock();
3081        st.record_undo();
3082    }
3083
3084    /// Programmatically inserts a newline into the prompt.
3085    ///
3086    /// This is the same editing operation as unbound `Enter`,
3087    /// `Shift-Enter`, or `Alt-Enter`.
3088    pub fn trigger_insert_newline(&self) -> Event {
3089        self.insert_newline()
3090    }
3091
3092    /// Programmatically submits the prompt or accepts a completion preview.
3093    ///
3094    /// This is the same operation as unbound `Ctrl-Enter`: if a
3095    /// completion candidate is previewed, it is accepted without
3096    /// submitting; otherwise the current prompt is submitted.
3097    pub fn trigger_submit_or_accept_completion(&self) -> Event {
3098        self.submit_or_accept_completion()
3099    }
3100
3101    /// Programmatically closes any open completion menu.
3102    ///
3103    /// Returns `true` when a menu was open and got dismissed. If the
3104    /// selected completion had previewed text in the input buffer, the
3105    /// buffer is restored to the text that opened the menu.
3106    pub fn dismiss_completion_menu(&self) -> bool {
3107        let mut st = self.handle.lock();
3108        st.dismiss_completion()
3109    }
3110
3111    /// Programmatically triggers a history step (the same operation
3112    /// `Up`/`Down` and `Ctrl-K`/`Ctrl-J` perform). Closes any open
3113    /// completion menu first so callers don't have to coordinate with
3114    /// the input loop.
3115    pub fn trigger_history_step(&self, delta: isize) {
3116        let mut st = self.handle.lock();
3117        st.editor.completion = None;
3118        st.step_history(delta);
3119    }
3120
3121    /// Programmatically triggers prompt undo.
3122    pub fn trigger_undo(&self) -> bool {
3123        let mut st = self.handle.lock();
3124        st.editor.completion = None;
3125        st.undo()
3126    }
3127
3128    /// Programmatically triggers prompt redo.
3129    pub fn trigger_redo(&self) -> bool {
3130        let mut st = self.handle.lock();
3131        st.editor.completion = None;
3132        st.redo()
3133    }
3134
3135    fn step_history_event(&self, delta: isize) -> io::Result<Option<Event>> {
3136        self.trigger_history_step(delta);
3137        Ok(Some(Event::BufferChanged))
3138    }
3139
3140    fn binding_action(&self, binding: &Option<KeyBinding>) -> Option<String> {
3141        binding
3142            .as_ref()
3143            .and_then(|key| self.bindings.get(key))
3144            .cloned()
3145    }
3146
3147    /// Handles keys that belong to an open completion menu before any
3148    /// configurable binding can match them. Returns `None` when no completion
3149    /// action applies, letting normal key handling continue.
3150    fn handle_completion_key(
3151        &self,
3152        key: KeyEvent,
3153        ctrl: bool,
3154        shift: bool,
3155        alt: bool,
3156    ) -> Option<Event> {
3157        match key.code {
3158            KeyCode::Tab => {
3159                let mut st = self.handle.lock();
3160                st.cycle_completion(1).then_some(Event::BufferChanged)
3161            }
3162            KeyCode::BackTab | KeyCode::Up => {
3163                let mut st = self.handle.lock();
3164                st.cycle_completion(-1).then_some(Event::BufferChanged)
3165            }
3166            KeyCode::Down => {
3167                let mut st = self.handle.lock();
3168                st.cycle_completion(1).then_some(Event::BufferChanged)
3169            }
3170            KeyCode::Esc => {
3171                let mut st = self.handle.lock();
3172                st.dismiss_completion().then_some(Event::BufferChanged)
3173            }
3174            KeyCode::Enter if ctrl || (!shift && !alt) => self.accept_completion_event(),
3175            _ => None,
3176        }
3177    }
3178
3179    fn move_cursor_left(&self) -> bool {
3180        let mut st = self.handle.lock();
3181        if st.editor.cursor == 0 {
3182            return false;
3183        }
3184        let prev = prev_char_boundary(&st.editor.buffer, st.editor.cursor);
3185        st.write_cursor(prev);
3186        true
3187    }
3188
3189    fn move_cursor_right(&self) -> bool {
3190        let mut st = self.handle.lock();
3191        if st.editor.buffer.len() <= st.editor.cursor {
3192            return false;
3193        }
3194        let next = next_char_boundary(&st.editor.buffer, st.editor.cursor);
3195        st.write_cursor(next);
3196        true
3197    }
3198
3199    fn move_cursor_start(&self) -> bool {
3200        let mut st = self.handle.lock();
3201        if st.editor.cursor == 0 {
3202            return false;
3203        }
3204        st.write_cursor(0);
3205        true
3206    }
3207
3208    fn move_cursor_end(&self) -> bool {
3209        let mut st = self.handle.lock();
3210        let len = st.editor.buffer.len();
3211        if st.editor.cursor == len {
3212            return false;
3213        }
3214        st.write_cursor(len);
3215        true
3216    }
3217
3218    fn delete_backward(&self) -> bool {
3219        let changed = {
3220            let mut st = self.handle.lock();
3221            if st.editor.cursor == 0 {
3222                return false;
3223            }
3224            st.record_undo();
3225            let prev = prev_char_boundary(&st.editor.buffer, st.editor.cursor);
3226            let cursor = st.editor.cursor;
3227            st.editor.buffer.drain(prev..cursor);
3228            st.write_cursor(prev);
3229            st.sync_buffer_to_history_nav();
3230            true
3231        };
3232        self.refresh_completion();
3233        changed
3234    }
3235
3236    fn delete_forward(&self) -> bool {
3237        let changed = {
3238            let mut st = self.handle.lock();
3239            if st.editor.buffer.len() <= st.editor.cursor {
3240                return false;
3241            }
3242            st.record_undo();
3243            let cursor = st.editor.cursor;
3244            let next = next_char_boundary(&st.editor.buffer, cursor);
3245            st.editor.buffer.drain(cursor..next);
3246            st.write_cursor(cursor);
3247            st.sync_buffer_to_history_nav();
3248            true
3249        };
3250        self.refresh_completion();
3251        changed
3252    }
3253
3254    fn clear_prompt(&self) -> bool {
3255        let changed = {
3256            let mut st = self.handle.lock();
3257            if st.editor.buffer.is_empty() {
3258                return false;
3259            }
3260            st.editor.ctrl_c_cancel_armed = false;
3261            st.record_undo();
3262            st.editor.buffer.clear();
3263            let abandoned_history_nav = st.editor.history_nav.take().is_some();
3264            st.editor.completion = None;
3265            st.write_cursor(0);
3266            if abandoned_history_nav {
3267                st.limit_input_history();
3268            }
3269            true
3270        };
3271        self.refresh_completion();
3272        changed
3273    }
3274
3275    fn clear_or_cancel_prompt(&self) -> Event {
3276        let mut st = self.handle.lock();
3277        if st.editor.buffer.is_empty() {
3278            if st.editor.ctrl_c_cancel_armed {
3279                st.editor.ctrl_c_cancel_armed = false;
3280                return Event::CancelPrompt;
3281            }
3282            st.editor.ctrl_c_cancel_armed = true;
3283            return Event::Notice(
3284                "Press Ctrl-C again to cancel the current response; use Ctrl-D to exit".to_owned(),
3285            );
3286        }
3287        st.editor.ctrl_c_cancel_armed = false;
3288        st.record_undo();
3289        st.editor.buffer.clear();
3290        let abandoned_history_nav = st.editor.history_nav.take().is_some();
3291        st.editor.completion = None;
3292        st.write_cursor(0);
3293        if abandoned_history_nav {
3294            st.limit_input_history();
3295        }
3296        drop(st);
3297        self.refresh_completion();
3298        Event::BufferChanged
3299    }
3300
3301    fn kill_to_start(&self) -> bool {
3302        let changed = {
3303            let mut st = self.handle.lock();
3304            if st.editor.cursor == 0 {
3305                return false;
3306            }
3307            st.record_undo();
3308            let cursor = st.editor.cursor;
3309            st.editor.buffer.drain(..cursor);
3310            st.write_cursor(0);
3311            st.sync_buffer_to_history_nav();
3312            true
3313        };
3314        self.refresh_completion();
3315        changed
3316    }
3317
3318    fn kill_word_left(&self) -> bool {
3319        let changed = {
3320            let mut st = self.handle.lock();
3321            if st.editor.cursor == 0 {
3322                return false;
3323            }
3324            let new_end = word_left_boundary(&st.editor.buffer, st.editor.cursor);
3325            st.record_undo();
3326            let cursor = st.editor.cursor;
3327            st.editor.buffer.drain(new_end..cursor);
3328            st.write_cursor(new_end);
3329            st.sync_buffer_to_history_nav();
3330            true
3331        };
3332        self.refresh_completion();
3333        changed
3334    }
3335
3336    fn move_cursor_vertical_event(&self, delta: isize) -> Option<Event> {
3337        let mut st = self.handle.lock();
3338        let target_col = st.vertical_target_col();
3339        if let Some(new_cursor) = move_cursor_vertical(&st, delta, target_col) {
3340            st.write_cursor_keep_sticky(new_cursor);
3341            return Some(Event::BufferChanged);
3342        }
3343        None
3344    }
3345
3346    fn cycle_or_move_up(&self) -> Option<Event> {
3347        let mut st = self.handle.lock();
3348        if st.cycle_completion(-1) {
3349            return Some(Event::BufferChanged);
3350        }
3351        let target_col = st.vertical_target_col();
3352        if let Some(new_cursor) = move_cursor_vertical(&st, -1, target_col) {
3353            st.write_cursor_keep_sticky(new_cursor);
3354            return Some(Event::BufferChanged);
3355        }
3356        if st.step_history(-1) {
3357            return Some(Event::BufferChanged);
3358        }
3359        None
3360    }
3361
3362    fn cycle_or_move_down(&self) -> Option<Event> {
3363        let mut st = self.handle.lock();
3364        if st.cycle_completion(1) {
3365            return Some(Event::BufferChanged);
3366        }
3367        let target_col = st.vertical_target_col();
3368        if let Some(new_cursor) = move_cursor_vertical(&st, 1, target_col) {
3369            st.write_cursor_keep_sticky(new_cursor);
3370            return Some(Event::BufferChanged);
3371        }
3372        if st.step_history(1) {
3373            return Some(Event::BufferChanged);
3374        }
3375        None
3376    }
3377
3378    fn cycle_completion_event(&self, delta: isize) -> Option<Event> {
3379        let mut st = self.handle.lock();
3380        st.cycle_completion(delta).then_some(Event::BufferChanged)
3381    }
3382
3383    fn dismiss_completion_event(&self) -> Option<Event> {
3384        let mut st = self.handle.lock();
3385        st.dismiss_completion().then_some(Event::BufferChanged)
3386    }
3387
3388    fn accept_completion_event(&self) -> Option<Event> {
3389        let accepted = {
3390            let mut st = self.handle.lock();
3391            st.accept_completion()
3392        };
3393        if !accepted {
3394            return None;
3395        }
3396        self.refresh_completion();
3397        Some(Event::CompletionAccept)
3398    }
3399
3400    /// Returns true when `action` is handled by [`Self::trigger_named_action`].
3401    pub fn is_named_action(action: &str) -> bool {
3402        named_action_handler(action).is_some()
3403    }
3404
3405    /// Runs one named raw prompt action, returning the event it produced.
3406    ///
3407    /// These action names make built-in editing and prompt UI behaviors
3408    /// available to the configurable binding layer.
3409    pub fn trigger_named_action(&self, action: &str) -> Option<Event> {
3410        named_action_handler(action).and_then(|handler| handler(self))
3411    }
3412
3413    fn backtab_action(&self) -> Option<Event> {
3414        Some(Event::BackTab)
3415    }
3416
3417    fn clear_prompt_action(&self) -> Option<Event> {
3418        self.clear_prompt().then_some(Event::BufferChanged)
3419    }
3420
3421    fn clear_or_cancel_prompt_action(&self) -> Option<Event> {
3422        Some(self.clear_or_cancel_prompt())
3423    }
3424
3425    fn move_cursor_end_action(&self) -> Option<Event> {
3426        self.move_cursor_end().then_some(Event::BufferChanged)
3427    }
3428
3429    fn move_cursor_left_action(&self) -> Option<Event> {
3430        self.move_cursor_left().then_some(Event::BufferChanged)
3431    }
3432
3433    fn move_cursor_right_action(&self) -> Option<Event> {
3434        self.move_cursor_right().then_some(Event::BufferChanged)
3435    }
3436
3437    fn move_cursor_start_action(&self) -> Option<Event> {
3438        self.move_cursor_start().then_some(Event::BufferChanged)
3439    }
3440
3441    fn delete_backward_action(&self) -> Option<Event> {
3442        self.delete_backward().then_some(Event::BufferChanged)
3443    }
3444
3445    fn delete_forward_action(&self) -> Option<Event> {
3446        self.delete_forward().then_some(Event::BufferChanged)
3447    }
3448
3449    fn escape_action(&self) -> Option<Event> {
3450        Some(Event::Escape)
3451    }
3452
3453    fn kill_to_start_action(&self) -> Option<Event> {
3454        self.kill_to_start().then_some(Event::BufferChanged)
3455    }
3456
3457    fn kill_word_left_action(&self) -> Option<Event> {
3458        self.kill_word_left().then_some(Event::BufferChanged)
3459    }
3460
3461    fn move_cursor_down_action(&self) -> Option<Event> {
3462        self.move_cursor_vertical_event(1)
3463    }
3464
3465    fn move_cursor_up_action(&self) -> Option<Event> {
3466        self.move_cursor_vertical_event(-1)
3467    }
3468
3469    fn prompt_eof_action(&self) -> Option<Event> {
3470        let is_empty = self.handle.lock().editor.buffer.is_empty();
3471        is_empty.then_some(Event::Eof)
3472    }
3473
3474    fn select_completion_next_action(&self) -> Option<Event> {
3475        self.cycle_completion_event(1)
3476    }
3477
3478    fn select_completion_previous_action(&self) -> Option<Event> {
3479        self.cycle_completion_event(-1)
3480    }
3481
3482    fn insert_newline(&self) -> Event {
3483        {
3484            let mut st = self.handle.lock();
3485            st.editor.completion = None;
3486            st.record_undo();
3487            let cursor = st.editor.cursor;
3488            st.editor.buffer.insert(cursor, '\n');
3489            st.write_cursor(cursor + 1);
3490            st.sync_buffer_to_history_nav();
3491        }
3492        self.refresh_completion();
3493        Event::BufferChanged
3494    }
3495
3496    fn submit_or_accept_completion(&self) -> Event {
3497        // If a candidate is previewed, accept it but stay on the line.
3498        // Acceptance can replace preview text, so surface a distinct event.
3499        if self.accept_completion_event().is_some() {
3500            return Event::CompletionAccept;
3501        }
3502        let started = path_std_time::Instant::now();
3503        let line = {
3504            let mut st = self.handle.lock();
3505            st.editor.completion = None;
3506            st.editor.last_submitted_recalled_source =
3507                st.editor.history_nav.as_ref().and_then(|nav| {
3508                    nav.entries
3509                        .get(nav.index)
3510                        .and_then(|entry| entry.source_index)
3511                });
3512            st.editor.history_nav = None;
3513            let line = st.editor.buffer.clone();
3514            st.push_current_as_history_entry(!self.defer_submitted_input_history_limit);
3515            st.editor.last_submitted_input_retained = st
3516                .editor
3517                .input_history
3518                .last()
3519                .is_some_and(|draft| draft.buffer == line);
3520            st.editor.last_submitted_revision = Some(st.editor.revision);
3521            line
3522        };
3523        tracing::trace!(
3524            target: "tau_cli::prompt_submission",
3525            stage = "raw_submit_clear",
3526            prompt_bytes = line.len(),
3527            stage_us = started.elapsed().as_micros(),
3528            "content-free prompt submission stage"
3529        );
3530        Event::Line(line)
3531    }
3532
3533    fn handle_enter_key(&self, ctrl: bool, shift: bool, alt: bool) -> Event {
3534        if shift || alt {
3535            // Shift+Enter / Alt+Enter keep their explicit newline affordance.
3536            // This also keeps newline working when a user binds plain Enter to
3537            // an action.
3538            // Shift+Enter only reaches us when the terminal stack emits CSI-u
3539            // format (e.g. `\e[13;2u`): native kitty protocol, fixterms, or
3540            // tmux 3.5+ with `extended-keys-format csi-u`. Crossterm does NOT
3541            // parse the xterm modifyOtherKeys CSI-27 form (`\e[27;2;13~`), so
3542            // tmux configured with `extended-keys-format xterm` will swallow
3543            // it. Alt+Enter is the universal fallback because every
3544            // terminal sends `\e\r` for it regardless of protocol
3545            // negotiation.
3546            return self.insert_newline();
3547        }
3548
3549        if ctrl {
3550            self.submit_or_accept_completion()
3551        } else {
3552            self.insert_newline()
3553        }
3554    }
3555
3556    fn write_cursor_start_raw(&self) {
3557        let mut st = self.handle.lock();
3558        st.write_cursor(0);
3559    }
3560
3561    fn write_cursor_end_raw(&self) {
3562        let mut st = self.handle.lock();
3563        let len = st.editor.buffer.len();
3564        st.write_cursor(len);
3565    }
3566
3567    fn kill_to_start_raw_event(&self) -> Event {
3568        {
3569            let mut st = self.handle.lock();
3570            st.record_undo();
3571            let cursor = st.editor.cursor;
3572            st.editor.buffer.drain(..cursor);
3573            st.write_cursor(0);
3574            st.sync_buffer_to_history_nav();
3575        }
3576        self.refresh_completion();
3577        Event::BufferChanged
3578    }
3579
3580    // Keep Ctrl-C's raw fallback local instead of delegating to
3581    // `clear_or_cancel_prompt`: this path historically cleared a non-empty
3582    // prompt without refreshing completions, and callers may observe that exact
3583    // event/refresh boundary.
3584    fn handle_ctrl_c_key(&self) -> Event {
3585        let mut st = self.handle.lock();
3586        if st.editor.buffer.is_empty() {
3587            if st.editor.ctrl_c_cancel_armed {
3588                st.editor.ctrl_c_cancel_armed = false;
3589                return Event::CancelPrompt;
3590            }
3591            st.editor.ctrl_c_cancel_armed = true;
3592            return Event::Notice(
3593                "Press Ctrl-C again to cancel the current response; use Ctrl-D to exit".to_owned(),
3594            );
3595        }
3596        st.editor.ctrl_c_cancel_armed = false;
3597        st.record_undo();
3598        st.editor.buffer.clear();
3599        let abandoned_history_nav = st.editor.history_nav.take().is_some();
3600        st.editor.completion = None;
3601        st.write_cursor(0);
3602        if abandoned_history_nav {
3603            st.limit_input_history();
3604        }
3605        Event::BufferChanged
3606    }
3607
3608    fn handle_control_char_key(&self, ch: char) -> io::Result<Option<Event>> {
3609        match ch {
3610            'd' => {
3611                let is_empty = self
3612                    .state
3613                    .lock()
3614                    .expect("term state mutex poisoned")
3615                    .editor
3616                    .buffer
3617                    .is_empty();
3618                Ok(is_empty.then_some(Event::Eof))
3619            }
3620            'c' => Ok(Some(self.handle_ctrl_c_key())),
3621            'u' => Ok(Some(self.kill_to_start_raw_event())),
3622            'w' => Ok(self.kill_word_left().then_some(Event::BufferChanged)),
3623            'a' => {
3624                self.write_cursor_start_raw();
3625                Ok(None)
3626            }
3627            'e' => {
3628                self.write_cursor_end_raw();
3629                Ok(None)
3630            }
3631            'o' | 'g' => Ok(Some(Event::ExternalEditor)),
3632            'j' => self.step_history_event(1),
3633            'k' => self.step_history_event(-1),
3634            _ => Ok(None),
3635        }
3636    }
3637
3638    fn insert_char_event(&self, ch: char) -> Event {
3639        {
3640            let mut st = self.handle.lock();
3641            st.record_undo();
3642            let cursor = st.editor.cursor;
3643            st.editor.buffer.insert(cursor, ch);
3644            st.write_cursor(cursor + ch.len_utf8());
3645            st.sync_buffer_to_history_nav();
3646        }
3647        self.refresh_completion();
3648        Event::BufferChanged
3649    }
3650
3651    fn handle_plain_edit_key(&self, code: KeyCode) -> Option<Event> {
3652        match code {
3653            KeyCode::Backspace => self.delete_backward().then_some(Event::BufferChanged),
3654            KeyCode::Delete => self.delete_forward().then_some(Event::BufferChanged),
3655            _ => None,
3656        }
3657    }
3658
3659    fn handle_plain_cursor_key(&self, code: KeyCode) {
3660        match code {
3661            KeyCode::Left => {
3662                self.move_cursor_left();
3663            }
3664            KeyCode::Right => {
3665                self.move_cursor_right();
3666            }
3667            KeyCode::Home => {
3668                self.write_cursor_start_raw();
3669            }
3670            KeyCode::End => {
3671                self.write_cursor_end_raw();
3672            }
3673            _ => {}
3674        }
3675    }
3676
3677    fn handle_vertical_key(&self, code: KeyCode, ctrl: bool) -> io::Result<Option<Event>> {
3678        match (code, ctrl) {
3679            (KeyCode::Up, true) => self.step_history_event(-1),
3680            (KeyCode::Down, true) => self.step_history_event(1),
3681            (KeyCode::Up, false) => Ok(self.cycle_or_move_up()),
3682            (KeyCode::Down, false) => Ok(self.cycle_or_move_down()),
3683            _ => Ok(None),
3684        }
3685    }
3686
3687    fn handle_unbound_key(
3688        &self,
3689        key: KeyEvent,
3690        ctrl: bool,
3691        shift: bool,
3692        alt: bool,
3693    ) -> io::Result<Option<Event>> {
3694        match key.code {
3695            KeyCode::Enter => Ok(Some(self.handle_enter_key(ctrl, shift, alt))),
3696            KeyCode::Char(ch) if ctrl => self.handle_control_char_key(ch),
3697            KeyCode::Char(ch) => Ok(Some(self.insert_char_event(ch))),
3698            KeyCode::Backspace | KeyCode::Delete => Ok(self.handle_plain_edit_key(key.code)),
3699            KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => {
3700                self.handle_plain_cursor_key(key.code);
3701                Ok(None)
3702            }
3703            KeyCode::Up | KeyCode::Down => self.handle_vertical_key(key.code, ctrl),
3704            KeyCode::BackTab => Ok(Some(Event::BackTab)),
3705            KeyCode::Esc => Ok(Some(Event::Escape)),
3706            _ => Ok(None),
3707        }
3708    }
3709
3710    fn handle_key(&self, key: KeyEvent) -> io::Result<Option<Event>> {
3711        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3712        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
3713        let alt = key.modifiers.contains(KeyModifiers::ALT);
3714        let binding = key_binding_for_event(key, ctrl);
3715        tracing::trace!(
3716            target: "tau_cli_term_raw::input",
3717            ?key,
3718            ctrl,
3719            shift,
3720            alt,
3721            ?binding,
3722            binding_count = self.bindings.len(),
3723            "handling key event"
3724        );
3725
3726        let ctrl_c = matches!(key.code, KeyCode::Char('c')) && ctrl;
3727        if !ctrl_c {
3728            self.handle.lock().editor.ctrl_c_cancel_armed = false;
3729        }
3730
3731        if let Some(event) = self.handle_completion_key(key, ctrl, shift, alt) {
3732            return Ok(Some(event));
3733        }
3734
3735        if let Some(action) = self.binding_action(&binding) {
3736            tracing::trace!(
3737                target: "tau_cli_term_raw::input",
3738                ?binding,
3739                action,
3740                "matched configured binding"
3741            );
3742            return Ok(Some(Event::Binding(action)));
3743        }
3744
3745        self.handle_unbound_key(key, ctrl, shift, alt)
3746    }
3747}
3748
3749impl Term {
3750    /// Signals the redraw thread to do one final render, reposition
3751    /// the cursor below all content, and exit. Blocks until complete.
3752    fn shutdown(&mut self) {
3753        // Set the flag first, then notify — the redraw thread checks
3754        // the flag before blocking on recv, so it will see it on the
3755        // next iteration.
3756        {
3757            let mut st = self.handle.lock();
3758            st.terminal.shutdown = true;
3759        }
3760        self.handle.release_redraw_notification();
3761
3762        if let Some(handle) = self.redraw_thread.take() {
3763            let _ = handle.join();
3764        }
3765    }
3766}
3767
3768fn word_left_boundary(buffer: &str, cursor: usize) -> usize {
3769    let before_cursor = &buffer[..cursor];
3770    let trimmed_end = before_cursor.trim_end_matches(char::is_whitespace).len();
3771    before_cursor[..trimmed_end]
3772        .char_indices()
3773        .rev()
3774        .find_map(|(index, ch)| ch.is_whitespace().then_some(index + ch.len_utf8()))
3775        .unwrap_or(0)
3776}
3777
3778fn read_real_raw_event(
3779    mut read: impl FnMut() -> io::Result<CtEvent>,
3780    mut term_size: impl FnMut() -> io::Result<(u16, u16)>,
3781) -> io::Result<RawEvent> {
3782    loop {
3783        let raw = read()?;
3784        tracing::trace!(target: "tau_cli_term_raw::input", ?raw, "terminal raw input event");
3785        match raw {
3786            CtEvent::Key(key) => {
3787                // The kitty protocol surfaces Press/Repeat/Release events; drop
3788                // Release here so each keystroke fires exactly once downstream.
3789                if key.kind == KeyEventKind::Release {
3790                    continue;
3791                }
3792                return Ok(RawEvent::Key(key));
3793            }
3794            CtEvent::Resize(w, h) => {
3795                let (actual_w, actual_h) = term_size().unwrap_or((0, 0));
3796                return Ok(RawEvent::Resize(
3797                    resample_resize_dimension(w, actual_w),
3798                    resample_resize_dimension(h, actual_h),
3799                ));
3800            }
3801            CtEvent::FocusGained => return Ok(RawEvent::FocusChanged { focused: true }),
3802            CtEvent::FocusLost => return Ok(RawEvent::FocusChanged { focused: false }),
3803            CtEvent::Paste(text) => return Ok(RawEvent::Paste(text)),
3804            // Mouse events: skip so the caller still observes stdin as
3805            // "blocking" without unbounded recursion under noisy input.
3806            _ => {}
3807        }
3808    }
3809}
3810
3811fn write_external_pause_features(
3812    writer: &mut impl Write,
3813    terminal_options: TerminalOptions,
3814) -> io::Result<()> {
3815    if !terminal_options.mouse {
3816        crossterm::execute!(writer, DisableMouseCapture)?;
3817    }
3818    crossterm::execute!(
3819        writer,
3820        PopKeyboardEnhancementFlags,
3821        crossterm::event::DisableFocusChange,
3822        crossterm::event::DisableBracketedPaste,
3823        SetCursorStyle::DefaultUserShape,
3824    )
3825}
3826
3827fn write_external_resume_features(
3828    writer: &mut impl Write,
3829    cursor_shape: CursorShape,
3830    terminal_options: TerminalOptions,
3831) -> io::Result<()> {
3832    if !terminal_options.mouse {
3833        crossterm::execute!(writer, DisableMouseCapture)?;
3834    }
3835    crossterm::execute!(
3836        writer,
3837        crossterm::event::EnableBracketedPaste,
3838        crossterm::event::EnableFocusChange,
3839        PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES),
3840        cursor_shape.crossterm_style()
3841    )
3842}
3843
3844fn initialize_terminal_features(
3845    writer: &mut impl Write,
3846    cursor_shape: CursorShape,
3847    terminal_options: TerminalOptions,
3848) -> io::Result<()> {
3849    if let Err(error) = write_external_resume_features(writer, cursor_shape, terminal_options) {
3850        // A failed write may have reached the terminal after changing a
3851        // feature. While Tau still owns this terminal, best-effort cleanup
3852        // restores the external-program-safe terminal state.
3853        let _ = write_external_pause_features(writer, terminal_options);
3854        return Err(error);
3855    }
3856    Ok(())
3857}
3858
3859impl Drop for Term {
3860    fn drop(&mut self) {
3861        self.shutdown();
3862        if self.should_write_drop_terminal_cleanup() {
3863            // Pair the terminal modes we set in `new`: disable paste/focus,
3864            // pop the keyboard-protocol push, and return cursor shape to the
3865            // user's configured default so shells and other programs don't
3866            // inherit Tau's prompt cursor.
3867            let _ = write_drop_terminal_cleanup(&mut io::stdout(), self.terminal_options);
3868            let _ = terminal::disable_raw_mode();
3869        }
3870    }
3871}
3872
3873impl Term {
3874    fn should_write_drop_terminal_cleanup(&self) -> bool {
3875        self.owns_raw_mode && !self.handle.lock().terminal.external_paused
3876    }
3877}
3878
3879fn write_drop_terminal_cleanup(
3880    writer: &mut impl Write,
3881    terminal_options: TerminalOptions,
3882) -> io::Result<()> {
3883    write_external_pause_features(writer, terminal_options)
3884}
3885
3886// --- Rendering helpers ---
3887
3888#[derive(Clone, Debug, PartialEq, Eq)]
3889enum LineSource {
3890    Block {
3891        id: BlockId,
3892        debug_id: String,
3893        wrapped_row: usize,
3894    },
3895    Input {
3896        wrapped_row: usize,
3897    },
3898    InputScrollIndicator,
3899}
3900
3901/// Lays out blocks referenced by an id list, skipping missing ids
3902/// and blocks with empty content (so callers can "hide" a block by
3903/// swapping its content to empty without leaving a blank row).
3904fn layout_id_list(
3905    ids: &[BlockId],
3906    blocks: &HashMap<BlockId, StyledBlock>,
3907    block_debug_ids: &HashMap<BlockId, String>,
3908    width: usize,
3909    out: &mut Vec<CellRow>,
3910    sources: &mut Vec<LineSource>,
3911) {
3912    for id in ids {
3913        if let Some(block) = blocks.get(id) {
3914            if block.is_empty() {
3915                continue;
3916            }
3917            let lines = layout_block(block, width);
3918            for (wrapped_row, line) in lines.into_iter().enumerate() {
3919                sources.push(LineSource::Block {
3920                    id: *id,
3921                    debug_id: block_debug_ids
3922                        .get(id)
3923                        .cloned()
3924                        .unwrap_or_else(|| "<unknown>".to_owned()),
3925                    wrapped_row,
3926                });
3927                out.push(line.into());
3928            }
3929        }
3930    }
3931}
3932
3933/// Cached layout for persistent history blocks.
3934struct HistoryLayoutCache {
3935    /// Terminal width used to lay out cached entries.
3936    width: usize,
3937    /// Shared-state history generation represented by this cache.
3938    generation: TerminalHistoryGeneration,
3939    /// Generation represented before the most recent refresh.
3940    previous_generation: TerminalHistoryGeneration,
3941    /// Rendered line where an append-only refresh began.
3942    appended_from_line: Option<usize>,
3943    /// Start line for each cached history entry plus one final end offset.
3944    entry_line_offsets: Vec<usize>,
3945    /// Rendered persistent-history lines.
3946    lines: Vec<CellRow>,
3947    /// Source metadata parallel to `lines`.
3948    sources: Vec<LineSource>,
3949}
3950
3951impl Default for HistoryLayoutCache {
3952    fn default() -> Self {
3953        Self {
3954            width: 0,
3955            generation: TerminalHistoryGeneration::default(),
3956            previous_generation: TerminalHistoryGeneration::default(),
3957            appended_from_line: None,
3958            entry_line_offsets: vec![0],
3959            lines: Vec::new(),
3960            sources: Vec::new(),
3961        }
3962    }
3963}
3964
3965impl HistoryLayoutCache {
3966    /// Refreshes the changed history suffix and returns entries laid out.
3967    fn refresh(&mut self, st: &mut SharedState) -> usize {
3968        if self.width == st.terminal.width && self.generation == st.layout.history_generation {
3969            return 0;
3970        }
3971
3972        let previous_generation = self.generation;
3973        let previous_entry_count = self.entry_line_offsets.len().saturating_sub(1);
3974        let width_changed = self.width != st.terminal.width;
3975        let requested_dirty_from = st.layout.history_dirty_from.take().unwrap_or(0);
3976        let can_reuse_prefix = !width_changed
3977            && requested_dirty_from <= previous_entry_count
3978            && requested_dirty_from <= st.layout.history.len();
3979        let dirty_from = if can_reuse_prefix {
3980            requested_dirty_from
3981        } else {
3982            0
3983        };
3984        let line_start = self
3985            .entry_line_offsets
3986            .get(dirty_from)
3987            .copied()
3988            .unwrap_or(0);
3989        let append_only = can_reuse_prefix
3990            && dirty_from == previous_entry_count
3991            && previous_entry_count <= st.layout.history.len();
3992
3993        self.lines.truncate(line_start);
3994        self.sources.truncate(line_start);
3995        self.entry_line_offsets.truncate(dirty_from + 1);
3996        for id in &st.layout.history[dirty_from..] {
3997            layout_id_list(
3998                std::slice::from_ref(id),
3999                &st.layout.blocks,
4000                &st.layout.block_debug_ids,
4001                st.terminal.width,
4002                &mut self.lines,
4003                &mut self.sources,
4004            );
4005            self.entry_line_offsets.push(self.lines.len());
4006        }
4007
4008        self.width = st.terminal.width;
4009        self.previous_generation = previous_generation;
4010        self.generation = st.layout.history_generation;
4011        self.appended_from_line = append_only.then_some(line_start);
4012        st.layout.history.len().saturating_sub(dirty_from)
4013    }
4014
4015    /// Rebuilds an independent cache without consuming redraw dirty state.
4016    fn rebuild(st: &SharedState) -> Self {
4017        let mut cache = Self {
4018            width: st.terminal.width,
4019            generation: st.layout.history_generation,
4020            ..Self::default()
4021        };
4022        for id in &st.layout.history {
4023            layout_id_list(
4024                std::slice::from_ref(id),
4025                &st.layout.blocks,
4026                &st.layout.block_debug_ids,
4027                st.terminal.width,
4028                &mut cache.lines,
4029                &mut cache.sources,
4030            );
4031            cache.entry_line_offsets.push(cache.lines.len());
4032        }
4033        cache
4034    }
4035}
4036
4037/// Layout for everything after persistent history.
4038struct TailLayout {
4039    /// Lines for above-active plus fixed prompt/status/suggestions rows.
4040    lines: Vec<CellRow>,
4041    /// Source block/zone for each tail line.
4042    sources: Vec<LineSource>,
4043    /// Number of leading `lines` entries that belong to above-active.
4044    active_height: usize,
4045    /// Absolute cursor row after persistent history is prepended.
4046    cursor_row: usize,
4047    /// Cursor column.
4048    cursor_col: usize,
4049}
4050
4051impl TailLayout {
4052    fn fixed_height(&self) -> usize {
4053        self.lines.len().saturating_sub(self.active_height)
4054    }
4055}
4056
4057/// Result of laying out all content.
4058struct LayoutAll {
4059    /// All rendered lines without rubber (log + fixed area).
4060    all_lines: Vec<CellRow>,
4061    /// Source block/zone for each rendered line.
4062    line_sources: Vec<LineSource>,
4063    /// Index in `all_lines` where the fixed area starts.
4064    ///
4065    /// Lines before this are scrollable log content. Lines from this point on
4066    /// are the prompt/status/suggestions area. Rubber rows may be inserted at
4067    /// this boundary to absorb visible log shrinkage without moving the fixed
4068    /// area upward.
4069    log_end: usize,
4070    /// Persistent-history generation used to build this layout.
4071    history_generation: TerminalHistoryGeneration,
4072    /// Terminal width used to build persistent-history lines.
4073    history_width: usize,
4074    /// Number of leading log rows owned by persistent history.
4075    history_height: usize,
4076    /// Absolute cursor row in `all_lines`.
4077    cursor_row: usize,
4078    /// Cursor column.
4079    cursor_col: usize,
4080}
4081
4082struct ViewPlan {
4083    /// Top row of the physical terminal viewport within `render_lines`.
4084    viewport_start: usize,
4085    rubber_height: usize,
4086    render_lines: Vec<CellRow>,
4087    cursor_row: usize,
4088}
4089
4090impl ViewPlan {
4091    fn visible_start(&self, _height: usize) -> usize {
4092        self.viewport_start.min(self.render_lines.len())
4093    }
4094
4095    fn visible_lines(&self, height: usize) -> &[CellRow] {
4096        let start = self.visible_start(height);
4097        let end = (start + height).min(self.render_lines.len());
4098        &self.render_lines[start..end]
4099    }
4100
4101    fn cursor_in_visible(&self, height: usize) -> usize {
4102        self.cursor_row.saturating_sub(self.visible_start(height))
4103    }
4104}
4105
4106struct PlanMetrics {
4107    viewport_start: usize,
4108    rubber_height: usize,
4109    render_len: usize,
4110    cursor_row: usize,
4111}
4112
4113/// Renderer-side model of the terminal content Tau believes it owns.
4114///
4115/// `viewport_start` is the top row of the physical terminal viewport within
4116/// the most recent planned `render_lines`. Rows before
4117/// `viewport_start.min(known_lines.len())` are scrollable log rows already in
4118/// terminal scrollback. `rubber_height` is temporary blank space inserted
4119/// between log and fixed rows to absorb visible shrinkage before pulling rows
4120/// back from scrollback.
4121#[derive(Default)]
4122struct TerminalModel {
4123    /// First absolute row currently represented by the physical viewport.
4124    viewport_start: usize,
4125    /// Temporary blank rows retaining the viewport after visible shrinkage.
4126    rubber_height: usize,
4127    /// Persistent-history generation represented by `known_lines`.
4128    history_generation: TerminalHistoryGeneration,
4129    /// Width used for the represented persistent-history layout.
4130    history_width: usize,
4131    /// Leading persistent-history rows in `known_lines`.
4132    history_height: usize,
4133    /// Mutable active rows following persistent history in `known_lines`.
4134    active_height: usize,
4135    /// Complete represented log rows, including hidden scrollback.
4136    known_lines: Vec<CellRow>,
4137    /// Source metadata parallel to `known_lines`.
4138    known_sources: Vec<LineSource>,
4139}
4140
4141impl TerminalModel {
4142    fn desired_viewport_start(layout: &LayoutAll, height: usize) -> usize {
4143        layout.all_lines.len().saturating_sub(height)
4144    }
4145
4146    fn history_cache_matches(&self, history: &HistoryLayoutCache) -> bool {
4147        self.history_generation == history.generation
4148            && self.history_width == history.width
4149            && history.lines.len() <= self.known_lines.len()
4150            && history.sources.len() <= self.known_sources.len()
4151    }
4152
4153    fn history_append_matches(&self, history: &HistoryLayoutCache) -> bool {
4154        self.history_generation == history.previous_generation
4155            && self.history_width == history.width
4156            && history.appended_from_line == Some(self.history_height)
4157            // History is inserted before the mutable active zone. If active rows
4158            // existed in the prior frame, an append can replace rather than
4159            // merely follow those rows; use the full hidden-prefix check.
4160            && self.active_height == 0
4161    }
4162
4163    fn hidden_prefix_changed(&self, layout: &LayoutAll) -> bool {
4164        hidden_lines_changed(
4165            &self.known_lines,
4166            &layout.all_lines[..layout.log_end],
4167            self.viewport_start.min(layout.log_end),
4168        )
4169    }
4170
4171    fn changed_hidden_line(&self, layout: &LayoutAll) -> Option<usize> {
4172        changed_line_in_range(
4173            &self.known_lines,
4174            &layout.all_lines[..layout.log_end],
4175            0..self.viewport_start.min(layout.log_end),
4176        )
4177    }
4178
4179    fn build_plan(layout: &LayoutAll, viewport_start: usize, rubber_height: usize) -> ViewPlan {
4180        let mut render_lines = Vec::with_capacity(layout.all_lines.len() + rubber_height);
4181        render_lines.extend_from_slice(&layout.all_lines[..layout.log_end]);
4182        render_lines
4183            .extend(std::iter::repeat_with(|| CellRow::new(Vec::new())).take(rubber_height));
4184        render_lines.extend_from_slice(&layout.all_lines[layout.log_end..]);
4185
4186        let cursor_row = if layout.log_end <= layout.cursor_row {
4187            layout.cursor_row + rubber_height
4188        } else {
4189            layout.cursor_row
4190        };
4191
4192        ViewPlan {
4193            viewport_start,
4194            rubber_height,
4195            render_lines,
4196            cursor_row,
4197        }
4198    }
4199
4200    fn full_redraw_plan(layout: &LayoutAll, height: usize) -> ViewPlan {
4201        let plan = Self::build_plan(layout, Self::desired_viewport_start(layout, height), 0);
4202        Self::keep_cursor_visible(plan, height)
4203    }
4204
4205    #[cfg(test)]
4206    fn bottom_aligned_plan(layout: &LayoutAll, height: usize) -> ViewPlan {
4207        let mut plan = Self::build_plan(layout, Self::desired_viewport_start(layout, height), 0);
4208        plan.viewport_start = plan.visible_start(height);
4209        plan
4210    }
4211
4212    fn keep_cursor_visible(mut plan: ViewPlan, height: usize) -> ViewPlan {
4213        let height = height.max(1);
4214        let bottom_start = plan.visible_start(height);
4215        let viewport_start = viewport_start_with_cursor(
4216            bottom_start,
4217            plan.cursor_row,
4218            plan.render_lines.len(),
4219            height,
4220        );
4221
4222        if viewport_start < bottom_start {
4223            let viewport_end = (viewport_start + height).min(plan.render_lines.len());
4224            plan.render_lines.truncate(viewport_end);
4225        }
4226
4227        plan.viewport_start = plan.visible_start(height);
4228        plan
4229    }
4230
4231    fn plan_metrics(
4232        &self,
4233        log_height: usize,
4234        fixed_height: usize,
4235        cursor_row: usize,
4236        height: usize,
4237    ) -> PlanMetrics {
4238        let height = height.max(1);
4239        let viewport_start = self.viewport_start.min(log_height);
4240        let mut rubber_height = self.rubber_height;
4241
4242        if fixed_height < height {
4243            let occupied = log_height.saturating_sub(viewport_start) + rubber_height + fixed_height;
4244            if occupied < height {
4245                // Only create rubber after the viewport has overflowed once.
4246                // Before that, keep the normal terminal behavior where the
4247                // prompt follows the transcript instead of being bottom-pinned.
4248                if 0 < self.viewport_start || 0 < rubber_height {
4249                    rubber_height += height - occupied;
4250                }
4251            } else if height < occupied {
4252                let overflow = occupied - height;
4253                let consume_rubber = rubber_height.min(overflow);
4254                rubber_height -= consume_rubber;
4255            }
4256        } else {
4257            rubber_height = 0;
4258        }
4259
4260        let render_len = log_height + rubber_height + fixed_height;
4261        let cursor_row = if log_height <= cursor_row {
4262            cursor_row + rubber_height
4263        } else {
4264            cursor_row
4265        };
4266        let bottom_start = render_len.saturating_sub(height);
4267        let visible_start =
4268            viewport_start_with_cursor(bottom_start, cursor_row, render_len, height);
4269        let render_len = if visible_start < bottom_start {
4270            (visible_start + height).min(render_len)
4271        } else {
4272            render_len
4273        };
4274
4275        PlanMetrics {
4276            viewport_start: render_len.saturating_sub(height),
4277            rubber_height,
4278            render_len,
4279            cursor_row,
4280        }
4281    }
4282
4283    fn plan_view(&self, layout: &LayoutAll, height: usize) -> ViewPlan {
4284        let fixed_height = layout.all_lines.len().saturating_sub(layout.log_end);
4285        let metrics = self.plan_metrics(layout.log_end, fixed_height, layout.cursor_row, height);
4286        let mut plan = Self::build_plan(layout, metrics.viewport_start, metrics.rubber_height);
4287        plan.cursor_row = metrics.cursor_row;
4288        plan.render_lines.truncate(metrics.render_len);
4289        plan.viewport_start = metrics.viewport_start;
4290        plan
4291    }
4292
4293    fn apply_fast_plan(
4294        &mut self,
4295        history: &HistoryLayoutCache,
4296        tail: &TailLayout,
4297        metrics: &PlanMetrics,
4298    ) {
4299        self.viewport_start = metrics.viewport_start;
4300        self.rubber_height = metrics.rubber_height;
4301        self.history_generation = history.generation;
4302        self.history_width = history.width;
4303        self.known_lines.truncate(self.history_height);
4304        self.known_sources.truncate(self.history_height);
4305        self.known_lines
4306            .extend_from_slice(&history.lines[self.history_height..]);
4307        self.known_sources
4308            .extend_from_slice(&history.sources[self.history_height..]);
4309        self.history_height = history.lines.len();
4310        self.active_height = tail.active_height;
4311        self.known_lines
4312            .extend_from_slice(&tail.lines[..tail.active_height]);
4313        self.known_sources
4314            .extend_from_slice(&tail.sources[..tail.active_height]);
4315    }
4316
4317    fn reset_to_layout(&mut self, layout: &LayoutAll, viewport_start: usize, rubber_height: usize) {
4318        self.viewport_start = viewport_start;
4319        self.rubber_height = rubber_height;
4320        self.history_generation = layout.history_generation;
4321        self.history_width = layout.history_width;
4322        self.history_height = layout.history_height;
4323        self.active_height = layout.log_end.saturating_sub(layout.history_height);
4324        self.known_lines = layout.all_lines[..layout.log_end].to_vec();
4325        self.known_sources = layout.line_sources[..layout.log_end].to_vec();
4326    }
4327}
4328
4329fn prompt_input_max_rows(terminal_height: usize) -> usize {
4330    (terminal_height.max(1) * PROMPT_INPUT_MAX_HEIGHT_PERCENT / 100).max(1)
4331}
4332
4333fn prompt_scroll_indicator_rows(
4334    show_indicator: bool,
4335    buffer_non_empty: bool,
4336    total_rows: usize,
4337    cap_rows: usize,
4338) -> usize {
4339    usize::from(show_indicator && buffer_non_empty && 2 <= cap_rows && cap_rows < total_rows)
4340}
4341
4342fn prompt_editable_rows(total_rows: usize, cap_rows: usize, indicator_rows: usize) -> usize {
4343    cap_rows
4344        .saturating_sub(indicator_rows)
4345        .max(1)
4346        .min(total_rows.max(1))
4347}
4348
4349fn prompt_scroll_indicator_text(
4350    start: usize,
4351    visible_rows: usize,
4352    total_rows: usize,
4353    width: usize,
4354) -> String {
4355    let end = (start + visible_rows).min(total_rows);
4356    let hidden_above = start;
4357    let hidden_below = total_rows.saturating_sub(end);
4358    let full = format!(
4359        "↕ prompt rows {}-{}/{}  ↑{} ↓{}",
4360        start + 1,
4361        end,
4362        total_rows,
4363        hidden_above,
4364        hidden_below
4365    );
4366    if display_width(&full) <= width {
4367        return full;
4368    }
4369    let compact = format!("↕ ↑{} ↓{}", hidden_above, hidden_below);
4370    if display_width(&compact) <= width {
4371        return compact;
4372    }
4373    truncate_to_width("↕", width)
4374}
4375
4376fn layout_tail(st: &SharedState, history_height: usize) -> TailLayout {
4377    let width = st.terminal.width;
4378    let mut lines: Vec<CellRow> = Vec::new();
4379    let mut sources: Vec<LineSource> = Vec::new();
4380
4381    layout_id_list(
4382        &st.layout.above_active,
4383        &st.layout.blocks,
4384        &st.layout.block_debug_ids,
4385        width,
4386        &mut lines,
4387        &mut sources,
4388    );
4389    let active_height = lines.len();
4390    layout_id_list(
4391        &st.layout.above_sticky,
4392        &st.layout.blocks,
4393        &st.layout.block_debug_ids,
4394        width,
4395        &mut lines,
4396        &mut sources,
4397    );
4398
4399    let above_end = history_height + lines.len();
4400
4401    let mut input_content = st.editor.left_prompt.clone();
4402    if st.editor.buffer.is_empty() {
4403        for span in st.editor.input_placeholder.spans() {
4404            input_content.push(span.clone());
4405        }
4406    } else {
4407        input_content.push(Span::plain(&st.editor.buffer));
4408    }
4409    // Preserve a trailing-newline blank row so a buffer ending in
4410    // `\n` (the user just hit Shift+Enter / Alt+Enter) gives the
4411    // cursor somewhere to sit and the prompt grows immediately
4412    // rather than only after the next typed character.
4413    let mut input_lines = layout_lines()
4414        .content(&input_content)
4415        .width(width)
4416        .preserve_last_newline(true)
4417        .call();
4418
4419    let left_cols = st.editor.left_prompt.char_count();
4420    let (buffer_cursor_row, cursor_col) =
4421        buffer_position_for_byte(&st.editor.buffer, st.editor.cursor, width, left_cols);
4422    // Prompt input is special because it owns a visible cursor. When the
4423    // cursor sits at the end and the final column has just been filled, it
4424    // must appear immediately at column 0 of the next visual row, growing the
4425    // prompt height before any further character is typed. This is easy to
4426    // overlook and has regressed repeatedly. Do not move this behavior into
4427    // general block layout: static blocks have no cursor and must not gain a
4428    // phantom trailing row just because their content exactly fills a line.
4429    while input_lines.len() <= buffer_cursor_row {
4430        input_lines.push(Vec::new());
4431    }
4432
4433    if !st.editor.right_prompt.is_empty() && !input_lines.is_empty() {
4434        let first_line = &input_lines[0];
4435        let right_cells = st.editor.right_prompt.to_cells();
4436        let first_cols: usize = first_line.iter().map(|c| c.col_width()).sum();
4437        let right_cols: usize = right_cells.iter().map(|c| c.col_width()).sum();
4438        let needed = first_cols + 1 + right_cols;
4439        if needed <= width && input_lines.len() == 1 {
4440            let padding = width - first_cols - right_cols;
4441            let mut padded = first_line.clone();
4442            padded.extend(std::iter::repeat_n(Cell::plain(' '), padding));
4443            padded.extend(right_cells);
4444            input_lines[0] = padded;
4445        }
4446    }
4447
4448    let input_total_rows = input_lines.len().max(1);
4449    let cap_rows = prompt_input_max_rows(st.terminal.height);
4450    let indicator_rows = prompt_scroll_indicator_rows(
4451        st.editor.show_prompt_scroll_indicator,
4452        !st.editor.buffer.is_empty(),
4453        input_total_rows,
4454        cap_rows,
4455    );
4456    let visible_input_rows = prompt_editable_rows(input_total_rows, cap_rows, indicator_rows);
4457    let viewport_start = viewport_start_with_cursor(
4458        st.editor.input_viewport_start,
4459        buffer_cursor_row,
4460        input_total_rows,
4461        visible_input_rows,
4462    );
4463    let cursor_row = above_end + indicator_rows + buffer_cursor_row.saturating_sub(viewport_start);
4464
4465    if indicator_rows == 1 {
4466        let indicator = prompt_scroll_indicator_text(
4467            viewport_start,
4468            visible_input_rows,
4469            input_total_rows,
4470            width,
4471        );
4472        sources.push(LineSource::InputScrollIndicator);
4473        lines.push(StyledText::from(indicator).to_cells().into());
4474    }
4475
4476    let viewport_end = (viewport_start + visible_input_rows).min(input_lines.len());
4477    for (wrapped_row, line) in input_lines
4478        .into_iter()
4479        .enumerate()
4480        .skip(viewport_start)
4481        .take(viewport_end.saturating_sub(viewport_start))
4482    {
4483        sources.push(LineSource::Input { wrapped_row });
4484        lines.push(line.into());
4485    }
4486    layout_id_list(
4487        &st.layout.suggestions,
4488        &st.layout.blocks,
4489        &st.layout.block_debug_ids,
4490        width,
4491        &mut lines,
4492        &mut sources,
4493    );
4494    layout_id_list(
4495        &st.layout.below,
4496        &st.layout.blocks,
4497        &st.layout.block_debug_ids,
4498        width,
4499        &mut lines,
4500        &mut sources,
4501    );
4502
4503    TailLayout {
4504        lines,
4505        sources,
4506        active_height,
4507        cursor_row,
4508        cursor_col,
4509    }
4510}
4511
4512fn layout_all_from_cached_history(history: &HistoryLayoutCache, tail: TailLayout) -> LayoutAll {
4513    let log_end = history.lines.len() + tail.active_height;
4514    let cursor_row = tail.cursor_row;
4515    let cursor_col = tail.cursor_col;
4516    let mut all_lines = Vec::with_capacity(history.lines.len() + tail.lines.len());
4517    all_lines.extend_from_slice(&history.lines);
4518    all_lines.extend(tail.lines);
4519
4520    let mut line_sources = Vec::with_capacity(history.sources.len() + tail.sources.len());
4521    line_sources.extend_from_slice(&history.sources);
4522    line_sources.extend(tail.sources);
4523
4524    LayoutAll {
4525        all_lines,
4526        line_sources,
4527        log_end,
4528        history_generation: history.generation,
4529        history_width: history.width,
4530        history_height: history.lines.len(),
4531        cursor_row,
4532        cursor_col,
4533    }
4534}
4535
4536/// Lays out the full content (history + above + input + below).
4537fn layout_all(st: &SharedState) -> LayoutAll {
4538    let history = HistoryLayoutCache::rebuild(st);
4539    let tail = layout_tail(st, history.lines.len());
4540    layout_all_from_cached_history(&history, tail)
4541}
4542
4543fn visible_lines_from_parts(
4544    history_lines: &[CellRow],
4545    tail: &TailLayout,
4546    metrics: &PlanMetrics,
4547) -> Vec<CellRow> {
4548    render_rows_from(history_lines, tail, metrics, metrics.viewport_start)
4549}
4550
4551/// Materializes rendered rows from `start` through the current plan end.
4552fn render_rows_from(
4553    history_lines: &[CellRow],
4554    tail: &TailLayout,
4555    metrics: &PlanMetrics,
4556    start: usize,
4557) -> Vec<CellRow> {
4558    let history_height = history_lines.len();
4559    let log_height = history_height + tail.active_height;
4560    let fixed_start = log_height + metrics.rubber_height;
4561    let mut rows = Vec::with_capacity(metrics.render_len.saturating_sub(start));
4562
4563    for idx in start..metrics.render_len {
4564        if idx < history_height {
4565            rows.push(
4566                history_lines
4567                    .get(idx)
4568                    .expect("requested history row should exist")
4569                    .clone(),
4570            );
4571        } else if idx < log_height {
4572            rows.push(
4573                tail.lines
4574                    .get(idx - history_height)
4575                    .expect("requested active row should exist")
4576                    .clone(),
4577            );
4578        } else if idx < fixed_start {
4579            rows.push(CellRow::new(Vec::new()));
4580        } else {
4581            rows.push(
4582                tail.lines
4583                    .get(tail.active_height + idx - fixed_start)
4584                    .expect("requested fixed row should exist")
4585                    .clone(),
4586            );
4587        }
4588    }
4589
4590    rows
4591}
4592
4593/// Builds the bounded scrolling input rebased at the prior viewport.
4594fn scrolling_suffix(
4595    history_lines: &[CellRow],
4596    tail: &TailLayout,
4597    metrics: &PlanMetrics,
4598    terminal_model: &TerminalModel,
4599) -> Vec<CellRow> {
4600    render_rows_from(history_lines, tail, metrics, terminal_model.viewport_start)
4601}
4602
4603// --- Redraw thread ---
4604
4605enum RenderFrame {
4606    Fast {
4607        tail: TailLayout,
4608        metrics: PlanMetrics,
4609    },
4610    Full {
4611        layout: LayoutAll,
4612    },
4613}
4614
4615struct RedrawPass {
4616    width: usize,
4617    height: usize,
4618    size_changed: bool,
4619    force_full: bool,
4620    sync_gen: RedrawSyncGeneration,
4621    pending_raw: Vec<String>,
4622    redraw_history_size: usize,
4623    frame: RenderFrame,
4624    /// Bounded opaque observations captured with this frame's layout.
4625    presentation_observations: Option<CapturedPresentationObservations>,
4626}
4627
4628struct FullRenderMark {
4629    reason: &'static str,
4630    prev_visible_start: usize,
4631    visible_start: usize,
4632    height: usize,
4633    changed_line: Option<usize>,
4634    previous_source: Option<LineSource>,
4635}
4636
4637struct FullRenderMarkInput {
4638    reason: &'static str,
4639    changed_line: Option<usize>,
4640    previous_source: Option<LineSource>,
4641}
4642
4643fn redraw_loop(
4644    state: Arc<Mutex<SharedState>>,
4645    notify_rx: tau_blocking_notify_channel::Receiver,
4646    writer: Box<dyn Write + Send>,
4647    input_tx: path_std_sync::mpsc::Sender<InputMessage>,
4648    sync_condvar: &std::sync::Condvar,
4649) {
4650    let mut writer = BufWriter::new(writer);
4651    let (w, h) = {
4652        let st = state.lock().expect("term state mutex poisoned");
4653        (st.terminal.width, st.terminal.height)
4654    };
4655    let mut screen = Screen::new(w);
4656    let mut prev_width = w;
4657    let mut prev_height = h;
4658    let mut history_cache = HistoryLayoutCache::default();
4659    let mut terminal_model = TerminalModel::default();
4660
4661    loop {
4662        if render_shutdown_if_requested(
4663            &state,
4664            &mut writer,
4665            &mut screen,
4666            &terminal_model,
4667            prev_width,
4668            sync_condvar,
4669        ) {
4670            break;
4671        }
4672
4673        if !wait_for_redraw_or_sync(&state, &notify_rx) {
4674            break;
4675        }
4676
4677        tracing::trace!(
4678            target: "tau_cli_term_raw::frontend_progress",
4679            "redraw prepare started"
4680        );
4681        let pass = match prepare_redraw_pass(
4682            &state,
4683            &mut history_cache,
4684            &terminal_model,
4685            prev_width,
4686            prev_height,
4687            sync_condvar,
4688        ) {
4689            Some(pass) => pass,
4690            None => continue,
4691        };
4692        let write_started = path_std_time::Instant::now();
4693        tracing::trace!(
4694            target: "tau_cli_term_raw::frontend_progress",
4695            "terminal write started"
4696        );
4697        let render_result = render_redraw_pass(
4698            &state,
4699            &mut writer,
4700            &mut screen,
4701            &history_cache,
4702            &mut terminal_model,
4703            &pass,
4704        );
4705        let write_elapsed = write_started.elapsed();
4706        if let Err(error) = render_result {
4707            trace_failed_presentation_observations(&state, &pass, "write", write_elapsed, &error);
4708            fail_terminal_output(&state, &input_tx, sync_condvar, error);
4709            discard_failed_output(writer);
4710            return;
4711        }
4712        tracing::trace!(
4713            target: "tau_cli_term_raw::frontend_progress",
4714            write_us = write_elapsed.as_micros(),
4715            "terminal write finished; flush started"
4716        );
4717        let flush_started = path_std_time::Instant::now();
4718        let output_result = writer.flush();
4719        let flush_elapsed = flush_started.elapsed();
4720        if let Err(error) = output_result {
4721            trace_failed_presentation_observations(&state, &pass, "flush", flush_elapsed, &error);
4722            fail_terminal_output(&state, &input_tx, sync_condvar, error);
4723            discard_failed_output(writer);
4724            return;
4725        }
4726        tracing::trace!(
4727            target: "tau_cli_term_raw::frontend_progress",
4728            write_us = write_elapsed.as_micros(),
4729            flush_us = flush_elapsed.as_micros(),
4730            "terminal write and flush finished"
4731        );
4732        trace_flushed_presentation_observations(&state, &pass);
4733        if (Duration::from_millis(500) <= write_elapsed
4734            || Duration::from_millis(500) <= flush_elapsed)
4735            && admit_stall_warning()
4736        {
4737            tracing::warn!(
4738                target: "tau_cli_term_raw::frontend_progress",
4739                write_ms = write_elapsed.as_millis(),
4740                flush_ms = flush_elapsed.as_millis(),
4741                "terminal output stalled"
4742            );
4743        }
4744
4745        prev_width = pass.width;
4746        prev_height = pass.height;
4747
4748        complete_redraw_sync(&state, pass.sync_gen, sync_condvar);
4749    }
4750}
4751
4752/// Drops a failed buffered writer without retrying bytes retained in its
4753/// userspace buffer.
4754fn discard_failed_output(writer: BufWriter<Box<dyn Write + Send>>) {
4755    let _ = writer.into_parts();
4756}
4757
4758fn render_shutdown_if_requested(
4759    state: &Arc<Mutex<SharedState>>,
4760    writer: &mut BufWriter<Box<dyn Write + Send>>,
4761    screen: &mut Screen,
4762    terminal_model: &TerminalModel,
4763    prev_width: usize,
4764    sync_condvar: &std::sync::Condvar,
4765) -> bool {
4766    let mut st = state.lock().expect("term state mutex poisoned");
4767    if !st.terminal.shutdown {
4768        return false;
4769    }
4770    if st.terminal.external_paused {
4771        st.terminal.sync_completed = st.terminal.sync_requested;
4772        drop(st);
4773        sync_condvar.notify_all();
4774        return true;
4775    }
4776
4777    // Final render + move cursor below all content.
4778    let layout = layout_all(&st);
4779    let height = st.terminal.height.max(1);
4780    let plan = terminal_model.plan_view(&layout, height);
4781    let visible = plan.visible_lines(height);
4782    let cursor_in_visible = plan.cursor_in_visible(height);
4783    drop(st);
4784
4785    screen.set_width(prev_width);
4786    let _ = screen.update(writer, visible, (cursor_in_visible, layout.cursor_col));
4787    let below = plan.render_lines.len().saturating_sub(plan.cursor_row + 1);
4788    for _ in 0..=below {
4789        let _ = writer.queue(crossterm::style::Print("\r\n"));
4790    }
4791    let _ = writer.flush();
4792    {
4793        let mut st = state.lock().expect("term state mutex poisoned");
4794        st.terminal.sync_completed = st.terminal.sync_requested;
4795    }
4796    sync_condvar.notify_all();
4797    true
4798}
4799
4800fn wait_for_redraw_or_sync(
4801    state: &Arc<Mutex<SharedState>>,
4802    notify_rx: &tau_blocking_notify_channel::Receiver,
4803) -> bool {
4804    // If a sync was requested but not yet completed, skip blocking on recv and
4805    // render immediately. Otherwise block until the next notification arrives.
4806    let trace_enabled = tracing::enabled!(
4807        target: "tau_cli_term_raw::frontend_progress",
4808        tracing::Level::TRACE
4809    );
4810    let lock_started = trace_enabled.then(path_std_time::Instant::now);
4811    let st = state.lock().expect("term state mutex poisoned");
4812    if let Some(lock_started) = lock_started {
4813        tracing::trace!(
4814            target: "tau_cli_term_raw::frontend_progress",
4815            lock_wait_us = lock_started.elapsed().as_micros(),
4816            stage = "notification_check",
4817            "terminal shared state acquired"
4818        );
4819    }
4820    if st.terminal.sync_completed < st.terminal.sync_requested {
4821        return true;
4822    }
4823    drop(st);
4824    let notification_started = trace_enabled.then(path_std_time::Instant::now);
4825    let result = notify_rx.recv().is_ok();
4826    if let Some(notification_started) = notification_started {
4827        tracing::trace!(
4828            target: "tau_cli_term_raw::frontend_progress",
4829            notification_wait_us = notification_started.elapsed().as_micros(),
4830            "redraw notification wait finished"
4831        );
4832    }
4833    result
4834}
4835
4836fn prepare_redraw_pass(
4837    state: &Arc<Mutex<SharedState>>,
4838    history_cache: &mut HistoryLayoutCache,
4839    terminal_model: &TerminalModel,
4840    prev_width: usize,
4841    prev_height: usize,
4842    sync_condvar: &std::sync::Condvar,
4843) -> Option<RedrawPass> {
4844    let trace_enabled = tracing::enabled!(
4845        target: "tau_cli_term_raw::frontend_progress",
4846        tracing::Level::TRACE
4847    );
4848    let lock_started = trace_enabled.then(path_std_time::Instant::now);
4849    let mut st = state.lock().expect("term state mutex poisoned");
4850    if let Some(lock_started) = lock_started {
4851        tracing::trace!(
4852            target: "tau_cli_term_raw::frontend_progress",
4853            lock_wait_us = lock_started.elapsed().as_micros(),
4854            stage = "redraw_prepare",
4855            "terminal shared state acquired"
4856        );
4857    }
4858    if st.terminal.redraw_suppression != 0 {
4859        // The notification that woke this pass has been consumed. Preserve it
4860        // for the outermost suppression guard so a no-op transaction cannot
4861        // swallow another producer's already-pending redraw.
4862        st.terminal.redraw_dirty_while_suppressed = true;
4863        st.terminal.sync_completed = st.terminal.sync_requested;
4864        sync_condvar.notify_all();
4865        return None;
4866    }
4867    if st.terminal.external_paused {
4868        st.terminal.sync_completed = st.terminal.sync_requested;
4869        sync_condvar.notify_all();
4870        return None;
4871    }
4872    let width = st.terminal.width;
4873    let height = st.terminal.height.max(1);
4874    let size_changed = prev_width != width || prev_height != height;
4875    // Take-and-clear so the flag is one-shot.
4876    let force_full = std::mem::take(&mut st.terminal.invalidate_screen);
4877    // Capture the sync generation we're rendering against. We must not advance
4878    // sync_completed beyond this value, because a later bump to sync_requested
4879    // may have arrived with state changes we haven't read yet.
4880    let sync_gen = st.terminal.sync_requested;
4881    let pending_raw = std::mem::take(&mut st.terminal.pending_raw);
4882    let redraw_history_size = st.terminal.redraw_history_size;
4883    let presentation_observations =
4884        (!st.presentation_observations.is_empty()).then(|| st.presentation_observations.capture());
4885
4886    let preparation_started = trace_enabled.then(path_std_time::Instant::now);
4887    history_cache.refresh(&mut st);
4888    let tail = layout_tail(&st, history_cache.lines.len());
4889    let log_height = history_cache.lines.len() + tail.active_height;
4890    let fixed_height = tail.fixed_height();
4891    let metrics = terminal_model.plan_metrics(log_height, fixed_height, tail.cursor_row, height);
4892    let can_fast = !size_changed
4893        && !force_full
4894        && ((terminal_model.history_cache_matches(history_cache)
4895            && metrics.viewport_start == terminal_model.viewport_start)
4896            || (terminal_model.history_append_matches(history_cache)
4897                && terminal_model.viewport_start <= metrics.viewport_start))
4898        && metrics.viewport_start <= history_cache.lines.len();
4899    let frame = if can_fast {
4900        RenderFrame::Fast { tail, metrics }
4901    } else {
4902        RenderFrame::Full {
4903            layout: layout_all_from_cached_history(history_cache, tail),
4904        }
4905    };
4906    let pass = RedrawPass {
4907        width,
4908        height,
4909        size_changed,
4910        force_full,
4911        sync_gen,
4912        pending_raw,
4913        redraw_history_size,
4914        frame,
4915        presentation_observations,
4916    };
4917    if let Some(preparation_started) = preparation_started {
4918        tracing::trace!(
4919            target: "tau_cli_term_raw::frontend_progress",
4920            preparation_us = preparation_started.elapsed().as_micros(),
4921            "redraw layout prepared"
4922        );
4923    }
4924    Some(pass)
4925}
4926
4927/// Reports only successful frame correlation after every pass write and flush.
4928fn trace_flushed_presentation_observations(_state: &Arc<Mutex<SharedState>>, pass: &RedrawPass) {
4929    let Some(observations) = &pass.presentation_observations else {
4930        return;
4931    };
4932    let flushed_at = path_std_time::Instant::now();
4933    for fact in &observations.facts {
4934        tracing::trace!(
4935            target: "tau_cli_term_raw::frontend_progress",
4936            delivery_id = fact.delivery_id.get(),
4937            fact = fact.fact,
4938            mutation_generation = fact.generation.get(),
4939            frame_generation = observations.generation.get(),
4940            mutation_to_flush_us = flushed_at.duration_since(fact.observed_at).as_micros(),
4941            "selected presentation mutation frame written and flushed"
4942        );
4943    }
4944    if observations.omitted != 0 {
4945        tracing::trace!(
4946            target: "tau_cli_term_raw::frontend_progress",
4947            frame_generation = observations.generation.get(),
4948            omitted = observations.omitted,
4949            "selected presentation flush observations omitted"
4950        );
4951    }
4952    #[cfg(test)]
4953    _state
4954        .lock()
4955        .expect("term state mutex poisoned")
4956        .presentation_observations
4957        .record_success_for_test(observations);
4958}
4959
4960/// Reports bounded failed-pass context without making a successful-frame claim.
4961fn trace_failed_presentation_observations(
4962    _state: &Arc<Mutex<SharedState>>,
4963    pass: &RedrawPass,
4964    stage: &'static str,
4965    stage_elapsed: Duration,
4966    error: &io::Error,
4967) {
4968    let Some(observations) = &pass.presentation_observations else {
4969        return;
4970    };
4971    #[cfg(test)]
4972    _state
4973        .lock()
4974        .expect("term state mutex poisoned")
4975        .presentation_failure_test_records
4976        .push((
4977            stage,
4978            stage_elapsed.as_micros(),
4979            observations.facts.len(),
4980            observations.omitted,
4981        ));
4982    tracing::trace!(
4983        target: "tau_cli_term_raw::frontend_progress",
4984        stage,
4985        stage_us = stage_elapsed.as_micros(),
4986        frame_generation = observations.generation.get(),
4987        indeterminate_facts = observations.facts.len(),
4988        omitted = observations.omitted,
4989        error_kind = ?error.kind(),
4990        "selected presentation redraw pass failed or is indeterminate"
4991    );
4992}
4993
4994fn render_redraw_pass(
4995    state: &Arc<Mutex<SharedState>>,
4996    writer: &mut BufWriter<Box<dyn Write + Send>>,
4997    screen: &mut Screen,
4998    history_cache: &HistoryLayoutCache,
4999    terminal_model: &mut TerminalModel,
5000    pass: &RedrawPass,
5001) -> io::Result<()> {
5002    // Pending escape sequences: emit before the frame so they sit outside any
5003    // synchronized-update bracket the renderer installs. SetUserVar and similar
5004    // OSC sequences don't affect visible state, so ordering relative to the
5005    // frame doesn't matter for correctness — putting them first just avoids any
5006    // chance of interleaving with a deferred frame.
5007    for seq in &pass.pending_raw {
5008        writer.write_all(seq.as_bytes())?;
5009    }
5010    if pass.force_full {
5011        // The terminal was clobbered by an external program ($EDITOR returned).
5012        // Wipe Screen's cached idea of what's on the terminal so `full_render`
5013        // redraws from scratch.
5014        screen.invalidate();
5015    }
5016
5017    match &pass.frame {
5018        RenderFrame::Fast { tail, metrics } => {
5019            render_fast_frame(
5020                writer,
5021                screen,
5022                history_cache,
5023                terminal_model,
5024                pass,
5025                tail,
5026                metrics,
5027            )?;
5028        }
5029        RenderFrame::Full { layout } => {
5030            render_full_frame(state, writer, screen, terminal_model, pass, layout)?;
5031        }
5032    }
5033    Ok(())
5034}
5035
5036fn render_fast_frame(
5037    writer: &mut BufWriter<Box<dyn Write + Send>>,
5038    screen: &mut Screen,
5039    history_cache: &HistoryLayoutCache,
5040    terminal_model: &mut TerminalModel,
5041    pass: &RedrawPass,
5042    tail: &TailLayout,
5043    metrics: &PlanMetrics,
5044) -> io::Result<()> {
5045    screen.set_width(pass.width);
5046    if terminal_model.viewport_start < metrics.viewport_start {
5047        let previous_viewport_start = terminal_model.viewport_start;
5048        // `Screen` retains only the old visible rows. Rebase the bounded suffix
5049        // beginning at that viewport to row zero: `prev_viewport_top = 0` then
5050        // describes the same physical screen without copying or comparing older
5051        // terminal scrollback.
5052        let suffix = scrolling_suffix(&history_cache.lines, tail, metrics, terminal_model);
5053        let cursor_row = metrics.cursor_row.saturating_sub(previous_viewport_start);
5054        screen.render_scrolling(
5055            writer,
5056            &suffix,
5057            0,
5058            pass.height,
5059            (cursor_row, tail.cursor_col),
5060        )?;
5061    } else {
5062        let visible = visible_lines_from_parts(&history_cache.lines, tail, metrics);
5063        let cursor_in_visible = metrics.cursor_row.saturating_sub(metrics.viewport_start);
5064        screen.update(writer, &visible, (cursor_in_visible, tail.cursor_col))?;
5065    }
5066    terminal_model.apply_fast_plan(history_cache, tail, metrics);
5067    Ok(())
5068}
5069
5070fn render_full_frame(
5071    state: &Arc<Mutex<SharedState>>,
5072    writer: &mut BufWriter<Box<dyn Write + Send>>,
5073    screen: &mut Screen,
5074    terminal_model: &mut TerminalModel,
5075    pass: &RedrawPass,
5076    layout: &LayoutAll,
5077) -> io::Result<()> {
5078    if pass.size_changed || pass.force_full {
5079        let reason = if pass.size_changed {
5080            "size_changed"
5081        } else {
5082            "force_full"
5083        };
5084        render_marked_full_frame(
5085            state,
5086            writer,
5087            screen,
5088            terminal_model,
5089            pass,
5090            layout,
5091            FullRenderMarkInput {
5092                reason,
5093                changed_line: None,
5094                previous_source: None,
5095            },
5096        )?;
5097        return Ok(());
5098    }
5099
5100    render_incremental_or_scroll_frame(state, writer, screen, terminal_model, pass, layout)
5101}
5102
5103fn render_incremental_or_scroll_frame(
5104    state: &Arc<Mutex<SharedState>>,
5105    writer: &mut BufWriter<Box<dyn Write + Send>>,
5106    screen: &mut Screen,
5107    terminal_model: &mut TerminalModel,
5108    pass: &RedrawPass,
5109    layout: &LayoutAll,
5110) -> io::Result<()> {
5111    screen.set_width(pass.width);
5112
5113    let hidden_prefix_changed = terminal_model.hidden_prefix_changed(layout);
5114    let incremental_plan = terminal_model.plan_view(layout, pass.height);
5115    let incremental_visible_start = incremental_plan.viewport_start;
5116
5117    if incremental_visible_start < terminal_model.viewport_start {
5118        render_viewport_moved_up_frame(state, writer, screen, terminal_model, pass, layout)
5119    } else if hidden_prefix_changed {
5120        render_hidden_prefix_changed_frame(state, writer, screen, terminal_model, pass, layout)
5121    } else if terminal_model.viewport_start < incremental_visible_start {
5122        render_scrolling_frame(
5123            writer,
5124            screen,
5125            terminal_model,
5126            pass,
5127            layout,
5128            incremental_plan,
5129        )
5130    } else {
5131        render_diff_frame(
5132            writer,
5133            screen,
5134            terminal_model,
5135            pass,
5136            layout,
5137            incremental_plan,
5138        )
5139    }
5140}
5141
5142fn render_marked_full_frame(
5143    state: &Arc<Mutex<SharedState>>,
5144    writer: &mut BufWriter<Box<dyn Write + Send>>,
5145    screen: &mut Screen,
5146    terminal_model: &mut TerminalModel,
5147    pass: &RedrawPass,
5148    layout: &LayoutAll,
5149    mark_input: FullRenderMarkInput,
5150) -> io::Result<()> {
5151    let plan = TerminalModel::full_redraw_plan(layout, pass.height);
5152    let mark = FullRenderMark {
5153        reason: mark_input.reason,
5154        prev_visible_start: terminal_model.viewport_start,
5155        visible_start: plan.viewport_start,
5156        height: pass.height,
5157        changed_line: mark_input.changed_line,
5158        previous_source: mark_input.previous_source,
5159    };
5160    mark_full_render(state, layout, mark);
5161    full_render(
5162        writer,
5163        screen,
5164        layout,
5165        &plan,
5166        pass.width,
5167        pass.height,
5168        pass.redraw_history_size,
5169    )?;
5170    reset_model_after_rendered_full_frame(terminal_model, pass, layout, plan);
5171    Ok(())
5172}
5173
5174fn render_viewport_moved_up_frame(
5175    state: &Arc<Mutex<SharedState>>,
5176    writer: &mut BufWriter<Box<dyn Write + Send>>,
5177    screen: &mut Screen,
5178    terminal_model: &mut TerminalModel,
5179    pass: &RedrawPass,
5180    layout: &LayoutAll,
5181) -> io::Result<()> {
5182    // The desired viewport moved upward to keep the input cursor visible. Rows
5183    // that should re-enter the screen may currently exist only in terminal
5184    // scrollback, which cannot be pulled back incrementally. Since we are
5185    // repainting from scratch, discard any rubber and paint the new viewport
5186    // directly.
5187    render_marked_full_frame(
5188        state,
5189        writer,
5190        screen,
5191        terminal_model,
5192        pass,
5193        layout,
5194        FullRenderMarkInput {
5195            reason: "viewport_moved_up",
5196            changed_line: None,
5197            previous_source: None,
5198        },
5199    )
5200}
5201
5202fn render_hidden_prefix_changed_frame(
5203    state: &Arc<Mutex<SharedState>>,
5204    writer: &mut BufWriter<Box<dyn Write + Send>>,
5205    screen: &mut Screen,
5206    terminal_model: &mut TerminalModel,
5207    pass: &RedrawPass,
5208    layout: &LayoutAll,
5209) -> io::Result<()> {
5210    // The terminal scrollback may contain rows whose logical content changed.
5211    // Clear it instead of trying to patch it incrementally. Since we are
5212    // repainting from scratch, discard any rubber and paint the new viewport
5213    // directly.
5214    let changed_line = terminal_model.changed_hidden_line(layout);
5215    let previous_source = changed_line
5216        .and_then(|idx| terminal_model.known_sources.get(idx))
5217        .cloned();
5218    render_marked_full_frame(
5219        state,
5220        writer,
5221        screen,
5222        terminal_model,
5223        pass,
5224        layout,
5225        FullRenderMarkInput {
5226            reason: "hidden_prefix_changed",
5227            changed_line,
5228            previous_source,
5229        },
5230    )
5231}
5232
5233fn render_scrolling_frame(
5234    writer: &mut BufWriter<Box<dyn Write + Send>>,
5235    screen: &mut Screen,
5236    terminal_model: &mut TerminalModel,
5237    pass: &RedrawPass,
5238    layout: &LayoutAll,
5239    plan: ViewPlan,
5240) -> io::Result<()> {
5241    // Content pushed log rows off the top. Use the scrolling renderer
5242    // (Pi-style). Rubber is part of the virtual tail, so it shrinks before any
5243    // extra log row enters scrollback.
5244    screen.render_scrolling(
5245        writer,
5246        &plan.render_lines,
5247        terminal_model.viewport_start,
5248        pass.height,
5249        (plan.cursor_row, layout.cursor_col),
5250    )?;
5251    terminal_model.reset_to_layout(layout, plan.viewport_start, plan.rubber_height);
5252    Ok(())
5253}
5254
5255fn render_diff_frame(
5256    writer: &mut BufWriter<Box<dyn Write + Send>>,
5257    screen: &mut Screen,
5258    terminal_model: &mut TerminalModel,
5259    pass: &RedrawPass,
5260    layout: &LayoutAll,
5261    plan: ViewPlan,
5262) -> io::Result<()> {
5263    // No new scrollback rows — normal differential update. This includes
5264    // visible shrinkage: rubber grows instead of moving the viewport
5265    // upward.
5266    let visible = plan.visible_lines(pass.height);
5267    let cursor_in_visible = plan.cursor_in_visible(pass.height);
5268    screen.update(writer, visible, (cursor_in_visible, layout.cursor_col))?;
5269    terminal_model.reset_to_layout(layout, plan.viewport_start, plan.rubber_height);
5270    Ok(())
5271}
5272
5273fn reset_model_after_rendered_full_frame(
5274    terminal_model: &mut TerminalModel,
5275    pass: &RedrawPass,
5276    layout: &LayoutAll,
5277    plan: ViewPlan,
5278) {
5279    let viewport_start =
5280        full_render_effective_viewport_start(layout, &plan, pass.height, pass.redraw_history_size);
5281    terminal_model.reset_to_layout(layout, viewport_start, plan.rubber_height);
5282}
5283
5284fn complete_redraw_sync(
5285    state: &Arc<Mutex<SharedState>>,
5286    sync_gen: RedrawSyncGeneration,
5287    sync_condvar: &std::sync::Condvar,
5288) {
5289    // Advance sync_completed to the generation we captured before rendering.
5290    // Using max() is defensive — renders are sequential so sync_gen is
5291    // monotonically increasing, but max() makes the invariant explicit.
5292    {
5293        let mut st = state.lock().expect("term state mutex poisoned");
5294        st.terminal.sync_completed = st.terminal.sync_completed.max(sync_gen);
5295    }
5296    sync_condvar.notify_all();
5297}
5298
5299/// Records the first output error, releases terminal waiters, and wakes the
5300/// attachment's input owner.
5301fn fail_terminal_output(
5302    state: &Arc<Mutex<SharedState>>,
5303    input_tx: &path_std_sync::mpsc::Sender<InputMessage>,
5304    sync_condvar: &std::sync::Condvar,
5305    error: io::Error,
5306) {
5307    let mut st = state.lock().expect("term state mutex poisoned");
5308    if st.terminal.output_failure.is_none() {
5309        tracing::error!(
5310            target: "tau_cli_term_raw::redraw",
5311            error = %error,
5312            "terminal output failed; stopping attachment renderer"
5313        );
5314        st.terminal.output_failure = Some(OutputFailure::new(error));
5315    }
5316    st.terminal.input_shutdown = true;
5317    st.terminal.sync_completed = st.terminal.sync_requested;
5318    drop(st);
5319    sync_condvar.notify_all();
5320    let _ = input_tx.send(InputMessage::Shutdown);
5321}
5322
5323fn changed_line_in_range(
5324    prev_all_lines: &[CellRow],
5325    all_lines: &[CellRow],
5326    range: std::ops::Range<usize>,
5327) -> Option<usize> {
5328    range
5329        .into_iter()
5330        .find(|idx| prev_all_lines.get(*idx) != all_lines.get(*idx))
5331}
5332
5333fn mark_full_render(state: &Arc<Mutex<SharedState>>, layout: &LayoutAll, mark: FullRenderMark) {
5334    let full_render_count = {
5335        let mut st = state.lock().expect("term state mutex poisoned");
5336        st.terminal.full_render_count += 1;
5337        st.terminal.full_render_count
5338    };
5339    let current_source = mark
5340        .changed_line
5341        .and_then(|idx| layout.line_sources.get(idx))
5342        .cloned();
5343    let previous = describe_line_source(mark.previous_source.as_ref());
5344    let current = describe_line_source(current_source.as_ref());
5345    tracing::info!(
5346        target: "tau_cli_term_raw::redraw",
5347        full_render_count,
5348        reason = mark.reason,
5349        prev_visible_start = mark.prev_visible_start,
5350        visible_start = mark.visible_start,
5351        height = mark.height,
5352        total_lines = layout.all_lines.len(),
5353        changed_line = mark.changed_line,
5354        previous_source = ?mark.previous_source,
5355        current_source = ?current_source,
5356        "full redraw caused by {}: {previous} -> {current}", mark.reason
5357    );
5358    tracing::trace!(
5359        target: "tau_cli_term_raw::redraw",
5360        full_render_count,
5361        reason = mark.reason,
5362        prev_visible_start = mark.prev_visible_start,
5363        visible_start = mark.visible_start,
5364        height = mark.height,
5365        total_lines = layout.all_lines.len(),
5366        changed_line = mark.changed_line,
5367        previous_source = ?mark.previous_source,
5368        current_source = ?current_source,
5369        "full render"
5370    );
5371}
5372
5373fn describe_line_source(source: Option<&LineSource>) -> String {
5374    match source {
5375        Some(LineSource::Block {
5376            id,
5377            debug_id,
5378            wrapped_row,
5379        }) => format!("block {:?} `{}` row {}", id, debug_id, wrapped_row),
5380        Some(LineSource::Input { wrapped_row }) => format!("input row {wrapped_row}"),
5381        Some(LineSource::InputScrollIndicator) => "input scroll indicator".to_owned(),
5382        None => "<missing>".to_owned(),
5383    }
5384}
5385
5386fn viewport_start_with_cursor(
5387    viewport_start: usize,
5388    cursor_row: usize,
5389    total_rows: usize,
5390    height: usize,
5391) -> usize {
5392    let height = height.max(1);
5393    let max_start = total_rows.saturating_sub(height);
5394    let mut start = viewport_start.min(max_start);
5395
5396    if cursor_row < start {
5397        start = cursor_row;
5398    } else if start + height <= cursor_row {
5399        start = (cursor_row + 1).saturating_sub(height);
5400    }
5401
5402    start.min(max_start)
5403}
5404
5405fn hidden_lines_changed(
5406    prev_all_lines: &[CellRow],
5407    all_lines: &[CellRow],
5408    prev_visible_start: usize,
5409) -> bool {
5410    (0..prev_visible_start).any(|idx| prev_all_lines.get(idx) != all_lines.get(idx))
5411}
5412
5413fn full_render_replay_start(
5414    layout: &LayoutAll,
5415    plan: &ViewPlan,
5416    redraw_history_size: usize,
5417) -> usize {
5418    let total = plan.render_lines.len();
5419    let log_end = layout.log_end.min(total);
5420    log_end.saturating_sub(redraw_history_size)
5421}
5422
5423fn full_render_effective_viewport_start(
5424    layout: &LayoutAll,
5425    plan: &ViewPlan,
5426    height: usize,
5427    redraw_history_size: usize,
5428) -> usize {
5429    let replay_start = full_render_replay_start(layout, plan, redraw_history_size);
5430    let replay_len = plan.render_lines.len().saturating_sub(replay_start);
5431    if height < replay_len {
5432        plan.render_lines.len().saturating_sub(height)
5433    } else {
5434        replay_start
5435    }
5436}
5437
5438/// Full re-render: clear screen + scrollback, output the configured suffix of
5439/// rendered history/log rows plus the fixed tail, and position the cursor. Used
5440/// on resize and after invalidation. Callers should pass a no-rubber plan so a
5441/// full repaint drops rubber instead of preserving temporary blank space.
5442/// Overflow rebuilds recent terminal scrollback naturally. After rendering,
5443/// Screen tracks the visible viewport for subsequent differential updates.
5444fn full_render(
5445    stdout: &mut impl Write,
5446    screen: &mut Screen,
5447    layout: &LayoutAll,
5448    plan: &ViewPlan,
5449    width: usize,
5450    height: usize,
5451    redraw_history_size: usize,
5452) -> io::Result<()> {
5453    screen.set_width(width);
5454
5455    let all_lines = &plan.render_lines;
5456    let replay_start = full_render_replay_start(layout, plan, redraw_history_size);
5457    let replay_lines = &all_lines[replay_start..];
5458    let replay_total = replay_lines.len();
5459    let effective_viewport_start =
5460        full_render_effective_viewport_start(layout, plan, height, redraw_history_size);
5461
5462    with_synchronized_update(stdout, |stdout| {
5463        // Clear screen, home cursor, and clear scrollback. The scrollback is
5464        // rebuilt by replaying the capped no-rubber suffix below.
5465        // Disable autowrap while replaying so exact-width rows don't
5466        // create phantom blank rows before the explicit CRLF between
5467        // logical rows.
5468        stdout.queue(Print("\x1b[2J\x1b[H\x1b[3J\x1b[?7l"))?;
5469
5470        // Output the capped logical suffix starting at the top. Overflow
5471        // scrolls into scrollback naturally. Short content stays at the
5472        // top, so the prompt sits directly under content instead of
5473        // being bottom-pinned by rubber.
5474        for (i, line) in replay_lines.iter().enumerate() {
5475            if 0 < i {
5476                stdout.queue(Print("\r\n"))?;
5477            }
5478            emit_styled_cells(stdout, line)?;
5479        }
5480
5481        stdout.queue(Print("\x1b[?7h"))?;
5482
5483        // After outputting, the cursor is at the last content line. When
5484        // content overflowed, that line is at the terminal bottom;
5485        // otherwise it is at its natural row below the transcript.
5486        let current_screen_row = if height <= replay_total {
5487            height - 1
5488        } else {
5489            replay_total.saturating_sub(1)
5490        };
5491
5492        let cursor_screen_row = plan.cursor_row.saturating_sub(effective_viewport_start);
5493
5494        let up = current_screen_row.saturating_sub(cursor_screen_row);
5495        if 0 < up {
5496            stdout.queue(MoveUp(up as u16))?;
5497        }
5498        stdout.queue(MoveToColumn(layout.cursor_col as u16))?;
5499        Ok(())
5500    })?;
5501
5502    // Track what's visible on the terminal so the next
5503    // screen.update() can diff correctly.
5504    let visible_end = (effective_viewport_start + height).min(plan.render_lines.len());
5505    let visible_lines = plan.render_lines[effective_viewport_start..visible_end].to_vec();
5506    let cursor_in_visible = plan.cursor_row.saturating_sub(effective_viewport_start);
5507    screen.reset_to(visible_lines, cursor_in_visible, layout.cursor_col);
5508
5509    Ok(())
5510}
5511
5512/// Queues one balanced synchronized-update transaction without flushing.
5513///
5514/// The closing marker is attempted even when `body` fails. If both operations
5515/// fail, the body error takes precedence because it identifies the first loss
5516/// of frame output.
5517fn with_synchronized_update<W, F>(writer: &mut W, body: F) -> io::Result<()>
5518where
5519    W: Write,
5520    F: FnOnce(&mut W) -> io::Result<()>,
5521{
5522    writer.queue(terminal::BeginSynchronizedUpdate)?;
5523    let body_result = body(writer);
5524    let end_result = writer.queue(terminal::EndSynchronizedUpdate).map(|_| ());
5525    body_result.and(end_result)
5526}
5527
5528// --- Helpers ---
5529
5530fn move_cursor_vertical(st: &SharedState, delta: isize, target_col: usize) -> Option<usize> {
5531    let width = st.terminal.width.max(1);
5532    let left_cols = st.editor.left_prompt.char_count();
5533    let (current_row, _) =
5534        buffer_position_for_byte(&st.editor.buffer, st.editor.cursor, width, left_cols);
5535
5536    let target_row = current_row as isize + delta;
5537    if target_row < 0 {
5538        return None;
5539    }
5540    let target_row = target_row as usize;
5541
5542    let (max_row, _) = buffer_end_position(&st.editor.buffer, width, left_cols);
5543    if max_row < target_row {
5544        return None;
5545    }
5546
5547    Some(byte_offset_for_buffer_position(
5548        &st.editor.buffer,
5549        target_row,
5550        target_col,
5551        width,
5552        left_cols,
5553    ))
5554}
5555
5556fn term_size() -> (usize, usize) {
5557    raw_term_size()
5558        .map(|(w, h)| (usize::from(w).max(1), usize::from(h).max(1)))
5559        .unwrap_or((80, 24))
5560}
5561
5562fn raw_term_size() -> io::Result<(u16, u16)> {
5563    terminal::size()
5564}
5565
5566fn resample_resize_dimension(reported: u16, actual: u16) -> u16 {
5567    if 0 < reported { reported } else { actual }
5568}
5569
5570fn effective_resize_dimension(reported: u16, fallback: usize) -> usize {
5571    let reported = usize::from(reported);
5572    if 0 < reported {
5573        reported
5574    } else {
5575        fallback.max(1)
5576    }
5577}
5578
5579fn size_event_dimension(value: usize) -> u16 {
5580    u16::try_from(value).unwrap_or(u16::MAX)
5581}
5582
5583fn normalize_paste_text(text: String) -> String {
5584    if !text.contains('\r') {
5585        return text;
5586    }
5587
5588    let mut normalized = String::with_capacity(text.len());
5589    let mut chars = text.chars().peekable();
5590    while let Some(ch) = chars.next() {
5591        if ch == '\r' {
5592            if chars.peek() == Some(&'\n') {
5593                chars.next();
5594            }
5595            normalized.push('\n');
5596        } else {
5597            normalized.push(ch);
5598        }
5599    }
5600    normalized
5601}
5602
5603fn is_prompt_line_break(grapheme: &str) -> bool {
5604    matches!(grapheme, "\n" | "\r\n" | "\r")
5605}
5606
5607fn initial_buffer_position(initial_cols: usize, width: usize) -> (usize, usize) {
5608    let width = width.max(1);
5609    (initial_cols / width, initial_cols % width)
5610}
5611
5612fn buffer_position_for_byte(
5613    s: &str,
5614    byte_pos: usize,
5615    width: usize,
5616    initial_cols: usize,
5617) -> (usize, usize) {
5618    let width = width.max(1);
5619    let mut pos = initial_buffer_position(initial_cols, width);
5620    let mut pending_exact_wrap = false;
5621
5622    for (byte, grapheme) in UnicodeSegmentation::grapheme_indices(s, true) {
5623        if byte_pos <= byte || byte_pos < byte + grapheme.len() {
5624            break;
5625        }
5626        advance_prompt_cursor_position(
5627            &mut pos.0,
5628            &mut pos.1,
5629            &mut pending_exact_wrap,
5630            grapheme,
5631            width,
5632        );
5633    }
5634
5635    pos
5636}
5637
5638fn advance_prompt_cursor_position(
5639    row: &mut usize,
5640    col: &mut usize,
5641    pending_exact_wrap: &mut bool,
5642    grapheme: &str,
5643    width: usize,
5644) {
5645    let width = width.max(1);
5646    if is_prompt_line_break(grapheme) {
5647        if *pending_exact_wrap {
5648            // A printable character exactly filled the previous visual row, so
5649            // the cursor is already at column 0 of this row. An explicit
5650            // newline at that byte position should consume that
5651            // pending wrap, not add a second blank row.
5652            *pending_exact_wrap = false;
5653        } else {
5654            *row += 1;
5655            *col = 0;
5656        }
5657        return;
5658    }
5659
5660    *pending_exact_wrap = false;
5661    let grapheme_width = display_width(grapheme);
5662    if 0 < *col && width < *col + grapheme_width {
5663        *row += 1;
5664        *col = 0;
5665    }
5666    *col += grapheme_width;
5667    if width <= *col {
5668        *row += *col / width;
5669        *col %= width;
5670        *pending_exact_wrap = grapheme_width != 0 && *col == 0;
5671    }
5672}
5673
5674fn buffer_end_position(s: &str, width: usize, initial_cols: usize) -> (usize, usize) {
5675    buffer_position_for_byte(s, s.len(), width, initial_cols)
5676}
5677
5678fn byte_offset_for_buffer_position(
5679    s: &str,
5680    target_row: usize,
5681    target_col: usize,
5682    width: usize,
5683    initial_cols: usize,
5684) -> usize {
5685    let mut row_col = initial_buffer_position(initial_cols, width);
5686    let mut pending_exact_wrap = false;
5687
5688    for (byte, grapheme) in UnicodeSegmentation::grapheme_indices(s, true) {
5689        let (row, col) = row_col;
5690        if target_row < row || (target_row == row && target_col <= col) {
5691            return byte;
5692        }
5693        if is_prompt_line_break(grapheme) && !pending_exact_wrap && target_row == row {
5694            return byte;
5695        }
5696
5697        let mut next = row_col;
5698        let mut next_pending_exact_wrap = pending_exact_wrap;
5699        advance_prompt_cursor_position(
5700            &mut next.0,
5701            &mut next.1,
5702            &mut next_pending_exact_wrap,
5703            grapheme,
5704            width,
5705        );
5706        if !is_prompt_line_break(grapheme)
5707            && (target_row < next.0 || (target_row == next.0 && target_col <= next.1))
5708        {
5709            return byte + grapheme.len();
5710        }
5711        row_col = next;
5712        pending_exact_wrap = next_pending_exact_wrap;
5713    }
5714
5715    s.len()
5716}
5717
5718fn clamp_cursor_to_grapheme_boundary(s: &str, cursor: usize) -> usize {
5719    let cursor = cursor.min(s.len());
5720    if cursor == s.len() {
5721        return cursor;
5722    }
5723
5724    let mut boundary = 0;
5725    for (idx, _) in UnicodeSegmentation::grapheme_indices(s, true) {
5726        if cursor < idx {
5727            break;
5728        }
5729        boundary = idx;
5730    }
5731    boundary
5732}
5733
5734fn prev_char_boundary(s: &str, pos: usize) -> usize {
5735    previous_grapheme_boundary(s, pos)
5736}
5737
5738fn next_char_boundary(s: &str, pos: usize) -> usize {
5739    next_grapheme_boundary(s, pos)
5740}
5741
5742#[cfg(test)]
5743mod tests;