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:
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 human‑friendly 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
|
|
||||||
/// one‑shot 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 {
|
|
||||||
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(
|
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,48 +182,44 @@ 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(
|
||||||
@@ -333,8 +228,8 @@ impl HistoryCell {
|
|||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +302,7 @@ impl HistoryCell {
|
|||||||
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,7 +344,7 @@ impl HistoryCell {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(HistoryCell::CompletedMcpToolCallWithImageOutput { _image: image })
|
Some(CompletedMcpToolCallWithImageOutput { _image: image })
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -468,9 +359,9 @@ impl HistoryCell {
|
|||||||
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 user‑friendly plan update styled like a checkbox todo list.
|
/// Render a user‑friendly 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,9 +693,7 @@ 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 file‑level summary of
|
/// Create a new `PendingPatch` cell that lists the file‑level summary of
|
||||||
@@ -830,7 +702,7 @@ impl HistoryCell {
|
|||||||
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, " ");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user