HistoryCell is a trait (#2283)

refactors HistoryCell to be a trait instead of an enum. Also collapse
the many "degenerate" HistoryCell enums which were just a store of lines
into a single PlainHistoryCell type.

The goal here is to allow more ways of rendering history cells (e.g.
expanded/collapsed/"live"), and I expect we will return to more varied
types of HistoryCell as we develop this area.
This commit is contained in:
Jeremy Rose
2025-08-14 14:10:05 -04:00
committed by GitHub
parent cdd33b2c04
commit 585f7b0679
5 changed files with 704 additions and 859 deletions

View File

@@ -44,7 +44,9 @@ use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult; use crate::bottom_pane::InputResult;
use crate::exec_command::strip_bash_lc_and_escape; use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::history_cell::CommandOutput; use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType; use crate::history_cell::PatchEventType;
// streaming internals are provided by crate::streaming and crate::markdown_stream // streaming internals are provided by crate::streaming and crate::markdown_stream
@@ -68,7 +70,7 @@ pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>, codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>, bottom_pane: BottomPane<'a>,
active_exec_cell: Option<HistoryCell>, active_exec_cell: Option<ExecCell>,
config: Config, config: Config,
initial_user_message: Option<UserMessage>, initial_user_message: Option<UserMessage>,
total_token_usage: TokenUsage, total_token_usage: TokenUsage,
@@ -123,7 +125,7 @@ impl ChatWidget<'_> {
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
self.bottom_pane self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count); .set_history_metadata(event.history_log_id, event.history_entry_count);
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true)); self.add_to_history(&history_cell::new_session_info(&self.config, event, true));
if let Some(user_message) = self.initial_user_message.take() { if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message); self.submit_user_message(user_message);
} }
@@ -195,14 +197,14 @@ impl ChatWidget<'_> {
} }
fn on_error(&mut self, message: String) { fn on_error(&mut self, message: String) {
self.add_to_history(HistoryCell::new_error_event(message)); self.add_to_history(&history_cell::new_error_event(message));
self.bottom_pane.set_task_running(false); self.bottom_pane.set_task_running(false);
self.stream.clear_all(); self.stream.clear_all();
self.mark_needs_redraw(); self.mark_needs_redraw();
} }
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) { fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
self.add_to_history(HistoryCell::new_plan_update(update)); self.add_to_history(&history_cell::new_plan_update(update));
} }
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
@@ -237,7 +239,7 @@ impl ChatWidget<'_> {
} }
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(HistoryCell::new_patch_event( self.add_to_history(&history_cell::new_patch_event(
PatchEventType::ApplyBegin { PatchEventType::ApplyBegin {
auto_approved: event.auto_approved, auto_approved: event.auto_approved,
}, },
@@ -372,7 +374,7 @@ impl ChatWidget<'_> {
self.active_exec_cell = None; self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions); let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending { for (command, parsed, output) in pending {
self.add_to_history(HistoryCell::new_completed_exec_command( self.add_to_history(&history_cell::new_completed_exec_command(
command, parsed, output, command, parsed, output,
)); ));
} }
@@ -384,9 +386,9 @@ impl ChatWidget<'_> {
event: codex_core::protocol::PatchApplyEndEvent, event: codex_core::protocol::PatchApplyEndEvent,
) { ) {
if event.success { if event.success {
self.add_to_history(HistoryCell::new_patch_apply_success(event.stdout)); self.add_to_history(&history_cell::new_patch_apply_success(event.stdout));
} else { } else {
self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr)); self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr));
} }
} }
@@ -402,7 +404,7 @@ impl ChatWidget<'_> {
.map(|r| format!("\n{r}")) .map(|r| format!("\n{r}"))
.unwrap_or_default() .unwrap_or_default()
); );
self.add_to_history(HistoryCell::new_background_event(text)); self.add_to_history(&history_cell::new_background_event(text));
let request = ApprovalRequest::Exec { let request = ApprovalRequest::Exec {
id, id,
@@ -419,7 +421,7 @@ impl ChatWidget<'_> {
ev: ApplyPatchApprovalRequestEvent, ev: ApplyPatchApprovalRequestEvent,
) { ) {
self.flush_answer_stream_with_separator(); self.flush_answer_stream_with_separator();
self.add_to_history(HistoryCell::new_patch_event( self.add_to_history(&history_cell::new_patch_event(
PatchEventType::ApprovalRequest, PatchEventType::ApprovalRequest,
ev.changes.clone(), ev.changes.clone(),
)); ));
@@ -446,11 +448,11 @@ impl ChatWidget<'_> {
); );
// Accumulate parsed commands into a single active Exec cell so they stack // Accumulate parsed commands into a single active Exec cell so they stack
match self.active_exec_cell.as_mut() { match self.active_exec_cell.as_mut() {
Some(HistoryCell::Exec(exec)) => { Some(exec) => {
exec.parsed.extend(ev.parsed_cmd); exec.parsed.extend(ev.parsed_cmd);
} }
_ => { _ => {
self.active_exec_cell = Some(HistoryCell::new_active_exec_command( self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.command, ev.command,
ev.parsed_cmd, ev.parsed_cmd,
)); ));
@@ -463,11 +465,11 @@ impl ChatWidget<'_> {
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
self.flush_answer_stream_with_separator(); self.flush_answer_stream_with_separator();
self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation)); self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation));
} }
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
self.flush_answer_stream_with_separator(); self.flush_answer_stream_with_separator();
self.add_to_history(HistoryCell::new_completed_mcp_tool_call( self.add_to_history(&*history_cell::new_completed_mcp_tool_call(
80, 80,
ev.invocation, ev.invocation,
ev.duration, ev.duration,
@@ -564,14 +566,14 @@ impl ChatWidget<'_> {
fn flush_active_exec_cell(&mut self) { fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() { if let Some(active) = self.active_exec_cell.take() {
self.app_event_tx self.app_event_tx
.send(AppEvent::InsertHistory(active.plain_lines())); .send(AppEvent::InsertHistory(active.display_lines()));
} }
} }
fn add_to_history(&mut self, cell: HistoryCell) { fn add_to_history(&mut self, cell: &dyn HistoryCell) {
self.flush_active_exec_cell(); self.flush_active_exec_cell();
self.app_event_tx self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines())); .send(AppEvent::InsertHistory(cell.display_lines()));
} }
fn submit_user_message(&mut self, user_message: UserMessage) { fn submit_user_message(&mut self, user_message: UserMessage) {
@@ -607,7 +609,7 @@ impl ChatWidget<'_> {
// Only show the text portion in conversation history. // Only show the text portion in conversation history.
if !text.is_empty() { if !text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(text.clone())); self.add_to_history(&history_cell::new_user_prompt(text.clone()));
} }
} }
@@ -680,18 +682,18 @@ impl ChatWidget<'_> {
} }
pub(crate) fn add_diff_output(&mut self, diff_output: String) { pub(crate) fn add_diff_output(&mut self, diff_output: String) {
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone())); self.add_to_history(&history_cell::new_diff_output(diff_output.clone()));
} }
pub(crate) fn add_status_output(&mut self) { pub(crate) fn add_status_output(&mut self) {
self.add_to_history(HistoryCell::new_status_output( self.add_to_history(&history_cell::new_status_output(
&self.config, &self.config,
&self.total_token_usage, &self.total_token_usage,
)); ));
} }
pub(crate) fn add_prompts_output(&mut self) { pub(crate) fn add_prompts_output(&mut self) {
self.add_to_history(HistoryCell::new_prompts_output()); self.add_to_history(&history_cell::new_prompts_output());
} }
/// Forward file-search results to the bottom pane. /// Forward file-search results to the bottom pane.

