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 Line(String),
198 Eof,
200 CancelPrompt,
202 Resize { width: u16, height: u16 },
204 FocusChanged { focused: bool },
206 BufferChanged,
210 BackTab,
212 Escape,
214 Action(String),
217}
218
219#[derive(Debug)]
221pub enum ExternalProgramError {
222 Command(String),
224 ForegroundOwnershipUnconfirmed {
226 message: String,
229 diagnostic: ForegroundRestorationDiagnostic,
231 },
232}
233
234impl ExternalProgramError {
235 #[must_use]
237 pub fn is_foreground_ownership_unconfirmed(&self) -> bool {
238 matches!(self, Self::ForegroundOwnershipUnconfirmed { .. })
239 }
240
241 #[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#[must_use]
283pub fn is_foreground_ownership_unconfirmed(error: &io::Error) -> bool {
284 foreground_restoration_diagnostic(error).is_some()
285}
286
287#[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
298pub struct HighTerm {
300 term: tau_cli_term_raw::Term,
301 handle: TermHandle,
302 theme: Theme,
303 editor_context: Arc<Mutex<EditorContext>>,
304 external_editor: Option<String>,
308 menu_block_id: Option<BlockId>,
312 prompt_history: Vec<String>,
316 last_submitted_prompt_retained: bool,
319 #[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 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 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 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 #[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 #[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 pub fn handle(&self) -> &TermHandle {
505 &self.handle
506 }
507
508 pub fn set_editor_context_handle(&mut self, editor_context: Arc<Mutex<EditorContext>>) {
517 self.editor_context = editor_context;
518 }
519
520 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 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 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 pub fn set_theme(&mut self, theme: Theme) {
597 self.theme = theme;
598 self.sync_menu_block();
599 self.handle.redraw();
600 }
601
602 pub fn redraw(&self) {
604 self.handle.redraw();
605 }
606
607 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 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 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 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 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 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 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 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 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 fn run_prompt_action(&mut self, action: PromptShellAction) -> PromptActionOutcome {
912 self.run_prompt_action_with_context(action, self.editor_context.clone())
913 }
914
915 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) => {} 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 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 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 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 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 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 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
1149fn 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#[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
1306fn 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
1383fn 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#[derive(Clone, Default)]
1410pub struct EditorContext {
1411 pub current_response: Option<String>,
1414 pub last_response: Option<String>,
1417 pub previous_prompt: Option<String>,
1420 pub chat_markdown: Option<String>,
1423 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
1503fn fatal_terminal_ownership(error: BoundedCommandError) -> io::Error {
1505 io::Error::other(error)
1506}
1507
1508impl PromptShellAction {
1509 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, ¤t, &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 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
2043fn 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}