mirror of
https://github.com/openai/codex.git
synced 2026-04-29 02:41:12 +03:00
513 lines
17 KiB
Markdown
513 lines
17 KiB
Markdown
# PR #1824: show a transient history cell for commands
|
||
|
||
- URL: https://github.com/openai/codex/pull/1824
|
||
- Author: nornagon-openai
|
||
- Created: 2025-08-04 18:28:02 UTC
|
||
- Updated: 2025-08-06 19:03:54 UTC
|
||
- Changes: +119/-71, Files changed: 2, Commits: 8
|
||
|
||
## Description
|
||
|
||
Adds a new "active history cell" for history bits that need to render more than once before they're inserted into the history. Only used for commands right now.
|
||
|
||
https://github.com/user-attachments/assets/925f01a0-e56d-4613-bc25-fdaa85d8aea5
|
||
|
||
## Full Diff
|
||
|
||
```diff
|
||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||
index 6d03be783b..d7120e80ff 100644
|
||
--- a/codex-rs/tui/src/chatwidget.rs
|
||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||
@@ -30,6 +30,8 @@ use codex_core::protocol::TurnDiffEvent;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use ratatui::buffer::Buffer;
|
||
+use ratatui::layout::Constraint;
|
||
+use ratatui::layout::Layout;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::widgets::Widget;
|
||
use ratatui::widgets::WidgetRef;
|
||
@@ -62,6 +64,7 @@ pub(crate) struct ChatWidget<'a> {
|
||
app_event_tx: AppEventSender,
|
||
codex_op_tx: UnboundedSender<Op>,
|
||
bottom_pane: BottomPane<'a>,
|
||
+ active_history_cell: Option<HistoryCell>,
|
||
config: Config,
|
||
initial_user_message: Option<UserMessage>,
|
||
token_usage: TokenUsage,
|
||
@@ -107,6 +110,17 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||
}
|
||
|
||
impl ChatWidget<'_> {
|
||
+ fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||
+ Layout::vertical([
|
||
+ Constraint::Max(
|
||
+ self.active_history_cell
|
||
+ .as_ref()
|
||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||
+ ),
|
||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||
+ ])
|
||
+ .areas(area)
|
||
+ }
|
||
fn emit_stream_header(&mut self, kind: StreamKind) {
|
||
use ratatui::text::Line as RLine;
|
||
if self.stream_header_emitted {
|
||
@@ -178,6 +192,7 @@ impl ChatWidget<'_> {
|
||
has_input_focus: true,
|
||
enhanced_keys_supported,
|
||
}),
|
||
+ active_history_cell: None,
|
||
config,
|
||
initial_user_message: create_initial_user_message(
|
||
initial_prompt.unwrap_or_default(),
|
||
@@ -197,6 +212,10 @@ impl ChatWidget<'_> {
|
||
|
||
pub fn desired_height(&self, width: u16) -> u16 {
|
||
self.bottom_pane.desired_height(width)
|
||
+ + self
|
||
+ .active_history_cell
|
||
+ .as_ref()
|
||
+ .map_or(0, |c| c.desired_height(width))
|
||
}
|
||
|
||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||
@@ -425,9 +444,11 @@ impl ChatWidget<'_> {
|
||
cwd: cwd.clone(),
|
||
},
|
||
);
|
||
- self.add_to_history(HistoryCell::new_active_exec_command(command));
|
||
+ self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
|
||
+ }
|
||
+ EventMsg::ExecCommandOutputDelta(_) => {
|
||
+ // TODO
|
||
}
|
||
- EventMsg::ExecCommandOutputDelta(_) => {}
|
||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||
call_id: _,
|
||
auto_approved,
|
||
@@ -438,8 +459,12 @@ impl ChatWidget<'_> {
|
||
changes,
|
||
));
|
||
}
|
||
- EventMsg::PatchApplyEnd(patch_apply_end_event) => {
|
||
- self.add_to_history(HistoryCell::new_patch_end_event(patch_apply_end_event));
|
||
+ EventMsg::PatchApplyEnd(event) => {
|
||
+ self.add_to_history(HistoryCell::new_patch_apply_end(
|
||
+ event.stdout,
|
||
+ event.stderr,
|
||
+ event.success,
|
||
+ ));
|
||
}
|
||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id,
|
||
@@ -450,6 +475,7 @@ impl ChatWidget<'_> {
|
||
}) => {
|
||
// Compute summary before moving stdout into the history cell.
|
||
let cmd = self.running_commands.remove(&call_id);
|
||
+ self.active_history_cell = None;
|
||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
|
||
CommandOutput {
|
||
@@ -543,6 +569,7 @@ impl ChatWidget<'_> {
|
||
CancellationEvent::Ignored => {}
|
||
}
|
||
if self.bottom_pane.is_task_running() {
|
||
+ self.active_history_cell = None;
|
||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||
self.submit_op(Op::Interrupt);
|
||
self.bottom_pane.set_task_running(false);
|
||
@@ -586,7 +613,8 @@ impl ChatWidget<'_> {
|
||
}
|
||
|
||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
- self.bottom_pane.cursor_pos(area)
|
||
+ let [_, bottom_pane_area] = self.layout_areas(area);
|
||
+ self.bottom_pane.cursor_pos(bottom_pane_area)
|
||
}
|
||
}
|
||
|
||
@@ -690,10 +718,11 @@ impl ChatWidget<'_> {
|
||
|
||
impl WidgetRef for &ChatWidget<'_> {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
- // In the hybrid inline viewport mode we only draw the interactive
|
||
- // bottom pane; history entries are injected directly into scrollback
|
||
- // via `Terminal::insert_before`.
|
||
- (&self.bottom_pane).render(area, buf);
|
||
+ let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||
+ (&self.bottom_pane).render(bottom_pane_area, buf);
|
||
+ if let Some(cell) = &self.active_history_cell {
|
||
+ cell.render_ref(active_cell_area, buf);
|
||
+ }
|
||
}
|
||
}
|
||
|
||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||
index 5b7d9246f7..facb0e0a8f 100644
|
||
--- a/codex-rs/tui/src/history_cell.rs
|
||
+++ b/codex-rs/tui/src/history_cell.rs
|
||
@@ -11,7 +11,6 @@ use codex_core::plan_tool::StepStatus;
|
||
use codex_core::plan_tool::UpdatePlanArgs;
|
||
use codex_core::protocol::FileChange;
|
||
use codex_core::protocol::McpInvocation;
|
||
-use codex_core::protocol::PatchApplyEndEvent;
|
||
use codex_core::protocol::SessionConfiguredEvent;
|
||
use codex_core::protocol::TokenUsage;
|
||
use image::DynamicImage;
|
||
@@ -24,6 +23,9 @@ use ratatui::style::Modifier;
|
||
use ratatui::style::Style;
|
||
use ratatui::text::Line as RtLine;
|
||
use ratatui::text::Span as RtSpan;
|
||
+use ratatui::widgets::Paragraph;
|
||
+use ratatui::widgets::WidgetRef;
|
||
+use ratatui::widgets::Wrap;
|
||
use std::collections::HashMap;
|
||
use std::io::Cursor;
|
||
use std::path::PathBuf;
|
||
@@ -62,35 +64,23 @@ fn line_to_static(line: &Line) -> Line<'static> {
|
||
/// scrollable list.
|
||
pub(crate) enum HistoryCell {
|
||
/// Welcome message.
|
||
- WelcomeMessage {
|
||
- view: TextBlock,
|
||
- },
|
||
+ WelcomeMessage { view: TextBlock },
|
||
|
||
/// Message from the user.
|
||
- UserPrompt {
|
||
- view: TextBlock,
|
||
- },
|
||
+ UserPrompt { view: TextBlock },
|
||
|
||
// AgentMessage and AgentReasoning variants were unused and have been removed.
|
||
/// An exec tool call that has not finished yet.
|
||
- ActiveExecCommand {
|
||
- view: TextBlock,
|
||
- },
|
||
+ ActiveExecCommand { view: TextBlock },
|
||
|
||
/// Completed exec tool call.
|
||
- CompletedExecCommand {
|
||
- view: TextBlock,
|
||
- },
|
||
+ CompletedExecCommand { view: TextBlock },
|
||
|
||
/// An MCP tool call that has not finished yet.
|
||
- ActiveMcpToolCall {
|
||
- view: TextBlock,
|
||
- },
|
||
+ ActiveMcpToolCall { view: TextBlock },
|
||
|
||
/// Completed MCP tool call where we show the result serialized as JSON.
|
||
- CompletedMcpToolCall {
|
||
- view: TextBlock,
|
||
- },
|
||
+ CompletedMcpToolCall { view: TextBlock },
|
||
|
||
/// Completed MCP tool call where the result is an image.
|
||
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
|
||
@@ -100,51 +90,34 @@ pub(crate) enum HistoryCell {
|
||
// 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,
|
||
- },
|
||
+ CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
|
||
|
||
/// Background event.
|
||
- BackgroundEvent {
|
||
- view: TextBlock,
|
||
- },
|
||
+ BackgroundEvent { view: TextBlock },
|
||
|
||
/// Output from the `/diff` command.
|
||
- GitDiffOutput {
|
||
- view: TextBlock,
|
||
- },
|
||
+ GitDiffOutput { view: TextBlock },
|
||
|
||
/// Output from the `/status` command.
|
||
- StatusOutput {
|
||
- view: TextBlock,
|
||
- },
|
||
+ StatusOutput { view: TextBlock },
|
||
|
||
/// Error event from the backend.
|
||
- ErrorEvent {
|
||
- view: TextBlock,
|
||
- },
|
||
+ ErrorEvent { view: TextBlock },
|
||
|
||
/// Info describing the newly-initialized session.
|
||
- SessionInfo {
|
||
- view: TextBlock,
|
||
- },
|
||
+ SessionInfo { view: TextBlock },
|
||
|
||
/// A pending code patch that is awaiting user approval. Mirrors the
|
||
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
|
||
/// model wants to apply before being prompted to approve or deny it.
|
||
- PendingPatch {
|
||
- view: TextBlock,
|
||
- },
|
||
-
|
||
- PatchEventEnd {
|
||
- view: TextBlock,
|
||
- },
|
||
+ 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,
|
||
- },
|
||
+ PlanUpdate { view: TextBlock },
|
||
+
|
||
+ /// Result of applying a patch (success or failure) with optional output.
|
||
+ PatchApplyResult { view: TextBlock },
|
||
}
|
||
|
||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||
@@ -165,8 +138,8 @@ impl HistoryCell {
|
||
| HistoryCell::CompletedExecCommand { view }
|
||
| HistoryCell::CompletedMcpToolCall { view }
|
||
| HistoryCell::PendingPatch { view }
|
||
- | HistoryCell::PatchEventEnd { view }
|
||
| HistoryCell::PlanUpdate { view }
|
||
+ | HistoryCell::PatchApplyResult { view }
|
||
| HistoryCell::ActiveExecCommand { view, .. }
|
||
| HistoryCell::ActiveMcpToolCall { view, .. } => {
|
||
view.lines.iter().map(line_to_static).collect()
|
||
@@ -177,6 +150,15 @@ impl HistoryCell {
|
||
],
|
||
}
|
||
}
|
||
+
|
||
+ 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(
|
||
config: &Config,
|
||
event: SessionConfiguredEvent,
|
||
@@ -612,7 +594,10 @@ impl HistoryCell {
|
||
PatchEventType::ApplyBegin {
|
||
auto_approved: false,
|
||
} => {
|
||
- let lines = vec![Line::from("patch applied".magenta().bold())];
|
||
+ let lines: Vec<Line<'static>> = vec![
|
||
+ Line::from("applying patch".magenta().bold()),
|
||
+ Line::from(""),
|
||
+ ];
|
||
return Self::PendingPatch {
|
||
view: TextBlock::new(lines),
|
||
};
|
||
@@ -661,29 +646,63 @@ impl HistoryCell {
|
||
}
|
||
}
|
||
|
||
- pub(crate) fn new_patch_end_event(patch_apply_end_event: PatchApplyEndEvent) -> Self {
|
||
- let PatchApplyEndEvent {
|
||
- call_id: _,
|
||
- stdout: _,
|
||
- stderr,
|
||
- success,
|
||
- } = patch_apply_end_event;
|
||
+ pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
|
||
+ let mut lines: Vec<Line<'static>> = Vec::new();
|
||
+
|
||
+ let status = if success {
|
||
+ RtSpan::styled("patch applied", Style::default().fg(Color::Green))
|
||
+ } else {
|
||
+ RtSpan::styled(
|
||
+ "patch failed",
|
||
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||
+ )
|
||
+ };
|
||
+ lines.push(RtLine::from(vec![
|
||
+ "patch".magenta().bold(),
|
||
+ " ".into(),
|
||
+ status,
|
||
+ ]));
|
||
|
||
- let mut lines: Vec<Line<'static>> = if success {
|
||
- vec![Line::from("patch applied successfully".italic())]
|
||
+ let src = if success {
|
||
+ if stdout.trim().is_empty() {
|
||
+ &stderr
|
||
+ } else {
|
||
+ &stdout
|
||
+ }
|
||
+ } else if stderr.trim().is_empty() {
|
||
+ &stdout
|
||
} else {
|
||
- let mut lines = vec![Line::from("patch failed".italic())];
|
||
- lines.extend(stderr.lines().map(|l| Line::from(l.to_string())));
|
||
- lines
|
||
+ &stderr
|
||
};
|
||
+
|
||
+ if !src.trim().is_empty() {
|
||
+ lines.push(Line::from(""));
|
||
+ let mut iter = src.lines();
|
||
+ for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||
+ lines.push(ansi_escape_line(raw).dim());
|
||
+ }
|
||
+ let remaining = iter.count();
|
||
+ if remaining > 0 {
|
||
+ lines.push(Line::from(format!("... {remaining} additional lines")).dim());
|
||
+ }
|
||
+ }
|
||
+
|
||
lines.push(Line::from(""));
|
||
|
||
- HistoryCell::PatchEventEnd {
|
||
+ HistoryCell::PatchApplyResult {
|
||
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 create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||
// Build a concise, human‑readable summary list similar to the
|
||
// `git status` short format so the user can reason about the
|
||
```
|
||
|
||
## Review Comments
|
||
|
||
### codex-rs/tui/src/chatwidget.rs
|
||
|
||
- Created: 2025-08-05 00:34:29 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2252846298
|
||
|
||
```diff
|
||
@@ -511,16 +523,34 @@ impl ChatWidget<'_> {
|
||
}
|
||
|
||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
- self.bottom_pane.cursor_pos(area)
|
||
+ let [_, bottom_pane_area] = Layout::vertical([
|
||
+ Constraint::Max(
|
||
+ self.active_history_cell
|
||
+ .as_ref()
|
||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||
+ ),
|
||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||
+ ])
|
||
+ .areas(area);
|
||
+ self.bottom_pane.cursor_pos(bottom_pane_area)
|
||
}
|
||
}
|
||
|
||
impl WidgetRef for &ChatWidget<'_> {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
- // In the hybrid inline viewport mode we only draw the interactive
|
||
- // bottom pane; history entries are injected directly into scrollback
|
||
- // via `Terminal::insert_before`.
|
||
- (&self.bottom_pane).render(area, buf);
|
||
+ let [active_cell_area, bottom_pane_area] = Layout::vertical([
|
||
+ Constraint::Max(
|
||
+ self.active_history_cell
|
||
+ .as_ref()
|
||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||
+ ),
|
||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||
+ ])
|
||
+ .areas(area);
|
||
```
|
||
|
||
> Should this be moved into a helper function so that `cursor_pos()` can reuse it?
|
||
|
||
- Created: 2025-08-06 06:01:22 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2255946730
|
||
|
||
```diff
|
||
@@ -104,6 +107,17 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||
}
|
||
|
||
impl ChatWidget<'_> {
|
||
+ fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||
+ Layout::vertical([
|
||
+ Constraint::Max(
|
||
+ self.active_history_cell
|
||
+ .as_ref()
|
||
+ .map_or(0, |c| c.desired_height(area.width)),
|
||
+ ),
|
||
+ Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||
+ ])
|
||
+ .areas(area)
|
||
+ }
|
||
```
|
||
|
||
> \n?
|
||
|
||
- Created: 2025-08-06 06:11:01 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2255961356
|
||
|
||
```diff
|
||
@@ -435,6 +456,13 @@ impl ChatWidget<'_> {
|
||
changes,
|
||
));
|
||
}
|
||
+ EventMsg::PatchApplyEnd(event) => {
|
||
+ self.add_to_history(HistoryCell::new_patch_apply_end(
|
||
+ event.stdout,
|
||
```
|
||
|
||
> I would just pass the whole `PatchApplyEndEvent` rather than pull the fields off here.
|
||
|
||
### codex-rs/tui/src/history_cell.rs
|
||
|
||
- Created: 2025-08-06 06:08:44 UTC | Link: https://github.com/openai/codex/pull/1824#discussion_r2255957518
|
||
|
||
```diff
|
||
@@ -598,6 +616,62 @@ impl HistoryCell {
|
||
view: TextBlock::new(lines),
|
||
}
|
||
}
|
||
+
|
||
+ pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
|
||
+ let mut lines: Vec<Line<'static>> = Vec::new();
|
||
+
|
||
+ let status = if success {
|
||
+ RtSpan::styled("patch applied", Style::default().fg(Color::Green))
|
||
+ } else {
|
||
+ RtSpan::styled(
|
||
+ "patch failed",
|
||
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||
+ )
|
||
+ };
|
||
+ lines.push(RtLine::from(vec![
|
||
+ "patch".magenta().bold(),
|
||
+ " ".into(),
|
||
+ status,
|
||
+ ]));
|
||
+
|
||
+ let src = if success {
|
||
+ if stdout.trim().is_empty() {
|
||
+ &stderr
|
||
+ } else {
|
||
+ &stdout
|
||
+ }
|
||
+ } else if stderr.trim().is_empty() {
|
||
+ &stdout
|
||
+ } else {
|
||
+ &stderr
|
||
+ };
|
||
```
|
||
|
||
> The way `apply_patch` works, `stdout` should never be empty on `success`. If anything, it is slightly redundant with what was just shown for `new_patch_event()`, so it might be fine to omit it. You can see that in #1866, I tried to be stingy on the amount of output.
|
||
>
|
||
> Up to you. |