View File

@@ -361,19 +361,22 @@ fn style_del() -> Style {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::history_cell::HistoryCell;
use crate::text_block::TextBlock;
use insta::assert_snapshot; use insta::assert_snapshot;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) { fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
let cell = HistoryCell::PendingPatch {
view: TextBlock::new(lines),
};
terminal terminal
.draw(|f| f.render_widget_ref(&cell, f.area())) .draw(|f| {
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.render_ref(f.area(), f.buffer_mut())
})
.expect("draw"); .expect("draw");
assert_snapshot!(name, terminal.backend()); assert_snapshot!(name, terminal.backend());
} }

View File

@@ -3,7 +3,6 @@ use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home; use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape; use crate::exec_command::strip_bash_lc_and_escape;
use crate::slash_command::SlashCommand; use crate::slash_command::SlashCommand;
use crate::text_block::TextBlock;
use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::format_and_truncate_tool_result;
use base64::Engine; use base64::Engine;
use codex_ansi_escape::ansi_escape_line; use codex_ansi_escape::ansi_escape_line;
@@ -50,18 +49,28 @@ pub(crate) enum PatchEventType {
ApplyBegin { auto_approved: bool }, ApplyBegin { auto_approved: bool },
} }
fn span_to_static(span: &Span) -> Span<'static> { /// Represents an event to display in the conversation history. Returns its
Span { /// `Vec<Line<'static>>` representation to make it easier to display in a
style: span.style, /// scrollable list.
content: std::borrow::Cow::Owned(span.content.clone().into_owned()), pub(crate) trait HistoryCell {
fn display_lines(&self) -> Vec<Line<'static>>;
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
} }
} }
fn line_to_static(line: &Line) -> Line<'static> { pub(crate) struct PlainHistoryCell {
Line { lines: Vec<Line<'static>>,
style: line.style, }
alignment: line.alignment,
spans: line.spans.iter().map(span_to_static).collect(), impl HistoryCell for PlainHistoryCell {
fn display_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
} }
} }
@@ -70,92 +79,30 @@ pub(crate) struct ExecCell {
pub(crate) parsed: Vec<ParsedCommand>, pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>, pub(crate) output: Option<CommandOutput>,
} }
impl HistoryCell for ExecCell {
fn display_lines(&self) -> Vec<Line<'static>> {
exec_command_lines(&self.command, &self.parsed, self.output.as_ref())
}
}
/// Represents an event to display in the conversation history. Returns its impl WidgetRef for &ExecCell {
/// `Vec<Line<'static>>` representation to make it easier to display in a fn render_ref(&self, area: Rect, buf: &mut Buffer) {
/// scrollable list. Paragraph::new(Text::from(self.display_lines()))
pub(crate) enum HistoryCell { .wrap(Wrap { trim: false })
/// Welcome message. .render(area, buf);
WelcomeMessage { }
view: TextBlock, }
},
/// Message from the user. struct CompletedMcpToolCallWithImageOutput {
UserPrompt {
view: TextBlock,
},
Exec(ExecCell),
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where we show the result serialized as JSON.
CompletedMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where the result is an image.
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
/// which could be a mix of text and images, so we need to tighten this up.
// NOTE: For image output we keep the *original* image around and lazily
// compute a resized copy that fits the available cell width. Caching the
// resized version avoids doing the potentially expensive rescale twice
// because the scroll-view first calls `height()` for layouting and then
// `render_window()` for painting.
CompletedMcpToolCallWithImageOutput {
_image: DynamicImage, _image: DynamicImage,
}, }
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
/// Background event. fn display_lines(&self) -> Vec<Line<'static>> {
BackgroundEvent { vec![
view: TextBlock, Line::from("tool result (image output omitted)"),
}, Line::from(""),
]
/// Output from the `/diff` command. }
GitDiffOutput {
view: TextBlock,
},
/// Output from the `/status` command.
StatusOutput {
view: TextBlock,
},
/// Output from the `/prompts` command.
PromptsOutput {
view: TextBlock,
},
/// Error event from the backend.
ErrorEvent {
view: TextBlock,
},
/// Info describing the newly-initialized session.
SessionInfo {
view: TextBlock,
},
/// A pending code patch that is awaiting user approval. Mirrors the
/// behaviour of `ExecCell` so the user sees *what* patch the
/// model wants to apply before being prompted to approve or deny it.
PendingPatch {
view: TextBlock,
},
/// A humanfriendly rendering of the model's current plan and step
/// statuses provided via the `update_plan` tool.
PlanUpdate {
view: TextBlock,
},
/// Result of applying a patch (success or failure) with optional output.
PatchApplyResult {
view: TextBlock,
},
} }
const TOOL_CALL_MAX_LINES: usize = 5; const TOOL_CALL_MAX_LINES: usize = 5;
@@ -181,63 +128,19 @@ fn pretty_provider_name(id: &str) -> String {
} }
} }
impl HistoryCell { pub(crate) fn new_background_event(message: String) -> PlainHistoryCell {
/// Return a cloned, plain representation of the cell's lines suitable for
/// oneshot insertion into the terminal scrollback. Image cells are
/// represented with a simple placeholder.
/// These lines are also rendered directly by ratatui wrapped in a Paragraph.
pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
match self {
HistoryCell::WelcomeMessage { view }
| HistoryCell::UserPrompt { view }
| HistoryCell::BackgroundEvent { view }
| HistoryCell::GitDiffOutput { view }
| HistoryCell::StatusOutput { view }
| HistoryCell::PromptsOutput { view }
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::PatchApplyResult { view }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
}
HistoryCell::Exec(ExecCell {
command,
parsed,
output,
}) => HistoryCell::exec_command_lines(command, parsed, output.as_ref()),
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
],
}
}
pub(crate) fn new_background_event(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("event".dim())); lines.push(Line::from("event".dim()));
lines.extend(message.lines().map(|line| ansi_escape_line(line).dim())); lines.extend(message.lines().map(|line| ansi_escape_line(line).dim()));
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::BackgroundEvent { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn desired_height(&self, width: u16) -> u16 { pub(crate) fn new_session_info(
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
pub(crate) fn new_session_info(
config: &Config, config: &Config,
event: SessionConfiguredEvent, event: SessionConfiguredEvent,
is_first_event: bool, is_first_event: bool,
) -> Self { ) -> PlainHistoryCell {
let SessionConfiguredEvent { let SessionConfiguredEvent {
model, model,
session_id: _, session_id: _,
@@ -269,13 +172,9 @@ impl HistoryCell {
Line::from(format!(" /prompts - {}", SlashCommand::Prompts.description()).dim()), Line::from(format!(" /prompts - {}", SlashCommand::Prompts.description()).dim()),
Line::from("".dim()), Line::from("".dim()),
]; ];
HistoryCell::WelcomeMessage { PlainHistoryCell { lines }
view: TextBlock::new(lines),
}
} else if config.model == model { } else if config.model == model {
HistoryCell::SessionInfo { PlainHistoryCell { lines: Vec::new() }
view: TextBlock::new(Vec::new()),
}
} else { } else {
let lines = vec![ let lines = vec![
Line::from("model changed:".magenta().bold()), Line::from("model changed:".magenta().bold()),
@@ -283,65 +182,61 @@ impl HistoryCell {
Line::from(format!("used: {model}")), Line::from(format!("used: {model}")),
Line::from(""), Line::from(""),
]; ];
HistoryCell::SessionInfo { PlainHistoryCell { lines }
view: TextBlock::new(lines),
}
}
} }
}
pub(crate) fn new_user_prompt(message: String) -> Self { pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("user".cyan().bold())); lines.push(Line::from("user".cyan().bold()));
lines.extend(message.lines().map(|l| Line::from(l.to_string()))); lines.extend(message.lines().map(|l| Line::from(l.to_string())));
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::UserPrompt { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_active_exec_command( pub(crate) fn new_active_exec_command(
command: Vec<String>, command: Vec<String>,
parsed: Vec<ParsedCommand>, parsed: Vec<ParsedCommand>,
) -> Self { ) -> ExecCell {
HistoryCell::new_exec_cell(command, parsed, None) new_exec_cell(command, parsed, None)
} }
pub(crate) fn new_completed_exec_command( pub(crate) fn new_completed_exec_command(
command: Vec<String>, command: Vec<String>,
parsed: Vec<ParsedCommand>, parsed: Vec<ParsedCommand>,
output: CommandOutput, output: CommandOutput,
) -> Self { ) -> ExecCell {
HistoryCell::new_exec_cell(command, parsed, Some(output)) new_exec_cell(command, parsed, Some(output))
} }
fn new_exec_cell( fn new_exec_cell(
command: Vec<String>, command: Vec<String>,
parsed: Vec<ParsedCommand>, parsed: Vec<ParsedCommand>,
output: Option<CommandOutput>, output: Option<CommandOutput>,
) -> Self { ) -> ExecCell {
HistoryCell::Exec(ExecCell { ExecCell {
command, command,
parsed, parsed,
output, output,
})
} }
}
fn exec_command_lines( fn exec_command_lines(
command: &[String], command: &[String],
parsed: &[ParsedCommand], parsed: &[ParsedCommand],
output: Option<&CommandOutput>, output: Option<&CommandOutput>,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
match parsed.is_empty() { match parsed.is_empty() {
true => HistoryCell::new_exec_command_generic(command, output), true => new_exec_command_generic(command, output),
false => HistoryCell::new_parsed_command(parsed, output), false => new_parsed_command(parsed, output),
}
} }
}
fn new_parsed_command( fn new_parsed_command(
parsed_commands: &[ParsedCommand], parsed_commands: &[ParsedCommand],
output: Option<&CommandOutput>, output: Option<&CommandOutput>,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = vec![match output { let mut lines: Vec<Line> = vec![match output {
None => Line::from("⚙︎ Working".magenta().bold()), None => Line::from("⚙︎ Working".magenta().bold()),
Some(o) if o.exit_code == 0 => Line::from("✓ Completed".green().bold()), Some(o) if o.exit_code == 0 => Line::from("✓ Completed".green().bold()),
@@ -381,12 +276,12 @@ impl HistoryCell {
lines.push(Line::from("")); lines.push(Line::from(""));
lines lines
} }
fn new_exec_command_generic( fn new_exec_command_generic(
command: &[String], command: &[String],
output: Option<&CommandOutput>, output: Option<&CommandOutput>,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
let command_escaped = strip_bash_lc_and_escape(command); let command_escaped = strip_bash_lc_and_escape(command);
let mut cmd_lines = command_escaped.lines(); let mut cmd_lines = command_escaped.lines();
@@ -405,9 +300,9 @@ impl HistoryCell {
lines.extend(output_lines(output, false, true)); lines.extend(output_lines(output, false, true));
lines lines
} }
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self { pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell {
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
let lines: Vec<Line> = vec![ let lines: Vec<Line> = vec![
title_line, title_line,
@@ -415,29 +310,25 @@ impl HistoryCell {
Line::from(""), Line::from(""),
]; ];
HistoryCell::ActiveMcpToolCall { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
/// If the first content is an image, return a new cell with the image. /// If the first content is an image, return a new cell with the image.
/// TODO(rgwood-dd): Handle images properly even if they're not the first result. /// TODO(rgwood-dd): Handle images properly even if they're not the first result.
fn try_new_completed_mcp_tool_call_with_image_output( fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<mcp_types::CallToolResult, String>, result: &Result<mcp_types::CallToolResult, String>,
) -> Option<Self> { ) -> Option<CompletedMcpToolCallWithImageOutput> {
match result { match result {
Ok(mcp_types::CallToolResult { content, .. }) => { Ok(mcp_types::CallToolResult { content, .. }) => {
if let Some(mcp_types::ContentBlock::ImageContent(image)) = content.first() { if let Some(mcp_types::ContentBlock::ImageContent(image)) = content.first() {
let raw_data = let raw_data = match base64::engine::general_purpose::STANDARD.decode(&image.data) {
match base64::engine::general_purpose::STANDARD.decode(&image.data) {
Ok(data) => data, Ok(data) => data,
Err(e) => { Err(e) => {
error!("Failed to decode image data: {e}"); error!("Failed to decode image data: {e}");
return None; return None;
} }
}; };
let reader = match ImageReader::new(Cursor::new(raw_data)).with_guessed_format() let reader = match ImageReader::new(Cursor::new(raw_data)).with_guessed_format() {
{
Ok(reader) => reader, Ok(reader) => reader,
Err(e) => { Err(e) => {
error!("Failed to guess image format: {e}"); error!("Failed to guess image format: {e}");
@@ -453,24 +344,24 @@ impl HistoryCell {
} }
}; };
Some(HistoryCell::CompletedMcpToolCallWithImageOutput { _image: image }) Some(CompletedMcpToolCallWithImageOutput { _image: image })
} else { } else {
None None
} }
} }
_ => None, _ => None,
} }
} }
pub(crate) fn new_completed_mcp_tool_call( pub(crate) fn new_completed_mcp_tool_call(
num_cols: usize, num_cols: usize,
invocation: McpInvocation, invocation: McpInvocation,
duration: Duration, duration: Duration,
success: bool, success: bool,
result: Result<mcp_types::CallToolResult, String>, result: Result<mcp_types::CallToolResult, String>,
) -> Self { ) -> Box<dyn HistoryCell> {
if let Some(cell) = Self::try_new_completed_mcp_tool_call_with_image_output(&result) { if let Some(cell) = try_new_completed_mcp_tool_call_with_image_output(&result) {
return cell; return Box::new(cell);
} }
let duration = format_duration(duration); let duration = format_duration(duration);
@@ -508,17 +399,11 @@ impl HistoryCell {
// TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall` // TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall`
"<image content>".to_string() "<image content>".to_string()
} }
mcp_types::ContentBlock::AudioContent(_) => { mcp_types::ContentBlock::AudioContent(_) => "<audio content>".to_string(),
"<audio content>".to_string()
}
mcp_types::ContentBlock::EmbeddedResource(resource) => { mcp_types::ContentBlock::EmbeddedResource(resource) => {
let uri = match resource.resource { let uri = match resource.resource {
EmbeddedResourceResource::TextResourceContents(text) => { EmbeddedResourceResource::TextResourceContents(text) => text.uri,
text.uri EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri,
}
EmbeddedResourceResource::BlobResourceContents(blob) => {
blob.uri
}
}; };
format!("embedded resource: {uri}") format!("embedded resource: {uri}")
} }
@@ -546,12 +431,10 @@ impl HistoryCell {
} }
}; };
HistoryCell::CompletedMcpToolCall { Box::new(PlainHistoryCell { lines })
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_diff_output(message: String) -> Self { pub(crate) fn new_diff_output(message: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/diff".magenta())); lines.push(Line::from("/diff".magenta()));
@@ -562,12 +445,10 @@ impl HistoryCell {
} }
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::GitDiffOutput { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> Self { pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/status".magenta())); lines.push(Line::from("/status".magenta()));
@@ -692,12 +573,10 @@ impl HistoryCell {
])); ]));
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::StatusOutput { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_prompts_output() -> Self { pub(crate) fn new_prompts_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![ let lines: Vec<Line<'static>> = vec![
Line::from("/prompts".magenta()), Line::from("/prompts".magenta()),
Line::from(""), Line::from(""),
@@ -709,21 +588,16 @@ impl HistoryCell {
Line::from(" 6. Improve documentation in @filename"), Line::from(" 6. Improve documentation in @filename"),
Line::from(""), Line::from(""),
]; ];
HistoryCell::PromptsOutput { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_error_event(message: String) -> Self { pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
let lines: Vec<Line<'static>> = let lines: Vec<Line<'static>> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()];
vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()]; PlainHistoryCell { lines }
HistoryCell::ErrorEvent { }
view: TextBlock::new(lines),
}
}
/// Render a userfriendly plan update styled like a checkbox todo list. /// Render a userfriendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> Self { pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
let UpdatePlanArgs { explanation, plan } = update; let UpdatePlanArgs { explanation, plan } = update;
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
@@ -819,18 +693,16 @@ impl HistoryCell {
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::PlanUpdate { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
/// Create a new `PendingPatch` cell that lists the filelevel summary of /// Create a new `PendingPatch` cell that lists the filelevel summary of
/// a proposed patch. The summary lines should already be formatted (e.g. /// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs"). /// "A path/to/file.rs").
pub(crate) fn new_patch_event( pub(crate) fn new_patch_event(
event_type: PatchEventType, event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>, changes: HashMap<PathBuf, FileChange>,
) -> Self { ) -> PlainHistoryCell {
let title = match &event_type { let title = match &event_type {
PatchEventType::ApprovalRequest => "proposed patch", PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin { PatchEventType::ApplyBegin {
@@ -843,9 +715,7 @@ impl HistoryCell {
Line::from("✏️ Applying patch".magenta().bold()), Line::from("✏️ Applying patch".magenta().bold()),
Line::from(""), Line::from(""),
]; ];
return Self::PendingPatch { return PlainHistoryCell { lines };
view: TextBlock::new(lines),
};
} }
}; };
@@ -853,12 +723,10 @@ impl HistoryCell {
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::PendingPatch { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_patch_apply_failure(stderr: String) -> Self { pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
// Failure title // Failure title
@@ -878,12 +746,10 @@ impl HistoryCell {
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::PatchApplyResult { PlainHistoryCell { lines }
view: TextBlock::new(lines), }
}
}
pub(crate) fn new_patch_apply_success(stdout: String) -> Self { pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
// Success title // Success title
@@ -905,18 +771,7 @@ impl HistoryCell {
lines.push(Line::from("")); lines.push(Line::from(""));
HistoryCell::PatchApplyResult { PlainHistoryCell { lines }
view: TextBlock::new(lines),
}
}
}
impl WidgetRef for &HistoryCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
.render(area, buf);
}
} }
fn output_lines( fn output_lines(
@@ -1017,7 +872,7 @@ mod tests {
let parsed = vec![ParsedCommand::Unknown { let parsed = vec![ParsedCommand::Unknown {
cmd: vec!["printf".into(), "foo\nbar".into()], cmd: vec!["printf".into(), "foo\nbar".into()],
}]; }];
let lines = HistoryCell::exec_command_lines(&[], &parsed, None); let lines = exec_command_lines(&[], &parsed, None);
assert!(lines.len() >= 3); assert!(lines.len() >= 3);
assert_eq!(lines[1].spans[0].content, ""); assert_eq!(lines[1].spans[0].content, "");
assert_eq!(lines[2].spans[0].content, " "); assert_eq!(lines[2].spans[0].content, " ");

View File

@@ -49,7 +49,6 @@ mod shimmer;
mod slash_command; mod slash_command;
mod status_indicator_widget; mod status_indicator_widget;
mod streaming; mod streaming;
mod text_block;
mod text_formatting; mod text_formatting;
mod tui; mod tui;
mod user_approval_widget; mod user_approval_widget;

View File

@@ -1,14 +0,0 @@
use ratatui::prelude::*;
/// A simple widget that just displays a list of `Line`s via a `Paragraph`.
/// This is the default rendering backend for most `HistoryCell` variants.
#[derive(Clone)]
pub(crate) struct TextBlock {
pub(crate) lines: Vec<Line<'static>>,
}
impl TextBlock {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
}
}