Simplify TUI session resume and internal event types

This commit is contained in:
Eric Traut
2026-04-29 01:09:37 -07:00
parent 8bc5ba4694
commit c4cd09957e
15 changed files with 320 additions and 822 deletions
+1 -2
View File
@@ -31,7 +31,6 @@ use codex_state::state_db_path;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use codex_tui::ExitReason;
use codex_tui::FinalOutput;
use codex_tui::UpdateAction;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
@@ -535,7 +534,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let mut lines = Vec::new();
if !token_usage.is_zero() {
lines.push(FinalOutput::from(token_usage).to_string());
lines.push(token_usage.to_string());
}
if let Some(resume_cmd) =
+1 -2
View File
@@ -71,7 +71,6 @@ use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
#[cfg(test)]
use crate::test_support::test_path_display;
use crate::token_usage::FinalOutput;
use crate::token_usage::TokenUsage;
use crate::transcript_reflow::TranscriptReflowState;
use crate::tui;
@@ -377,7 +376,7 @@ fn session_summary(
thread_name: Option<String>,
rollout_path: Option<&Path>,
) -> Option<SessionSummary> {
let usage_line = (!token_usage.is_zero()).then(|| FinalOutput::from(token_usage).to_string());
let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string());
let thread_id =
resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id);
let resume_command =
+72 -194
View File
@@ -57,6 +57,7 @@ use crate::bottom_pane::StatusSurfacePreviewData;
use crate::bottom_pane::StatusSurfacePreviewItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::diff_model::FileChange;
use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::Constrained;
@@ -82,23 +83,6 @@ use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::token_usage::TokenUsage;
use crate::token_usage::TokenUsageInfo;
use crate::tool_activity::ExecCommandBeginEvent;
use crate::tool_activity::ExecCommandEndEvent;
use crate::tool_activity::ExecCommandOutputDeltaEvent;
use crate::tool_activity::HookCompletedEvent;
use crate::tool_activity::HookStartedEvent;
use crate::tool_activity::ImageGenerationBeginEvent;
use crate::tool_activity::ImageGenerationEndEvent;
use crate::tool_activity::McpInvocation;
use crate::tool_activity::McpToolCallBeginEvent;
use crate::tool_activity::McpToolCallEndEvent;
use crate::tool_activity::PatchApplyBeginEvent;
use crate::tool_activity::PatchApplyEndEvent;
use crate::tool_activity::TerminalInteractionEvent;
use crate::tool_activity::ViewImageToolCallEvent;
use crate::tool_activity::WebSearchBeginEvent;
use crate::tool_activity::WebSearchEndEvent;
use crate::turn_state::TurnAbortReason;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
@@ -310,6 +294,7 @@ use crate::get_git_diff::get_git_diff;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::history_cell::HookCell;
use crate::history_cell::McpInvocation;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::WebSearchCell;
@@ -336,7 +321,12 @@ use self::goal_status::GoalStatusState;
use self::goal_status::goal_status_indicator_from_app_goal;
mod goal_menu;
mod interrupts;
use self::interrupts::ExecCommandBeginEvent;
use self::interrupts::ExecCommandEndEvent;
use self::interrupts::InterruptManager;
use self::interrupts::McpToolCallBeginEvent;
use self::interrupts::McpToolCallEndEvent;
use self::interrupts::PatchApplyEndEvent;
mod keymap_picker;
mod mcp_startup;
use self::mcp_startup::McpStartupStatus;
@@ -1532,6 +1522,12 @@ enum PlanModeNudgeScope {
Thread(ThreadId),
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum TurnAbortReason {
Interrupted,
BudgetLimited,
}
/// Returns whether `text` contains the standalone word `plan`.
///
/// This intentionally mirrors the App suggestion heuristic instead of trying to infer broader
@@ -1561,58 +1557,6 @@ impl ThreadItemRenderSource {
}
}
fn hook_output_entry_from_notification(
entry: codex_app_server_protocol::HookOutputEntry,
) -> codex_app_server_protocol::HookOutputEntry {
codex_app_server_protocol::HookOutputEntry {
kind: entry.kind,
text: entry.text,
}
}
fn hook_run_summary_from_notification(
run: codex_app_server_protocol::HookRunSummary,
) -> codex_app_server_protocol::HookRunSummary {
codex_app_server_protocol::HookRunSummary {
id: run.id,
event_name: run.event_name,
handler_type: run.handler_type,
execution_mode: run.execution_mode,
scope: run.scope,
source_path: run.source_path,
source: run.source,
display_order: run.display_order,
status: run.status,
status_message: run.status_message,
started_at: run.started_at,
completed_at: run.completed_at,
duration_ms: run.duration_ms,
entries: run
.entries
.into_iter()
.map(hook_output_entry_from_notification)
.collect(),
}
}
fn hook_started_event_from_notification(
notification: codex_app_server_protocol::HookStartedNotification,
) -> HookStartedEvent {
HookStartedEvent {
turn_id: notification.turn_id,
run: hook_run_summary_from_notification(notification.run),
}
}
fn hook_completed_event_from_notification(
notification: codex_app_server_protocol::HookCompletedNotification,
) -> HookCompletedEvent {
HookCompletedEvent {
turn_id: notification.turn_id,
run: hook_run_summary_from_notification(notification.run),
}
}
fn app_server_request_id_to_mcp_request_id(
request_id: &codex_app_server_protocol::RequestId,
) -> codex_protocol::mcp::RequestId {
@@ -3728,8 +3672,8 @@ impl ChatWidget {
self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
}
fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) {
self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk);
fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) {
self.track_unified_exec_output_chunk(call_id, delta.as_bytes());
if !self.bottom_pane.is_task_running() {
return;
}
@@ -3742,13 +3686,13 @@ impl ChatWidget {
return;
};
if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) {
if cell.append_output(call_id, delta) {
self.bump_active_cell_revision();
self.request_redraw();
}
}
fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) {
fn on_terminal_interaction(&mut self, process_id: String, stdin: String) {
if !self.bottom_pane.is_task_running() {
return;
}
@@ -3756,9 +3700,9 @@ impl ChatWidget {
let command_display = self
.unified_exec_processes
.iter()
.find(|process| process.key == ev.process_id)
.find(|process| process.key == process_id)
.map(|process| process.command_display.clone());
if ev.stdin.is_empty() {
if stdin.is_empty() {
// Empty stdin means we are polling for background output.
// Surface this in the status indicator (single "waiting" surface) instead of
// the transcript. Keep the header short so the interrupt hint remains visible.
@@ -3773,17 +3717,17 @@ impl ChatWidget {
/*details_max_lines*/ 1,
);
match &mut self.unified_exec_wait_streak {
Some(wait) if wait.process_id == ev.process_id => {
Some(wait) if wait.process_id == process_id => {
wait.update_command_display(command_display);
}
Some(_) => {
self.flush_unified_exec_wait_streak();
self.unified_exec_wait_streak =
Some(UnifiedExecWaitStreak::new(ev.process_id, command_display));
Some(UnifiedExecWaitStreak::new(process_id, command_display));
}
None => {
self.unified_exec_wait_streak =
Some(UnifiedExecWaitStreak::new(ev.process_id, command_display));
Some(UnifiedExecWaitStreak::new(process_id, command_display));
}
}
self.request_redraw();
@@ -3791,43 +3735,45 @@ impl ChatWidget {
if self
.unified_exec_wait_streak
.as_ref()
.is_some_and(|wait| wait.process_id == ev.process_id)
.is_some_and(|wait| wait.process_id == process_id)
{
self.flush_unified_exec_wait_streak();
}
self.add_to_history(history_cell::new_unified_exec_interaction(
command_display,
ev.stdin,
stdin,
));
}
}
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(history_cell::new_patch_event(
event.changes,
&self.config.cwd,
));
fn on_patch_apply_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd));
}
fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) {
fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_view_image_tool_call(
event.path,
path,
&self.config.cwd,
));
self.request_redraw();
}
fn on_image_generation_begin(&mut self, _event: ImageGenerationBeginEvent) {
fn on_image_generation_begin(&mut self) {
self.flush_answer_stream_with_separator();
}
fn on_image_generation_end(&mut self, event: ImageGenerationEndEvent) {
fn on_image_generation_end(
&mut self,
call_id: String,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_image_generation_call(
event.call_id,
event.revised_prompt,
event.saved_path,
call_id,
revised_prompt,
saved_path,
));
self.request_redraw();
}
@@ -3939,11 +3885,11 @@ impl ChatWidget {
self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2));
}
fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) {
fn on_web_search_begin(&mut self, call_id: String) {
self.flush_answer_stream_with_separator();
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_active_web_search_call(
ev.call_id,
call_id,
String::new(),
self.config.animations,
)));
@@ -3951,13 +3897,13 @@ impl ChatWidget {
self.request_redraw();
}
fn on_web_search_end(&mut self, ev: WebSearchEndEvent) {
fn on_web_search_end(
&mut self,
call_id: String,
query: String,
action: codex_protocol::models::WebSearchAction,
) {
self.flush_answer_stream_with_separator();
let WebSearchEndEvent {
call_id,
query,
action,
} = ev;
let mut handled = false;
if let Some(cell) = self
.active_cell
@@ -4047,17 +3993,17 @@ impl ChatWidget {
self.request_redraw();
}
fn on_hook_started(&mut self, event: HookStartedEvent) {
fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
self.flush_answer_stream_with_separator();
self.flush_completed_hook_output();
match self.active_hook_cell.as_mut() {
Some(cell) => {
cell.start_run(event.run);
cell.start_run(run);
self.bump_active_cell_revision();
}
None => {
self.active_hook_cell = Some(history_cell::new_active_hook_cell(
event.run,
run,
self.config.animations,
));
self.bump_active_cell_revision();
@@ -4066,8 +4012,7 @@ impl ChatWidget {
self.request_redraw();
}
fn on_hook_completed(&mut self, event: HookCompletedEvent) {
let completed = event.run;
fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) {
let completed_existing_run = self
.active_hook_cell
.as_mut()
@@ -4667,7 +4612,6 @@ impl ChatWidget {
invocation,
duration,
result,
..
} = ev;
let extra_cell = match self
@@ -5981,7 +5925,7 @@ impl ChatWidget {
ThreadItem::CommandExecution {
id,
command,
cwd,
cwd: _,
process_id,
source,
status,
@@ -5997,9 +5941,7 @@ impl ChatWidget {
self.on_exec_command_begin(ExecCommandBeginEvent {
call_id: id,
process_id,
turn_id: turn_id.clone(),
command: split_command_string(&command),
cwd,
parsed_cmd: command_actions
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
@@ -6012,43 +5954,25 @@ impl ChatWidget {
self.on_exec_command_end(ExecCommandEndEvent {
call_id: id,
process_id,
turn_id: turn_id.clone(),
command: split_command_string(&command),
cwd,
parsed_cmd: command_actions
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source,
interaction_input: None,
stdout: String::new(),
stderr: String::new(),
aggregated_output: aggregated_output.clone(),
exit_code: exit_code.unwrap_or_default(),
duration: Duration::from_millis(
duration_ms.unwrap_or_default().max(0) as u64
),
formatted_output: aggregated_output,
status: match status {
codex_app_server_protocol::CommandExecutionStatus::Completed => {
codex_app_server_protocol::CommandExecutionStatus::Completed
}
codex_app_server_protocol::CommandExecutionStatus::Failed => {
codex_app_server_protocol::CommandExecutionStatus::Failed
}
codex_app_server_protocol::CommandExecutionStatus::Declined => {
codex_app_server_protocol::CommandExecutionStatus::Declined
}
codex_app_server_protocol::CommandExecutionStatus::InProgress => {
codex_app_server_protocol::CommandExecutionStatus::Failed
}
},
});
}
}
ThreadItem::FileChange {
id,
changes,
id: _,
changes: _,
status,
} => {
if !matches!(
@@ -6056,29 +5980,11 @@ impl ChatWidget {
codex_app_server_protocol::PatchApplyStatus::InProgress
) {
self.on_patch_apply_end(PatchApplyEndEvent {
call_id: id,
turn_id: turn_id.clone(),
stdout: String::new(),
stderr: String::new(),
success: !matches!(
status,
codex_app_server_protocol::PatchApplyStatus::Failed
),
changes: file_update_changes_to_display(changes),
status: match status {
codex_app_server_protocol::PatchApplyStatus::Completed => {
codex_app_server_protocol::PatchApplyStatus::Completed
}
codex_app_server_protocol::PatchApplyStatus::Failed => {
codex_app_server_protocol::PatchApplyStatus::Failed
}
codex_app_server_protocol::PatchApplyStatus::Declined => {
codex_app_server_protocol::PatchApplyStatus::Declined
}
codex_app_server_protocol::PatchApplyStatus::InProgress => {
codex_app_server_protocol::PatchApplyStatus::Failed
}
},
});
}
}
@@ -6087,7 +5993,6 @@ impl ChatWidget {
server,
tool,
arguments,
mcp_app_resource_uri,
result,
error,
duration_ms,
@@ -6100,7 +6005,6 @@ impl ChatWidget {
tool,
arguments: Some(arguments),
},
mcp_app_resource_uri,
duration: Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64),
result: match (result, error) {
(_, Some(error)) => Err(error.message),
@@ -6118,34 +6022,25 @@ impl ChatWidget {
});
}
ThreadItem::WebSearch { id, query, action } => {
self.on_web_search_begin(WebSearchBeginEvent {
call_id: id.clone(),
});
self.on_web_search_end(WebSearchEndEvent {
call_id: id,
self.on_web_search_begin(id.clone());
self.on_web_search_end(
id,
query,
action: action
action
.map(web_search_action_to_core)
.unwrap_or(codex_protocol::models::WebSearchAction::Other),
});
);
}
ThreadItem::ImageView { id, path } => {
self.on_view_image_tool_call(ViewImageToolCallEvent { call_id: id, path });
ThreadItem::ImageView { id: _, path } => {
self.on_view_image_tool_call(path);
}
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
saved_path,
..
} => {
self.on_image_generation_end(ImageGenerationEndEvent {
call_id: id,
result,
revised_prompt,
status,
saved_path,
});
self.on_image_generation_end(id, revised_prompt, saved_path);
}
ThreadItem::EnteredReviewMode { review, .. } => {
if from_replay {
@@ -6311,18 +6206,10 @@ impl ChatWidget {
}
ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(),
ServerNotification::TerminalInteraction(notification) => {
self.on_terminal_interaction(TerminalInteractionEvent {
call_id: notification.item_id,
process_id: notification.process_id,
stdin: notification.stdin,
})
self.on_terminal_interaction(notification.process_id, notification.stdin)
}
ServerNotification::CommandExecutionOutputDelta(notification) => {
self.on_exec_command_output_delta(ExecCommandOutputDeltaEvent {
call_id: notification.item_id,
stream: codex_app_server_protocol::CommandExecOutputStream::Stdout,
chunk: notification.delta.into_bytes(),
});
self.on_exec_command_output_delta(&notification.item_id, &notification.delta);
}
ServerNotification::FileChangeOutputDelta(notification) => {
self.on_patch_apply_output_delta(notification.item_id, notification.delta);
@@ -6348,10 +6235,10 @@ impl ChatWidget {
})
}
ServerNotification::HookStarted(notification) => {
self.on_hook_started(hook_started_event_from_notification(notification));
self.on_hook_started(notification.run);
}
ServerNotification::HookCompleted(notification) => {
self.on_hook_completed(hook_completed_event_from_notification(notification));
self.on_hook_completed(notification.run);
}
ServerNotification::Error(notification) => {
if notification.will_retry {
@@ -6568,7 +6455,7 @@ impl ChatWidget {
ThreadItem::CommandExecution {
id,
command,
cwd,
cwd: _,
process_id,
source,
command_actions,
@@ -6577,9 +6464,7 @@ impl ChatWidget {
self.on_exec_command_begin(ExecCommandBeginEvent {
call_id: id,
process_id,
turn_id: notification.turn_id,
command: split_command_string(&command),
cwd,
parsed_cmd: command_actions
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
@@ -6588,20 +6473,14 @@ impl ChatWidget {
interaction_input: None,
});
}
ThreadItem::FileChange { id, changes, .. } => {
self.on_patch_apply_begin(PatchApplyBeginEvent {
call_id: id,
turn_id: notification.turn_id,
auto_approved: false,
changes: file_update_changes_to_display(changes),
});
ThreadItem::FileChange { id: _, changes, .. } => {
self.on_patch_apply_begin(file_update_changes_to_display(changes));
}
ThreadItem::McpToolCall {
id,
server,
tool,
arguments,
mcp_app_resource_uri,
..
} => {
self.on_mcp_tool_call_begin(McpToolCallBeginEvent {
@@ -6611,14 +6490,13 @@ impl ChatWidget {
tool,
arguments: Some(arguments),
},
mcp_app_resource_uri,
});
}
ThreadItem::WebSearch { id, .. } => {
self.on_web_search_begin(WebSearchBeginEvent { call_id: id });
self.on_web_search_begin(id);
}
ThreadItem::ImageGeneration { id, .. } => {
self.on_image_generation_begin(ImageGenerationBeginEvent { call_id: id });
ThreadItem::ImageGeneration { .. } => {
self.on_image_generation_begin();
}
ThreadItem::CollabAgentToolCall {
id,
+51 -8
View File
@@ -1,19 +1,65 @@
//! Queue prompt overlays and deferred tool activity while another interrupt is visible.
use std::collections::VecDeque;
use std::time::Duration;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::approval_events::ApplyPatchApprovalRequestEvent;
use crate::approval_events::ExecApprovalRequestEvent;
use crate::tool_activity::ExecCommandBeginEvent;
use crate::tool_activity::ExecCommandEndEvent;
use crate::tool_activity::McpToolCallBeginEvent;
use crate::tool_activity::McpToolCallEndEvent;
use crate::tool_activity::PatchApplyEndEvent;
use crate::history_cell::McpInvocation;
use codex_app_server_protocol::CommandExecutionSource;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use super::ChatWidget;
#[derive(Debug, Clone)]
pub(crate) struct ExecCommandBeginEvent {
pub(crate) call_id: String,
pub(crate) process_id: Option<String>,
pub(crate) command: Vec<String>,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
pub(crate) source: CommandExecutionSource,
pub(crate) interaction_input: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ExecCommandEndEvent {
pub(crate) call_id: String,
pub(crate) process_id: Option<String>,
pub(crate) command: Vec<String>,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
pub(crate) source: CommandExecutionSource,
pub(crate) interaction_input: Option<String>,
pub(crate) aggregated_output: String,
pub(crate) exit_code: i32,
pub(crate) duration: Duration,
pub(crate) formatted_output: String,
}
#[derive(Debug, Clone)]
pub(crate) struct McpToolCallBeginEvent {
pub(crate) call_id: String,
pub(crate) invocation: McpInvocation,
}
#[derive(Debug, Clone)]
pub(crate) struct McpToolCallEndEvent {
pub(crate) call_id: String,
pub(crate) invocation: McpInvocation,
pub(crate) duration: Duration,
pub(crate) result: Result<CallToolResult, String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PatchApplyEndEvent {
pub(crate) success: bool,
pub(crate) stderr: String,
}
#[derive(Debug)]
pub(crate) enum QueuedInterrupt {
ExecApproval(ExecApprovalRequestEvent),
@@ -149,7 +195,6 @@ impl QueuedInterrupt {
#[cfg(test)]
mod tests {
use crate::approval_events::ExecApprovalRequestEvent;
use crate::tool_activity::ExecCommandBeginEvent;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -185,9 +230,7 @@ mod tests {
ExecCommandBeginEvent {
call_id: call_id.to_string(),
process_id: None,
turn_id: "turn".to_string(),
command: vec!["true".to_string()],
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
parsed_cmd: Vec::new(),
source: ExecCommandSource::Agent,
interaction_input: None,
+2 -7
View File
@@ -4,6 +4,8 @@
//! snapshot-based so that layout regressions and status/header changes show up as stable,
//! reviewable diffs.
pub(super) use super::interrupts::ExecCommandBeginEvent;
pub(super) use super::interrupts::ExecCommandEndEvent;
pub(super) use super::*;
pub(super) use crate::app_command::AppCommand as Op;
pub(super) use crate::app_event::AppEvent;
@@ -30,13 +32,6 @@ pub(super) use crate::test_support::test_path_buf;
pub(super) use crate::test_support::test_path_display;
pub(super) use crate::token_usage::TokenUsage;
pub(super) use crate::token_usage::TokenUsageInfo;
pub(super) use crate::tool_activity::ExecCommandBeginEvent;
pub(super) use crate::tool_activity::ExecCommandEndEvent;
pub(super) use crate::tool_activity::ImageGenerationEndEvent;
pub(super) use crate::tool_activity::PatchApplyBeginEvent;
pub(super) use crate::tool_activity::PatchApplyEndEvent;
pub(super) use crate::tool_activity::TerminalInteractionEvent;
pub(super) use crate::tool_activity::ViewImageToolCallEvent;
pub(super) use crate::tui::FrameRequester;
pub(super) use assert_matches::assert_matches;
pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType;
+21 -82
View File
@@ -348,25 +348,19 @@ async fn exec_end_without_begin_uses_event_command() {
"echo orphaned".to_string(),
];
let parsed_cmd = codex_shell_command::parse_command::parse_command(&command);
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
handle_exec_end(
&mut chat,
ExecCommandEndEvent {
call_id: "call-orphan".to_string(),
process_id: None,
turn_id: "turn-1".to_string(),
command,
cwd,
parsed_cmd,
source: ExecCommandSource::Agent,
interaction_input: None,
stdout: "done".to_string(),
stderr: String::new(),
aggregated_output: "done".to_string(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "done".to_string(),
status: AppServerCommandExecutionStatus::Completed,
},
);
@@ -609,10 +603,7 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() {
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
);
handle_terminal_interaction(
&mut chat,
terminal_interaction_event("call-1", "proc-1", "ls\n"),
);
terminal_interaction(&mut chat, "call-1", "proc-1", "ls\n");
let cells = drain_insert_history(&mut rx);
assert!(
@@ -708,11 +699,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
recent_chunks: Vec::new(),
});
chat.on_terminal_interaction(TerminalInteractionEvent {
call_id: "call-1".to_string(),
process_id: "proc-1".to_string(),
stdin: String::new(),
});
terminal_interaction(&mut chat, "call-1", "proc-1", "");
assert!(chat.active_cell.is_none());
assert_eq!(
@@ -841,13 +828,7 @@ async fn view_image_tool_call_adds_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let image_path = chat.config.cwd.join("example.png");
handle_view_image_tool_call(
&mut chat,
ViewImageToolCallEvent {
call_id: "call-image".into(),
path: image_path,
},
);
handle_view_image_tool_call(&mut chat, "call-image", image_path);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
@@ -861,13 +842,9 @@ async fn image_generation_call_adds_history_cell() {
handle_image_generation_end(
&mut chat,
ImageGenerationEndEvent {
call_id: "call-image-generation".into(),
status: "completed".into(),
revised_prompt: Some("A tiny blue square".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
},
"call-image-generation",
Some("A tiny blue square".into()),
Some(test_path_buf("/tmp/ig-1.png").abs()),
);
let cells = drain_insert_history(&mut rx);
@@ -1371,13 +1348,7 @@ async fn apply_patch_events_emit_history_cells() {
content: "hello\n".to_string(),
},
);
let begin = PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: true,
changes: changes2,
};
handle_patch_apply_begin(&mut chat, begin);
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", changes2);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected apply block cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
@@ -1394,16 +1365,13 @@ async fn apply_patch_events_emit_history_cells() {
content: "hello\n".to_string(),
},
);
let end = PatchApplyEndEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
stdout: "ok\n".into(),
stderr: String::new(),
success: true,
changes: end_changes,
status: AppServerPatchApplyStatus::Completed,
};
handle_patch_apply_end(&mut chat, end);
handle_patch_apply_end(
&mut chat,
"c1",
"turn-c1",
end_changes,
AppServerPatchApplyStatus::Completed,
);
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
@@ -1442,15 +1410,7 @@ async fn apply_patch_manual_approval_adjusts_header() {
content: "hello\n".to_string(),
},
);
handle_patch_apply_begin(
&mut chat,
PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: false,
changes: apply_changes,
},
);
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected apply block cell to be sent");
@@ -1496,15 +1456,7 @@ async fn apply_patch_manual_flow_snapshot() {
content: "hello\n".to_string(),
},
);
handle_patch_apply_begin(
&mut chat,
PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: false,
changes: apply_changes,
},
);
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
let approved_lines = drain_insert_history(&mut rx)
.pop()
.expect("approved patch cell");
@@ -1613,15 +1565,7 @@ async fn apply_patch_full_flow_integration_like() {
PathBuf::from("pkg.rs"),
FileChange::Add { content: "".into() },
);
handle_patch_apply_begin(
&mut chat,
PatchApplyBeginEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
auto_approved: false,
changes: changes2,
},
);
handle_patch_apply_begin(&mut chat, "call-1", "turn-call-1", changes2);
let mut end_changes = HashMap::new();
end_changes.insert(
PathBuf::from("pkg.rs"),
@@ -1629,15 +1573,10 @@ async fn apply_patch_full_flow_integration_like() {
);
handle_patch_apply_end(
&mut chat,
PatchApplyEndEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
stdout: String::from("ok"),
stderr: String::new(),
success: true,
changes: end_changes,
status: AppServerPatchApplyStatus::Completed,
},
"call-1",
"turn-call-1",
end_changes,
AppServerPatchApplyStatus::Completed,
);
}
+58 -72
View File
@@ -751,14 +751,19 @@ fn file_update_changes_from_tui(changes: HashMap<PathBuf, FileChange>) -> Vec<Fi
.collect()
}
pub(super) fn handle_patch_apply_begin(chat: &mut ChatWidget, event: PatchApplyBeginEvent) {
pub(super) fn handle_patch_apply_begin(
chat: &mut ChatWidget,
call_id: impl Into<String>,
turn_id: impl Into<String>,
changes: HashMap<PathBuf, FileChange>,
) {
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
turn_id: turn_id.into(),
item: AppServerThreadItem::FileChange {
id: event.call_id,
changes: file_update_changes_from_tui(event.changes),
id: call_id.into(),
changes: file_update_changes_from_tui(changes),
status: AppServerPatchApplyStatus::InProgress,
},
}),
@@ -766,46 +771,61 @@ pub(super) fn handle_patch_apply_begin(chat: &mut ChatWidget, event: PatchApplyB
);
}
pub(super) fn handle_patch_apply_end(chat: &mut ChatWidget, event: PatchApplyEndEvent) {
pub(super) fn handle_patch_apply_end(
chat: &mut ChatWidget,
call_id: impl Into<String>,
turn_id: impl Into<String>,
changes: HashMap<PathBuf, FileChange>,
status: AppServerPatchApplyStatus,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
turn_id: turn_id.into(),
item: AppServerThreadItem::FileChange {
id: event.call_id,
changes: file_update_changes_from_tui(event.changes),
status: event.status,
id: call_id.into(),
changes: file_update_changes_from_tui(changes),
status,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_view_image_tool_call(chat: &mut ChatWidget, event: ViewImageToolCallEvent) {
pub(super) fn handle_view_image_tool_call(
chat: &mut ChatWidget,
call_id: impl Into<String>,
path: AbsolutePathBuf,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::ImageView {
id: event.call_id,
path: event.path,
id: call_id.into(),
path,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_image_generation_end(chat: &mut ChatWidget, event: ImageGenerationEndEvent) {
pub(super) fn handle_image_generation_end(
chat: &mut ChatWidget,
call_id: impl Into<String>,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::ImageGeneration {
id: event.call_id,
status: event.status,
revised_prompt: event.revised_prompt,
result: event.result,
saved_path: event.saved_path,
id: call_id.into(),
status: "completed".to_string(),
revised_prompt,
result: String::new(),
saved_path,
},
}),
/*replay_kind*/ None,
@@ -908,14 +928,11 @@ pub(super) fn begin_exec_with_source(
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let parsed_cmd: Vec<ParsedCommand> =
codex_shell_command::parse_command::parse_command(&command);
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
let interaction_input = None;
let event = ExecCommandBeginEvent {
call_id: call_id.to_string(),
process_id: None,
turn_id: "turn-1".to_string(),
command,
cwd,
parsed_cmd,
source,
interaction_input,
@@ -931,13 +948,10 @@ pub(super) fn begin_unified_exec_startup(
raw_cmd: &str,
) -> ExecCommandBeginEvent {
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
let event = ExecCommandBeginEvent {
call_id: call_id.to_string(),
process_id: Some(process_id.to_string()),
turn_id: "turn-1".to_string(),
command,
cwd,
parsed_cmd: Vec::new(),
source: ExecCommandSource::UnifiedExecStartup,
interaction_input: None,
@@ -947,20 +961,24 @@ pub(super) fn begin_unified_exec_startup(
}
pub(super) fn handle_exec_begin(chat: &mut ChatWidget, event: ExecCommandBeginEvent) {
let cwd = chat.config.cwd.clone();
let command_actions = event
.parsed_cmd
.iter()
.cloned()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &event.cwd))
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &cwd))
.collect();
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item: AppServerThreadItem::CommandExecution {
id: event.call_id,
command: codex_shell_command::parse_command::shlex_join(&event.command),
cwd: event.cwd,
cwd,
process_id: event.process_id,
source: event.source,
status: AppServerCommandExecutionStatus::InProgress,
@@ -997,36 +1015,6 @@ pub(super) fn terminal_interaction(
);
}
pub(super) fn handle_terminal_interaction(chat: &mut ChatWidget, event: TerminalInteractionEvent) {
chat.handle_server_notification(
ServerNotification::TerminalInteraction(
codex_app_server_protocol::TerminalInteractionNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item_id: event.call_id,
process_id: event.process_id,
stdin: event.stdin,
},
),
/*replay_kind*/ None,
);
}
pub(super) fn terminal_interaction_event(
call_id: &str,
process_id: &str,
stdin: &str,
) -> TerminalInteractionEvent {
TerminalInteractionEvent {
call_id: call_id.to_string(),
process_id: process_id.to_string(),
stdin: stdin.to_string(),
}
}
pub(super) fn complete_assistant_message(
chat: &mut ChatWidget,
item_id: &str,
@@ -1181,9 +1169,7 @@ pub(super) fn end_exec(
};
let ExecCommandBeginEvent {
call_id,
turn_id,
command,
cwd,
parsed_cmd,
source,
interaction_input,
@@ -1194,46 +1180,46 @@ pub(super) fn end_exec(
ExecCommandEndEvent {
call_id,
process_id,
turn_id,
command,
cwd,
parsed_cmd,
source,
interaction_input,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
aggregated_output: aggregated.clone(),
exit_code,
duration: std::time::Duration::from_millis(5),
formatted_output: aggregated,
status: if exit_code == 0 {
AppServerCommandExecutionStatus::Completed
} else {
AppServerCommandExecutionStatus::Failed
},
},
);
}
pub(super) fn handle_exec_end(chat: &mut ChatWidget, event: ExecCommandEndEvent) {
let cwd = chat.config.cwd.clone();
let duration_ms = i64::try_from(event.duration.as_millis()).unwrap_or(i64::MAX);
let command_actions = event
.parsed_cmd
.iter()
.cloned()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &event.cwd))
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &cwd))
.collect();
let status = if event.exit_code == 0 {
AppServerCommandExecutionStatus::Completed
} else {
AppServerCommandExecutionStatus::Failed
};
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item: AppServerThreadItem::CommandExecution {
id: event.call_id,
command: codex_shell_command::parse_command::shlex_join(&event.command),
cwd: event.cwd,
cwd,
process_id: event.process_id,
source: event.source,
status: event.status,
status,
command_actions,
aggregated_output: (!event.aggregated_output.is_empty())
.then_some(event.aggregated_output),
@@ -2671,15 +2671,12 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
path: "diff_render.rs".into(),
},
];
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
handle_exec_begin(
&mut chat,
ExecCommandBeginEvent {
call_id: "c1".into(),
process_id: None,
turn_id: "turn-1".into(),
command: command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::Agent,
interaction_input: None,
@@ -2690,19 +2687,14 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
ExecCommandEndEvent {
call_id: "c1".into(),
process_id: None,
turn_id: "turn-1".into(),
command,
cwd,
parsed_cmd,
source: ExecCommandSource::Agent,
interaction_input: None,
stdout: String::new(),
stderr: String::new(),
aggregated_output: String::new(),
exit_code: 0,
duration: std::time::Duration::from_millis(16000),
formatted_output: String::new(),
status: AppServerCommandExecutionStatus::Completed,
},
);
handle_turn_started(&mut chat, "turn-1");
+7 -1
View File
@@ -37,7 +37,6 @@ use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
use crate::tool_activity::McpInvocation;
use crate::tooltips;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::update_action::UpdateAction;
@@ -1580,6 +1579,13 @@ pub(crate) struct McpToolCallCell {
animations_enabled: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct McpInvocation {
pub(crate) server: String,
pub(crate) tool: String,
pub(crate) arguments: Option<serde_json::Value>,
}
impl McpToolCallCell {
pub(crate) fn new(
call_id: String,
-3
View File
@@ -62,7 +62,6 @@ use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
pub use token_usage::FinalOutput;
pub use token_usage::TokenUsage;
use tracing::Level;
use tracing::error;
@@ -169,11 +168,9 @@ mod terminal_title;
mod text_formatting;
mod theme_picker;
mod token_usage;
mod tool_activity;
mod tooltips;
mod transcript_reflow;
mod tui;
mod turn_state;
mod ui_consts;
pub(crate) mod update_action;
pub use update_action::UpdateAction;
+1 -2
View File
@@ -6,7 +6,6 @@ use codex_config::LoaderOverrides;
use codex_tui::AppExitInfo;
use codex_tui::Cli;
use codex_tui::ExitReason;
use codex_tui::FinalOutput;
use codex_tui::run_main;
use codex_utils_cli::CliConfigOverrides;
use supports_color::Stream;
@@ -20,7 +19,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let mut lines = Vec::new();
if !token_usage.is_zero() {
lines.push(FinalOutput::from(token_usage).to_string());
lines.push(token_usage.to_string());
}
if let Some(resume_cmd) =
+96 -232
View File
@@ -1,8 +1,8 @@
//! Resolve saved-session state needed before resuming or forking a thread.
//!
//! The app-server API owns normal thread lifecycle data. This module handles the local fallback
//! path used before a thread has been resumed, where TUI may need the saved cwd or model from the
//! local rollout JSONL to rebuild config or render an inactive session accurately.
//! The app-server API owns normal thread lifecycle data. This module coordinates
//! the TUI-specific cwd prompt and falls back to local rollout metadata only
//! before the app server has resumed the selected thread.
use std::io;
use std::path::Path;
@@ -21,6 +21,13 @@ use serde::Deserialize;
use serde_json::Value;
use tokio::io::AsyncBufReadExt;
#[derive(Default)]
struct RolloutResumeState {
thread_id: Option<ThreadId>,
cwd: Option<PathBuf>,
model: Option<String>,
}
#[derive(Deserialize)]
struct SessionMetadata {
id: ThreadId,
@@ -28,7 +35,7 @@ struct SessionMetadata {
}
#[derive(Deserialize)]
struct LatestTurnContext {
struct TurnContextResumeState {
cwd: PathBuf,
model: String,
}
@@ -51,10 +58,10 @@ pub(crate) async fn resolve_session_thread_id(
) -> Option<ThreadId> {
match id_str_if_uuid {
Some(id_str) => ThreadId::from_string(id_str).ok(),
None => read_session_metadata(path)
None => read_rollout_resume_state(path)
.await
.ok()
.map(|metadata| metadata.id),
.and_then(|state| state.thread_id),
}
}
@@ -71,7 +78,10 @@ pub(crate) async fn read_session_model(
}
let path = path?;
read_latest_turn_context(path).await.map(|item| item.model)
read_rollout_resume_state(path)
.await
.ok()
.and_then(|state| state.model)
}
pub(crate) async fn resolve_cwd_for_resume_or_fork(
@@ -113,17 +123,9 @@ async fn read_session_cwd(
return Some(metadata.cwd);
}
// Prefer the latest TurnContext cwd so resume/fork reflects the most recent
// session directory (for the changed-cwd prompt) when DB data is unavailable.
// The alternative would be mutating the session metadata line when the session cwd
// changes, but the rollout is an append-only JSONL log and rewriting the head
// would be error-prone.
let path = path?;
if let Some(cwd) = read_latest_turn_context(path).await.map(|item| item.cwd) {
return Some(cwd);
}
match read_session_metadata(path).await {
Ok(metadata) => Some(metadata.cwd),
match read_rollout_resume_state(path).await {
Ok(state) => state.cwd,
Err(err) => {
let rollout_path = path.display().to_string();
tracing::warn!(
@@ -140,86 +142,68 @@ pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
!path_utils::paths_match_after_normalization(current_cwd, session_cwd)
}
async fn read_session_metadata(path: &Path) -> io::Result<SessionMetadata> {
async fn read_rollout_resume_state(path: &Path) -> io::Result<RolloutResumeState> {
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut state = RolloutResumeState::default();
let mut saw_record = false;
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
saw_record = true;
let record = serde_json::from_str::<RawRecord>(trimmed).map_err(|err| {
io::Error::other(format!(
"failed to parse rollout line in {}: {err}",
path.display()
))
})?;
if record.item_type != "session_meta" {
return Err(io::Error::other(format!(
"rollout at {} does not start with session metadata",
path.display()
)));
}
let payload = record.payload.ok_or_else(|| {
io::Error::other(format!(
"session metadata in {} is missing a payload",
path.display()
))
})?;
return serde_json::from_value(payload).map_err(|err| {
io::Error::other(format!(
"failed to parse session metadata in {}: {err}",
path.display()
))
});
}
Err(io::Error::other(format!(
"rollout at {} is empty",
path.display()
)))
}
async fn read_latest_turn_context(path: &Path) -> Option<LatestTurnContext> {
let text = tokio::fs::read_to_string(path).await.ok()?;
for line in text.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(record) = serde_json::from_str::<RawRecord>(trimmed) else {
continue;
};
if record.item_type != "turn_context" {
continue;
}
let Some(payload) = record.payload else {
continue;
};
if let Ok(item) = serde_json::from_value(payload) {
return Some(item);
match record.item_type.as_str() {
"session_meta" if state.thread_id.is_none() => {
let metadata =
serde_json::from_value::<SessionMetadata>(payload).map_err(|err| {
io::Error::other(format!(
"failed to parse session metadata in {}: {err}",
path.display()
))
})?;
state.thread_id = Some(metadata.id);
state.cwd.get_or_insert(metadata.cwd);
}
"turn_context" => {
if let Ok(turn_context) = serde_json::from_value::<TurnContextResumeState>(payload)
{
state.cwd = Some(turn_context.cwd);
state.model = Some(turn_context.model);
}
}
_ => {}
}
}
None
if saw_record {
Ok(state)
} else {
Err(io::Error::other(format!(
"rollout at {} is empty",
path.display()
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_features::Feature;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.build()
.await
}
fn rollout_line(
timestamp: &str,
item_type: &str,
@@ -232,35 +216,6 @@ mod tests {
})
}
fn turn_context_line(config: &Config, cwd: PathBuf, timestamp: &str) -> serde_json::Value {
let model = config
.model
.clone()
.unwrap_or_else(|| "gpt-5.1".to_string());
rollout_line(
timestamp,
"turn_context",
serde_json::json!({
"cwd": cwd,
"model": model,
}),
)
}
fn session_meta_line(thread_id: ThreadId, cwd: PathBuf, timestamp: &str) -> serde_json::Value {
rollout_line(
timestamp,
"session_meta",
serde_json::json!({
"id": thread_id.to_string(),
"timestamp": timestamp,
"cwd": cwd,
"originator": "test",
"cli_version": "test",
}),
)
}
fn write_rollout_lines(path: &Path, lines: &[serde_json::Value]) -> std::io::Result<()> {
let mut text = String::new();
for line in lines {
@@ -271,162 +226,71 @@ mod tests {
}
#[tokio::test]
async fn read_session_cwd_returns_none_without_sqlite_or_rollout_path() -> std::io::Result<()> {
async fn rollout_resume_state_prefers_latest_turn_context() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let cwd = read_session_cwd(&config, ThreadId::new(), /*path*/ None).await;
assert_eq!(cwd, None);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let first = temp_dir.path().join("first");
let second = temp_dir.path().join("second");
std::fs::create_dir_all(&first)?;
std::fs::create_dir_all(&second)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[
turn_context_line(&config, first, "t0"),
turn_context_line(&config, second.clone(), "t1"),
],
)?;
let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, second);
Ok(())
}
#[tokio::test]
async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()>
{
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let current = temp_dir.path().join("current");
let thread_id = ThreadId::new();
let original = temp_dir.path().join("original");
let latest = temp_dir.path().join("latest");
std::fs::create_dir_all(&current)?;
std::fs::create_dir_all(&latest)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[
session_meta_line(ThreadId::new(), current.clone(), "t0"),
turn_context_line(&config, latest.clone(), "t1"),
rollout_line(
"t0",
"session_meta",
serde_json::json!({
"id": thread_id,
"cwd": original,
"originator": "test",
"cli_version": "test",
}),
),
rollout_line(
"t1",
"turn_context",
serde_json::json!({ "cwd": temp_dir.path().join("middle"), "model": "middle" }),
),
rollout_line(
"t2",
"turn_context",
serde_json::json!({ "cwd": latest.clone(), "model": "latest" }),
),
],
)?;
let session_cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(session_cwd, latest);
assert!(cwds_differ(&current, &session_cwd));
let state = read_rollout_resume_state(&rollout_path).await?;
assert_eq!(state.thread_id, Some(thread_id));
assert_eq!(state.cwd, Some(latest));
assert_eq!(state.model, Some("latest".to_string()));
Ok(())
}
#[tokio::test]
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
async fn rollout_resume_state_falls_back_to_session_meta() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let session_cwd = temp_dir.path().join("session");
std::fs::create_dir_all(&session_cwd)?;
let thread_id = ThreadId::new();
let cwd = temp_dir.path().join("session");
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[session_meta_line(
ThreadId::new(),
session_cwd.clone(),
&[rollout_line(
"t0",
"session_meta",
serde_json::json!({
"id": thread_id,
"cwd": cwd.clone(),
"originator": "test",
"cli_version": "test",
}),
)],
)?;
let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, session_cwd);
Ok(())
}
let state = read_rollout_resume_state(&rollout_path).await?;
#[tokio::test]
async fn read_session_cwd_prefers_sqlite_when_thread_id_present() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config
.features
.enable(Feature::Sqlite)
.expect("test config should allow sqlite");
let thread_id = ThreadId::new();
let rollout_cwd = temp_dir.path().join("rollout-cwd");
let sqlite_cwd = temp_dir.path().join("sqlite-cwd");
std::fs::create_dir_all(&rollout_cwd)?;
std::fs::create_dir_all(&sqlite_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[turn_context_line(&config, rollout_cwd, "t0")],
)?;
let runtime = codex_state::StateRuntime::init(
config.codex_home.to_path_buf(),
config.model_provider_id.clone(),
)
.await
.map_err(std::io::Error::other)?;
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.map_err(std::io::Error::other)?;
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
chrono::Utc::now(),
serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
);
builder.cwd = sqlite_cwd.clone();
let metadata = builder.build(config.model_provider_id.as_str());
runtime
.upsert_thread(&metadata)
.await
.map_err(std::io::Error::other)?;
let cwd = read_session_cwd(&config, thread_id, Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, sqlite_cwd);
Ok(())
}
#[tokio::test]
async fn resolve_session_thread_id_reads_minimal_session_metadata() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let thread_id = ThreadId::new();
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[session_meta_line(
thread_id,
temp_dir.path().to_path_buf(),
"t0",
)],
)?;
let resolved = resolve_session_thread_id(&rollout_path, /*id_str_if_uuid*/ None).await;
assert_eq!(resolved, Some(thread_id));
assert_eq!(state.thread_id, Some(thread_id));
assert_eq!(state.cwd, Some(cwd));
assert_eq!(state.model, None);
Ok(())
}
}
+10 -35
View File
@@ -1,7 +1,8 @@
//! TUI token usage and final-output formatting models.
//! TUI token usage models and display formatting.
use std::fmt;
use codex_protocol::num_format::format_with_separators;
use serde::Deserialize;
use serde::Serialize;
@@ -57,38 +58,26 @@ pub(crate) struct TokenUsageInfo {
pub(crate) model_context_window: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalOutput {
pub token_usage: TokenUsage,
}
impl From<TokenUsage> for FinalOutput {
fn from(token_usage: TokenUsage) -> Self {
Self { token_usage }
}
}
impl fmt::Display for FinalOutput {
impl fmt::Display for TokenUsage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let token_usage = &self.token_usage;
write!(
f,
"Token usage: total={} input={}{} output={}{}",
format_with_separators(token_usage.blended_total()),
format_with_separators(token_usage.non_cached_input()),
if token_usage.cached_input() > 0 {
format_with_separators(self.blended_total()),
format_with_separators(self.non_cached_input()),
if self.cached_input() > 0 {
format!(
" (+ {} cached)",
format_with_separators(token_usage.cached_input())
format_with_separators(self.cached_input())
)
} else {
String::new()
},
format_with_separators(token_usage.output_tokens),
if token_usage.reasoning_output_tokens > 0 {
format_with_separators(self.output_tokens),
if self.reasoning_output_tokens > 0 {
format!(
" (reasoning {})",
format_with_separators(token_usage.reasoning_output_tokens)
format_with_separators(self.reasoning_output_tokens)
)
} else {
String::new()
@@ -96,17 +85,3 @@ impl fmt::Display for FinalOutput {
)
}
}
fn format_with_separators(value: i64) -> String {
let sign = if value < 0 { "-" } else { "" };
let digits = value.abs().to_string();
let mut out = String::new();
for (i, ch) in digits.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(ch);
}
let grouped: String = out.chars().rev().collect();
format!("{sign}{grouped}")
}
-163
View File
@@ -1,163 +0,0 @@
//! Internal tool activity models used by chat cells and interrupt queueing.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use crate::diff_model::FileChange;
use codex_app_server_protocol::CommandExecOutputStream;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::HookRunSummary;
use codex_app_server_protocol::PatchApplyStatus;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::parse_command::ParsedCommand;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpInvocation {
pub(crate) server: String,
pub(crate) tool: String,
pub(crate) arguments: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpToolCallBeginEvent {
pub(crate) call_id: String,
pub(crate) invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) mcp_app_resource_uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpToolCallEndEvent {
pub(crate) call_id: String,
pub(crate) invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) mcp_app_resource_uri: Option<String>,
pub(crate) duration: Duration,
pub(crate) result: Result<CallToolResult, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WebSearchBeginEvent {
pub(crate) call_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WebSearchEndEvent {
pub(crate) call_id: String,
pub(crate) query: String,
pub(crate) action: codex_protocol::models::WebSearchAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ImageGenerationBeginEvent {
pub(crate) call_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ImageGenerationEndEvent {
pub(crate) call_id: String,
pub(crate) status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) revised_prompt: Option<String>,
pub(crate) result: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) saved_path: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExecCommandBeginEvent {
pub(crate) call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) process_id: Option<String>,
pub(crate) turn_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
#[serde(default)]
pub(crate) source: CommandExecutionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) interaction_input: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExecCommandEndEvent {
pub(crate) call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) process_id: Option<String>,
pub(crate) turn_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
#[serde(default)]
pub(crate) source: CommandExecutionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) interaction_input: Option<String>,
pub(crate) stdout: String,
pub(crate) stderr: String,
#[serde(default)]
pub(crate) aggregated_output: String,
pub(crate) exit_code: i32,
pub(crate) duration: Duration,
pub(crate) formatted_output: String,
pub(crate) status: CommandExecutionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ViewImageToolCallEvent {
pub(crate) call_id: String,
pub(crate) path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct ExecCommandOutputDeltaEvent {
pub(crate) call_id: String,
pub(crate) stream: CommandExecOutputStream,
pub(crate) chunk: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct TerminalInteractionEvent {
pub(crate) call_id: String,
pub(crate) process_id: String,
pub(crate) stdin: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PatchApplyBeginEvent {
pub(crate) call_id: String,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) auto_approved: bool,
pub(crate) changes: HashMap<PathBuf, FileChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PatchApplyEndEvent {
pub(crate) call_id: String,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) success: bool,
#[serde(default)]
pub(crate) changes: HashMap<PathBuf, FileChange>,
pub(crate) status: PatchApplyStatus,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct HookStartedEvent {
pub(crate) turn_id: Option<String>,
pub(crate) run: HookRunSummary,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct HookCompletedEvent {
pub(crate) turn_id: Option<String>,
pub(crate) run: HookRunSummary,
}
-11
View File
@@ -1,11 +0,0 @@
//! Turn lifecycle state used by TUI interruption and completion handling.
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TurnAbortReason {
Interrupted,
BudgetLimited,
}