use std::{ffi as path_std_ffi, process as path_std_process, time as path_std_time};
mod bounded_command;
pub mod completion;
pub mod resolve;
#[cfg(test)]
mod tests;
use std::io;
use std::sync::{Arc, Mutex};
pub use bounded_command::ForegroundRestorationDiagnostic;
use bounded_command::{
BoundedCommandError, ProcessOwnership, run_with_bounded_stdout,
run_with_bounded_stdout_after_spawn, run_with_inherited_stdio,
};
pub use completion::{
ArgCompleter, CommandCompletion, CommandName, CompletionData, CompletionItem, CompletionRule,
CompletionRules,
};
#[cfg(test)]
pub(crate) use tau_cli_term_raw::RawEvent as TestRawEvent;
pub use tau_cli_term_raw::{
Align, BlockId, Cell, Color, CursorShape, OpaquePresentationFact, OutputSnapshot,
PresentationInvalidation, PresentationObservationKey, PriorityLine, PriorityLineAlignment,
PriorityLinePriority, PriorityLineTruncation, RedrawSuppressionGuard, RendererDeliveryId, Span,
Style, StyledBlock, StyledText, TermHandle, TerminalOptions, TwoLineElision, is_output_failure,
sanitize_hyperlink_target,
};
use tau_cli_term_raw::{Candidate, Event as RawEvent};
use tau_term_screen::{display_width, truncate_to_width};
use tau_themes::Theme;
const PROMPT_TRAILER_MARKER: &str =
"<!-- TAU trailer: everything after this line will be ignored -->";
const PROMPT_COMMAND_OUTPUT_LIMIT_BYTES: usize = 1024 * 1024;
const COMPLETION_COMMAND_OUTPUT_LIMIT_BYTES: usize = 256 * 1024;
const COMPLETION_COMMAND_TIMEOUT: std::time::Duration = path_std_time::Duration::from_secs(10);
const PROMPT_COMMAND_TIMEOUT: std::time::Duration = path_std_time::Duration::from_secs(60 * 60);
const AGENT_PICKER_OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
const AGENT_PICKER_TIMEOUT: std::time::Duration = path_std_time::Duration::from_secs(5 * 60);
const AGENT_PICKER_FZF_ARGS: &[&str] = &[
"--height=100%",
"--delimiter=\t",
"--with-nth=16",
"--no-multi",
"--no-hscroll",
"--prompt=agent> ",
];
const AGENT_PICKER_SOURCE_FIELDS: usize = 15;
const AGENT_PICKER_COLUMNS: [AgentPickerColumn; 4] = [
AgentPickerColumn {
source_field: usize::MAX,
minimum_width: 8,
preferred_width: 24,
max_width: 40,
},
AgentPickerColumn {
source_field: 10,
minimum_width: 9,
preferred_width: 9,
max_width: 9,
},
AgentPickerColumn {
source_field: 11,
minimum_width: 9,
preferred_width: 9,
max_width: 9,
},
AgentPickerColumn {
source_field: 13,
minimum_width: 12,
preferred_width: 24,
max_width: 40,
},
];
const AGENT_PICKER_COLUMN_GAP: &str = " ";
const AGENT_PICKER_FZF_DECORATION_WIDTH: usize = 4;
const PROMPT_HISTORY_SEARCH_MAX_ROWS: usize = 200;
const PROMPT_HISTORY_SUMMARY_MAX_CHARS: usize = 240;
const PROMPT_HISTORY_PREVIEW_MAX_BYTES: usize = 64 * 1024;
const PROMPT_HISTORY_PREVIEW_TOTAL_BYTES: usize = 1024 * 1024;
const PROMPT_HISTORY_MAX_ENTRIES: usize = 1000;
const PROMPT_HISTORY_MAX_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone, Copy)]
struct PromptHistoryLimits {
max_entries: usize,
max_bytes: usize,
}
const COMPLETION_MENU_BLOCK_ID: BlockId = BlockId(u64::MAX);
struct AgentPickerColumn {
source_field: usize,
minimum_width: usize,
preferred_width: usize,
max_width: usize,
}
struct ExternalResumeGuard<F: FnMut() -> io::Result<()>> {
resume: F,
armed: bool,
}
struct AgentPickerHooks<P, R, A> {
pause: P,
resume: R,
after_spawn: A,
}
impl<F: FnMut() -> io::Result<()>> ExternalResumeGuard<F> {
fn new(resume: F) -> Self {
Self {
resume,
armed: true,
}
}
fn finish(mut self) -> io::Result<()> {
self.armed = false;
(self.resume)()
}
fn disarm(mut self) {
self.armed = false;
}
}
impl<F: FnMut() -> io::Result<()>> Drop for ExternalResumeGuard<F> {
fn drop(&mut self) {
if self.armed
&& let Err(error) = (self.resume)()
{
tracing::warn!(
target: "tau_cli::input",
%error,
"failed to resume terminal after external command"
);
}
}
}
fn preserve_pause_on_unconfirmed_foreground<T, F: FnMut() -> io::Result<()>>(
guard: ExternalResumeGuard<F>,
result: Result<T, BoundedCommandError>,
) -> Result<T, BoundedCommandError> {
if result
.as_ref()
.is_err_and(BoundedCommandError::is_foreground_ownership_unconfirmed)
{
guard.disarm();
}
result
}
pub enum Event {
PasteUpload {
id: u64,
text: std::sync::Arc<str>,
},
PasteCancelled {
id: u64,
},
Line(String),
Eof,
CancelPrompt,
Resize { width: u16, height: u16 },
FocusChanged { focused: bool },
BufferChanged,
BackTab,
Escape,
Action(String),
}
#[derive(Debug)]
pub enum ExternalProgramError {
Command(String),
ForegroundOwnershipUnconfirmed {
message: String,
diagnostic: ForegroundRestorationDiagnostic,
},
}
impl ExternalProgramError {
#[must_use]
pub fn is_foreground_ownership_unconfirmed(&self) -> bool {
matches!(self, Self::ForegroundOwnershipUnconfirmed { .. })
}
#[must_use]
pub fn foreground_restoration_diagnostic(&self) -> Option<ForegroundRestorationDiagnostic> {
match self {
Self::ForegroundOwnershipUnconfirmed { diagnostic, .. } => Some(*diagnostic),
Self::Command(_) => None,
}
}
}
impl std::fmt::Display for ExternalProgramError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Command(error) => formatter.write_str(error),
Self::ForegroundOwnershipUnconfirmed { message, .. } => formatter.write_str(message),
}
}
}
impl std::error::Error for ExternalProgramError {}
impl From<BoundedCommandError> for ExternalProgramError {
fn from(error: BoundedCommandError) -> Self {
match error.foreground_restoration_diagnostic() {
Some(diagnostic) => Self::ForegroundOwnershipUnconfirmed {
message: error.to_string(),
diagnostic,
},
None => Self::Command(error.to_string()),
}
}
}
impl From<String> for ExternalProgramError {
fn from(error: String) -> Self {
Self::Command(error)
}
}
#[must_use]
pub fn is_foreground_ownership_unconfirmed(error: &io::Error) -> bool {
foreground_restoration_diagnostic(error).is_some()
}
#[must_use]
pub fn foreground_restoration_diagnostic(
error: &io::Error,
) -> Option<ForegroundRestorationDiagnostic> {
error
.get_ref()
.and_then(|source| source.downcast_ref::<BoundedCommandError>())
.and_then(BoundedCommandError::foreground_restoration_diagnostic)
}
pub struct HighTerm {
term: tau_cli_term_raw::Term,
handle: TermHandle,
theme: Theme,
editor_context: Arc<Mutex<EditorContext>>,
external_editor: Option<String>,
menu_block_id: Option<BlockId>,
prompt_history: Vec<String>,
last_submitted_prompt_retained: bool,
#[cfg(test)]
prompt_history_limit_override: Option<PromptHistoryLimits>,
completion_command_rules: completion::CompletionCommandRules,
last_command_completion_token: Option<String>,
}
impl HighTerm {
pub fn run_with_external_terminal<T>(&self, callback: impl FnOnce() -> T) -> io::Result<T> {
run_with_external_terminal_hooks(
callback,
|| self.term.pause_for_external(),
|| self.term.resume_after_external(),
)
}
pub fn new(
left_prompt: impl Into<StyledText>,
commands: Vec<CommandCompletion>,
theme: Theme,
bindings: impl IntoIterator<Item = (String, String)>,
terminal_options: TerminalOptions,
) -> io::Result<(Self, TermHandle, CompletionData)> {
Self::new_with_completion_rules(
left_prompt,
commands,
theme,
bindings,
std::iter::empty(),
CompletionRules::default(),
terminal_options,
)
}
pub fn new_with_input_history(
left_prompt: impl Into<StyledText>,
commands: Vec<CommandCompletion>,
theme: Theme,
bindings: impl IntoIterator<Item = (String, String)>,
input_history: impl IntoIterator<Item = String>,
terminal_options: TerminalOptions,
) -> io::Result<(Self, TermHandle, CompletionData)> {
Self::new_with_completion_rules(
left_prompt,
commands,
theme,
bindings,
input_history,
CompletionRules::default(),
terminal_options,
)
}
pub fn new_with_completion_rules(
left_prompt: impl Into<StyledText>,
commands: Vec<CommandCompletion>,
theme: Theme,
bindings: impl IntoIterator<Item = (String, String)>,
input_history: impl IntoIterator<Item = String>,
completion_rules: CompletionRules,
terminal_options: TerminalOptions,
) -> io::Result<(Self, TermHandle, CompletionData)> {
let data = CompletionData::new();
let (term, handle) = Self::new_with_completion_rules_and_data(
left_prompt,
commands,
theme,
bindings,
input_history,
completion_rules,
terminal_options,
data.clone(),
)?;
Ok((term, handle, data))
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_completion_rules_and_data(
left_prompt: impl Into<StyledText>,
commands: Vec<CommandCompletion>,
theme: Theme,
bindings: impl IntoIterator<Item = (String, String)>,
input_history: impl IntoIterator<Item = String>,
completion_rules: CompletionRules,
terminal_options: TerminalOptions,
data: CompletionData,
) -> io::Result<(Self, TermHandle)> {
let input_history: Vec<String> = input_history.into_iter().collect();
let (mut term, handle) = tau_cli_term_raw::Term::new(left_prompt, terminal_options)?;
term.defer_submitted_input_history_limit();
term.seed_input_history(input_history.clone());
term.set_bindings(bindings);
let handle_clone = handle.clone();
let completion_command_rules = completion_rules.command_rules().clone();
term.set_completion_source(Some(make_completion_source(
commands,
data,
completion_rules.clone(),
)));
let external_editor = resolve_external_editor();
Ok((
Self {
term,
handle,
theme,
editor_context: Arc::new(Mutex::new(EditorContext::default())),
external_editor,
menu_block_id: None,
prompt_history: bounded_seeded_prompt_history(
input_history
.into_iter()
.filter(|entry| !entry.is_empty())
.collect(),
),
last_submitted_prompt_retained: false,
#[cfg(test)]
prompt_history_limit_override: None,
completion_command_rules,
last_command_completion_token: None,
},
handle_clone,
))
}
#[cfg(test)]
pub(crate) fn new_for_test(
term: tau_cli_term_raw::Term,
handle: TermHandle,
commands: Vec<CommandCompletion>,
theme: Theme,
bindings: impl IntoIterator<Item = (String, String)>,
) -> (Self, CompletionData) {
Self::new_for_test_with_completion_rules(
term,
handle,
commands,
theme,
bindings,
CompletionRules::default(),
)
}
#[cfg(test)]
pub(crate) fn new_for_test_with_completion_rules(
mut term: tau_cli_term_raw::Term,
handle: TermHandle,
commands: Vec<CommandCompletion>,
theme: Theme,
bindings: impl IntoIterator<Item = (String, String)>,
completion_rules: CompletionRules,
) -> (Self, CompletionData) {
term.defer_submitted_input_history_limit();
let data = CompletionData::new();
let data_clone = data.clone();
let completion_command_rules = completion_rules.command_rules().clone();
term.set_completion_source(Some(make_completion_source(
commands,
data,
completion_rules.clone(),
)));
term.set_bindings(bindings);
(
Self {
term,
handle,
theme,
editor_context: Arc::new(Mutex::new(EditorContext::default())),
external_editor: None,
menu_block_id: None,
prompt_history: Vec::new(),
last_submitted_prompt_retained: false,
prompt_history_limit_override: None,
completion_command_rules,
last_command_completion_token: None,
},
data_clone,
)
}
pub fn handle(&self) -> &TermHandle {
&self.handle
}
pub fn set_editor_context_handle(&mut self, editor_context: Arc<Mutex<EditorContext>>) {
self.editor_context = editor_context;
}
pub fn edit_prompt_with_response(&mut self, response: String) -> io::Result<()> {
let mut context = self
.editor_context
.lock()
.expect("editor context mutex poisoned")
.clone();
context.current_response = None;
context.last_response = Some(response);
context.previous_prompt = None;
context.chat_markdown = None;
self.edit_prompt_with_context(context, None)
}
pub fn edit_prompt_with_chat(&mut self, chat_markdown: String) -> io::Result<()> {
let recovery = self
.editor_context
.lock()
.expect("editor context mutex poisoned")
.edited_trailer_recovery
.clone();
self.edit_prompt_with_context(
EditorContext {
chat_markdown: Some(chat_markdown),
edited_trailer_recovery: recovery.clone(),
..EditorContext::default()
},
recovery,
)
}
fn edit_prompt_with_context(
&mut self,
context: EditorContext,
hidden_recovery: Option<String>,
) -> io::Result<()> {
let temporary = Arc::new(Mutex::new(context));
let outcome = self.run_prompt_action_with_context(
PromptShellAction::Edit(PromptShellCommand {
command: "$TAU_EDITOR \"$TAU_PROMPT_PATH\"".to_owned(),
trim: false,
}),
temporary.clone(),
);
let edited_recovery = temporary
.lock()
.expect("editor context mutex poisoned")
.edited_trailer_recovery
.clone()
.or(hidden_recovery);
self.editor_context
.lock()
.expect("editor context mutex poisoned")
.edited_trailer_recovery = edited_recovery;
if !matches!(outcome, PromptActionOutcome::Fatal(_)) {
self.handle.redraw_sync();
}
match outcome {
PromptActionOutcome::Fatal(error) => Err(error),
PromptActionOutcome::BufferChanged
| PromptActionOutcome::Continue
| PromptActionOutcome::Return(_) => Ok(()),
}
}
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
self.sync_menu_block();
self.handle.redraw();
}
pub fn redraw(&self) {
self.handle.redraw();
}
pub fn replace_last_submitted_prompt(&mut self, text: String) {
if self.last_submitted_prompt_retained {
if let Some(last) = self.prompt_history.last_mut() {
*last = text.clone();
}
} else if !text.is_empty() {
self.prompt_history.push(text.clone());
}
self.term.replace_last_submitted_input(text);
}
pub fn pick_agent_row_with_fzf(
&self,
rows: &str,
) -> Result<Option<String>, ExternalProgramError> {
self.pick_agent_row_with_command(
path_std_ffi::OsStr::new("fzf"),
rows,
AGENT_PICKER_TIMEOUT,
ProcessOwnership::ForegroundProcessGroup,
)
}
fn pick_agent_row_with_command(
&self,
program: &std::ffi::OsStr,
rows: &str,
timeout: std::time::Duration,
ownership: ProcessOwnership,
) -> Result<Option<String>, ExternalProgramError> {
self.pick_agent_row_with_command_and_terminal(
program,
rows,
timeout,
ownership,
AgentPickerHooks {
pause: || self.term.pause_for_external(),
resume: || self.term.resume_after_external(),
after_spawn: || Ok(()),
},
)
}
fn pick_agent_row_with_command_and_terminal(
&self,
program: &std::ffi::OsStr,
rows: &str,
timeout: std::time::Duration,
ownership: ProcessOwnership,
hooks: AgentPickerHooks<
impl FnOnce() -> io::Result<()>,
impl FnMut() -> io::Result<()>,
impl FnOnce() -> Result<(), String>,
>,
) -> Result<Option<String>, ExternalProgramError> {
let picker_rows = format_agent_picker_rows(rows, self.handle.size().0)
.map_err(ExternalProgramError::Command)?;
(hooks.pause)().map_err(|error| format!("could not release terminal: {error}"))?;
let guard = ExternalResumeGuard::new(hooks.resume);
let selection = run_agent_fzf_command_with_ownership(
program,
&picker_rows,
timeout,
ownership,
hooks.after_spawn,
);
if selection
.as_ref()
.is_err_and(BoundedCommandError::is_foreground_ownership_unconfirmed)
{
guard.disarm();
return selection.map_err(ExternalProgramError::from);
}
guard.finish().map_err(|error| {
ExternalProgramError::Command(format!(
"could not resume terminal after agent picker: {error}"
))
})?;
selection.map_err(ExternalProgramError::from)
}
pub fn dismiss_completion_menu(&mut self) -> bool {
let dismissed = self.term.dismiss_completion_menu();
if dismissed {
self.sync_menu_block();
self.handle.redraw();
}
dismissed
}
pub fn print_output(
&self,
debug_id: impl Into<String>,
block: impl Into<StyledBlock>,
) -> BlockId {
self.handle.print_output(debug_id, block)
}
pub fn get_next_event(&mut self) -> io::Result<Event> {
loop {
let raw = self.term.get_next_event()?;
match self.handle_next_raw_event(raw) {
NextEventStep::Return(event) => return Ok(event),
NextEventStep::Continue => continue,
NextEventStep::Fatal(error) => return Err(error),
}
}
}
fn handle_next_raw_event(&mut self, raw: RawEvent) -> NextEventStep {
match raw {
RawEvent::PasteUpload { id, text } => {
NextEventStep::Return(Event::PasteUpload { id, text })
}
RawEvent::PasteCancelled { id } => NextEventStep::Return(Event::PasteCancelled { id }),
RawEvent::BufferChanged => self.handle_buffer_changed_event(),
RawEvent::CompletionRefresh => {
self.sync_menu_block();
self.handle.redraw();
NextEventStep::Continue
}
RawEvent::CompletionAccept => self.handle_completion_accept_event(),
RawEvent::BackTab => NextEventStep::Return(Event::BackTab),
RawEvent::Escape => NextEventStep::Return(Event::Escape),
RawEvent::Line(line) => self.handle_line_event(line),
RawEvent::Eof => {
self.sync_menu_block();
NextEventStep::Return(Event::Eof)
}
RawEvent::CancelPrompt => {
self.sync_menu_block();
self.handle.redraw_sync();
NextEventStep::Return(Event::CancelPrompt)
}
RawEvent::Resize { width, height } => {
self.sync_menu_block();
self.handle.redraw();
NextEventStep::Return(Event::Resize { width, height })
}
RawEvent::FocusChanged { focused } => {
NextEventStep::Return(Event::FocusChanged { focused })
}
RawEvent::Notice(message) => self.handle_notice_event(&message),
RawEvent::ExternalEditor => self.handle_external_editor_event(),
RawEvent::Binding(action) => self.handle_binding_event(&action),
}
}
fn handle_buffer_changed_event(&mut self) -> NextEventStep {
let completion_changed = match self.maybe_run_command_completion() {
Ok(changed) => changed,
Err(error) => return NextEventStep::Fatal(error),
};
if completion_changed {
self.sync_menu_block();
self.handle.redraw_sync();
return NextEventStep::Return(Event::BufferChanged);
}
self.sync_menu_block();
self.handle.redraw();
NextEventStep::Return(Event::BufferChanged)
}
fn handle_completion_accept_event(&mut self) -> NextEventStep {
self.sync_menu_block();
self.handle.redraw();
NextEventStep::Continue
}
fn handle_line_event(&mut self, line: String) -> NextEventStep {
let started = path_std_time::Instant::now();
self.record_submitted_prompt(&line);
let history_finished = path_std_time::Instant::now();
let redraw_requested = self.sync_menu_block();
let menu_finished = path_std_time::Instant::now();
if redraw_requested {
self.handle.redraw();
}
tracing::trace!(
target: "tau_cli::prompt_submission",
stage = "highterm_history_menu",
prompt_bytes = line.len(),
history_us = history_finished.duration_since(started).as_micros(),
menu_sync_us = menu_finished.duration_since(history_finished).as_micros(),
redraw_request_us = menu_finished.elapsed().as_micros(),
redraw_requested,
stage_us = started.elapsed().as_micros(),
"content-free prompt submission stage"
);
NextEventStep::Return(Event::Line(line))
}
fn handle_notice_event(&mut self, message: &str) -> NextEventStep {
self.sync_menu_block();
self.print_local(message);
self.handle.redraw_sync();
NextEventStep::Return(Event::BufferChanged)
}
fn handle_external_editor_event(&mut self) -> NextEventStep {
self.sync_menu_block();
let outcome = self.run_prompt_action(PromptShellAction::Edit(PromptShellCommand {
command: "$TAU_EDITOR \"$TAU_PROMPT_PATH\"".to_owned(),
trim: false,
}));
if !matches!(outcome, PromptActionOutcome::Fatal(_)) {
self.handle.redraw_sync();
}
outcome.into_next_event_step()
}
fn handle_binding_event(&mut self, action: &str) -> NextEventStep {
self.sync_menu_block();
let outcome = self.run_binding(action);
if !matches!(outcome, PromptActionOutcome::Fatal(_)) {
self.handle.redraw_sync();
}
outcome.into_next_event_step()
}
fn sync_menu_block(&mut self) -> bool {
match self.term.completion_state() {
Some(view) => {
let (width, height) = self.handle.size();
let block = completion::render_menu_block(&view, &self.theme, width, height);
let id = match self.menu_block_id {
Some(id) => id,
None => {
let id = COMPLETION_MENU_BLOCK_ID;
self.handle.set_block(id, "");
self.handle.push_suggestions(id);
self.menu_block_id = Some(id);
id
}
};
self.handle.set_block(id, block);
true
}
None => {
if let Some(id) = self.menu_block_id.take() {
self.handle.remove_suggestions(id);
self.handle.remove_block(id);
true
} else {
false
}
}
}
}
fn run_binding(&mut self, action: &str) -> PromptActionOutcome {
tracing::trace!(target: "tau_cli::input", action, "running prompt binding");
if tau_cli_term_raw::Term::is_named_action(action) {
return self
.term
.trigger_named_action(action)
.map_or(PromptActionOutcome::Continue, |raw| {
self.apply_raw_prompt_event(raw)
});
}
let Some(action) = PromptShellAction::parse(action) else {
self.print_local(&format!("binding: unknown action `{action}`"));
return PromptActionOutcome::BufferChanged;
};
self.run_prompt_action(action)
}
fn run_prompt_action(&mut self, action: PromptShellAction) -> PromptActionOutcome {
self.run_prompt_action_with_context(action, self.editor_context.clone())
}
fn run_prompt_action_with_context(
&mut self,
action: PromptShellAction,
editor_context: Arc<Mutex<EditorContext>>,
) -> PromptActionOutcome {
match run_prompt_shell_action(
&self.term,
&self.handle,
editor_context,
self.external_editor.as_deref(),
&self.prompt_history,
action,
) {
Ok(Some(PromptShellResult::Replace(new_text))) => {
let cursor = new_text.len();
self.handle.set_buffer(new_text, cursor);
self.sync_menu_block();
}
Ok(Some(PromptShellResult::ReplacePreservingUndo(new_text))) => {
let cursor = new_text.len();
self.handle.set_buffer_preserving_undo(new_text, cursor);
self.sync_menu_block();
}
Ok(Some(PromptShellResult::Insert(text))) => {
let mut buffer = self.handle.get_buffer();
let cursor = self.handle.get_cursor();
buffer.insert_str(cursor, &text);
self.handle.set_buffer(buffer, cursor + text.len());
self.sync_menu_block();
}
Ok(Some(PromptShellResult::Action(action))) => {
return PromptActionOutcome::Return(Event::Action(action));
}
Ok(Some(PromptShellResult::History(delta))) => {
self.term.trigger_history_step(delta);
self.sync_menu_block();
}
Ok(Some(PromptShellResult::Undo)) => {
self.term.trigger_undo();
self.sync_menu_block();
}
Ok(Some(PromptShellResult::Redo)) => {
self.term.trigger_redo();
self.sync_menu_block();
}
Ok(Some(PromptShellResult::RawEvent(raw))) => {
return self.apply_raw_prompt_event(raw);
}
Ok(None) => {} Err(error) if error.is_foreground_ownership_unconfirmed() => {
return PromptActionOutcome::Fatal(fatal_terminal_ownership(error));
}
Err(error) => self.print_local(&format!("prompt action: {error}")),
}
PromptActionOutcome::BufferChanged
}
fn apply_raw_prompt_event(&mut self, raw: RawEvent) -> PromptActionOutcome {
match raw {
RawEvent::PasteUpload { id, text } => {
PromptActionOutcome::Return(Event::PasteUpload { id, text })
}
RawEvent::PasteCancelled { id } => {
PromptActionOutcome::Return(Event::PasteCancelled { id })
}
RawEvent::BufferChanged => {
self.sync_menu_block();
PromptActionOutcome::BufferChanged
}
RawEvent::CompletionRefresh => {
self.sync_menu_block();
self.handle.redraw();
PromptActionOutcome::Continue
}
RawEvent::CompletionAccept => {
self.sync_menu_block();
PromptActionOutcome::Continue
}
RawEvent::Line(line) => {
let started = path_std_time::Instant::now();
self.record_submitted_prompt(&line);
let history_finished = path_std_time::Instant::now();
self.sync_menu_block();
tracing::trace!(
target: "tau_cli::prompt_submission",
stage = "highterm_history_menu",
prompt_bytes = line.len(),
history_us = history_finished.duration_since(started).as_micros(),
menu_sync_us = history_finished.elapsed().as_micros(),
redraw_request_us = 0_u64,
redraw_requested = false,
stage_us = started.elapsed().as_micros(),
"content-free prompt submission stage"
);
PromptActionOutcome::Return(Event::Line(line))
}
RawEvent::Eof => PromptActionOutcome::Return(Event::Eof),
RawEvent::CancelPrompt => PromptActionOutcome::Return(Event::CancelPrompt),
RawEvent::Resize { width, height } => {
PromptActionOutcome::Return(Event::Resize { width, height })
}
RawEvent::FocusChanged { focused } => {
PromptActionOutcome::Return(Event::FocusChanged { focused })
}
RawEvent::BackTab => PromptActionOutcome::Return(Event::BackTab),
RawEvent::Escape => PromptActionOutcome::Return(Event::Escape),
RawEvent::Notice(message) => {
self.print_local(&message);
PromptActionOutcome::BufferChanged
}
RawEvent::Binding(_) | RawEvent::ExternalEditor => {
unreachable!("unsupported prompt action event")
}
}
}
fn record_submitted_prompt(&mut self, line: &str) {
if line.is_empty() {
return;
}
let history_line = canonical_literal_colon_prompt(line).unwrap_or_else(|| line.to_owned());
self.prompt_history.push(history_line.clone());
self.last_submitted_prompt_retained = true;
self.term.replace_last_submitted_input(history_line);
}
pub fn finalize_last_submitted_prompt_history(&mut self) {
let history = std::mem::take(&mut self.prompt_history);
self.prompt_history = self.bounded_prompt_history_for_attachment(history);
self.last_submitted_prompt_retained = self.prompt_history.last().is_some();
self.term.finalize_submitted_input_history();
}
fn bounded_prompt_history_for_attachment(&self, prompt_history: Vec<String>) -> Vec<String> {
Self::bounded_prompt_history_with_limits(
prompt_history,
self.effective_prompt_history_limits(),
)
}
fn bounded_prompt_history_with_limits(
mut prompt_history: Vec<String>,
limits: PromptHistoryLimits,
) -> Vec<String> {
prompt_history.retain(|entry| !entry.is_empty() && entry.len() <= limits.max_bytes);
let mut retained_bytes = 0;
let mut retained_start = prompt_history.len();
for (retained_entries, (index, entry)) in
prompt_history.iter().enumerate().rev().enumerate()
{
if retained_entries == limits.max_entries
|| entry.len() > limits.max_bytes - retained_bytes
{
break;
}
retained_bytes += entry.len();
retained_start = index;
}
prompt_history.drain(..retained_start);
prompt_history
}
fn prompt_history_limits() -> PromptHistoryLimits {
PromptHistoryLimits {
max_entries: PROMPT_HISTORY_MAX_ENTRIES,
max_bytes: PROMPT_HISTORY_MAX_BYTES,
}
}
fn effective_prompt_history_limits(&self) -> PromptHistoryLimits {
#[cfg(test)]
if let Some(limits) = self.prompt_history_limit_override {
return limits;
}
Self::prompt_history_limits()
}
fn maybe_run_command_completion(&mut self) -> io::Result<bool> {
let buffer = self.handle.get_buffer();
let cursor = self.handle.get_cursor();
let Some((command, before, after)) = self
.completion_command_rules
.command_for_exact_token(&buffer, cursor)
else {
self.last_command_completion_token = None;
return Ok(false);
};
let token_key = format!("{before}\0{cursor}");
if self.last_command_completion_token.as_deref() == Some(token_key.as_str()) {
return Ok(false);
}
self.last_command_completion_token = Some(token_key);
let completion_result = match command {
completion::CommandCompletionMatch::Command(command) => {
run_completion_command(&self.term, command)
}
completion::CommandCompletionMatch::EmptyCommand => {
Err(empty_completion_command_error())
}
};
match completion_result {
Ok(Some(text)) => {
let new_text = format!("{before}{text}{after}");
let new_cursor = before.len() + text.len();
self.handle.set_buffer(new_text, new_cursor);
self.last_command_completion_token = None;
Ok(true)
}
Ok(None) => Ok(false),
Err(error) if error.is_foreground_ownership_unconfirmed() => {
Err(fatal_terminal_ownership(error))
}
Err(error) => {
self.print_local(&format!("completion command: {error}"));
Ok(true)
}
}
}
fn print_local(&self, message: &str) {
let block = resolve::themed_block(
&self.theme,
tau_themes::names::SYSTEM_INFO,
message.to_owned(),
);
self.handle.print_output("prompt-action-error", block);
}
}
fn run_with_external_terminal_hooks<T>(
callback: impl FnOnce() -> T,
pause: impl FnOnce() -> io::Result<()>,
resume: impl FnMut() -> io::Result<()>,
) -> io::Result<T> {
pause()?;
let guard = ExternalResumeGuard::new(resume);
let result = callback();
guard.finish()?;
Ok(result)
}
fn bounded_seeded_prompt_history(prompt_history: Vec<String>) -> Vec<String> {
HighTerm::bounded_prompt_history_with_limits(prompt_history, HighTerm::prompt_history_limits())
}
#[must_use]
pub fn canonical_literal_colon_prompt(line: &str) -> Option<String> {
let leading_len = line.len() - line.trim_start().len();
line.get(leading_len..)?
.strip_prefix("::")
.map(|suffix| format!("{}:{suffix}", &line[..leading_len]))
}
fn run_agent_fzf_command_with_ownership(
program: &std::ffi::OsStr,
rows: &str,
timeout: std::time::Duration,
ownership: ProcessOwnership,
after_spawn: impl FnOnce() -> Result<(), String>,
) -> Result<Option<String>, BoundedCommandError> {
let mut command = path_std_process::Command::new(program);
command
.args(AGENT_PICKER_FZF_ARGS)
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let output = run_with_bounded_stdout_after_spawn(
&mut command,
Some(rows.as_bytes()),
AGENT_PICKER_OUTPUT_LIMIT_BYTES,
timeout,
ownership,
after_spawn,
)
.map_err(|error| match error {
BoundedCommandError::Command(error) => {
BoundedCommandError::Command(format!("fzf failed: {error}"))
}
error @ BoundedCommandError::ForegroundOwnershipUnconfirmed { .. } => error,
})?;
match output.status.code() {
Some(1 | 130) => Ok(None),
_ if !output.status.success() => Err(format!(
"fzf exited with status {}",
output.status.code().map_or_else(
|| "terminated by signal".to_owned(),
|code| code.to_string()
)
)
.into()),
_ => parse_agent_fzf_output(output.stdout).map_err(BoundedCommandError::Command),
}
}
fn format_agent_picker_rows(rows: &str, terminal_width: usize) -> Result<String, String> {
let fields = rows
.lines()
.map(|row| {
let fields = row.split('\t').collect::<Vec<_>>();
if fields.len() != AGENT_PICKER_SOURCE_FIELDS {
return Err(format!(
"agent row has {} fields instead of {AGENT_PICKER_SOURCE_FIELDS}",
fields.len()
));
}
Ok(fields)
})
.collect::<Result<Vec<_>, _>>()?;
let content_width = terminal_width.saturating_sub(AGENT_PICKER_FZF_DECORATION_WIDTH);
let column_widths = agent_picker_column_widths(&fields, content_width);
let mut output = String::new();
for fields in fields {
let display = AGENT_PICKER_COLUMNS
.iter()
.zip(&column_widths)
.map(|(column, &width)| {
let source = agent_picker_column_value(&fields, column.source_field);
let value = truncate_to_width(&source, width);
let padding = width.saturating_sub(display_width(&value));
format!("{value}{}", " ".repeat(padding))
})
.collect::<Vec<_>>()
.join(AGENT_PICKER_COLUMN_GAP)
.trim_end()
.to_owned();
output.push_str(&fields.join("\t"));
output.push('\t');
output.push_str(&display);
output.push('\n');
}
Ok(output)
}
fn agent_picker_column_widths(rows: &[Vec<&str>], content_width: usize) -> Vec<usize> {
let mut column_count = AGENT_PICKER_COLUMNS.len();
while 1 < column_count
&& content_width
< AGENT_PICKER_COLUMNS[..column_count]
.iter()
.map(|column| column.minimum_width)
.sum::<usize>()
+ AGENT_PICKER_COLUMN_GAP.len() * column_count.saturating_sub(1)
{
column_count -= 1;
}
if content_width == 0 {
return Vec::new();
}
let gap_width = AGENT_PICKER_COLUMN_GAP.len() * column_count.saturating_sub(1);
let available = content_width.saturating_sub(gap_width);
let natural = AGENT_PICKER_COLUMNS[..column_count]
.iter()
.map(|column| {
rows.iter()
.map(|row| display_width(&agent_picker_column_value(row, column.source_field)))
.max()
.unwrap_or(1)
.max(1)
.min(column.max_width)
})
.collect::<Vec<_>>();
let mut widths = vec![1; column_count];
let mut remaining = available.saturating_sub(column_count);
for targets in [
&AGENT_PICKER_COLUMNS[..column_count]
.iter()
.map(|column| column.minimum_width)
.collect::<Vec<_>>(),
&AGENT_PICKER_COLUMNS[..column_count]
.iter()
.map(|column| column.preferred_width)
.collect::<Vec<_>>(),
natural.as_slice(),
] {
while 0 < remaining {
let mut grew = false;
for (column, width) in widths.iter_mut().enumerate() {
let target = targets[column].min(natural[column]);
if *width < target && 0 < remaining {
*width += 1;
remaining -= 1;
grew = true;
}
}
if !grew {
break;
}
}
}
widths
}
fn agent_picker_column_value(fields: &[&str], source_field: usize) -> String {
if source_field == usize::MAX {
format!("{}{} @{}", fields[12], fields[14], fields[0])
} else {
fields[source_field].to_owned()
}
}
fn parse_agent_fzf_output(output: Vec<u8>) -> Result<Option<String>, String> {
let output =
String::from_utf8(output).map_err(|error| format!("fzf output was not UTF-8: {error}"))?;
let output = output.strip_suffix('\n').unwrap_or(&output);
let output = output.strip_suffix('\r').unwrap_or(output);
if output.is_empty() {
return Ok(None);
}
if output.contains(['\n', '\r']) {
return Err("fzf returned more than one row".to_owned());
}
let (row, _) = output
.rsplit_once('\t')
.ok_or_else(|| "fzf returned a malformed agent row".to_owned())?;
if row.split('\t').count() != AGENT_PICKER_SOURCE_FIELDS {
return Err("fzf returned a malformed agent row".to_owned());
}
Ok(Some(row.to_owned()))
}
fn make_completion_source(
commands: Vec<CommandCompletion>,
data: CompletionData,
rules: CompletionRules,
) -> Box<dyn tau_cli_term_raw::CompletionSource> {
let commands = Arc::new(commands);
let rules = Arc::new(rules);
Box::new(move |buffer: &str, cursor: usize| -> Vec<Candidate> {
completion::build_candidates_with_rules(&commands, &data, &rules, buffer, cursor)
})
}
fn run_completion_command(
term: &tau_cli_term_raw::Term,
command: &completion::CompletionCommand,
) -> Result<Option<String>, BoundedCommandError> {
term.pause_for_external()
.map_err(|e| format!("could not release terminal: {e}"))?;
let guard = ExternalResumeGuard::new(|| term.resume_after_external());
let mut command_builder = path_std_process::Command::new(command.program());
command_builder
.args(command.args())
.stdin(path_std_process::Stdio::null())
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let output = preserve_pause_on_unconfirmed_foreground(
guard,
run_with_bounded_stdout(
&mut command_builder,
None,
COMPLETION_COMMAND_OUTPUT_LIMIT_BYTES,
COMPLETION_COMMAND_TIMEOUT,
ProcessOwnership::ForegroundProcessGroup,
),
)?;
if !output.status.success() {
return Ok(None);
}
let text = String::from_utf8(output.stdout)
.map_err(|e| format!("command output was not utf-8: {e}"))?;
let text = text.trim().to_owned();
if text.is_empty() {
Ok(None)
} else {
Ok(Some(text))
}
}
fn empty_completion_command_error() -> BoundedCommandError {
"empty command".to_owned().into()
}
struct PromptShellCommand {
command: String,
trim: bool,
}
enum PromptShellAction {
Insert(PromptShellCommand),
Edit(PromptShellCommand),
HistorySearch(PromptShellCommand),
Action(String),
PromptNext,
PromptPrevious,
PromptUndo,
PromptRedo,
SubmitPrompt,
InsertNewline,
}
#[derive(Clone, Default)]
pub struct EditorContext {
pub current_response: Option<String>,
pub last_response: Option<String>,
pub previous_prompt: Option<String>,
pub chat_markdown: Option<String>,
pub edited_trailer_recovery: Option<String>,
}
impl EditorContext {
fn update_edited_trailer_recovery(&mut self, original_text: &str, edited_text: &str) {
let Some((_, original_trailer)) = split_at_prompt_trailer_marker(original_text) else {
return;
};
self.edited_trailer_recovery = edited_trailer_recovery(original_trailer, edited_text);
}
}
enum PromptShellResult {
Insert(String),
Replace(String),
ReplacePreservingUndo(String),
Action(String),
History(isize),
Undo,
Redo,
RawEvent(RawEvent),
}
#[derive(Clone, Copy)]
enum PromptShellExternalKind {
Insert,
Edit,
HistorySearch,
}
struct PromptShellExternalAction {
kind: PromptShellExternalKind,
shell: PromptShellCommand,
}
enum PromptShellDispatch {
Immediate(PromptShellResult),
External(PromptShellExternalAction),
}
struct PromptHistoryPicker {
rows: String,
prompt_dir: tempfile::TempDir,
}
enum PromptShellCommandOutput {
Edited,
Captured(Vec<u8>),
}
enum PromptActionOutcome {
BufferChanged,
Continue,
Return(Event),
Fatal(io::Error),
}
enum NextEventStep {
Return(Event),
Continue,
Fatal(io::Error),
}
impl PromptActionOutcome {
fn into_next_event_step(self) -> NextEventStep {
match self {
Self::BufferChanged => NextEventStep::Return(Event::BufferChanged),
Self::Continue => NextEventStep::Continue,
Self::Return(event) => NextEventStep::Return(event),
Self::Fatal(error) => NextEventStep::Fatal(error),
}
}
}
fn fatal_terminal_ownership(error: BoundedCommandError) -> io::Error {
io::Error::other(error)
}
impl PromptShellAction {
fn parse(action: &str) -> Option<Self> {
match action {
"prompt-next" => return Some(Self::PromptNext),
"prompt-previous" => return Some(Self::PromptPrevious),
"prompt-undo" => return Some(Self::PromptUndo),
"prompt-redo" => return Some(Self::PromptRedo),
"submit-prompt" => return Some(Self::SubmitPrompt),
"insert-newline" => return Some(Self::InsertNewline),
_ => {}
}
let mut parts = action.splitn(3, ':');
let name = parts.next()?;
let (Some(mode), Some(command)) = (parts.next(), parts.next()) else {
return (!action.is_empty() && !action.contains(':'))
.then(|| Self::Action(action.to_owned()));
};
let trim = match mode {
"trim" => true,
"raw" => false,
_ => return None,
};
let command = PromptShellCommand {
command: command.to_owned(),
trim,
};
match name {
"shell-prompt-insert" => Some(Self::Insert(command)),
"shell-prompt-edit" => Some(Self::Edit(command)),
"prompt-history-search" => Some(Self::HistorySearch(command)),
_ => None,
}
}
}
fn prompt_shell_dispatch(
action: PromptShellAction,
term: &tau_cli_term_raw::Term,
) -> PromptShellDispatch {
match action {
PromptShellAction::PromptNext => {
PromptShellDispatch::Immediate(PromptShellResult::History(1))
}
PromptShellAction::PromptPrevious => {
PromptShellDispatch::Immediate(PromptShellResult::History(-1))
}
PromptShellAction::PromptUndo => PromptShellDispatch::Immediate(PromptShellResult::Undo),
PromptShellAction::PromptRedo => PromptShellDispatch::Immediate(PromptShellResult::Redo),
PromptShellAction::Action(action) => {
PromptShellDispatch::Immediate(PromptShellResult::Action(action))
}
PromptShellAction::SubmitPrompt => PromptShellDispatch::Immediate(
PromptShellResult::RawEvent(term.trigger_submit_or_accept_completion()),
),
PromptShellAction::InsertNewline => PromptShellDispatch::Immediate(
PromptShellResult::RawEvent(term.trigger_insert_newline()),
),
PromptShellAction::Insert(shell) => {
PromptShellDispatch::External(PromptShellExternalAction {
kind: PromptShellExternalKind::Insert,
shell,
})
}
PromptShellAction::Edit(shell) => {
PromptShellDispatch::External(PromptShellExternalAction {
kind: PromptShellExternalKind::Edit,
shell,
})
}
PromptShellAction::HistorySearch(shell) => {
PromptShellDispatch::External(PromptShellExternalAction {
kind: PromptShellExternalKind::HistorySearch,
shell,
})
}
}
}
fn run_prompt_shell_action(
term: &tau_cli_term_raw::Term,
handle: &TermHandle,
editor_context: Arc<Mutex<EditorContext>>,
external_editor: Option<&str>,
prompt_history: &[String],
action: PromptShellAction,
) -> Result<Option<PromptShellResult>, BoundedCommandError> {
let external_action = match prompt_shell_dispatch(action, term) {
PromptShellDispatch::Immediate(result) => return Ok(Some(result)),
PromptShellDispatch::External(external_action) => external_action,
};
let current = trim_prompt_newlines(&handle.get_buffer()).to_owned();
let cursor = handle.get_cursor();
let tmp = tempfile::Builder::new()
.prefix("tau-prompt-")
.suffix(".tau.md")
.tempfile()
.map_err(|e| format!("could not create tempfile: {e}"))?;
let file_text = prompt_shell_file_text(external_action.kind, ¤t, &editor_context);
std::fs::write(tmp.path(), file_text.as_bytes())
.map_err(|e| format!("could not write tempfile: {e}"))?;
let history_picker = match external_action.kind {
PromptShellExternalKind::HistorySearch => {
match prepare_history_picker(term, prompt_history)? {
Some(history_picker) => Some(history_picker),
None => return Ok(None),
}
}
PromptShellExternalKind::Insert | PromptShellExternalKind::Edit => None,
};
let command = external_action.shell.command.as_str();
tracing::trace!(
target: "tau_cli::input",
command,
prompt_path = %tmp.path().display(),
cursor,
"spawning prompt shell action"
);
if command.trim().is_empty() {
return Err("empty shell command".to_owned().into());
}
term.pause_for_external()
.map_err(|e| format!("could not release terminal: {e}"))?;
let guard = ExternalResumeGuard::new(|| term.resume_after_external());
let mut command_builder = prompt_shell_command_builder(
command,
tmp.path(),
cursor,
external_editor,
history_picker.as_ref(),
);
let command_result = preserve_pause_on_unconfirmed_foreground(
guard,
run_external_prompt_shell_command(
&mut command_builder,
external_action.kind,
history_picker.as_ref(),
),
);
let Some(output) = command_result? else {
return Ok(None);
};
match output {
PromptShellCommandOutput::Captured(stdout) => {
return Ok(prompt_shell_captured_result(
external_action.kind,
external_action.shell.trim,
stdout,
prompt_history,
)?);
}
PromptShellCommandOutput::Edited => {}
}
let new_text =
std::fs::read_to_string(tmp.path()).map_err(|e| format!("could not read tempfile: {e}"))?;
editor_context
.lock()
.expect("editor context mutex poisoned")
.update_edited_trailer_recovery(&file_text, &new_text);
let new_text = strip_prompt_trailer(&new_text);
let new_text = trim_prompt_newlines(new_text).to_owned();
Ok(Some(PromptShellResult::Replace(new_text)))
}
fn prompt_shell_file_text(
kind: PromptShellExternalKind,
current: &str,
editor_context: &Arc<Mutex<EditorContext>>,
) -> String {
match kind {
PromptShellExternalKind::Edit => append_prompt_trailer(current, editor_context),
PromptShellExternalKind::Insert | PromptShellExternalKind::HistorySearch => {
current.to_owned()
}
}
}
fn prepare_history_picker(
term: &tau_cli_term_raw::Term,
prompt_history: &[String],
) -> Result<Option<PromptHistoryPicker>, String> {
let rows = prompt_history_search_rows(prompt_history);
if rows.is_empty() {
return Ok(None);
}
let prompt_dir = prompt_history_preview_dir(prompt_history)?;
term.record_prompt_undo();
Ok(Some(PromptHistoryPicker { rows, prompt_dir }))
}
fn prompt_shell_command_builder(
command: &str,
prompt_path: &std::path::Path,
cursor: usize,
external_editor: Option<&str>,
history_picker: Option<&PromptHistoryPicker>,
) -> std::process::Command {
let mut command_builder = path_std_process::Command::new("sh");
command_builder
.arg("-c")
.arg(command)
.env("TAU_PROMPT_PATH", prompt_path)
.env("TAU_PROMPT_COLUMN", (cursor + 1).to_string())
.env("TAU_PROMPT_ROW", "1")
.env("TAU_EDITOR", external_editor.unwrap_or(""));
if let Some(history_picker) = history_picker {
command_builder.env("TAU_PROMPT_HISTORY_DIR", history_picker.prompt_dir.path());
}
command_builder
}
fn run_external_prompt_shell_command(
command_builder: &mut std::process::Command,
kind: PromptShellExternalKind,
history_picker: Option<&PromptHistoryPicker>,
) -> Result<Option<PromptShellCommandOutput>, BoundedCommandError> {
match kind {
PromptShellExternalKind::Edit => run_prompt_edit_command(command_builder),
PromptShellExternalKind::Insert | PromptShellExternalKind::HistorySearch => {
run_prompt_capture_command(command_builder, history_picker)
}
}
}
fn run_prompt_edit_command(
command_builder: &mut std::process::Command,
) -> Result<Option<PromptShellCommandOutput>, BoundedCommandError> {
command_builder
.stdin(path_std_process::Stdio::inherit())
.stdout(path_std_process::Stdio::inherit())
.stderr(path_std_process::Stdio::inherit());
let status = run_with_inherited_stdio(
command_builder,
PROMPT_COMMAND_TIMEOUT,
ProcessOwnership::ForegroundProcessGroup,
)?
.status;
Ok(status.success().then_some(PromptShellCommandOutput::Edited))
}
fn run_prompt_capture_command(
command_builder: &mut std::process::Command,
history_picker: Option<&PromptHistoryPicker>,
) -> Result<Option<PromptShellCommandOutput>, BoundedCommandError> {
command_builder.stdin(if history_picker.is_some() {
path_std_process::Stdio::piped()
} else {
path_std_process::Stdio::null()
});
command_builder
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let stdin_input = history_picker.map(|history_picker| history_picker.rows.as_bytes());
let output = run_with_bounded_stdout(
command_builder,
stdin_input,
PROMPT_COMMAND_OUTPUT_LIMIT_BYTES,
PROMPT_COMMAND_TIMEOUT,
ProcessOwnership::ForegroundProcessGroup,
)?;
Ok(output
.status
.success()
.then_some(PromptShellCommandOutput::Captured(output.stdout)))
}
fn prompt_shell_captured_result(
kind: PromptShellExternalKind,
trim: bool,
stdout: Vec<u8>,
prompt_history: &[String],
) -> Result<Option<PromptShellResult>, String> {
let text =
String::from_utf8(stdout).map_err(|e| format!("command output was not utf-8: {e}"))?;
let text = trim_prompt_shell_output(text, trim);
match kind {
PromptShellExternalKind::Insert => Ok(Some(PromptShellResult::Insert(text))),
PromptShellExternalKind::HistorySearch => {
selected_prompt_history_result(text, prompt_history)
}
PromptShellExternalKind::Edit => unreachable!(),
}
}
fn trim_prompt_shell_output(text: String, trim: bool) -> String {
if trim { text.trim().to_owned() } else { text }
}
fn selected_prompt_history_result(
selected: String,
prompt_history: &[String],
) -> Result<Option<PromptShellResult>, String> {
let selected_index = selected.split('\t').next().unwrap_or("").trim();
if selected_index.is_empty() {
return Ok(None);
}
let index = selected_index
.parse::<usize>()
.map_err(|e| format!("history selection was not an index: {e}"))?;
let text = prompt_history
.get(index)
.ok_or_else(|| format!("history selection index {index} is out of range"))?
.clone();
Ok(Some(PromptShellResult::ReplacePreservingUndo(text)))
}
fn prompt_history_search_rows(prompt_history: &[String]) -> String {
let mut rows = String::new();
for (index, prompt) in bounded_prompt_history_entries(prompt_history) {
rows.push_str(&index.to_string());
rows.push('\t');
rows.push_str(&prompt_history_summary(prompt));
rows.push('\n');
}
rows
}
fn prompt_history_preview_dir(prompt_history: &[String]) -> Result<tempfile::TempDir, String> {
let dir = tempfile::Builder::new()
.prefix("tau-prompt-history-")
.tempdir()
.map_err(|e| format!("could not create prompt history tempdir: {e}"))?;
let mut remaining_total = PROMPT_HISTORY_PREVIEW_TOTAL_BYTES;
for (index, prompt) in bounded_prompt_history_entries(prompt_history) {
let preview = bounded_prompt_history_preview(prompt, &mut remaining_total);
std::fs::write(dir.path().join(index.to_string()), preview.as_bytes())
.map_err(|e| format!("could not write prompt history preview {index}: {e}"))?;
}
Ok(dir)
}
fn bounded_prompt_history_entries(
prompt_history: &[String],
) -> impl Iterator<Item = (usize, &str)> {
prompt_history
.iter()
.enumerate()
.rev()
.filter(|(_, prompt)| !prompt.is_empty())
.take(PROMPT_HISTORY_SEARCH_MAX_ROWS)
.map(|(index, prompt)| (index, prompt.as_str()))
}
fn prompt_history_summary(prompt: &str) -> String {
let mut summary = String::new();
let mut summary_chars = 0usize;
let mut pending_space = false;
for ch in prompt.chars() {
if ch.is_whitespace() {
pending_space = !summary.is_empty();
continue;
}
if pending_space {
if summary_chars + 1 >= PROMPT_HISTORY_SUMMARY_MAX_CHARS {
append_prompt_history_summary_ellipsis(&mut summary, &mut summary_chars);
return summary;
}
summary.push(' ');
summary_chars += 1;
pending_space = false;
}
if summary_chars + 1 >= PROMPT_HISTORY_SUMMARY_MAX_CHARS {
append_prompt_history_summary_ellipsis(&mut summary, &mut summary_chars);
return summary;
}
summary.push(ch);
summary_chars += 1;
}
summary
}
fn append_prompt_history_summary_ellipsis(summary: &mut String, summary_chars: &mut usize) {
if *summary_chars == PROMPT_HISTORY_SUMMARY_MAX_CHARS {
summary.pop();
*summary_chars -= 1;
}
summary.push('…');
*summary_chars += 1;
}
fn bounded_prompt_history_preview(prompt: &str, remaining_total: &mut usize) -> String {
const TRUNCATED: &str = "\n[history preview truncated]\n";
if *remaining_total == 0 {
return String::new();
}
let budget = PROMPT_HISTORY_PREVIEW_MAX_BYTES.min(*remaining_total);
if prompt.len() <= budget {
*remaining_total = remaining_total.saturating_sub(prompt.len());
return prompt.to_owned();
}
let content_budget = if budget > TRUNCATED.len() {
budget - TRUNCATED.len()
} else {
budget
};
let end = previous_char_boundary(prompt, content_budget);
let mut preview = prompt[..end].to_owned();
if preview.len() + TRUNCATED.len() <= budget {
preview.push_str(TRUNCATED);
}
*remaining_total = remaining_total.saturating_sub(preview.len());
preview
}
fn previous_char_boundary(text: &str, index: usize) -> usize {
let mut index = index.min(text.len());
while !text.is_char_boundary(index) {
index -= 1;
}
index
}
fn append_prompt_trailer(current: &str, editor_context: &Arc<Mutex<EditorContext>>) -> String {
let context = editor_context
.lock()
.expect("editor context mutex poisoned")
.clone();
if context.current_response.is_none()
&& context.last_response.is_none()
&& context.previous_prompt.is_none()
&& context.chat_markdown.is_none()
&& context.edited_trailer_recovery.is_none()
{
return current.to_owned();
}
let mut out = trim_prompt_newlines(current).to_owned();
out.push_str("\n\n");
out.push_str(PROMPT_TRAILER_MARKER);
out.push('\n');
if let Some(chat_markdown) = context.chat_markdown {
out.push_str(&chat_markdown);
return out;
}
if let Some(text) = context
.current_response
.as_deref()
.filter(|t| !t.is_empty())
{
out.push_str("\n## Current response in progress\n\n");
push_markdown_quote(&mut out, text);
}
if let Some(text) = context.last_response.as_deref().filter(|t| !t.is_empty()) {
out.push_str("\n## Last response\n\n");
push_markdown_quote(&mut out, text);
}
if let Some(text) = context.previous_prompt.as_deref().filter(|t| !t.is_empty()) {
out.push_str("\n## Previous prompt\n\n");
push_markdown_quote(&mut out, text);
}
if let Some(text) = context
.edited_trailer_recovery
.as_deref()
.filter(|t| !t.is_empty())
{
out.push_str("\n## Previously edited text below TAU trailer\n\n");
out.push_str(
"Move anything you want to keep above the TAU trailer marker; \
leaving this section unchanged will discard it after this editor session.\n\n",
);
out.push_str(text);
if !out.ends_with('\n') {
out.push('\n');
}
}
out
}
fn trim_prompt_newlines(text: &str) -> &str {
text.trim_matches(['\n', '\r'])
}
fn strip_prompt_trailer(text: &str) -> &str {
let Some((before, _)) = split_at_prompt_trailer_marker(text) else {
return text;
};
trim_prompt_before_trailer_marker(before)
}
fn edited_trailer_recovery(original_trailer: &str, edited_text: &str) -> Option<String> {
let (_, edited_trailer) = split_at_prompt_trailer_marker(edited_text)?;
if edited_trailer == original_trailer {
return None;
}
let trimmed = trim_prompt_newlines(edited_trailer);
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn trim_prompt_before_trailer_marker(before: &str) -> &str {
before
.strip_suffix("\n\n")
.or_else(|| before.strip_suffix("\r\n\r\n"))
.or_else(|| before.strip_suffix('\n'))
.or_else(|| before.strip_suffix("\r\n"))
.unwrap_or(before)
}
fn split_at_prompt_trailer_marker(text: &str) -> Option<(&str, &str)> {
let mut line_start = 0;
for line in text.split_inclusive('\n') {
let line_without_newline = line.strip_suffix('\n').unwrap_or(line);
let line_without_ending = line_without_newline
.strip_suffix('\r')
.unwrap_or(line_without_newline);
if line_without_ending == PROMPT_TRAILER_MARKER {
let trailer_start = line_start + line.len();
return Some((&text[..line_start], &text[trailer_start..]));
}
line_start += line.len();
}
None
}
fn push_markdown_quote(out: &mut String, text: &str) {
for line in text.lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
}
fn resolve_external_editor() -> Option<String> {
for var in ["EDITOR", "VISUAL"] {
if let Some(val) = std::env::var_os(var) {
let s = val.to_string_lossy();
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
}
["hx", "vim", "vi", "nano"]
.into_iter()
.find(|cand| which::which(cand).is_ok())
.map(str::to_owned)
}