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