1use 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 -->";
48const 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);
56const 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 = " ";
96const 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;
102const PROMPT_HISTORY_MAX_ENTRIES: usize = 1000;
104const PROMPT_HISTORY_MAX_BYTES: usize = 16 * 1024 * 1024;
106#[derive(Clone, Copy)]
108struct PromptHistoryLimits {
109 max_entries: usize,
111 max_bytes: usize,
113}
114const COMPLETION_MENU_BLOCK_ID: BlockId = BlockId(u64::MAX);
115
116struct AgentPickerColumn {
118 source_field: usize,
120 minimum_width: usize,
122 preferred_width: usize,
124 max_width: usize,
126}
127
128struct ExternalResumeGuard<F: FnMut() -> io::Result<()>> {
130 resume: F,
132 armed: bool,
134}
135
136struct AgentPickerHooks<P, R, A> {
138 pause: P,
140 resume: R,
142 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 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
179fn 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
193pub enum Event {
195 PasteUpload {
197 id: u64,
199 text: std::sync::Arc<str>,
201 },
202 PasteCancelled {
204 id: u64,
206 },
207 Line(String),
210 Eof,
212 CancelPrompt,
214 Resize { width: u16, height: u16 },
216 FocusChanged { focused: bool },
218 BufferChanged,
222 BackTab,
224 Escape,
226 Action(String),
229}
230
231#[derive(Debug)]
233pub enum ExternalProgramError {
234 Command(String),
236 ForegroundOwnershipUnconfirmed {
238 message: String,
241 diagnostic: ForegroundRestorationDiagnostic,
243 },
244}
245
246impl ExternalProgramError {
247 #[must_use]
249 pub fn is_foreground_ownership_unconfirmed(&self) -> bool {
250 matches!(self, Self::ForegroundOwnershipUnconfirmed { .. })
251 }
252
253 #[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#[must_use]
295pub fn is_foreground_ownership_unconfirmed(error: &io::Error) -> bool {
296 foreground_restoration_diagnostic(error).is_some()
297}
298
299#[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
310pub struct HighTerm {
312 term: tau_cli_term_raw::Term,
313 handle: TermHandle,
314 theme: Theme,
315 editor_context: Arc<Mutex<EditorContext>>,
316 external_editor: Option<String>,
320 menu_block_id: Option<BlockId>,
324 prompt_history: Vec<String>,
328 last_submitted_prompt_retained: bool,
331 #[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 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 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 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 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 #[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 #[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 pub fn handle(&self) -> &TermHandle {
532 &self.handle
533 }
534
535 pub fn set_editor_context_handle(&mut self, editor_context: Arc<Mutex<EditorContext>>) {
544 self.editor_context = editor_context;
545 }
546
547 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 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 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 pub fn set_theme(&mut self, theme: Theme) {
624 self.theme = theme;
625 self.sync_menu_block();
626 self.handle.redraw();
627 }
628
629 pub fn redraw(&self) {
631 self.handle.redraw();
632 }
633
634 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 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 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 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 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 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 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 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 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 fn run_prompt_action(&mut self, action: PromptShellAction) -> PromptActionOutcome {
943 self.run_prompt_action_with_context(action, self.editor_context.clone())
944 }
945
946 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) => {} 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 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 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 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 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 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 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
1186fn 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
1199fn 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#[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
1356fn 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
1433fn 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#[derive(Clone, Default)]
1460pub struct EditorContext {
1461 pub current_response: Option<String>,
1464 pub last_response: Option<String>,
1467 pub previous_prompt: Option<String>,
1470 pub chat_markdown: Option<String>,
1473 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
1553fn fatal_terminal_ownership(error: BoundedCommandError) -> io::Error {
1555 io::Error::other(error)
1556}
1557
1558impl PromptShellAction {
1559 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, ¤t, &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 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
2093fn 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}