Skip to main content

tau_cli_term/
lib.rs

1//! Higher-level terminal prompt with command completion.
2//!
3//! This crate is now a thin shell around [`tau_cli_term_raw`]: the
4//! raw layer owns the input state machine (history navigation,
5//! completion menu lifecycle, key dispatch). This crate plugs in the
6//! *content* (which candidates exist for a given buffer) and the
7//! *presentation* (how the menu is rendered as a styled block under
8//! the prompt). It also handles `$EDITOR` integration, which doesn't
9//! belong in the raw layer.
10//! The ownership split and subprocess/editor contracts are summarized in
11//! `ARCH-tau-cli-term`.
12
13use std::{ffi as path_std_ffi, process as path_std_process, time as path_std_time};
14
15mod bounded_command;
16pub mod completion;
17pub mod resolve;
18#[cfg(test)]
19mod tests;
20
21use std::io;
22use std::sync::{Arc, Mutex};
23
24pub use bounded_command::ForegroundRestorationDiagnostic;
25use bounded_command::{
26    BoundedCommandError, ProcessOwnership, run_with_bounded_stdout,
27    run_with_bounded_stdout_after_spawn, run_with_inherited_stdio,
28};
29pub use completion::{
30    ArgCompleter, CommandCompletion, CommandName, CompletionData, CompletionItem, CompletionRule,
31    CompletionRules,
32};
33#[cfg(test)]
34pub(crate) use tau_cli_term_raw::RawEvent as TestRawEvent;
35pub use tau_cli_term_raw::{
36    Align, BlockId, Cell, Color, CursorShape, OpaquePresentationFact, OutputSnapshot,
37    PresentationInvalidation, PresentationObservationKey, PriorityLine, PriorityLineAlignment,
38    PriorityLinePriority, PriorityLineTruncation, RedrawSuppressionGuard, RendererDeliveryId, Span,
39    Style, StyledBlock, StyledText, TermHandle, TerminalOptions, TwoLineElision, is_output_failure,
40    sanitize_hyperlink_target,
41};
42use tau_cli_term_raw::{Candidate, Event as RawEvent};
43use tau_term_screen::{display_width, truncate_to_width};
44use tau_themes::Theme;
45
46const PROMPT_TRAILER_MARKER: &str =
47    "<!-- TAU trailer: everything after this line will be ignored -->";
48// Keep user-facing command limit docs in FEATURES.md and
49// docs/cli-keybindings.md in sync with these values.
50const PROMPT_COMMAND_OUTPUT_LIMIT_BYTES: usize = 1024 * 1024;
51const COMPLETION_COMMAND_OUTPUT_LIMIT_BYTES: usize = 256 * 1024;
52const COMPLETION_COMMAND_TIMEOUT: std::time::Duration = path_std_time::Duration::from_secs(10);
53const PROMPT_COMMAND_TIMEOUT: std::time::Duration = path_std_time::Duration::from_secs(60 * 60);
54const AGENT_PICKER_OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
55const AGENT_PICKER_TIMEOUT: std::time::Duration = path_std_time::Duration::from_secs(5 * 60);
56// Keep the first eleven fields synchronized with docs/list-agents.md#output.
57// The final four source fields are picker-only cost/work status/activity; the
58// presentation field is removed after fzf returns the complete input row.
59const AGENT_PICKER_FZF_ARGS: &[&str] = &[
60    "--height=100%",
61    "--delimiter=\t",
62    "--with-nth=16",
63    "--no-multi",
64    "--no-hscroll",
65    "--prompt=agent> ",
66];
67const AGENT_PICKER_SOURCE_FIELDS: usize = 15;
68
69const AGENT_PICKER_COLUMNS: [AgentPickerColumn; 4] = [
70    AgentPickerColumn {
71        source_field: usize::MAX,
72        minimum_width: 8,
73        preferred_width: 24,
74        max_width: 40,
75    },
76    AgentPickerColumn {
77        source_field: 10,
78        minimum_width: 9,
79        preferred_width: 9,
80        max_width: 9,
81    },
82    AgentPickerColumn {
83        source_field: 11,
84        minimum_width: 9,
85        preferred_width: 9,
86        max_width: 9,
87    },
88    AgentPickerColumn {
89        source_field: 13,
90        minimum_width: 12,
91        preferred_width: 24,
92        max_width: 40,
93    },
94];
95const AGENT_PICKER_COLUMN_GAP: &str = "  ";
96// fzf reserves screen columns for its pointer, marker, and gutter.
97const AGENT_PICKER_FZF_DECORATION_WIDTH: usize = 4;
98const PROMPT_HISTORY_SEARCH_MAX_ROWS: usize = 200;
99const PROMPT_HISTORY_SUMMARY_MAX_CHARS: usize = 240;
100const PROMPT_HISTORY_PREVIEW_MAX_BYTES: usize = 64 * 1024;
101const PROMPT_HISTORY_PREVIEW_TOTAL_BYTES: usize = 1024 * 1024;
102/// Maximum number of nonempty search entries retained for one attachment.
103const PROMPT_HISTORY_MAX_ENTRIES: usize = 1000;
104/// Maximum primary UTF-8 text retained for one attachment's search entries.
105const PROMPT_HISTORY_MAX_BYTES: usize = 16 * 1024 * 1024;
106/// Independent HighTerm search-history retention limits for one attachment.
107#[derive(Clone, Copy)]
108struct PromptHistoryLimits {
109    /// Maximum retained nonempty search entries.
110    max_entries: usize,
111    /// Maximum retained primary UTF-8 bytes.
112    max_bytes: usize,
113}
114const COMPLETION_MENU_BLOCK_ID: BlockId = BlockId(u64::MAX);
115
116/// One width-aware picker presentation column.
117struct AgentPickerColumn {
118    /// Zero-based index in the source TSV row.
119    source_field: usize,
120    /// Smallest useful width before lower-priority columns should be omitted.
121    minimum_width: usize,
122    /// Width targeted before distributing spare space toward natural width.
123    preferred_width: usize,
124    /// Maximum display width even when the terminal has spare space.
125    max_width: usize,
126}
127
128/// Re-acquires raw terminal state on every external-command exit path.
129struct ExternalResumeGuard<F: FnMut() -> io::Result<()>> {
130    /// Resume operation for the terminal whose external pause remains armed.
131    resume: F,
132    /// Cleared only after an explicit successful-or-reported resume attempt.
133    armed: bool,
134}
135
136/// Injectable terminal lifecycle and child-readiness operations for one picker.
137struct AgentPickerHooks<P, R, A> {
138    /// Releases terminal ownership before spawning the picker.
139    pause: P,
140    /// Restores terminal ownership after the picker finishes.
141    resume: R,
142    /// Observes child readiness before the command deadline begins.
143    after_spawn: A,
144}
145
146impl<F: FnMut() -> io::Result<()>> ExternalResumeGuard<F> {
147    fn new(resume: F) -> Self {
148        Self {
149            resume,
150            armed: true,
151        }
152    }
153
154    fn finish(mut self) -> io::Result<()> {
155        self.armed = false;
156        (self.resume)()
157    }
158
159    /// Prevents terminal resume when foreground ownership is unconfirmed.
160    fn disarm(mut self) {
161        self.armed = false;
162    }
163}
164
165impl<F: FnMut() -> io::Result<()>> Drop for ExternalResumeGuard<F> {
166    fn drop(&mut self) {
167        if self.armed
168            && let Err(error) = (self.resume)()
169        {
170            tracing::warn!(
171                target: "tau_cli::input",
172                %error,
173                "failed to resume terminal after external command"
174            );
175        }
176    }
177}
178
179/// Applies the fail-stop cut before an armed terminal-resume guard is dropped.
180fn preserve_pause_on_unconfirmed_foreground<T, F: FnMut() -> io::Result<()>>(
181    guard: ExternalResumeGuard<F>,
182    result: Result<T, BoundedCommandError>,
183) -> Result<T, BoundedCommandError> {
184    if result
185        .as_ref()
186        .is_err_and(BoundedCommandError::is_foreground_ownership_unconfirmed)
187    {
188        guard.disarm();
189    }
190    result
191}
192
193/// High-level events surfaced to the caller.
194pub enum Event {
195    /// Upload one normalized paste while its original draft stays frozen.
196    PasteUpload {
197        /// Exact local attempt identity.
198        id: u64,
199        /// Normalized source kept outside editable prompt and histories.
200        text: std::sync::Arc<str>,
201    },
202    /// Cancel one paste upload without canceling an agent prompt.
203    PasteCancelled {
204        /// Attempt canceled without changing the original draft.
205        id: u64,
206    },
207    /// The user submitted a line (pressed Enter by default, Ctrl-Enter,
208    /// or ran `submit-prompt` with no completion preview).
209    Line(String),
210    /// The user signalled EOF (Ctrl-D on empty line).
211    Eof,
212    /// The user requested prompt cancellation with a second consecutive Ctrl-C.
213    CancelPrompt,
214    /// The terminal was resized.
215    Resize { width: u16, height: u16 },
216    /// The terminal reported focus gained or lost.
217    FocusChanged { focused: bool },
218    /// The input buffer changed (or the completion menu cycled,
219    /// opened, or closed). Caller should redraw any prompt-derived
220    /// UI.
221    BufferChanged,
222    /// Shift+Tab pressed outside an open completion menu.
223    BackTab,
224    /// Escape pressed outside an open completion menu.
225    Escape,
226    /// A binding requested an application-defined action without touching the
227    /// prompt draft.
228    Action(String),
229}
230
231/// Failure while an interactive external program temporarily owns the terminal.
232#[derive(Debug)]
233pub enum ExternalProgramError {
234    /// Ordinary command failure after terminal ownership was restored.
235    Command(String),
236    /// Fatal failure because Tau could not confirm foreground ownership.
237    ForegroundOwnershipUnconfirmed {
238        /// Complete user-facing failure message retained for top-level
239        /// reporting.
240        message: String,
241        /// Bounded private diagnostic for the failed restoration syscall.
242        diagnostic: ForegroundRestorationDiagnostic,
243    },
244}
245
246impl ExternalProgramError {
247    /// Returns whether the interactive attachment must exit without resuming.
248    #[must_use]
249    pub fn is_foreground_ownership_unconfirmed(&self) -> bool {
250        matches!(self, Self::ForegroundOwnershipUnconfirmed { .. })
251    }
252
253    /// Returns the bounded restoration diagnostic for an ownership fail-stop.
254    #[must_use]
255    pub fn foreground_restoration_diagnostic(&self) -> Option<ForegroundRestorationDiagnostic> {
256        match self {
257            Self::ForegroundOwnershipUnconfirmed { diagnostic, .. } => Some(*diagnostic),
258            Self::Command(_) => None,
259        }
260    }
261}
262
263impl std::fmt::Display for ExternalProgramError {
264    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            Self::Command(error) => formatter.write_str(error),
267            Self::ForegroundOwnershipUnconfirmed { message, .. } => formatter.write_str(message),
268        }
269    }
270}
271
272impl std::error::Error for ExternalProgramError {}
273
274impl From<BoundedCommandError> for ExternalProgramError {
275    fn from(error: BoundedCommandError) -> Self {
276        match error.foreground_restoration_diagnostic() {
277            Some(diagnostic) => Self::ForegroundOwnershipUnconfirmed {
278                message: error.to_string(),
279                diagnostic,
280            },
281            None => Self::Command(error.to_string()),
282        }
283    }
284}
285
286impl From<String> for ExternalProgramError {
287    fn from(error: String) -> Self {
288        Self::Command(error)
289    }
290}
291
292/// Returns whether an input I/O error requires attachment-only terminal
293/// fail-stop.
294#[must_use]
295pub fn is_foreground_ownership_unconfirmed(error: &io::Error) -> bool {
296    foreground_restoration_diagnostic(error).is_some()
297}
298
299/// Returns the bounded restoration diagnostic from an input I/O fail-stop.
300#[must_use]
301pub fn foreground_restoration_diagnostic(
302    error: &io::Error,
303) -> Option<ForegroundRestorationDiagnostic> {
304    error
305        .get_ref()
306        .and_then(|source| source.downcast_ref::<BoundedCommandError>())
307        .and_then(BoundedCommandError::foreground_restoration_diagnostic)
308}
309
310/// Higher-level terminal prompt with completion support.
311pub struct HighTerm {
312    term: tau_cli_term_raw::Term,
313    handle: TermHandle,
314    theme: Theme,
315    editor_context: Arc<Mutex<EditorContext>>,
316    /// Editor command resolved once at startup: `$EDITOR`, else
317    /// `$VISUAL`, else the first of `hx`/`vim`/`vi`/`nano` found on
318    /// `$PATH`. Passed to shell actions as `$TAU_EDITOR`.
319    external_editor: Option<String>,
320    /// Block id for the completion menu, allocated lazily on first
321    /// open. Reused across opens; content swapped to empty when the
322    /// menu is hidden.
323    menu_block_id: Option<BlockId>,
324    /// Submitted prompt history used by prompt-history search. Seeded
325    /// from persistent history at startup and extended with submitted
326    /// prompts from this process.
327    prompt_history: Vec<String>,
328    /// Whether the most recently submitted prompt survived search-history
329    /// retention and can be replaced by its final redacted form.
330    last_submitted_prompt_retained: bool,
331    /// Optional small limits used by focused test-only history state machines.
332    #[cfg(test)]
333    prompt_history_limit_override: Option<PromptHistoryLimits>,
334    completion_command_rules: completion::CompletionCommandRules,
335    last_command_completion_token: Option<String>,
336}
337
338impl HighTerm {
339    /// Runs an in-process interactive callback while it exclusively owns the
340    /// terminal.
341    ///
342    /// Tau pauses redraws and releases raw terminal features before invoking
343    /// `callback`, then restores terminal state and requests a fresh repaint.
344    /// A release failure prevents the callback from running. The resume guard
345    /// also restores terminal state if the callback unwinds.
346    pub fn run_with_external_terminal<T>(&self, callback: impl FnOnce() -> T) -> io::Result<T> {
347        run_with_external_terminal_hooks(
348            callback,
349            || self.term.pause_for_external(),
350            || self.term.resume_after_external(),
351        )
352    }
353
354    /// Creates a new terminal with the given prompt and commands.
355    ///
356    /// Returns the terminal, a thread-safe handle for rendering, and a
357    /// [`CompletionData`] handle for pushing dynamic argument completions
358    /// from background threads.
359    pub fn new(
360        left_prompt: impl Into<StyledText>,
361        commands: Vec<CommandCompletion>,
362        theme: Theme,
363        bindings: impl IntoIterator<Item = (String, String)>,
364        terminal_options: TerminalOptions,
365    ) -> io::Result<(Self, TermHandle, CompletionData)> {
366        Self::new_with_completion_rules(
367            left_prompt,
368            commands,
369            theme,
370            bindings,
371            std::iter::empty(),
372            CompletionRules::default(),
373            terminal_options,
374        )
375    }
376
377    /// Creates a new terminal and seeds prompt input history.
378    pub fn new_with_input_history(
379        left_prompt: impl Into<StyledText>,
380        commands: Vec<CommandCompletion>,
381        theme: Theme,
382        bindings: impl IntoIterator<Item = (String, String)>,
383        input_history: impl IntoIterator<Item = String>,
384        terminal_options: TerminalOptions,
385    ) -> io::Result<(Self, TermHandle, CompletionData)> {
386        Self::new_with_completion_rules(
387            left_prompt,
388            commands,
389            theme,
390            bindings,
391            input_history,
392            CompletionRules::default(),
393            terminal_options,
394        )
395    }
396
397    /// Creates a new terminal with explicit prompt completion rules.
398    pub fn new_with_completion_rules(
399        left_prompt: impl Into<StyledText>,
400        commands: Vec<CommandCompletion>,
401        theme: Theme,
402        bindings: impl IntoIterator<Item = (String, String)>,
403        input_history: impl IntoIterator<Item = String>,
404        completion_rules: CompletionRules,
405        terminal_options: TerminalOptions,
406    ) -> io::Result<(Self, TermHandle, CompletionData)> {
407        let data = CompletionData::new();
408        let (term, handle) = Self::new_with_completion_rules_and_data(
409            left_prompt,
410            commands,
411            theme,
412            bindings,
413            input_history,
414            completion_rules,
415            terminal_options,
416            data.clone(),
417        )?;
418        Ok((term, handle, data))
419    }
420
421    /// Creates a terminal with caller-owned mutable completion state.
422    ///
423    /// Callers may populate this state before the input loop starts, then
424    /// update it later from background owners through its shared handle.
425    #[allow(clippy::too_many_arguments)]
426    pub fn new_with_completion_rules_and_data(
427        left_prompt: impl Into<StyledText>,
428        commands: Vec<CommandCompletion>,
429        theme: Theme,
430        bindings: impl IntoIterator<Item = (String, String)>,
431        input_history: impl IntoIterator<Item = String>,
432        completion_rules: CompletionRules,
433        terminal_options: TerminalOptions,
434        data: CompletionData,
435    ) -> io::Result<(Self, TermHandle)> {
436        let input_history: Vec<String> = input_history.into_iter().collect();
437        let (mut term, handle) = tau_cli_term_raw::Term::new(left_prompt, terminal_options)?;
438        term.defer_submitted_input_history_limit();
439        term.seed_input_history(input_history.clone());
440        term.set_bindings(bindings);
441        let handle_clone = handle.clone();
442        let completion_command_rules = completion_rules.command_rules().clone();
443        term.set_completion_source(Some(make_completion_source(
444            commands,
445            data,
446            completion_rules.clone(),
447        )));
448        let external_editor = resolve_external_editor();
449        Ok((
450            Self {
451                term,
452                handle,
453                theme,
454                editor_context: Arc::new(Mutex::new(EditorContext::default())),
455                external_editor,
456                menu_block_id: None,
457                prompt_history: bounded_seeded_prompt_history(
458                    input_history
459                        .into_iter()
460                        .filter(|entry| !entry.is_empty())
461                        .collect(),
462                ),
463                last_submitted_prompt_retained: false,
464                #[cfg(test)]
465                prompt_history_limit_override: None,
466                completion_command_rules,
467                last_command_completion_token: None,
468            },
469            handle_clone,
470        ))
471    }
472
473    #[cfg(test)]
474    pub(crate) fn new_for_test(
475        term: tau_cli_term_raw::Term,
476        handle: TermHandle,
477        commands: Vec<CommandCompletion>,
478        theme: Theme,
479        bindings: impl IntoIterator<Item = (String, String)>,
480    ) -> (Self, CompletionData) {
481        Self::new_for_test_with_completion_rules(
482            term,
483            handle,
484            commands,
485            theme,
486            bindings,
487            CompletionRules::default(),
488        )
489    }
490
491    /// Creates a virtual terminal with explicit completion rules for focused
492    /// runtime tests.
493    #[cfg(test)]
494    pub(crate) fn new_for_test_with_completion_rules(
495        mut term: tau_cli_term_raw::Term,
496        handle: TermHandle,
497        commands: Vec<CommandCompletion>,
498        theme: Theme,
499        bindings: impl IntoIterator<Item = (String, String)>,
500        completion_rules: CompletionRules,
501    ) -> (Self, CompletionData) {
502        term.defer_submitted_input_history_limit();
503        let data = CompletionData::new();
504        let data_clone = data.clone();
505        let completion_command_rules = completion_rules.command_rules().clone();
506        term.set_completion_source(Some(make_completion_source(
507            commands,
508            data,
509            completion_rules.clone(),
510        )));
511        term.set_bindings(bindings);
512        (
513            Self {
514                term,
515                handle,
516                theme,
517                editor_context: Arc::new(Mutex::new(EditorContext::default())),
518                external_editor: None,
519                menu_block_id: None,
520                prompt_history: Vec::new(),
521                last_submitted_prompt_retained: false,
522                prompt_history_limit_override: None,
523                completion_command_rules,
524                last_command_completion_token: None,
525            },
526            data_clone,
527        )
528    }
529
530    /// Returns a reference to the [`TermHandle`].
531    pub fn handle(&self) -> &TermHandle {
532        &self.handle
533    }
534
535    /// Replaces the editor-context storage with a shared handle.
536    ///
537    /// Use this when another component (e.g. the event renderer) owns
538    /// the authoritative context and needs the prompt's external-editor
539    /// integration to read conversation context and write prompt-trailer
540    /// recovery state through the same `Arc`. The previously-owned
541    /// `EditorContext` is dropped.
542    /// `EditorContext` is dropped.
543    pub fn set_editor_context_handle(&mut self, editor_context: Arc<Mutex<EditorContext>>) {
544        self.editor_context = editor_context;
545    }
546
547    /// Opens the ordinary prompt editor with one explicitly selected response.
548    ///
549    /// The selected response temporarily replaces the renderer-published
550    /// response context. Existing editor recovery survives the invocation,
551    /// and any newly edited trailer text becomes the next recovery value.
552    pub fn edit_prompt_with_response(&mut self, response: String) -> io::Result<()> {
553        let mut context = self
554            .editor_context
555            .lock()
556            .expect("editor context mutex poisoned")
557            .clone();
558        context.current_response = None;
559        context.last_response = Some(response);
560        context.previous_prompt = None;
561        context.chat_markdown = None;
562        self.edit_prompt_with_context(context, None)
563    }
564
565    /// Opens the ordinary prompt editor with an exact Markdown chat export
566    /// below the established trailer marker.
567    ///
568    /// Terminal pause/resume, command failure, prompt replacement, and edited
569    /// trailer recovery use the same path as `shell-prompt-edit`.
570    pub fn edit_prompt_with_chat(&mut self, chat_markdown: String) -> io::Result<()> {
571        let recovery = self
572            .editor_context
573            .lock()
574            .expect("editor context mutex poisoned")
575            .edited_trailer_recovery
576            .clone();
577        self.edit_prompt_with_context(
578            EditorContext {
579                chat_markdown: Some(chat_markdown),
580                edited_trailer_recovery: recovery.clone(),
581                ..EditorContext::default()
582            },
583            recovery,
584        )
585    }
586
587    /// Runs one editor invocation with an isolated trailer projection.
588    fn edit_prompt_with_context(
589        &mut self,
590        context: EditorContext,
591        hidden_recovery: Option<String>,
592    ) -> io::Result<()> {
593        let temporary = Arc::new(Mutex::new(context));
594        let outcome = self.run_prompt_action_with_context(
595            PromptShellAction::Edit(PromptShellCommand {
596                command: "$TAU_EDITOR \"$TAU_PROMPT_PATH\"".to_owned(),
597                trim: false,
598            }),
599            temporary.clone(),
600        );
601        let edited_recovery = temporary
602            .lock()
603            .expect("editor context mutex poisoned")
604            .edited_trailer_recovery
605            .clone()
606            .or(hidden_recovery);
607        self.editor_context
608            .lock()
609            .expect("editor context mutex poisoned")
610            .edited_trailer_recovery = edited_recovery;
611        if !matches!(outcome, PromptActionOutcome::Fatal(_)) {
612            self.handle.redraw_sync();
613        }
614        match outcome {
615            PromptActionOutcome::Fatal(error) => Err(error),
616            PromptActionOutcome::BufferChanged
617            | PromptActionOutcome::Continue
618            | PromptActionOutcome::Return(_) => Ok(()),
619        }
620    }
621
622    /// Replaces the prompt UI theme for future local rendering.
623    pub fn set_theme(&mut self, theme: Theme) {
624        self.theme = theme;
625        self.sync_menu_block();
626        self.handle.redraw();
627    }
628
629    /// Triggers a redraw.
630    pub fn redraw(&self) {
631        self.handle.redraw();
632    }
633
634    /// Replaces the most recently submitted prompt in navigation and search
635    /// history.
636    ///
637    /// Higher layers use this when a submitted line has a safer presentation
638    /// form than the raw value that must remain available to the immediate
639    /// routing stack.
640    pub fn replace_last_submitted_prompt(&mut self, text: String) {
641        if self.last_submitted_prompt_retained {
642            if let Some(last) = self.prompt_history.last_mut() {
643                *last = text.clone();
644            }
645        } else if !text.is_empty() {
646            self.prompt_history.push(text.clone());
647        }
648        self.term.replace_last_submitted_input(text);
649    }
650
651    /// Runs the optional `fzf` agent-row picker while safely releasing raw
652    /// mode.
653    ///
654    /// `rows` must be headerless TSV with the stable agent id in field one.
655    /// Cancellation returns `Ok(None)`. The external `fzf` interaction uses a
656    /// five-minute command timeout, including for an empty input roster. Tau
657    /// checks foreground restoration after settling `fzf` and preserves the
658    /// primary outcome or failure alongside any restoration failure. If
659    /// ownership remains unconfirmed, the caller must exit the affected
660    /// attachment without resuming terminal input or output.
661    pub fn pick_agent_row_with_fzf(
662        &self,
663        rows: &str,
664    ) -> Result<Option<String>, ExternalProgramError> {
665        self.pick_agent_row_with_command(
666            path_std_ffi::OsStr::new("fzf"),
667            rows,
668            AGENT_PICKER_TIMEOUT,
669            ProcessOwnership::ForegroundProcessGroup,
670        )
671    }
672
673    /// Runs an injected picker with explicit execution bounds and ownership.
674    fn pick_agent_row_with_command(
675        &self,
676        program: &std::ffi::OsStr,
677        rows: &str,
678        timeout: std::time::Duration,
679        ownership: ProcessOwnership,
680    ) -> Result<Option<String>, ExternalProgramError> {
681        self.pick_agent_row_with_command_and_terminal(
682            program,
683            rows,
684            timeout,
685            ownership,
686            AgentPickerHooks {
687                pause: || self.term.pause_for_external(),
688                resume: || self.term.resume_after_external(),
689                after_spawn: || Ok(()),
690            },
691        )
692    }
693
694    /// Runs an injected picker between explicit terminal pause/resume
695    /// operations.
696    fn pick_agent_row_with_command_and_terminal(
697        &self,
698        program: &std::ffi::OsStr,
699        rows: &str,
700        timeout: std::time::Duration,
701        ownership: ProcessOwnership,
702        hooks: AgentPickerHooks<
703            impl FnOnce() -> io::Result<()>,
704            impl FnMut() -> io::Result<()>,
705            impl FnOnce() -> Result<(), String>,
706        >,
707    ) -> Result<Option<String>, ExternalProgramError> {
708        let picker_rows = format_agent_picker_rows(rows, self.handle.size().0)
709            .map_err(ExternalProgramError::Command)?;
710        (hooks.pause)().map_err(|error| format!("could not release terminal: {error}"))?;
711        let guard = ExternalResumeGuard::new(hooks.resume);
712        let selection = run_agent_fzf_command_with_ownership(
713            program,
714            &picker_rows,
715            timeout,
716            ownership,
717            hooks.after_spawn,
718        );
719        if selection
720            .as_ref()
721            .is_err_and(BoundedCommandError::is_foreground_ownership_unconfirmed)
722        {
723            guard.disarm();
724            return selection.map_err(ExternalProgramError::from);
725        }
726        guard.finish().map_err(|error| {
727            ExternalProgramError::Command(format!(
728                "could not resume terminal after agent picker: {error}"
729            ))
730        })?;
731        selection.map_err(ExternalProgramError::from)
732    }
733
734    /// Closes the active completion menu, if any, and updates its rendered
735    /// block.
736    ///
737    /// Returns `true` when a menu was open. Call this from application-level
738    /// state transitions that make the active completion context stale but do
739    /// not otherwise change the prompt buffer. Dismissing a previewed candidate
740    /// may restore the previous buffer and cursor; this method updates the
741    /// rendered menu but does not emit a [`Event::BufferChanged`] event.
742    pub fn dismiss_completion_menu(&mut self) -> bool {
743        let dismissed = self.term.dismiss_completion_menu();
744        if dismissed {
745            self.sync_menu_block();
746            self.handle.redraw();
747        }
748        dismissed
749    }
750
751    /// Appends persistent output to history.
752    pub fn print_output(
753        &self,
754        debug_id: impl Into<String>,
755        block: impl Into<StyledBlock>,
756    ) -> BlockId {
757        self.handle.print_output(debug_id, block)
758    }
759
760    /// Blocks until the next high-level event, syncing the
761    /// completion menu block to the raw term's current state.
762    pub fn get_next_event(&mut self) -> io::Result<Event> {
763        loop {
764            let raw = self.term.get_next_event()?;
765            match self.handle_next_raw_event(raw) {
766                NextEventStep::Return(event) => return Ok(event),
767                NextEventStep::Continue => continue,
768                NextEventStep::Fatal(error) => return Err(error),
769            }
770        }
771    }
772
773    fn handle_next_raw_event(&mut self, raw: RawEvent) -> NextEventStep {
774        match raw {
775            RawEvent::PasteUpload { id, text } => {
776                NextEventStep::Return(Event::PasteUpload { id, text })
777            }
778            RawEvent::PasteCancelled { id } => NextEventStep::Return(Event::PasteCancelled { id }),
779            RawEvent::BufferChanged => self.handle_buffer_changed_event(),
780            RawEvent::CompletionRefresh => {
781                self.sync_menu_block();
782                self.handle.redraw();
783                NextEventStep::Continue
784            }
785            RawEvent::CompletionAccept => self.handle_completion_accept_event(),
786            RawEvent::BackTab => NextEventStep::Return(Event::BackTab),
787            RawEvent::Escape => NextEventStep::Return(Event::Escape),
788            RawEvent::Line(line) => self.handle_line_event(line),
789            RawEvent::Eof => {
790                self.sync_menu_block();
791                NextEventStep::Return(Event::Eof)
792            }
793            RawEvent::CancelPrompt => {
794                self.sync_menu_block();
795                self.handle.redraw_sync();
796                NextEventStep::Return(Event::CancelPrompt)
797            }
798            RawEvent::Resize { width, height } => {
799                self.sync_menu_block();
800                self.handle.redraw();
801                NextEventStep::Return(Event::Resize { width, height })
802            }
803            RawEvent::FocusChanged { focused } => {
804                NextEventStep::Return(Event::FocusChanged { focused })
805            }
806            RawEvent::Notice(message) => self.handle_notice_event(&message),
807            RawEvent::ExternalEditor => self.handle_external_editor_event(),
808            RawEvent::Binding(action) => self.handle_binding_event(&action),
809        }
810    }
811
812    fn handle_buffer_changed_event(&mut self) -> NextEventStep {
813        let completion_changed = match self.maybe_run_command_completion() {
814            Ok(changed) => changed,
815            Err(error) => return NextEventStep::Fatal(error),
816        };
817        if completion_changed {
818            self.sync_menu_block();
819            self.handle.redraw_sync();
820            return NextEventStep::Return(Event::BufferChanged);
821        }
822        self.sync_menu_block();
823        self.handle.redraw();
824        NextEventStep::Return(Event::BufferChanged)
825    }
826
827    fn handle_completion_accept_event(&mut self) -> NextEventStep {
828        // Accept-without-submit: the buffer already reflects the chosen
829        // candidate. Sync the menu (now closed) and loop so the user has to
830        // press Enter again to actually submit.
831        self.sync_menu_block();
832        self.handle.redraw();
833        NextEventStep::Continue
834    }
835
836    fn handle_line_event(&mut self, line: String) -> NextEventStep {
837        let started = path_std_time::Instant::now();
838        self.record_submitted_prompt(&line);
839        let history_finished = path_std_time::Instant::now();
840        let redraw_requested = self.sync_menu_block();
841        let menu_finished = path_std_time::Instant::now();
842        if redraw_requested {
843            self.handle.redraw();
844        }
845        tracing::trace!(
846            target: "tau_cli::prompt_submission",
847            stage = "highterm_history_menu",
848            prompt_bytes = line.len(),
849            history_us = history_finished.duration_since(started).as_micros(),
850            menu_sync_us = menu_finished.duration_since(history_finished).as_micros(),
851            redraw_request_us = menu_finished.elapsed().as_micros(),
852            redraw_requested,
853            stage_us = started.elapsed().as_micros(),
854            "content-free prompt submission stage"
855        );
856        NextEventStep::Return(Event::Line(line))
857    }
858
859    fn handle_notice_event(&mut self, message: &str) -> NextEventStep {
860        self.sync_menu_block();
861        self.print_local(message);
862        self.handle.redraw_sync();
863        NextEventStep::Return(Event::BufferChanged)
864    }
865
866    fn handle_external_editor_event(&mut self) -> NextEventStep {
867        self.sync_menu_block();
868        let outcome = self.run_prompt_action(PromptShellAction::Edit(PromptShellCommand {
869            command: "$TAU_EDITOR \"$TAU_PROMPT_PATH\"".to_owned(),
870            trim: false,
871        }));
872        if !matches!(outcome, PromptActionOutcome::Fatal(_)) {
873            self.handle.redraw_sync();
874        }
875        outcome.into_next_event_step()
876    }
877
878    fn handle_binding_event(&mut self, action: &str) -> NextEventStep {
879        self.sync_menu_block();
880        let outcome = self.run_binding(action);
881        if !matches!(outcome, PromptActionOutcome::Fatal(_)) {
882            self.handle.redraw_sync();
883        }
884        outcome.into_next_event_step()
885    }
886
887    /// Updates the suggestion block to match the raw term's completion state.
888    ///
889    /// Returns whether synchronization wrote visible menu state.
890    /// Callers that already have a redraw request for another prompt mutation
891    /// can skip a second request when this returns `false`.
892    fn sync_menu_block(&mut self) -> bool {
893        match self.term.completion_state() {
894            Some(view) => {
895                let (width, height) = self.handle.size();
896                let block = completion::render_menu_block(&view, &self.theme, width, height);
897                let id = match self.menu_block_id {
898                    Some(id) => id,
899                    None => {
900                        let id = COMPLETION_MENU_BLOCK_ID;
901                        self.handle.set_block(id, "");
902                        self.handle.push_suggestions(id);
903                        self.menu_block_id = Some(id);
904                        id
905                    }
906                };
907                self.handle.set_block(id, block);
908                true
909            }
910            None => {
911                if let Some(id) = self.menu_block_id.take() {
912                    self.handle.remove_suggestions(id);
913                    self.handle.remove_block(id);
914                    true
915                } else {
916                    false
917                }
918            }
919        }
920    }
921
922    fn run_binding(&mut self, action: &str) -> PromptActionOutcome {
923        tracing::trace!(target: "tau_cli::input", action, "running prompt binding");
924        if tau_cli_term_raw::Term::is_named_action(action) {
925            return self
926                .term
927                .trigger_named_action(action)
928                .map_or(PromptActionOutcome::Continue, |raw| {
929                    self.apply_raw_prompt_event(raw)
930                });
931        }
932        let Some(action) = PromptShellAction::parse(action) else {
933            self.print_local(&format!("binding: unknown action `{action}`"));
934            return PromptActionOutcome::BufferChanged;
935        };
936        self.run_prompt_action(action)
937    }
938
939    /// Runs a [`PromptShellAction`] and applies its result to the
940    /// input buffer. Errors (spawn failure, bad utf-8, no editor)
941    /// surface as a themed info line above the prompt.
942    fn run_prompt_action(&mut self, action: PromptShellAction) -> PromptActionOutcome {
943        self.run_prompt_action_with_context(action, self.editor_context.clone())
944    }
945
946    /// Runs a prompt action against one explicit editor-context projection.
947    fn run_prompt_action_with_context(
948        &mut self,
949        action: PromptShellAction,
950        editor_context: Arc<Mutex<EditorContext>>,
951    ) -> PromptActionOutcome {
952        match run_prompt_shell_action(
953            &self.term,
954            &self.handle,
955            editor_context,
956            self.external_editor.as_deref(),
957            &self.prompt_history,
958            action,
959        ) {
960            Ok(Some(PromptShellResult::Replace(new_text))) => {
961                let cursor = new_text.len();
962                self.handle.set_buffer(new_text, cursor);
963                self.sync_menu_block();
964            }
965            Ok(Some(PromptShellResult::ReplacePreservingUndo(new_text))) => {
966                let cursor = new_text.len();
967                self.handle.set_buffer_preserving_undo(new_text, cursor);
968                self.sync_menu_block();
969            }
970            Ok(Some(PromptShellResult::Insert(text))) => {
971                let mut buffer = self.handle.get_buffer();
972                let cursor = self.handle.get_cursor();
973                buffer.insert_str(cursor, &text);
974                self.handle.set_buffer(buffer, cursor + text.len());
975                self.sync_menu_block();
976            }
977            Ok(Some(PromptShellResult::Action(action))) => {
978                return PromptActionOutcome::Return(Event::Action(action));
979            }
980            Ok(Some(PromptShellResult::History(delta))) => {
981                self.term.trigger_history_step(delta);
982                self.sync_menu_block();
983            }
984            Ok(Some(PromptShellResult::Undo)) => {
985                self.term.trigger_undo();
986                self.sync_menu_block();
987            }
988            Ok(Some(PromptShellResult::Redo)) => {
989                self.term.trigger_redo();
990                self.sync_menu_block();
991            }
992            Ok(Some(PromptShellResult::RawEvent(raw))) => {
993                return self.apply_raw_prompt_event(raw);
994            }
995            Ok(None) => {} // shell exited non-zero or no output applies.
996            Err(error) if error.is_foreground_ownership_unconfirmed() => {
997                return PromptActionOutcome::Fatal(fatal_terminal_ownership(error));
998            }
999            Err(error) => self.print_local(&format!("prompt action: {error}")),
1000        }
1001        PromptActionOutcome::BufferChanged
1002    }
1003
1004    fn apply_raw_prompt_event(&mut self, raw: RawEvent) -> PromptActionOutcome {
1005        match raw {
1006            RawEvent::PasteUpload { id, text } => {
1007                PromptActionOutcome::Return(Event::PasteUpload { id, text })
1008            }
1009            RawEvent::PasteCancelled { id } => {
1010                PromptActionOutcome::Return(Event::PasteCancelled { id })
1011            }
1012            RawEvent::BufferChanged => {
1013                self.sync_menu_block();
1014                PromptActionOutcome::BufferChanged
1015            }
1016            RawEvent::CompletionRefresh => {
1017                self.sync_menu_block();
1018                self.handle.redraw();
1019                PromptActionOutcome::Continue
1020            }
1021            RawEvent::CompletionAccept => {
1022                self.sync_menu_block();
1023                PromptActionOutcome::Continue
1024            }
1025            RawEvent::Line(line) => {
1026                let started = path_std_time::Instant::now();
1027                self.record_submitted_prompt(&line);
1028                let history_finished = path_std_time::Instant::now();
1029                self.sync_menu_block();
1030                tracing::trace!(
1031                    target: "tau_cli::prompt_submission",
1032                    stage = "highterm_history_menu",
1033                    prompt_bytes = line.len(),
1034                    history_us = history_finished.duration_since(started).as_micros(),
1035                    menu_sync_us = history_finished.elapsed().as_micros(),
1036                    redraw_request_us = 0_u64,
1037                    redraw_requested = false,
1038                    stage_us = started.elapsed().as_micros(),
1039                    "content-free prompt submission stage"
1040                );
1041                PromptActionOutcome::Return(Event::Line(line))
1042            }
1043            RawEvent::Eof => PromptActionOutcome::Return(Event::Eof),
1044            RawEvent::CancelPrompt => PromptActionOutcome::Return(Event::CancelPrompt),
1045            RawEvent::Resize { width, height } => {
1046                PromptActionOutcome::Return(Event::Resize { width, height })
1047            }
1048            RawEvent::FocusChanged { focused } => {
1049                PromptActionOutcome::Return(Event::FocusChanged { focused })
1050            }
1051            RawEvent::BackTab => PromptActionOutcome::Return(Event::BackTab),
1052            RawEvent::Escape => PromptActionOutcome::Return(Event::Escape),
1053            RawEvent::Notice(message) => {
1054                self.print_local(&message);
1055                PromptActionOutcome::BufferChanged
1056            }
1057            RawEvent::Binding(_) | RawEvent::ExternalEditor => {
1058                unreachable!("unsupported prompt action event")
1059            }
1060        }
1061    }
1062
1063    /// Records one submitted prompt using the canonical literal-escape
1064    /// spelling.
1065    fn record_submitted_prompt(&mut self, line: &str) {
1066        if line.is_empty() {
1067            return;
1068        }
1069        let history_line = canonical_literal_colon_prompt(line).unwrap_or_else(|| line.to_owned());
1070        self.prompt_history.push(history_line.clone());
1071        self.last_submitted_prompt_retained = true;
1072        self.term.replace_last_submitted_input(history_line);
1073    }
1074
1075    /// Bounds both histories after the input loop has finalized this
1076    /// submission's canonical or redacted presentation.
1077    pub fn finalize_last_submitted_prompt_history(&mut self) {
1078        let history = std::mem::take(&mut self.prompt_history);
1079        self.prompt_history = self.bounded_prompt_history_for_attachment(history);
1080        self.last_submitted_prompt_retained = self.prompt_history.last().is_some();
1081        self.term.finalize_submitted_input_history();
1082    }
1083
1084    /// Returns an attachment-local bounded suffix using this instance's limits.
1085    fn bounded_prompt_history_for_attachment(&self, prompt_history: Vec<String>) -> Vec<String> {
1086        Self::bounded_prompt_history_with_limits(
1087            prompt_history,
1088            self.effective_prompt_history_limits(),
1089        )
1090    }
1091
1092    /// Returns the newest permitted suffix under explicit entry and byte
1093    /// limits.
1094    fn bounded_prompt_history_with_limits(
1095        mut prompt_history: Vec<String>,
1096        limits: PromptHistoryLimits,
1097    ) -> Vec<String> {
1098        prompt_history.retain(|entry| !entry.is_empty() && entry.len() <= limits.max_bytes);
1099
1100        let mut retained_bytes = 0;
1101        let mut retained_start = prompt_history.len();
1102        for (retained_entries, (index, entry)) in
1103            prompt_history.iter().enumerate().rev().enumerate()
1104        {
1105            if retained_entries == limits.max_entries
1106                || entry.len() > limits.max_bytes - retained_bytes
1107            {
1108                break;
1109            }
1110            retained_bytes += entry.len();
1111            retained_start = index;
1112        }
1113        prompt_history.drain(..retained_start);
1114        prompt_history
1115    }
1116
1117    /// Returns the production HighTerm history limits.
1118    fn prompt_history_limits() -> PromptHistoryLimits {
1119        PromptHistoryLimits {
1120            max_entries: PROMPT_HISTORY_MAX_ENTRIES,
1121            max_bytes: PROMPT_HISTORY_MAX_BYTES,
1122        }
1123    }
1124
1125    /// Returns production limits or a focused test's explicit local limits.
1126    fn effective_prompt_history_limits(&self) -> PromptHistoryLimits {
1127        #[cfg(test)]
1128        if let Some(limits) = self.prompt_history_limit_override {
1129            return limits;
1130        }
1131        Self::prompt_history_limits()
1132    }
1133
1134    fn maybe_run_command_completion(&mut self) -> io::Result<bool> {
1135        let buffer = self.handle.get_buffer();
1136        let cursor = self.handle.get_cursor();
1137        let Some((command, before, after)) = self
1138            .completion_command_rules
1139            .command_for_exact_token(&buffer, cursor)
1140        else {
1141            self.last_command_completion_token = None;
1142            return Ok(false);
1143        };
1144        let token_key = format!("{before}\0{cursor}");
1145        if self.last_command_completion_token.as_deref() == Some(token_key.as_str()) {
1146            return Ok(false);
1147        }
1148        self.last_command_completion_token = Some(token_key);
1149        let completion_result = match command {
1150            completion::CommandCompletionMatch::Command(command) => {
1151                run_completion_command(&self.term, command)
1152            }
1153            completion::CommandCompletionMatch::EmptyCommand => {
1154                Err(empty_completion_command_error())
1155            }
1156        };
1157        match completion_result {
1158            Ok(Some(text)) => {
1159                let new_text = format!("{before}{text}{after}");
1160                let new_cursor = before.len() + text.len();
1161                self.handle.set_buffer(new_text, new_cursor);
1162                self.last_command_completion_token = None;
1163                Ok(true)
1164            }
1165            Ok(None) => Ok(false),
1166            Err(error) if error.is_foreground_ownership_unconfirmed() => {
1167                Err(fatal_terminal_ownership(error))
1168            }
1169            Err(error) => {
1170                self.print_local(&format!("completion command: {error}"));
1171                Ok(true)
1172            }
1173        }
1174    }
1175
1176    fn print_local(&self, message: &str) {
1177        let block = resolve::themed_block(
1178            &self.theme,
1179            tau_themes::names::SYSTEM_INFO,
1180            message.to_owned(),
1181        );
1182        self.handle.print_output("prompt-action-error", block);
1183    }
1184}
1185
1186/// Runs one callback between injectable terminal release and resume operations.
1187fn run_with_external_terminal_hooks<T>(
1188    callback: impl FnOnce() -> T,
1189    pause: impl FnOnce() -> io::Result<()>,
1190    resume: impl FnMut() -> io::Result<()>,
1191) -> io::Result<T> {
1192    pause()?;
1193    let guard = ExternalResumeGuard::new(resume);
1194    let result = callback();
1195    guard.finish()?;
1196    Ok(result)
1197}
1198
1199/// Bounds startup-seeded history with production limits before an attachment
1200/// owns it. Attachment mutations use
1201/// [`HighTerm::bounded_prompt_history_for_attachment`].
1202fn bounded_seeded_prompt_history(prompt_history: Vec<String>) -> Vec<String> {
1203    HighTerm::bounded_prompt_history_with_limits(prompt_history, HighTerm::prompt_history_limits())
1204}
1205
1206/// Returns canonical literal-colon prompt text for a line beginning with `::`.
1207///
1208/// Leading whitespace is preserved while exactly one colon is removed from the
1209/// first non-whitespace token. Lines that do not use the escape return `None`.
1210#[must_use]
1211pub fn canonical_literal_colon_prompt(line: &str) -> Option<String> {
1212    let leading_len = line.len() - line.trim_start().len();
1213    line.get(leading_len..)?
1214        .strip_prefix("::")
1215        .map(|suffix| format!("{}:{suffix}", &line[..leading_len]))
1216}
1217
1218fn run_agent_fzf_command_with_ownership(
1219    program: &std::ffi::OsStr,
1220    rows: &str,
1221    timeout: std::time::Duration,
1222    ownership: ProcessOwnership,
1223    after_spawn: impl FnOnce() -> Result<(), String>,
1224) -> Result<Option<String>, BoundedCommandError> {
1225    let mut command = path_std_process::Command::new(program);
1226    command
1227        .args(AGENT_PICKER_FZF_ARGS)
1228        .stdout(path_std_process::Stdio::piped())
1229        .stderr(path_std_process::Stdio::null());
1230    let output = run_with_bounded_stdout_after_spawn(
1231        &mut command,
1232        Some(rows.as_bytes()),
1233        AGENT_PICKER_OUTPUT_LIMIT_BYTES,
1234        timeout,
1235        ownership,
1236        after_spawn,
1237    )
1238    .map_err(|error| match error {
1239        BoundedCommandError::Command(error) => {
1240            BoundedCommandError::Command(format!("fzf failed: {error}"))
1241        }
1242        error @ BoundedCommandError::ForegroundOwnershipUnconfirmed { .. } => error,
1243    })?;
1244    match output.status.code() {
1245        Some(1 | 130) => Ok(None),
1246        _ if !output.status.success() => Err(format!(
1247            "fzf exited with status {}",
1248            output.status.code().map_or_else(
1249                || "terminated by signal".to_owned(),
1250                |code| code.to_string()
1251            )
1252        )
1253        .into()),
1254        _ => parse_agent_fzf_output(output.stdout).map_err(BoundedCommandError::Command),
1255    }
1256}
1257
1258fn format_agent_picker_rows(rows: &str, terminal_width: usize) -> Result<String, String> {
1259    let fields = rows
1260        .lines()
1261        .map(|row| {
1262            let fields = row.split('\t').collect::<Vec<_>>();
1263            if fields.len() != AGENT_PICKER_SOURCE_FIELDS {
1264                return Err(format!(
1265                    "agent row has {} fields instead of {AGENT_PICKER_SOURCE_FIELDS}",
1266                    fields.len()
1267                ));
1268            }
1269            Ok(fields)
1270        })
1271        .collect::<Result<Vec<_>, _>>()?;
1272    let content_width = terminal_width.saturating_sub(AGENT_PICKER_FZF_DECORATION_WIDTH);
1273    let column_widths = agent_picker_column_widths(&fields, content_width);
1274    let mut output = String::new();
1275    for fields in fields {
1276        let display = AGENT_PICKER_COLUMNS
1277            .iter()
1278            .zip(&column_widths)
1279            .map(|(column, &width)| {
1280                let source = agent_picker_column_value(&fields, column.source_field);
1281                let value = truncate_to_width(&source, width);
1282                let padding = width.saturating_sub(display_width(&value));
1283                format!("{value}{}", " ".repeat(padding))
1284            })
1285            .collect::<Vec<_>>()
1286            .join(AGENT_PICKER_COLUMN_GAP)
1287            .trim_end()
1288            .to_owned();
1289        output.push_str(&fields.join("\t"));
1290        output.push('\t');
1291        output.push_str(&display);
1292        output.push('\n');
1293    }
1294    Ok(output)
1295}
1296
1297fn agent_picker_column_widths(rows: &[Vec<&str>], content_width: usize) -> Vec<usize> {
1298    let mut column_count = AGENT_PICKER_COLUMNS.len();
1299    while 1 < column_count
1300        && content_width
1301            < AGENT_PICKER_COLUMNS[..column_count]
1302                .iter()
1303                .map(|column| column.minimum_width)
1304                .sum::<usize>()
1305                + AGENT_PICKER_COLUMN_GAP.len() * column_count.saturating_sub(1)
1306    {
1307        column_count -= 1;
1308    }
1309    if content_width == 0 {
1310        return Vec::new();
1311    }
1312    let gap_width = AGENT_PICKER_COLUMN_GAP.len() * column_count.saturating_sub(1);
1313    let available = content_width.saturating_sub(gap_width);
1314    let natural = AGENT_PICKER_COLUMNS[..column_count]
1315        .iter()
1316        .map(|column| {
1317            rows.iter()
1318                .map(|row| display_width(&agent_picker_column_value(row, column.source_field)))
1319                .max()
1320                .unwrap_or(1)
1321                .max(1)
1322                .min(column.max_width)
1323        })
1324        .collect::<Vec<_>>();
1325    let mut widths = vec![1; column_count];
1326    let mut remaining = available.saturating_sub(column_count);
1327    for targets in [
1328        &AGENT_PICKER_COLUMNS[..column_count]
1329            .iter()
1330            .map(|column| column.minimum_width)
1331            .collect::<Vec<_>>(),
1332        &AGENT_PICKER_COLUMNS[..column_count]
1333            .iter()
1334            .map(|column| column.preferred_width)
1335            .collect::<Vec<_>>(),
1336        natural.as_slice(),
1337    ] {
1338        while 0 < remaining {
1339            let mut grew = false;
1340            for (column, width) in widths.iter_mut().enumerate() {
1341                let target = targets[column].min(natural[column]);
1342                if *width < target && 0 < remaining {
1343                    *width += 1;
1344                    remaining -= 1;
1345                    grew = true;
1346                }
1347            }
1348            if !grew {
1349                break;
1350            }
1351        }
1352    }
1353    widths
1354}
1355
1356/// Return one source column, synthesizing the mandatory visual prefix.
1357fn agent_picker_column_value(fields: &[&str], source_field: usize) -> String {
1358    if source_field == usize::MAX {
1359        format!("{}{} @{}", fields[12], fields[14], fields[0])
1360    } else {
1361        fields[source_field].to_owned()
1362    }
1363}
1364
1365fn parse_agent_fzf_output(output: Vec<u8>) -> Result<Option<String>, String> {
1366    let output =
1367        String::from_utf8(output).map_err(|error| format!("fzf output was not UTF-8: {error}"))?;
1368    let output = output.strip_suffix('\n').unwrap_or(&output);
1369    let output = output.strip_suffix('\r').unwrap_or(output);
1370    if output.is_empty() {
1371        return Ok(None);
1372    }
1373    if output.contains(['\n', '\r']) {
1374        return Err("fzf returned more than one row".to_owned());
1375    }
1376    let (row, _) = output
1377        .rsplit_once('\t')
1378        .ok_or_else(|| "fzf returned a malformed agent row".to_owned())?;
1379    if row.split('\t').count() != AGENT_PICKER_SOURCE_FIELDS {
1380        return Err("fzf returned a malformed agent row".to_owned());
1381    }
1382    Ok(Some(row.to_owned()))
1383}
1384
1385fn make_completion_source(
1386    commands: Vec<CommandCompletion>,
1387    data: CompletionData,
1388    rules: CompletionRules,
1389) -> Box<dyn tau_cli_term_raw::CompletionSource> {
1390    let commands = Arc::new(commands);
1391    let rules = Arc::new(rules);
1392    Box::new(move |buffer: &str, cursor: usize| -> Vec<Candidate> {
1393        completion::build_candidates_with_rules(&commands, &data, &rules, buffer, cursor)
1394    })
1395}
1396
1397fn run_completion_command(
1398    term: &tau_cli_term_raw::Term,
1399    command: &completion::CompletionCommand,
1400) -> Result<Option<String>, BoundedCommandError> {
1401    term.pause_for_external()
1402        .map_err(|e| format!("could not release terminal: {e}"))?;
1403    let guard = ExternalResumeGuard::new(|| term.resume_after_external());
1404    let mut command_builder = path_std_process::Command::new(command.program());
1405    command_builder
1406        .args(command.args())
1407        .stdin(path_std_process::Stdio::null())
1408        .stdout(path_std_process::Stdio::piped())
1409        .stderr(path_std_process::Stdio::null());
1410    let output = preserve_pause_on_unconfirmed_foreground(
1411        guard,
1412        run_with_bounded_stdout(
1413            &mut command_builder,
1414            None,
1415            COMPLETION_COMMAND_OUTPUT_LIMIT_BYTES,
1416            COMPLETION_COMMAND_TIMEOUT,
1417            ProcessOwnership::ForegroundProcessGroup,
1418        ),
1419    )?;
1420    if !output.status.success() {
1421        return Ok(None);
1422    }
1423    let text = String::from_utf8(output.stdout)
1424        .map_err(|e| format!("command output was not utf-8: {e}"))?;
1425    let text = text.trim().to_owned();
1426    if text.is_empty() {
1427        Ok(None)
1428    } else {
1429        Ok(Some(text))
1430    }
1431}
1432
1433/// Returns the established diagnostic for a public completion command with no
1434/// executable argv element.
1435fn empty_completion_command_error() -> BoundedCommandError {
1436    "empty command".to_owned().into()
1437}
1438
1439struct PromptShellCommand {
1440    command: String,
1441    trim: bool,
1442}
1443
1444enum PromptShellAction {
1445    Insert(PromptShellCommand),
1446    Edit(PromptShellCommand),
1447    HistorySearch(PromptShellCommand),
1448    Action(String),
1449    PromptNext,
1450    PromptPrevious,
1451    PromptUndo,
1452    PromptRedo,
1453    SubmitPrompt,
1454    InsertNewline,
1455}
1456
1457/// Conversation context and prompt-editor recovery state appended below the
1458/// prompt trailer when the user edits the prompt in an external editor.
1459#[derive(Clone, Default)]
1460pub struct EditorContext {
1461    /// Response text currently streaming or otherwise in progress, included as
1462    /// read-only context when editing the next prompt.
1463    pub current_response: Option<String>,
1464    /// Most recent completed response text, included as read-only context when
1465    /// editing the next prompt.
1466    pub last_response: Option<String>,
1467    /// Previous submitted prompt text, included as read-only context when
1468    /// editing the next prompt.
1469    pub previous_prompt: Option<String>,
1470    /// Exact Markdown conversation rendered directly below the trailer marker
1471    /// for a history-aware editor invocation.
1472    pub chat_markdown: Option<String>,
1473    /// Text recovered from a previous edit where the normally ignored trailer
1474    /// section was modified before the editor exited. This is set only when the
1475    /// edited trailer differs from the generated trailer, cleared when the
1476    /// trailer is unchanged or the marker is deleted, rendered below the marker
1477    /// on the next edit, and never promoted into the prompt unless the user
1478    /// manually moves it above the marker.
1479    pub edited_trailer_recovery: Option<String>,
1480}
1481
1482impl EditorContext {
1483    fn update_edited_trailer_recovery(&mut self, original_text: &str, edited_text: &str) {
1484        let Some((_, original_trailer)) = split_at_prompt_trailer_marker(original_text) else {
1485            return;
1486        };
1487        self.edited_trailer_recovery = edited_trailer_recovery(original_trailer, edited_text);
1488    }
1489}
1490
1491enum PromptShellResult {
1492    Insert(String),
1493    Replace(String),
1494    ReplacePreservingUndo(String),
1495    Action(String),
1496    History(isize),
1497    Undo,
1498    Redo,
1499    RawEvent(RawEvent),
1500}
1501
1502#[derive(Clone, Copy)]
1503enum PromptShellExternalKind {
1504    Insert,
1505    Edit,
1506    HistorySearch,
1507}
1508
1509struct PromptShellExternalAction {
1510    kind: PromptShellExternalKind,
1511    shell: PromptShellCommand,
1512}
1513
1514enum PromptShellDispatch {
1515    Immediate(PromptShellResult),
1516    External(PromptShellExternalAction),
1517}
1518
1519struct PromptHistoryPicker {
1520    rows: String,
1521    prompt_dir: tempfile::TempDir,
1522}
1523
1524enum PromptShellCommandOutput {
1525    Edited,
1526    Captured(Vec<u8>),
1527}
1528
1529enum PromptActionOutcome {
1530    BufferChanged,
1531    Continue,
1532    Return(Event),
1533    Fatal(io::Error),
1534}
1535
1536enum NextEventStep {
1537    Return(Event),
1538    Continue,
1539    Fatal(io::Error),
1540}
1541
1542impl PromptActionOutcome {
1543    fn into_next_event_step(self) -> NextEventStep {
1544        match self {
1545            Self::BufferChanged => NextEventStep::Return(Event::BufferChanged),
1546            Self::Continue => NextEventStep::Continue,
1547            Self::Return(event) => NextEventStep::Return(event),
1548            Self::Fatal(error) => NextEventStep::Fatal(error),
1549        }
1550    }
1551}
1552
1553/// Converts terminal-ownership loss into a fatal interactive-input error.
1554fn fatal_terminal_ownership(error: BoundedCommandError) -> io::Error {
1555    io::Error::other(error)
1556}
1557
1558impl PromptShellAction {
1559    // Keep prompt-local action names, Term::trigger_named_action,
1560    // crates/tau-cli/src/chat.rs::encode_binding_action,
1561    // built-in.cli-bindings.yaml, and docs/cli-keybindings.md in sync.
1562    fn parse(action: &str) -> Option<Self> {
1563        match action {
1564            "prompt-next" => return Some(Self::PromptNext),
1565            "prompt-previous" => return Some(Self::PromptPrevious),
1566            "prompt-undo" => return Some(Self::PromptUndo),
1567            "prompt-redo" => return Some(Self::PromptRedo),
1568            "submit-prompt" => return Some(Self::SubmitPrompt),
1569            "insert-newline" => return Some(Self::InsertNewline),
1570            _ => {}
1571        }
1572        let mut parts = action.splitn(3, ':');
1573        let name = parts.next()?;
1574        let (Some(mode), Some(command)) = (parts.next(), parts.next()) else {
1575            return (!action.is_empty() && !action.contains(':'))
1576                .then(|| Self::Action(action.to_owned()));
1577        };
1578        let trim = match mode {
1579            "trim" => true,
1580            "raw" => false,
1581            _ => return None,
1582        };
1583        let command = PromptShellCommand {
1584            command: command.to_owned(),
1585            trim,
1586        };
1587        match name {
1588            "shell-prompt-insert" => Some(Self::Insert(command)),
1589            "shell-prompt-edit" => Some(Self::Edit(command)),
1590            "prompt-history-search" => Some(Self::HistorySearch(command)),
1591            _ => None,
1592        }
1593    }
1594}
1595
1596fn prompt_shell_dispatch(
1597    action: PromptShellAction,
1598    term: &tau_cli_term_raw::Term,
1599) -> PromptShellDispatch {
1600    match action {
1601        PromptShellAction::PromptNext => {
1602            PromptShellDispatch::Immediate(PromptShellResult::History(1))
1603        }
1604        PromptShellAction::PromptPrevious => {
1605            PromptShellDispatch::Immediate(PromptShellResult::History(-1))
1606        }
1607        PromptShellAction::PromptUndo => PromptShellDispatch::Immediate(PromptShellResult::Undo),
1608        PromptShellAction::PromptRedo => PromptShellDispatch::Immediate(PromptShellResult::Redo),
1609        PromptShellAction::Action(action) => {
1610            PromptShellDispatch::Immediate(PromptShellResult::Action(action))
1611        }
1612        PromptShellAction::SubmitPrompt => PromptShellDispatch::Immediate(
1613            PromptShellResult::RawEvent(term.trigger_submit_or_accept_completion()),
1614        ),
1615        PromptShellAction::InsertNewline => PromptShellDispatch::Immediate(
1616            PromptShellResult::RawEvent(term.trigger_insert_newline()),
1617        ),
1618        PromptShellAction::Insert(shell) => {
1619            PromptShellDispatch::External(PromptShellExternalAction {
1620                kind: PromptShellExternalKind::Insert,
1621                shell,
1622            })
1623        }
1624        PromptShellAction::Edit(shell) => {
1625            PromptShellDispatch::External(PromptShellExternalAction {
1626                kind: PromptShellExternalKind::Edit,
1627                shell,
1628            })
1629        }
1630        PromptShellAction::HistorySearch(shell) => {
1631            PromptShellDispatch::External(PromptShellExternalAction {
1632                kind: PromptShellExternalKind::HistorySearch,
1633                shell,
1634            })
1635        }
1636    }
1637}
1638
1639fn run_prompt_shell_action(
1640    term: &tau_cli_term_raw::Term,
1641    handle: &TermHandle,
1642    editor_context: Arc<Mutex<EditorContext>>,
1643    external_editor: Option<&str>,
1644    prompt_history: &[String],
1645    action: PromptShellAction,
1646) -> Result<Option<PromptShellResult>, BoundedCommandError> {
1647    let external_action = match prompt_shell_dispatch(action, term) {
1648        PromptShellDispatch::Immediate(result) => return Ok(Some(result)),
1649        PromptShellDispatch::External(external_action) => external_action,
1650    };
1651    let current = trim_prompt_newlines(&handle.get_buffer()).to_owned();
1652    let cursor = handle.get_cursor();
1653    let tmp = tempfile::Builder::new()
1654        .prefix("tau-prompt-")
1655        .suffix(".tau.md")
1656        .tempfile()
1657        .map_err(|e| format!("could not create tempfile: {e}"))?;
1658    let file_text = prompt_shell_file_text(external_action.kind, &current, &editor_context);
1659    std::fs::write(tmp.path(), file_text.as_bytes())
1660        .map_err(|e| format!("could not write tempfile: {e}"))?;
1661
1662    let history_picker = match external_action.kind {
1663        PromptShellExternalKind::HistorySearch => {
1664            match prepare_history_picker(term, prompt_history)? {
1665                Some(history_picker) => Some(history_picker),
1666                None => return Ok(None),
1667            }
1668        }
1669        PromptShellExternalKind::Insert | PromptShellExternalKind::Edit => None,
1670    };
1671
1672    let command = external_action.shell.command.as_str();
1673    tracing::trace!(
1674        target: "tau_cli::input",
1675        command,
1676        prompt_path = %tmp.path().display(),
1677        cursor,
1678        "spawning prompt shell action"
1679    );
1680    if command.trim().is_empty() {
1681        return Err("empty shell command".to_owned().into());
1682    }
1683
1684    term.pause_for_external()
1685        .map_err(|e| format!("could not release terminal: {e}"))?;
1686    // RAII so a spawn error / panic still restores raw mode.
1687    let guard = ExternalResumeGuard::new(|| term.resume_after_external());
1688
1689    let mut command_builder = prompt_shell_command_builder(
1690        command,
1691        tmp.path(),
1692        cursor,
1693        external_editor,
1694        history_picker.as_ref(),
1695    );
1696    let command_result = preserve_pause_on_unconfirmed_foreground(
1697        guard,
1698        run_external_prompt_shell_command(
1699            &mut command_builder,
1700            external_action.kind,
1701            history_picker.as_ref(),
1702        ),
1703    );
1704    let Some(output) = command_result? else {
1705        return Ok(None);
1706    };
1707    match output {
1708        PromptShellCommandOutput::Captured(stdout) => {
1709            return Ok(prompt_shell_captured_result(
1710                external_action.kind,
1711                external_action.shell.trim,
1712                stdout,
1713                prompt_history,
1714            )?);
1715        }
1716        PromptShellCommandOutput::Edited => {}
1717    }
1718
1719    let new_text =
1720        std::fs::read_to_string(tmp.path()).map_err(|e| format!("could not read tempfile: {e}"))?;
1721    editor_context
1722        .lock()
1723        .expect("editor context mutex poisoned")
1724        .update_edited_trailer_recovery(&file_text, &new_text);
1725    let new_text = strip_prompt_trailer(&new_text);
1726    let new_text = trim_prompt_newlines(new_text).to_owned();
1727    Ok(Some(PromptShellResult::Replace(new_text)))
1728}
1729
1730fn prompt_shell_file_text(
1731    kind: PromptShellExternalKind,
1732    current: &str,
1733    editor_context: &Arc<Mutex<EditorContext>>,
1734) -> String {
1735    match kind {
1736        PromptShellExternalKind::Edit => append_prompt_trailer(current, editor_context),
1737        PromptShellExternalKind::Insert | PromptShellExternalKind::HistorySearch => {
1738            current.to_owned()
1739        }
1740    }
1741}
1742
1743fn prepare_history_picker(
1744    term: &tau_cli_term_raw::Term,
1745    prompt_history: &[String],
1746) -> Result<Option<PromptHistoryPicker>, String> {
1747    let rows = prompt_history_search_rows(prompt_history);
1748    if rows.is_empty() {
1749        return Ok(None);
1750    }
1751    let prompt_dir = prompt_history_preview_dir(prompt_history)?;
1752    term.record_prompt_undo();
1753    Ok(Some(PromptHistoryPicker { rows, prompt_dir }))
1754}
1755
1756fn prompt_shell_command_builder(
1757    command: &str,
1758    prompt_path: &std::path::Path,
1759    cursor: usize,
1760    external_editor: Option<&str>,
1761    history_picker: Option<&PromptHistoryPicker>,
1762) -> std::process::Command {
1763    let mut command_builder = path_std_process::Command::new("sh");
1764    command_builder
1765        .arg("-c")
1766        .arg(command)
1767        .env("TAU_PROMPT_PATH", prompt_path)
1768        .env("TAU_PROMPT_COLUMN", (cursor + 1).to_string())
1769        .env("TAU_PROMPT_ROW", "1")
1770        .env("TAU_EDITOR", external_editor.unwrap_or(""));
1771    if let Some(history_picker) = history_picker {
1772        command_builder.env("TAU_PROMPT_HISTORY_DIR", history_picker.prompt_dir.path());
1773    }
1774    command_builder
1775}
1776
1777fn run_external_prompt_shell_command(
1778    command_builder: &mut std::process::Command,
1779    kind: PromptShellExternalKind,
1780    history_picker: Option<&PromptHistoryPicker>,
1781) -> Result<Option<PromptShellCommandOutput>, BoundedCommandError> {
1782    match kind {
1783        PromptShellExternalKind::Edit => run_prompt_edit_command(command_builder),
1784        PromptShellExternalKind::Insert | PromptShellExternalKind::HistorySearch => {
1785            run_prompt_capture_command(command_builder, history_picker)
1786        }
1787    }
1788}
1789
1790fn run_prompt_edit_command(
1791    command_builder: &mut std::process::Command,
1792) -> Result<Option<PromptShellCommandOutput>, BoundedCommandError> {
1793    command_builder
1794        .stdin(path_std_process::Stdio::inherit())
1795        .stdout(path_std_process::Stdio::inherit())
1796        .stderr(path_std_process::Stdio::inherit());
1797    let status = run_with_inherited_stdio(
1798        command_builder,
1799        PROMPT_COMMAND_TIMEOUT,
1800        ProcessOwnership::ForegroundProcessGroup,
1801    )?
1802    .status;
1803    Ok(status.success().then_some(PromptShellCommandOutput::Edited))
1804}
1805
1806fn run_prompt_capture_command(
1807    command_builder: &mut std::process::Command,
1808    history_picker: Option<&PromptHistoryPicker>,
1809) -> Result<Option<PromptShellCommandOutput>, BoundedCommandError> {
1810    command_builder.stdin(if history_picker.is_some() {
1811        path_std_process::Stdio::piped()
1812    } else {
1813        path_std_process::Stdio::null()
1814    });
1815    command_builder
1816        .stdout(path_std_process::Stdio::piped())
1817        .stderr(path_std_process::Stdio::null());
1818    let stdin_input = history_picker.map(|history_picker| history_picker.rows.as_bytes());
1819    let output = run_with_bounded_stdout(
1820        command_builder,
1821        stdin_input,
1822        PROMPT_COMMAND_OUTPUT_LIMIT_BYTES,
1823        PROMPT_COMMAND_TIMEOUT,
1824        ProcessOwnership::ForegroundProcessGroup,
1825    )?;
1826    Ok(output
1827        .status
1828        .success()
1829        .then_some(PromptShellCommandOutput::Captured(output.stdout)))
1830}
1831
1832fn prompt_shell_captured_result(
1833    kind: PromptShellExternalKind,
1834    trim: bool,
1835    stdout: Vec<u8>,
1836    prompt_history: &[String],
1837) -> Result<Option<PromptShellResult>, String> {
1838    let text =
1839        String::from_utf8(stdout).map_err(|e| format!("command output was not utf-8: {e}"))?;
1840    let text = trim_prompt_shell_output(text, trim);
1841    match kind {
1842        PromptShellExternalKind::Insert => Ok(Some(PromptShellResult::Insert(text))),
1843        PromptShellExternalKind::HistorySearch => {
1844            selected_prompt_history_result(text, prompt_history)
1845        }
1846        PromptShellExternalKind::Edit => unreachable!(),
1847    }
1848}
1849
1850fn trim_prompt_shell_output(text: String, trim: bool) -> String {
1851    if trim { text.trim().to_owned() } else { text }
1852}
1853
1854fn selected_prompt_history_result(
1855    selected: String,
1856    prompt_history: &[String],
1857) -> Result<Option<PromptShellResult>, String> {
1858    let selected_index = selected.split('\t').next().unwrap_or("").trim();
1859    if selected_index.is_empty() {
1860        return Ok(None);
1861    }
1862    let index = selected_index
1863        .parse::<usize>()
1864        .map_err(|e| format!("history selection was not an index: {e}"))?;
1865    let text = prompt_history
1866        .get(index)
1867        .ok_or_else(|| format!("history selection index {index} is out of range"))?
1868        .clone();
1869    Ok(Some(PromptShellResult::ReplacePreservingUndo(text)))
1870}
1871
1872fn prompt_history_search_rows(prompt_history: &[String]) -> String {
1873    let mut rows = String::new();
1874    for (index, prompt) in bounded_prompt_history_entries(prompt_history) {
1875        rows.push_str(&index.to_string());
1876        rows.push('\t');
1877        rows.push_str(&prompt_history_summary(prompt));
1878        rows.push('\n');
1879    }
1880    rows
1881}
1882
1883fn prompt_history_preview_dir(prompt_history: &[String]) -> Result<tempfile::TempDir, String> {
1884    let dir = tempfile::Builder::new()
1885        .prefix("tau-prompt-history-")
1886        .tempdir()
1887        .map_err(|e| format!("could not create prompt history tempdir: {e}"))?;
1888    let mut remaining_total = PROMPT_HISTORY_PREVIEW_TOTAL_BYTES;
1889    for (index, prompt) in bounded_prompt_history_entries(prompt_history) {
1890        let preview = bounded_prompt_history_preview(prompt, &mut remaining_total);
1891        std::fs::write(dir.path().join(index.to_string()), preview.as_bytes())
1892            .map_err(|e| format!("could not write prompt history preview {index}: {e}"))?;
1893    }
1894    Ok(dir)
1895}
1896
1897fn bounded_prompt_history_entries(
1898    prompt_history: &[String],
1899) -> impl Iterator<Item = (usize, &str)> {
1900    prompt_history
1901        .iter()
1902        .enumerate()
1903        .rev()
1904        .filter(|(_, prompt)| !prompt.is_empty())
1905        .take(PROMPT_HISTORY_SEARCH_MAX_ROWS)
1906        .map(|(index, prompt)| (index, prompt.as_str()))
1907}
1908
1909fn prompt_history_summary(prompt: &str) -> String {
1910    let mut summary = String::new();
1911    let mut summary_chars = 0usize;
1912    let mut pending_space = false;
1913
1914    for ch in prompt.chars() {
1915        if ch.is_whitespace() {
1916            pending_space = !summary.is_empty();
1917            continue;
1918        }
1919
1920        if pending_space {
1921            if summary_chars + 1 >= PROMPT_HISTORY_SUMMARY_MAX_CHARS {
1922                append_prompt_history_summary_ellipsis(&mut summary, &mut summary_chars);
1923                return summary;
1924            }
1925            summary.push(' ');
1926            summary_chars += 1;
1927            pending_space = false;
1928        }
1929
1930        if summary_chars + 1 >= PROMPT_HISTORY_SUMMARY_MAX_CHARS {
1931            append_prompt_history_summary_ellipsis(&mut summary, &mut summary_chars);
1932            return summary;
1933        }
1934        summary.push(ch);
1935        summary_chars += 1;
1936    }
1937
1938    summary
1939}
1940
1941fn append_prompt_history_summary_ellipsis(summary: &mut String, summary_chars: &mut usize) {
1942    if *summary_chars == PROMPT_HISTORY_SUMMARY_MAX_CHARS {
1943        summary.pop();
1944        *summary_chars -= 1;
1945    }
1946    summary.push('…');
1947    *summary_chars += 1;
1948}
1949
1950fn bounded_prompt_history_preview(prompt: &str, remaining_total: &mut usize) -> String {
1951    const TRUNCATED: &str = "\n[history preview truncated]\n";
1952    if *remaining_total == 0 {
1953        return String::new();
1954    }
1955
1956    let budget = PROMPT_HISTORY_PREVIEW_MAX_BYTES.min(*remaining_total);
1957    if prompt.len() <= budget {
1958        *remaining_total = remaining_total.saturating_sub(prompt.len());
1959        return prompt.to_owned();
1960    }
1961
1962    let content_budget = if budget > TRUNCATED.len() {
1963        budget - TRUNCATED.len()
1964    } else {
1965        budget
1966    };
1967    let end = previous_char_boundary(prompt, content_budget);
1968    let mut preview = prompt[..end].to_owned();
1969    if preview.len() + TRUNCATED.len() <= budget {
1970        preview.push_str(TRUNCATED);
1971    }
1972    *remaining_total = remaining_total.saturating_sub(preview.len());
1973    preview
1974}
1975
1976fn previous_char_boundary(text: &str, index: usize) -> usize {
1977    let mut index = index.min(text.len());
1978    while !text.is_char_boundary(index) {
1979        index -= 1;
1980    }
1981    index
1982}
1983
1984fn append_prompt_trailer(current: &str, editor_context: &Arc<Mutex<EditorContext>>) -> String {
1985    let context = editor_context
1986        .lock()
1987        .expect("editor context mutex poisoned")
1988        .clone();
1989    if context.current_response.is_none()
1990        && context.last_response.is_none()
1991        && context.previous_prompt.is_none()
1992        && context.chat_markdown.is_none()
1993        && context.edited_trailer_recovery.is_none()
1994    {
1995        return current.to_owned();
1996    }
1997
1998    let mut out = trim_prompt_newlines(current).to_owned();
1999    out.push_str("\n\n");
2000    out.push_str(PROMPT_TRAILER_MARKER);
2001    out.push('\n');
2002    if let Some(chat_markdown) = context.chat_markdown {
2003        out.push_str(&chat_markdown);
2004        return out;
2005    }
2006    if let Some(text) = context
2007        .current_response
2008        .as_deref()
2009        .filter(|t| !t.is_empty())
2010    {
2011        out.push_str("\n## Current response in progress\n\n");
2012        push_markdown_quote(&mut out, text);
2013    }
2014    if let Some(text) = context.last_response.as_deref().filter(|t| !t.is_empty()) {
2015        out.push_str("\n## Last response\n\n");
2016        push_markdown_quote(&mut out, text);
2017    }
2018    if let Some(text) = context.previous_prompt.as_deref().filter(|t| !t.is_empty()) {
2019        out.push_str("\n## Previous prompt\n\n");
2020        push_markdown_quote(&mut out, text);
2021    }
2022    if let Some(text) = context
2023        .edited_trailer_recovery
2024        .as_deref()
2025        .filter(|t| !t.is_empty())
2026    {
2027        out.push_str("\n## Previously edited text below TAU trailer\n\n");
2028        out.push_str(
2029            "Move anything you want to keep above the TAU trailer marker; \
2030             leaving this section unchanged will discard it after this editor session.\n\n",
2031        );
2032        out.push_str(text);
2033        if !out.ends_with('\n') {
2034            out.push('\n');
2035        }
2036    }
2037    out
2038}
2039
2040fn trim_prompt_newlines(text: &str) -> &str {
2041    text.trim_matches(['\n', '\r'])
2042}
2043
2044fn strip_prompt_trailer(text: &str) -> &str {
2045    let Some((before, _)) = split_at_prompt_trailer_marker(text) else {
2046        return text;
2047    };
2048    trim_prompt_before_trailer_marker(before)
2049}
2050
2051fn edited_trailer_recovery(original_trailer: &str, edited_text: &str) -> Option<String> {
2052    let (_, edited_trailer) = split_at_prompt_trailer_marker(edited_text)?;
2053    if edited_trailer == original_trailer {
2054        return None;
2055    }
2056    let trimmed = trim_prompt_newlines(edited_trailer);
2057    (!trimmed.is_empty()).then(|| trimmed.to_owned())
2058}
2059
2060fn trim_prompt_before_trailer_marker(before: &str) -> &str {
2061    before
2062        .strip_suffix("\n\n")
2063        .or_else(|| before.strip_suffix("\r\n\r\n"))
2064        .or_else(|| before.strip_suffix('\n'))
2065        .or_else(|| before.strip_suffix("\r\n"))
2066        .unwrap_or(before)
2067}
2068
2069fn split_at_prompt_trailer_marker(text: &str) -> Option<(&str, &str)> {
2070    let mut line_start = 0;
2071    for line in text.split_inclusive('\n') {
2072        let line_without_newline = line.strip_suffix('\n').unwrap_or(line);
2073        let line_without_ending = line_without_newline
2074            .strip_suffix('\r')
2075            .unwrap_or(line_without_newline);
2076        if line_without_ending == PROMPT_TRAILER_MARKER {
2077            let trailer_start = line_start + line.len();
2078            return Some((&text[..line_start], &text[trailer_start..]));
2079        }
2080        line_start += line.len();
2081    }
2082    None
2083}
2084
2085fn push_markdown_quote(out: &mut String, text: &str) {
2086    for line in text.lines() {
2087        out.push_str("> ");
2088        out.push_str(line);
2089        out.push('\n');
2090    }
2091}
2092
2093/// Resolves the external editor once at startup: `$EDITOR`, then `$VISUAL`,
2094/// then the first of `hx`/`vim`/`vi`/`nano` found on `$PATH`. The resolved
2095/// command is exposed to prompt shell actions as `$TAU_EDITOR`.
2096fn resolve_external_editor() -> Option<String> {
2097    for var in ["EDITOR", "VISUAL"] {
2098        if let Some(val) = std::env::var_os(var) {
2099            let s = val.to_string_lossy();
2100            let trimmed = s.trim();
2101            if !trimmed.is_empty() {
2102                return Some(trimmed.to_owned());
2103            }
2104        }
2105    }
2106    ["hx", "vim", "vi", "nano"]
2107        .into_iter()
2108        .find(|cand| which::which(cand).is_ok())
2109        .map(str::to_owned)
2110}