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