diff --git a/app/src/ai/blocklist/action_model/execute/request_file_edits.rs b/app/src/ai/blocklist/action_model/execute/request_file_edits.rs index e6f6e9a72e..f75a01e0ab 100644 --- a/app/src/ai/blocklist/action_model/execute/request_file_edits.rs +++ b/app/src/ai/blocklist/action_model/execute/request_file_edits.rs @@ -146,6 +146,9 @@ impl RequestFileEditsExecutor { return ActionExecution::InvalidAction; }; + // TODO(surface-agnostic-file-edit-execution): non-GUI surfaces (e.g. the TUI) have no + // CodeDiffView, so file-edit tool calls are not executable here yet. The stacked + // surface-agnostic refactor routes execution through a shared PersistDiffModel instead. let Some(diff_view) = self.diff_views.get(id) else { log::warn!("Tried to execute a RequestFileEdits action without a diff view"); return ActionExecution::NotReady; diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 92e9d81a3a..099ef4e8aa 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -32,14 +32,13 @@ pub(crate) mod codebase_index_speedbump_banner; pub(crate) mod telemetry_banner; pub(super) mod view_util; -pub use action_model::BlocklistAIActionModel; #[cfg_attr(target_family = "wasm", allow(unused_imports))] pub(crate) use action_model::{ apply_edits, read_local_file_context, BlocklistAIActionEvent, FileReadResult, - ReadFileContextResult, RequestFileEditsFormatKind, ShellCommandExecutor, - ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - StartAgentRequestId, + ReadFileContextResult, RequestFileEditsFormatKind, StartAgentExecutor, StartAgentExecutorEvent, + StartAgentRequest, StartAgentRequestId, }; +pub use action_model::{BlocklistAIActionModel, ShellCommandExecutor, ShellCommandExecutorEvent}; #[cfg(any(test, feature = "integration_tests"))] pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index c800c1e32c..7bba9637b7 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -4,8 +4,10 @@ pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, }; +pub use crate::ai::agent::task::TaskId; pub use crate::ai::agent::{ - AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, + AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchangeId, AIAgentInput, + AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, MessageId, ServerOutputId, Shared, UserQueryMode, }; pub use crate::ai::blocklist::agent_view::{ @@ -24,6 +26,7 @@ pub use crate::ai::blocklist::history_model::{ }; pub use crate::ai::blocklist::{ BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, + ShellCommandExecutor, ShellCommandExecutorEvent, }; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::llms::LLMId; @@ -36,7 +39,7 @@ pub use crate::terminal::local_tty::{ TerminalManager as LocalTtyTerminalManager, TerminalManagerInit, TerminalSurfaceInit, TerminalSurfaceResult, }; -pub use crate::terminal::model::block::{Block, BlockId}; +pub use crate::terminal::model::block::{AgentInteractionMetadata, Block, BlockId}; pub use crate::terminal::model::blockgrid::BlockGrid; pub use crate::terminal::model::blocks::{ BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem, TotalIndex, diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 45723ccdfc..b45a54bcf5 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -3,7 +3,8 @@ use std::rc::Rc; use warp::tui_export::{ - AIAgentExchangeId, AIAgentTextSection, AIBlockModel, AIConversationId, Appearance, + AIAgentAction, AIAgentExchangeId, AIAgentOutputMessageType, AIAgentTextSection, AIBlockModel, + AIConversationId, Appearance, }; use warp_core::ui::color::blend::Blend; // `ThemeFill` is the theme-layer color (it supports blend/opacity); `Fill` below @@ -19,11 +20,13 @@ use warpui_core::{AppContext, Entity, EntityIdMap, TuiView}; const INPUT_PREFIX: &str = "≫ "; -/// Renderable pieces of an agent block; this will grow as we add tool calls and other sub-elements. +/// Renderable pieces of an agent block; this will grow as we render richer sections. #[derive(Clone, Debug, Eq, PartialEq)] enum TuiAIBlockSection { Input(String), PlainText(String), + /// A lightweight status row standing in for an agent tool call. + ToolCall(Box), } /// A thin TUI rich-content view adapter backed by one agent exchange. @@ -103,19 +106,41 @@ impl TuiAIBlock { sections.push(TuiAIBlockSection::Input(input)); } + // Walk output messages in order so tool-call rows interleave with text. if let Some(output) = self.model.status(app).output_to_render() { let output = output.get(); - sections.extend(output.text_from_agent_output().flat_map(|text| { - text.sections.iter().filter_map(|section| match section { - AIAgentTextSection::PlainText { text } => (!text.text().is_empty()) - .then(|| TuiAIBlockSection::PlainText(text.text().to_owned())), - // Add item variants here as the TUI learns to render richer sections. - AIAgentTextSection::Code { .. } - | AIAgentTextSection::Table { .. } - | AIAgentTextSection::Image { .. } - | AIAgentTextSection::MermaidDiagram { .. } => None, - }) - })); + for message in &output.messages { + match &message.message { + AIAgentOutputMessageType::Text(text) => { + sections.extend(text.sections.iter().filter_map(|section| { + match section { + AIAgentTextSection::PlainText { text } => (!text.text().is_empty()) + .then(|| TuiAIBlockSection::PlainText(text.text().to_owned())), + // Add item variants here as the TUI learns to render richer sections. + AIAgentTextSection::Code { .. } + | AIAgentTextSection::Table { .. } + | AIAgentTextSection::Image { .. } + | AIAgentTextSection::MermaidDiagram { .. } => None, + } + })); + } + AIAgentOutputMessageType::Action(action) => { + sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + } + AIAgentOutputMessageType::Reasoning { .. } + | AIAgentOutputMessageType::Summarization { .. } + | AIAgentOutputMessageType::Subagent(_) + | AIAgentOutputMessageType::TodoOperation(_) + | AIAgentOutputMessageType::WebSearch(_) + | AIAgentOutputMessageType::WebFetch(_) + | AIAgentOutputMessageType::CommentsAddressed { .. } + | AIAgentOutputMessageType::DebugOutput { .. } + | AIAgentOutputMessageType::ArtifactCreated(_) + | AIAgentOutputMessageType::SkillInvoked(_) + | AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } + | AIAgentOutputMessageType::EventsFromAgents { .. } => {} + } + } } sections @@ -191,6 +216,20 @@ impl TuiAIBlockSection { .with_padding_top(top_padding) .finish() } + Self::ToolCall(_action) => { + // TODO: add richer rendering for each tool call type. This is just a rendering stub to build off of. + let text_color = + Fill::from(ThemeFill::from(theme.terminal_colors().bright.black)).into(); + TuiContainer::new( + TuiText::new("executed a tool call").with_style( + TuiStyle::default() + .fg(text_color) + .add_modifier(Modifier::DIM), + ), + ) + .with_padding_top(top_padding) + .finish() + } } } } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 14604989f5..05d811b0f5 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -1,10 +1,10 @@ use std::rc::Rc; use warp::tui_export::{ - AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, - AIAgentText, AIAgentTextSection, AIBlockModel, AIBlockOutputStatus, AIConversationId, - AIRequestType, Appearance, LLMId, MessageId, OutputStatusUpdateCallback, ServerOutputId, - Shared, UserQueryMode, + AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchangeId, AIAgentInput, + AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, + AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, Appearance, LLMId, + MessageId, OutputStatusUpdateCallback, ServerOutputId, Shared, TaskId, UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; @@ -99,6 +99,11 @@ fn expected_output_text_color(app: &AppContext) -> Color { CoreFill::from(ThemeFill::from(theme.terminal_colors().normal.white)).into() } +fn expected_tool_call_text_color(app: &AppContext) -> Color { + let theme = Appearance::as_ref(app).theme(); + CoreFill::from(ThemeFill::from(theme.terminal_colors().bright.black)).into() +} + #[test] fn agent_block_extracts_input_and_plain_text_from_model() { App::test((), |app| async move { @@ -126,6 +131,158 @@ fn agent_block_extracts_input_and_plain_text_from_model() { }); } +#[test] +fn agent_block_renders_tool_calls_in_message_order() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.read(|app_ctx| { + let action = test_action("action-1"); + let block = test_agent_block(FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + text_message( + "message-1", + vec![AIAgentTextSection::PlainText { + text: "before".to_owned().into(), + }], + ), + action_message("message-2", action.clone()), + text_message( + "message-3", + vec![AIAgentTextSection::PlainText { + text: "after".to_owned().into(), + }], + ), + ]), + }); + + assert_eq!( + block.sections(app_ctx), + vec![ + TuiAIBlockSection::PlainText("before".to_owned()), + TuiAIBlockSection::ToolCall(Box::new(action.clone())), + TuiAIBlockSection::PlainText("after".to_owned()), + ] + ); + + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + block.render_element(app_ctx), + TuiRect::new(0, 0, 40, 4), + app_ctx, + ); + assert_eq!( + frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect::>(), + vec!["before", "executed a tool call", "after", ""], + ); + assert_eq!( + frame.buffer[(0, 1)].fg, + expected_tool_call_text_color(app_ctx) + ); + assert!(frame.buffer[(0, 1)].modifier.contains(Modifier::DIM)); + }); + }); +} + +#[test] +fn agent_block_renders_multiple_tool_calls_in_order() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.read(|app_ctx| { + let first = test_action("action-1"); + let second = test_action("action-2"); + let block = test_agent_block(FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("message-1", first.clone()), + action_message("message-2", second.clone()), + ]), + }); + + assert_eq!( + block.sections(app_ctx), + vec![ + TuiAIBlockSection::ToolCall(Box::new(first)), + TuiAIBlockSection::ToolCall(Box::new(second)), + ] + ); + + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + block.render_element(app_ctx), + TuiRect::new(0, 0, 40, 3), + app_ctx, + ); + assert_eq!( + frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect::>(), + vec!["executed a tool call", "executed a tool call", ""], + ); + }); + }); +} + +#[test] +fn agent_block_desired_height_accounts_for_tool_call_stub() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.read(|app_ctx| { + let block = test_agent_block(FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![action_message( + "message-1", + test_action("action-1"), + )]), + }); + // One tool-call stub line plus the block's bottom padding row. + assert_eq!(block.desired_height(40, app_ctx), 2); + }); + }); +} + +#[test] +fn agent_block_ignores_unsupported_message_variants() { + App::test((), |app| async move { + app.read(|app_ctx| { + let block = test_agent_block(FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + text_message( + "message-1", + vec![AIAgentTextSection::PlainText { + text: "before".to_owned().into(), + }], + ), + reasoning_message("message-2"), + text_message( + "message-3", + vec![AIAgentTextSection::PlainText { + text: "after".to_owned().into(), + }], + ), + ]), + }); + + assert_eq!( + block.sections(app_ctx), + vec![ + TuiAIBlockSection::PlainText("before".to_owned()), + TuiAIBlockSection::PlainText("after".to_owned()), + ] + ); + }); + }); +} + #[test] fn agent_block_omits_unsupported_sections_until_the_tui_can_render_them() { App::test((), |app| async move { @@ -207,18 +364,63 @@ impl AIBlockModel for FakeAgentBlockModel { /// Builds a completed output status with one text message. fn complete_output(sections: Vec) -> AIBlockOutputStatus { + complete_output_messages(vec![text_message("message-1", sections)]) +} + +/// Builds a completed output status from explicit output messages. +fn complete_output_messages(messages: Vec) -> AIBlockOutputStatus { AIBlockOutputStatus::Complete { output: Shared::new(AIAgentOutput { - messages: vec![AIAgentOutputMessage { - id: MessageId::new("message-1".to_owned()), - message: AIAgentOutputMessageType::Text(AIAgentText { sections }), - citations: Vec::new(), - }], + messages, ..Default::default() }), } } +/// Builds a text output message from plain-text sections. +fn text_message(id: &str, sections: Vec) -> AIAgentOutputMessage { + AIAgentOutputMessage { + id: MessageId::new(id.to_owned()), + message: AIAgentOutputMessageType::Text(AIAgentText { sections }), + citations: Vec::new(), + } +} + +/// Builds an action (tool call) output message. +fn action_message(id: &str, action: AIAgentAction) -> AIAgentOutputMessage { + AIAgentOutputMessage { + id: MessageId::new(id.to_owned()), + message: AIAgentOutputMessageType::Action(action), + citations: Vec::new(), + } +} + +/// Builds a reasoning output message (an unsupported variant for the TUI). +fn reasoning_message(id: &str) -> AIAgentOutputMessage { + AIAgentOutputMessage { + id: MessageId::new(id.to_owned()), + message: AIAgentOutputMessageType::Reasoning { + text: AIAgentText { + sections: vec![AIAgentTextSection::PlainText { + text: "thinking".to_owned().into(), + }], + }, + finished_duration: None, + }, + citations: Vec::new(), + } +} + +/// Builds a tool-call action for message-ordering tests. +fn test_action(id: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(id.to_owned()), + task_id: TaskId::new("task-1".to_owned()), + action: AIAgentActionType::InitProject, + requires_result: true, + } +} + /// Builds one user-query input for model-backed extraction tests. fn query_input(query: &str) -> AIAgentInput { AIAgentInput::UserQuery { diff --git a/crates/warp_tui/src/conversation_selection.rs b/crates/warp_tui/src/conversation_selection.rs index 3bb6e8fca5..43f0165cd5 100644 --- a/crates/warp_tui/src/conversation_selection.rs +++ b/crates/warp_tui/src/conversation_selection.rs @@ -21,14 +21,13 @@ impl TuiConversationSelection { &BlocklistAIHistoryModel::handle(ctx), |selection, _, event, ctx| selection.handle_history_event(event, ctx), ); - let pending_query_state = - if warp_core::execution_mode::AppExecutionMode::as_ref(ctx).is_sandboxed() { - PendingQueryState::New { - autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion, - } - } else { - PendingQueryState::default() - }; + + // TODO: Implement actual permissions once settings are in place and there is a UI for permissions requests. + // For now, we just always set fast-forward to on. + let pending_query_state = PendingQueryState::New { + autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion, + }; + Self { terminal_surface_id, pending_query_state, @@ -120,7 +119,15 @@ impl ConversationSelection for TuiConversationSelection { ctx: &mut ModelContext>, ) { let previous_conversation_id = self.selected_id(); - self.set_pending_query_state(PendingQueryState::default(), ctx); + // TODO: Implement actual permissions once settings are in place and there is a UI for permissions requests. + // For now, we just always set fast-forward to on. + self.set_pending_query_state( + PendingQueryState::New { + autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion, + }, + ctx, + ); + if let Some(previous_conversation_id) = previous_conversation_id { Self::emit_deactivated(previous_conversation_id, false, ctx); } diff --git a/crates/warp_tui/src/conversation_selection_tests.rs b/crates/warp_tui/src/conversation_selection_tests.rs index aab90b10c9..c3da12ab84 100644 --- a/crates/warp_tui/src/conversation_selection_tests.rs +++ b/crates/warp_tui/src/conversation_selection_tests.rs @@ -126,7 +126,7 @@ fn tui_selection_reconciles_split_and_removed_selection() { #[test] fn tui_new_conversation_preserves_pending_autoexecute_override() { App::test((), |mut app| async move { - app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, true, ctx)); + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::default()); let terminal_surface_id = EntityId::new(); let selection = app.add_model(|ctx| { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index cc31458ede..dd8112c305 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,11 +1,16 @@ //! Authenticated terminal-session TUI surface. +use std::borrow::Cow; +use std::sync::Arc; +use parking_lot::FairMutex; use warp::editor::CodeEditorModel; use warp::tui_export::{ - ActiveSession, AgentViewEntryOrigin, Appearance, BlocklistAIActionModel, - BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, ConversationSelection, - ConversationSelectionHandle, GetRelevantFilesController, ModelEvent, PtyIntent, PtyIntentEvent, - TerminalSurface, TerminalSurfaceInit, + AIAgentPtyWriteMode, ActiveSession, AgentInteractionMetadata, AgentViewEntryOrigin, Appearance, + BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIHistoryModel, BlocklistAIInputModel, CommandExecutionSource, ConversationSelection, + ConversationSelectionHandle, ExecuteCommandEvent, GetRelevantFilesController, ModelEvent, + PtyIntent, PtyIntentEvent, ShellCommandExecutorEvent, TerminalModel, TerminalSurface, + TerminalSurfaceInit, }; use warp_core::ui::theme::Fill as ThemeFill; use warpui::SingletonEntity; @@ -25,12 +30,24 @@ use crate::transcript_view::TuiTranscriptView; const INITIAL_INPUT_WIDTH: u16 = 80; const MAX_INPUT_TEXT_ROWS: u16 = 6; -/// This TUI surface does not emit direct PTY intents. -pub(crate) struct TuiTerminalSessionEvent; +/// Events emitted by the TUI terminal session surface. +pub(crate) enum TuiTerminalSessionEvent { + ExecuteCommand(Box), + WriteAgentInput { + bytes: Cow<'static, [u8]>, + mode: AIAgentPtyWriteMode, + }, +} impl PtyIntentEvent for TuiTerminalSessionEvent { fn pty_intent(&self) -> Option { - None + match self { + Self::ExecuteCommand(event) => Some(PtyIntent::ExecuteCommand((**event).clone())), + Self::WriteAgentInput { bytes, mode } => Some(PtyIntent::WriteAgentInput { + bytes: bytes.clone(), + mode: *mode, + }), + } } } @@ -95,7 +112,7 @@ impl TuiTerminalSessionView { ai_input_model, context_model, conversation_selection.clone(), - action_model, + action_model.clone(), active_session, model.clone(), terminal_surface_id, @@ -119,6 +136,13 @@ impl TuiTerminalSessionView { } }); + // Bridge shared shell-tool executor events into terminal-manager PTY intents. + let shell_command_executor = action_model.as_ref(ctx).shell_command_executor(ctx); + let model_for_shell_events = model.clone(); + ctx.subscribe_to_model(&shell_command_executor, move |view, _, event, ctx| { + view.handle_shell_command_executor_event(event, &model_for_shell_events, ctx); + }); + // These events update block metadata or grids the transcript reads. // PTY output redraws are driven by `wakeups_rx` below. ctx.subscribe_to_model(&model_events, |_, _, event, ctx| match event { @@ -167,6 +191,59 @@ impl TuiTerminalSessionView { controller.send_user_query_in_conversation(prompt, conversation_id, None, ctx); }); } + + /// Bridges shared shell-tool executor events into terminal-manager PTY intents. + fn handle_shell_command_executor_event( + &mut self, + event: &ShellCommandExecutorEvent, + model: &Arc>, + ctx: &mut ViewContext, + ) { + match event { + ShellCommandExecutorEvent::ExecuteCommand { action_id, command } => { + let Some((session_id, conversation_id)) = (|| { + let model = model.lock(); + let session_id = model.block_list().active_block().session_id()?; + let conversation_id = BlocklistAIHistoryModel::as_ref(ctx) + .conversation_id_for_action(action_id, ctx.view_id())?; + Some((session_id, conversation_id)) + })() else { + log::warn!( + "Unable to execute TUI agent-requested command for action {action_id:?}" + ); + return; + }; + + ctx.emit(TuiTerminalSessionEvent::ExecuteCommand(Box::new( + ExecuteCommandEvent { + command: command.clone(), + session_id, + workflow_id: None, + workflow_command: None, + should_add_command_to_history: true, + source: CommandExecutionSource::AI { + metadata: AgentInteractionMetadata::new_hidden( + action_id.clone(), + conversation_id, + ), + }, + }, + ))); + } + ShellCommandExecutorEvent::WriteToPty { input, mode } => { + ctx.emit(TuiTerminalSessionEvent::WriteAgentInput { + bytes: Cow::Owned(input.to_vec()), + mode: *mode, + }); + } + // TODO(tui-agent-cancel): we need to think about how we want to handle ctrl c. + // Right now it shuts down the entire app, but we should probably mimic the pattern from claude code, amp, etc. + // and have one ctrl c shut down any in progress conversation or tool call, and a double ctrl c actually close the app + // (with some ephemeral message after the first ctrl c). + ShellCommandExecutorEvent::CancelExecution + | ShellCommandExecutorEvent::TransferControlToUser { .. } => {} + } + } } impl Entity for TuiTerminalSessionView { diff --git a/specs/tui-agent-tool-calls/TECH.md b/specs/tui-agent-tool-calls/TECH.md new file mode 100644 index 0000000000..74c2718d5b --- /dev/null +++ b/specs/tui-agent-tool-calls/TECH.md @@ -0,0 +1,169 @@ +# TUI Agent Tool Calls TECH +## Context +The TUI transcript renders terminal blocks and simple AI exchange blocks in canonical terminal block-list order. AI exchange blocks currently render user input plus streamed plain-text output, but action/tool-call messages are invisible in the transcript even though the shared Agent Mode controller already receives and queues those actions. +Relevant code at `526ade4522df0e65f138c67dcbcb90f1a3ce63e9`: +- [`crates/warp_tui/src/session.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/crates/warp_tui/src/session.rs) — boots the headless app, creates `LocalTtyTerminalManager::`, and keeps the TUI driver plus terminal manager alive. +- [`crates/warp_tui/src/terminal_session_view.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/crates/warp_tui/src/terminal_session_view.rs) — constructs the production AI stack for the TUI surface: `ActiveSession`, `TuiConversationSelection`, `BlocklistAIContextModel`, `BlocklistAIInputModel`, `BlocklistAIActionModel`, and `BlocklistAIController`. +- [`crates/warp_tui/src/transcript_view.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/crates/warp_tui/src/transcript_view.rs) — subscribes to `BlocklistAIHistoryModel`, creates `TuiAIBlock`s for visible exchanges, and appends them to `TerminalModel::BlockList` as `RichContentType::AIBlock`. +- [`crates/warp_tui/src/tui_block_list_viewport_source.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/crates/warp_tui/src/tui_block_list_viewport_source.rs) — walks the canonical block-list sum tree, measures dirty rich-content views, updates cached heights, and renders terminal blocks plus TUI agent blocks. +- [`crates/warp_tui/src/agent_block.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/crates/warp_tui/src/agent_block.rs) — currently derives `TuiAIBlockSection`s from `output.text_from_agent_output()`, which only yields `AIAgentOutputMessageType::Text` and therefore drops action/tool-call messages. +- [`app/src/ai/agent/mod.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/app/src/ai/agent/mod.rs) — defines `AIAgentOutput`, ordered `AIAgentOutputMessage`s, `AIAgentOutputMessageType::Action(AIAgentAction)`, and helper iterators such as `text_from_agent_output()` and `actions()`. +- [`app/src/ai/blocklist/block/view_impl/output.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/app/src/ai/blocklist/block/view_impl/output.rs) — GUI AI block rendering loops over `output.messages` in order and delegates each message variant to the appropriate renderer. +- [`app/src/ai/blocklist/controller.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/app/src/ai/blocklist/controller.rs) — when a response stream finishes, queues emitted actions through `BlocklistAIActionModel::queue_actions` and later sends completed action results back to the model. +- [`app/src/ai/blocklist/action_model.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/app/src/ai/blocklist/action_model.rs) and [`app/src/ai/blocklist/action_model/execute.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/app/src/ai/blocklist/action_model/execute.rs) — own the shared action queue, preprocessing, permission checks, serial/parallel scheduling, cancellation, and per-action execution dispatch. +- [`app/src/ai/blocklist/action_model/execute/request_file_edits.rs`](https://github.com/warpdotdev/warp/blob/526ade4522df0e65f138c67dcbcb90f1a3ce63e9/app/src/ai/blocklist/action_model/execute/request_file_edits.rs) — currently requires a registered GUI `CodeDiffView` before `RequestFileEdits` can execute. +The current TUI transcript work deliberately uses production-shaped models instead of a separate TUI-only conversation pipeline. This feature should keep that direction: render action messages in the TUI, but continue to execute tool calls through the shared action model. +## Proposed changes +### End-to-end data flow +```mermaid +flowchart LR + User["TUI input submit"] --> Session["TuiTerminalSessionView"] + Session --> Controller["BlocklistAIController"] + Controller --> Stream["AI response stream"] + Stream --> History["BlocklistAIHistoryModel
exchange output"] + Stream --> Actions["BlocklistAIActionModel
queue_actions"] + Actions --> Executor["BlocklistAIActionExecutor
shared tool execution"] + Executor --> Result["AIAgentInput::ActionResult
follow-up request"] + History --> Transcript["TuiTranscriptView
dirty rich content"] + Transcript --> Block["TuiAIBlock"] + Block --> Stub["executed a tool call"] +``` +The rendering path and the execution path share the same `AIAgentOutputMessageType::Action` source message but remain separate: +- Rendering derives a transcript item from the stored output message. +- Execution is driven by `BlocklistAIController` and `BlocklistAIActionModel`. +- Completed action results flow back to the LLM as `AIAgentInput::ActionResult`; the first TUI UI does not render those results. +### Ordered message-to-section adapter +Replace `TuiAIBlock::sections`' output extraction with an ordered pass over `AIAgentOutput.messages`. The GUI AI block already follows this shape in `output.rs`: one ordered pass over messages, then variant-specific rendering. The TUI matches the ordering pattern without porting the GUI renderer. +```mermaid +flowchart LR + Output["AIAgentOutput"] --> Messages["messages
server order"] + Messages --> Adapter["TuiAIBlock::sections"] + Adapter --> Input["Input(String)"] + Adapter --> Text["PlainText(String)"] + Adapter --> Tool["ToolCall(Box)"] + Input --> Render["TuiAIBlockSection::render_element"] + Text --> Render + Tool --> Render + Render --> Label["TuiText
executed a tool call"] +``` +Use a flat section enum so the block stays extensible as more message types get TUI renderers: +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +enum TuiAIBlockSection { + Input(String), + PlainText(String), + ToolCall(Box), +} +``` +The `ToolCall` variant carries the full `AIAgentAction` even though the first renderer ignores the fields. The section is derived render data, not durable UI state. Carrying the action now gives future renderers direct access to `id`, `task_id`, `requires_result`, and the concrete `AIAgentActionType` without changing the adapter API. `AIAgentAction` is large, so the variant is boxed to satisfy clippy's `large_enum_variant`. +The adapter behavior should be: +- Each input's `AIAgentInput::display_query()` value is joined with newlines into a single `TuiAIBlockSection::Input`; the renderer splits it per line, prefixing the first line with the prompt marker and indenting continuation lines beneath it. +- `AIAgentOutputMessageType::Text(AIAgentText { sections })` becomes one `TuiAIBlockSection::PlainText` per non-empty `AIAgentTextSection::PlainText`. +- `AIAgentOutputMessageType::Action(action)` becomes one `TuiAIBlockSection::ToolCall(Box::new(action.clone()))`. +- Code, table, image, Mermaid, reasoning, summarization, todo, subagent, web, artifact, skill, message-bus, lifecycle-event, debug, and comments-addressed messages remain unsupported until the TUI has specific renderers for them. +The `ToolCall` arm of `TuiAIBlockSection::render_element` renders exactly `executed a tool call`. The first implementation does not include the tool name, status, arguments, or result details. The stub is styled as a muted status row (`theme.terminal_colors().bright.black` plus `Modifier::DIM`) so it reads as a tool-call event rather than blending into the agent's plain-text prose. +### State ownership +Do not store a materialized `Vec` on `TuiAIBlock`. Like the GUI AI block, durable state should be stored only when it represents interaction state that must survive renders. +`TuiAIBlock` holds the exchange identity and backing model; sections are re-derived on each render: +```rust +pub(super) struct TuiAIBlock { + conversation_id: AIConversationId, + exchange_id: AIAgentExchangeId, + model: Rc>, +} +``` +Later stateful renderers can add maps keyed by stable IDs: +- `MessageId` for collapsible reasoning, summarization, web, or todo sections. +- `AIAgentActionId` for expandable or status-aware tool cards. +Even when those maps exist, `output.messages` should remain the ordering source. The render adapter can consult state maps while deriving sections, but it should not replace the ordered message pass with independently ordered per-type collections. +### Redraw and height updates +No action-model event subscription is needed for the static tool-call label. New action messages enter the exchange output through the existing response-stream/history path. `TuiTranscriptView::mark_exchange_dirty` already marks the owning rich-content view dirty on `UpdatedStreamingExchange`, and `TuiBlockListViewportSource` already measures dirty rich-content views and writes updated heights back to the terminal block list. +```mermaid +flowchart LR + Event["UpdatedStreamingExchange"] --> Dirty["TuiTranscriptView::mark_exchange_dirty"] + Dirty --> Mark["block_list_mut().mark_rich_content_dirty(view_id)"] + Mark --> Source["TuiBlockListViewportSource::visible_items"] + Source --> Measure["TuiAIBlock::desired_height"] + Measure --> Heights["update_rich_content_heights"] + Source --> Render["render child view"] +``` +When future TUI tool cards show live states like queued, blocked, running, failed, or cancelled, `TuiTranscriptView` should subscribe to `BlocklistAIActionModel` events and dirty the owning rich-content item by action ID. That coupling should not be added until visible status depends on it. +### Automatic execution policy +TUI-created conversations should opt into `AIConversationAutoexecuteMode::RunToCompletion` through `TuiConversationSelection`, so emitted tool calls can execute without a first-pass TUI approval UI. +`TuiConversationSelection::new` takes the new-conversation autoexecute mode explicitly: +```rust +pub(super) fn new( + terminal_surface_id: EntityId, + autoexecute_override: AIConversationAutoexecuteMode, + ctx: &mut ModelContext>, +) -> Self +``` +Call it from `TuiTerminalSessionView::new` with `AIConversationAutoexecuteMode::RunToCompletion`. Callers pass the mode directly rather than deriving it from sandbox detection. Keep `toggle_pending_query_autoexecute` intact so a future TUI affordance can switch between `RunToCompletion` and `RespectUserSettings`. +Do not duplicate permission logic in the TUI. `BlocklistAIPermissions`, execution profiles, and autonomous/sandboxed execution behavior remain inside the shared action execution path. +### Shell-command PTY bridge +Shell-command tool calls require one TUI-specific bridge because the shared shell command executor emits model events rather than directly writing to the TUI PTY. +Add PTY-driving variants to `TuiTerminalSessionEvent`: +```rust +pub(crate) enum TuiTerminalSessionEvent { + ExecuteCommand(Box), + WriteAgentInput { + bytes: Cow<'static, [u8]>, + mode: AIAgentPtyWriteMode, + }, +} +``` +Update `PtyIntentEvent` so: +- `ExecuteCommand` maps to `PtyIntent::ExecuteCommand`. +- `WriteAgentInput` maps to `PtyIntent::WriteAgentInput`. +Subscribe `TuiTerminalSessionView` to `action_model.as_ref(ctx).shell_command_executor(ctx)` and translate: +- `ShellCommandExecutorEvent::ExecuteCommand { action_id, command }` into `TuiTerminalSessionEvent::ExecuteCommand`. +- `ShellCommandExecutorEvent::WriteToPty { input, mode }` into `TuiTerminalSessionEvent::WriteAgentInput`. +- `CancelExecution` and `TransferControlToUser { .. }` are no-ops for the first pass. Cancellation is left as a `TODO(tui-agent-cancel)`: the GUI cancel path sends an interrupt (ETX) to the running command's PTY because the user's Ctrl-C is routed to the agent block instead of the command's shell, and the TUI should mirror that once a control-handoff affordance exists. +The command event should use `CommandExecutionSource::AI` and `AgentInteractionMetadata::new_hidden(action_id, conversation_id)`. The conversation ID should be looked up with `BlocklistAIHistoryModel::conversation_id_for_action(action_id, ctx.view_id())`, and the active session ID should come from the current terminal model active block. +### Headless file-edit execution (deferred) +`RequestFileEdits` renders in the transcript like other tool calls, but it is not executed on non-GUI surfaces in this branch: `RequestFileEditsExecutor::execute` still requires a registered GUI `CodeDiffView` and returns `NotReady` otherwise (`app/src/ai/blocklist/action_model/execute/request_file_edits.rs`). +Making file edits executable without a GUI review view is handled in the stacked `surface-agnostic-file-edit-execution` follow-up, which routes both surfaces through a shared, non-GUI `PersistDiffModel`. This branch deliberately adds no headless persistence path. +### Public TUI export boundary +Extend `app/src/tui_export.rs` only for types that `warp_tui` must name directly: +- `AIAgentAction`, `AIAgentActionId`, `AIAgentActionType`, and `TaskId` for the `ToolCall` section variant and test fixtures. +- `AIAgentPtyWriteMode`, `AgentInteractionMetadata`, and `ShellCommandExecutorEvent` for the PTY bridge. +Do not export action status/result types for this feature. The static stub renderer does not need them. +### Boundaries and non-goals +Do not port GUI inline-action components into the TUI. `RequestedCommand`, `CodeDiffView`, `RunAgentsCardView`, and `AskUserQuestionView` remain GUI-specific. +Do not render full tool results in this change. Finished tool results continue to flow back to the model as `AIAgentInput::ActionResult` through `BlocklistAIController`. +Do not add TUI approval editors for commands, run-agents configs, or ask-user-question answers. `RunToCompletion` is the first-step execution policy. +Do not change canonical transcript ordering. Agent blocks remain `RichContentType::AIBlock` entries in `TerminalModel::BlockList`, and terminal blocks continue to render through `render_terminal_block_rows`. +## Testing and validation +Add unit tests in `crates/warp_tui/src/agent_block_tests.rs` for the ordered message adapter: +- `Text -> Action -> Text` renders as text, `executed a tool call`, text in that order. +- Multiple action messages render multiple `ToolCall` stub lines. +- The `ToolCall` section preserves the action ID and action type in derived content even though rendering only emits the static label. +- Unsupported text sections and unsupported message variants are ignored without disturbing adjacent supported items. +- `desired_height` accounts for the tool-call stub line. +Keep transcript dirty/reflow tests focused on `UpdatedStreamingExchange`; no action-model event dirtying test is needed for the static stub. +The shell-command PTY bridge is covered by compilation only for this branch. `TuiTerminalSessionEvent`'s `PtyIntentEvent` mapping and the shell-executor event translation are exercised through the type system rather than dedicated unit tests, since constructing an `ExecuteCommandEvent` requires `SessionId`, which is intentionally not exported through `tui_export` for this feature. Add direct tests when a session-id test seam is available. +File-edit execution is out of scope for this branch (deferred to the stacked follow-up), so no `RequestFileEditsExecutor` persistence tests are added here. +Run targeted tests first: +```bash +cargo test -p warp_tui agent_block +cargo test -p warp_tui transcript_view +cargo test -p warp_tui tui_block_list_viewport_source +cargo test -p warp request_file_edits +``` +Then run formatting and the standard Rust validation for touched crates: +```bash +./script/format +cargo clippy --workspace --all-targets --all-features --tests -- -D warnings +``` +## Parallelization +Do not use child agents for the implementation. The work is tightly coupled across one TUI surface, one render adapter, and shared executor exports. Splitting implementation across worktrees would create more merge overhead than wall-clock savings. +The implementation sequence should be: +```mermaid +flowchart LR + A["TUI exports"] --> B["Ordered TUI render adapter"] + B --> C["Autoexecute selection constructor"] + C --> D["Shell PTY bridge"] + D --> F["Targeted tests"] + F --> G["Format + clippy"] +``` +The shell bridge lands in this branch; headless file-edit execution is a separate stacked follow-up because it primarily refactors GUI persistence rather than the TUI transcript path.