From 9a2b34213b5530ab3c616400c1e4d355aa78156d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 20 Apr 2026 17:46:11 -0700 Subject: [PATCH 1/4] /statusline & /title - Shared preview values (#18435) This PR makes the `/statusline` and `/title` setup UIs share one preview-value source instead of each surface using its own examples. Both pickers now render consistent live values when available, and stable placeholders when they are not. It also resolves live preview values at the shared preview-item layer, so `/title` preview can use real runtime values for title-specific cases like status text, task progress, and project-name fallback behavior. - Adds a shared preview data model for status surfaces - Maps status-line items and terminal-title items onto that shared preview list - Feeds both setup views from the same chatwidget-derived preview data, with terminal-title-specific formatting applied before `/title` preview renders - Keeps project-root preview aligned with status-line behavior while project in /title keeps its title fallback/truncation behavior - Adds snapshot coverage for live-only, hardcoded-only, and mixed cases Test Steps - Open Codex TUI and launch `/statusline`. - Toggle and reorder items, then verify the preview uses current session values when possible, and placeholder values for missing values (ex: no thread ID). - Open `/title` and verify it shows the same normalized values, including live status/task-progress values when available. --- codex-rs/tui/src/bottom_pane/mod.rs | 6 +- ..._snapshot_uses_runtime_preview_values.snap | 21 -- ...up__tests__terminal_title_setup_basic.snap | 21 -- .../tui/src/bottom_pane/status_line_setup.rs | 197 +++---------- .../src/bottom_pane/status_surface_preview.rs | 173 ++++++++++++ codex-rs/tui/src/bottom_pane/title_setup.rs | 120 ++++---- codex-rs/tui/src/chatwidget.rs | 37 ++- ...tatus_line_setup_popup_hardcoded_only.snap | 20 ++ ...ts__status_line_setup_popup_live_only.snap | 20 ++ ..._tests__status_line_setup_popup_mixed.snap | 20 ++ ...tatus_surface_previews_hardcoded_only.snap | 6 + ...ts__status_surface_previews_live_only.snap | 6 + ..._tests__status_surface_previews_mixed.snap | 6 + ...inal_title_setup_popup_hardcoded_only.snap | 20 ++ ..._terminal_title_setup_popup_live_only.snap | 20 ++ ...sts__terminal_title_setup_popup_mixed.snap | 20 ++ .../tui/src/chatwidget/status_surfaces.rs | 32 ++- codex-rs/tui/src/chatwidget/tests.rs | 1 + .../tests/status_surface_previews.rs | 261 ++++++++++++++++++ 19 files changed, 723 insertions(+), 284 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap create mode 100644 codex-rs/tui/src/bottom_pane/status_surface_preview.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b62ce7cc10..c9dc4db263 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -49,6 +49,7 @@ mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; +mod status_surface_preview; mod title_setup; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; @@ -107,10 +108,13 @@ pub(crate) use feedback_view::feedback_upload_consent_params; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; -pub(crate) use status_line_setup::StatusLinePreviewData; pub(crate) use status_line_setup::StatusLineSetupView; +pub(crate) use status_surface_preview::StatusSurfacePreviewData; +pub(crate) use status_surface_preview::StatusSurfacePreviewItem; pub(crate) use title_setup::TerminalTitleItem; pub(crate) use title_setup::TerminalTitleSetupView; +#[cfg(test)] +pub(crate) use title_setup::preview_line_for_title_items; mod paste_burst; mod pending_input_preview; mod pending_thread_approvals; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap deleted file mode 100644 index 20aca1e334..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tui/src/bottom_pane/status_line_setup.rs -expression: "render_lines(&view, 72)" ---- - - Configure Status Line - Select which items to display in the status line. - - Type to search - > -› [x] model-name Current model name - [x] current-dir Current working directory - [x] git-branch Current Git branch (omitted when unavaila… - [ ] model-with-reasoning Current model name with reasoning level - [ ] project-root Project root directory (omitted when unav… - [ ] context-remaining Percentage of context window remaining (o… - [ ] context-used Percentage of context window used (omitte… - [ ] five-hour-limit Remaining usage on 5-hour usage limit (om… - - gpt-5-codex · ~/codex-rs · jif/statusline-preview - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap deleted file mode 100644 index 9a6d412874..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tui/src/bottom_pane/title_setup.rs -expression: "render_lines(&view, 84)" ---- - - Configure Terminal Title - Select which items to display in the terminal title. - - Type to search - > -› [x] project Project name (falls back to current directory name) - [x] spinner Animated task spinner (omitted while idle or when animations… - [x] status Compact session status text (Ready, Working, Thinking) - [x] thread Current thread title (omitted until available) - [ ] app-name Codex app name - [ ] git-branch Current Git branch (omitted when unavailable) - [ ] model Current model name - [ ] task-progress Latest task progress from update_plan (omitted until availab… - - my-project ⠋ Working | Investigate flaky test - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4c7e29c053..0ae5ddd9c1 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -19,8 +19,6 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::text::Line; -use std::collections::BTreeMap; use std::collections::HashSet; use strum::IntoEnumIterator; use strum_macros::Display; @@ -33,6 +31,8 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem; use crate::render::renderable::Renderable; /// Available items that can be displayed in the status line. @@ -138,36 +138,26 @@ impl StatusLineItem { StatusLineItem::ThreadTitle => "Current thread title (omitted unless changed by user)", } } -} -/// Runtime values used to preview the current status-line selection. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub(crate) struct StatusLinePreviewData { - values: BTreeMap, -} - -impl StatusLinePreviewData { - pub(crate) fn from_iter(values: I) -> Self - where - I: IntoIterator, - { - Self { - values: values.into_iter().collect(), - } - } - - fn line_for_items(&self, items: &[MultiSelectItem]) -> Option> { - let preview = items - .iter() - .filter(|item| item.enabled) - .filter_map(|item| item.id.parse::().ok()) - .filter_map(|item| self.values.get(&item).cloned()) - .collect::>() - .join(" · "); - if preview.is_empty() { - None - } else { - Some(Line::from(preview)) + pub(crate) fn preview_item(self) -> StatusSurfacePreviewItem { + match self { + StatusLineItem::ModelName => StatusSurfacePreviewItem::Model, + StatusLineItem::ModelWithReasoning => StatusSurfacePreviewItem::ModelWithReasoning, + StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir, + StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot, + StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch, + StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining, + StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed, + StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit, + StatusLineItem::WeeklyLimit => StatusSurfacePreviewItem::WeeklyLimit, + StatusLineItem::CodexVersion => StatusSurfacePreviewItem::CodexVersion, + StatusLineItem::ContextWindowSize => StatusSurfacePreviewItem::ContextWindowSize, + StatusLineItem::UsedTokens => StatusSurfacePreviewItem::UsedTokens, + StatusLineItem::TotalInputTokens => StatusSurfacePreviewItem::TotalInputTokens, + StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens, + StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId, + StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, + StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, } } } @@ -197,7 +187,7 @@ impl StatusLineSetupView { /// enabled. Remaining items are appended and marked as disabled. pub(crate) fn new( status_line_items: Option<&[String]>, - preview_data: StatusLinePreviewData, + preview_data: StatusSurfacePreviewData, app_event_tx: AppEventSender, ) -> Self { let mut used_ids = HashSet::new(); @@ -236,7 +226,15 @@ impl StatusLineSetupView { ]) .items(items) .enable_ordering() - .on_preview(move |items| preview_data.line_for_items(items)) + .on_preview(move |items| { + preview_data.line_for_items( + items + .iter() + .filter(|item| item.enabled) + .filter_map(|item| item.id.parse::().ok()) + .map(StatusLineItem::preview_item), + ) + }) .on_confirm(|ids, app_event| { let items = ids .iter() @@ -291,14 +289,7 @@ impl Renderable for StatusLineSetupView { #[cfg(test)] mod tests { use super::*; - use crate::app_event_sender::AppEventSender; - use insta::assert_snapshot; use pretty_assertions::assert_eq; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use tokio::sync::mpsc::unbounded_channel; - - use crate::app_event::AppEvent; #[test] fn context_used_accepts_context_usage_legacy_id() { @@ -324,130 +315,4 @@ mod tests { "context-remaining" ); } - - #[test] - fn preview_uses_runtime_values() { - let preview_data = StatusLinePreviewData::from_iter([ - (StatusLineItem::ModelName, "gpt-5".to_string()), - (StatusLineItem::CurrentDir, "/repo".to_string()), - ]); - let items = vec![ - MultiSelectItem { - id: StatusLineItem::ModelName.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - MultiSelectItem { - id: StatusLineItem::CurrentDir.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - ]; - - assert_eq!( - preview_data.line_for_items(&items), - Some(Line::from("gpt-5 · /repo")) - ); - } - - #[test] - fn preview_omits_items_without_runtime_values() { - let preview_data = - StatusLinePreviewData::from_iter([(StatusLineItem::ModelName, "gpt-5".to_string())]); - let items = vec![ - MultiSelectItem { - id: StatusLineItem::ModelName.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - MultiSelectItem { - id: StatusLineItem::GitBranch.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - ]; - - assert_eq!( - preview_data.line_for_items(&items), - Some(Line::from("gpt-5")) - ); - } - - #[test] - fn preview_includes_thread_title() { - let preview_data = StatusLinePreviewData::from_iter([ - (StatusLineItem::ModelName, "gpt-5".to_string()), - (StatusLineItem::ThreadTitle, "Roadmap cleanup".to_string()), - ]); - let items = vec![ - MultiSelectItem { - id: StatusLineItem::ModelName.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - MultiSelectItem { - id: StatusLineItem::ThreadTitle.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - ]; - - assert_eq!( - preview_data.line_for_items(&items), - Some(Line::from("gpt-5 · Roadmap cleanup")) - ); - } - - #[test] - fn setup_view_snapshot_uses_runtime_preview_values() { - let (tx_raw, _rx) = unbounded_channel::(); - let view = StatusLineSetupView::new( - Some(&[ - StatusLineItem::ModelName.to_string(), - StatusLineItem::CurrentDir.to_string(), - StatusLineItem::GitBranch.to_string(), - ]), - StatusLinePreviewData::from_iter([ - (StatusLineItem::ModelName, "gpt-5-codex".to_string()), - (StatusLineItem::CurrentDir, "~/codex-rs".to_string()), - ( - StatusLineItem::GitBranch, - "jif/statusline-preview".to_string(), - ), - (StatusLineItem::WeeklyLimit, "weekly 82%".to_string()), - ]), - AppEventSender::new(tx_raw), - ); - - assert_snapshot!(render_lines(&view, /*width*/ 72)); - } - - fn render_lines(view: &StatusLineSetupView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line - }) - .collect::>() - .join("\n") - } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs new file mode 100644 index 0000000000..93fca824ca --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeMap; + +use ratatui::text::Line; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) enum StatusSurfacePreviewItem { + AppName, + ProjectName, + ProjectRoot, + CurrentDir, + Status, + ThreadTitle, + GitBranch, + ContextRemaining, + ContextUsed, + FiveHourLimit, + WeeklyLimit, + CodexVersion, + ContextWindowSize, + UsedTokens, + TotalInputTokens, + TotalOutputTokens, + SessionId, + FastMode, + Model, + ModelWithReasoning, + TaskProgress, +} + +impl StatusSurfacePreviewItem { + fn placeholder(self) -> &'static str { + match self { + StatusSurfacePreviewItem::AppName => "codex", + StatusSurfacePreviewItem::ProjectName => "my-project", + StatusSurfacePreviewItem::ProjectRoot => "my-project", + StatusSurfacePreviewItem::CurrentDir => "~/my-project/subdir", + StatusSurfacePreviewItem::Status => "Working", + StatusSurfacePreviewItem::ThreadTitle => "thread title", + StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", + StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", + StatusSurfacePreviewItem::ContextUsed => "Context 0% used", + StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", + StatusSurfacePreviewItem::WeeklyLimit => "weekly 0%", + StatusSurfacePreviewItem::CodexVersion => "0.0.0", + StatusSurfacePreviewItem::ContextWindowSize => "0 window", + StatusSurfacePreviewItem::UsedTokens => "0 used", + StatusSurfacePreviewItem::TotalInputTokens => "0 in", + StatusSurfacePreviewItem::TotalOutputTokens => "0 out", + StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", + StatusSurfacePreviewItem::FastMode => "Fast on", + StatusSurfacePreviewItem::Model => "gpt-5.2-codex", + StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", + StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0", + } + } + + pub(crate) fn iter() -> impl Iterator { + [ + Self::AppName, + Self::ProjectName, + Self::ProjectRoot, + Self::CurrentDir, + Self::Status, + Self::ThreadTitle, + Self::GitBranch, + Self::ContextRemaining, + Self::ContextUsed, + Self::FiveHourLimit, + Self::WeeklyLimit, + Self::CodexVersion, + Self::ContextWindowSize, + Self::UsedTokens, + Self::TotalInputTokens, + Self::TotalOutputTokens, + Self::SessionId, + Self::FastMode, + Self::Model, + Self::ModelWithReasoning, + Self::TaskProgress, + ] + .into_iter() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PreviewValue { + text: String, + is_placeholder: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StatusSurfacePreviewData { + values: BTreeMap, +} + +impl Default for StatusSurfacePreviewData { + fn default() -> Self { + let mut data = Self { + values: BTreeMap::new(), + }; + for item in StatusSurfacePreviewItem::iter() { + data.set_placeholder(item, item.placeholder()); + } + data + } +} + +impl StatusSurfacePreviewData { + pub(crate) fn from_iter(values: I) -> Self + where + I: IntoIterator, + V: Into, + { + let mut data = Self::default(); + for (item, value) in values { + data.set_live(item, value); + } + data + } + + pub(crate) fn set_live(&mut self, item: StatusSurfacePreviewItem, value: V) + where + V: Into, + { + self.values.insert( + item, + PreviewValue { + text: value.into(), + is_placeholder: false, + }, + ); + } + + pub(crate) fn set_placeholder(&mut self, item: StatusSurfacePreviewItem, value: V) + where + V: Into, + { + if self + .values + .get(&item) + .is_some_and(|value| !value.is_placeholder) + { + return; + } + self.values.insert( + item, + PreviewValue { + text: value.into(), + is_placeholder: true, + }, + ); + } + + pub(crate) fn value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { + self.values.get(&item).map(|value| value.text.as_str()) + } + + pub(crate) fn line_for_items(&self, items: I) -> Option> + where + I: IntoIterator, + { + let preview = items + .into_iter() + .filter_map(|item| self.value_for(item)) + .collect::>() + .join(" · "); + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index b9899e32d6..0595a17974 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -22,6 +22,8 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem; use crate::render::renderable::Renderable; /// Available items that can be displayed in the terminal title. @@ -68,20 +70,16 @@ impl TerminalTitleItem { } } - /// Example text used when previewing the title picker. - /// - /// These are illustrative sample values, not live data from the current - /// session. - pub(crate) fn preview_example(self) -> &'static str { + pub(crate) fn preview_item(self) -> Option { match self { - TerminalTitleItem::AppName => "codex", - TerminalTitleItem::Project => "my-project", - TerminalTitleItem::Spinner => "⠋", - TerminalTitleItem::Status => "Working", - TerminalTitleItem::Thread => "Investigate flaky test", - TerminalTitleItem::GitBranch => "feat/awesome-feature", - TerminalTitleItem::Model => "gpt-5.2-codex", - TerminalTitleItem::TaskProgress => "Tasks 2/5", + TerminalTitleItem::AppName => Some(StatusSurfacePreviewItem::AppName), + TerminalTitleItem::Project => Some(StatusSurfacePreviewItem::ProjectName), + TerminalTitleItem::Spinner => None, + TerminalTitleItem::Status => Some(StatusSurfacePreviewItem::Status), + TerminalTitleItem::Thread => Some(StatusSurfacePreviewItem::ThreadTitle), + TerminalTitleItem::GitBranch => Some(StatusSurfacePreviewItem::GitBranch), + TerminalTitleItem::Model => Some(StatusSurfacePreviewItem::Model), + TerminalTitleItem::TaskProgress => Some(StatusSurfacePreviewItem::TaskProgress), } } @@ -103,6 +101,39 @@ impl TerminalTitleItem { } } +pub(crate) fn preview_line_for_title_items( + items: &[TerminalTitleItem], + preview_data: &StatusSurfacePreviewData, +) -> Option> { + let mut previous = None; + let preview = items + .iter() + .copied() + .fold(String::new(), |mut preview, item| { + if item == TerminalTitleItem::Spinner { + preview.push_str(item.separator_from_previous(previous)); + preview.push('⠋'); + previous = Some(item); + return preview; + } + let Some(value) = item + .preview_item() + .and_then(|preview_item| preview_data.value_for(preview_item)) + else { + return preview; + }; + preview.push_str(item.separator_from_previous(previous)); + preview.push_str(value); + previous = Some(item); + preview + }); + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } +} + fn parse_terminal_title_items(ids: impl Iterator) -> Option> where T: AsRef, @@ -128,7 +159,11 @@ impl TerminalTitleSetupView { /// main TUI still warns about them when rendering the actual title, but the /// picker itself only exposes the selectable items it can meaningfully /// preview and persist. - pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + title_items: Option<&[String]>, + preview_data: StatusSurfacePreviewData, + app_event_tx: AppEventSender, + ) -> Self { let selected_items = title_items .into_iter() .flatten() @@ -161,25 +196,14 @@ impl TerminalTitleSetupView { ]) .items(items) .enable_ordering() - .on_preview(|items| { + .on_preview(move |items| { let items = parse_terminal_title_items( items .iter() .filter(|item| item.enabled) .map(|item| item.id.as_str()), )?; - let mut preview = String::new(); - let mut previous = None; - for item in items.iter().copied() { - preview.push_str(item.separator_from_previous(previous)); - preview.push_str(item.preview_example()); - previous = Some(item); - } - if preview.is_empty() { - None - } else { - Some(Line::from(preview)) - } + preview_line_for_title_items(&items, &preview_data) }) .on_change(|items, app_event| { let Some(items) = parse_terminal_title_items( @@ -243,49 +267,7 @@ impl Renderable for TerminalTitleSetupView { #[cfg(test)] mod tests { use super::*; - use insta::assert_snapshot; use pretty_assertions::assert_eq; - use tokio::sync::mpsc::unbounded_channel; - - fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - let lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line - }) - .collect(); - lines.join("\n") - } - - #[test] - fn renders_title_setup_popup() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let selected = [ - "project".to_string(), - "spinner".to_string(), - "status".to_string(), - "thread".to_string(), - ]; - let view = TerminalTitleSetupView::new(Some(&selected), tx); - assert_snapshot!( - "terminal_title_setup_basic", - render_lines(&view, /*width*/ 84) - ); - } #[test] fn parse_terminal_title_items_preserves_order() { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f93c4bc2f9..9c54d322e1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -52,8 +52,9 @@ use crate::app_server_session::ThreadSessionState; #[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; -use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::StatusSurfacePreviewData; +use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; @@ -7595,10 +7596,7 @@ impl ChatWidget { let configured_status_line_items = self.configured_status_line_items(); let view = StatusLineSetupView::new( Some(configured_status_line_items.as_slice()), - StatusLinePreviewData::from_iter(StatusLineItem::iter().filter_map(|item| { - self.status_line_value_for_item(&item) - .map(|value| (item, value)) - })), + self.status_surface_preview_data(), self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); @@ -7609,11 +7607,40 @@ impl ChatWidget { self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); let view = TerminalTitleSetupView::new( Some(configured_terminal_title_items.as_slice()), + self.terminal_title_preview_data(), self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); } + fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { + StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { + self.status_surface_preview_value_for_item(item) + .map(|value| (item, value)) + })) + } + + fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { + let mut preview_data = self.status_surface_preview_data(); + let now = Instant::now(); + for item in [ + TerminalTitleItem::Project, + TerminalTitleItem::Thread, + TerminalTitleItem::GitBranch, + TerminalTitleItem::Model, + TerminalTitleItem::TaskProgress, + ] { + let Some(preview_item) = item.preview_item() else { + continue; + }; + let Some(value) = self.terminal_title_value_for_item(item, now) else { + continue; + }; + preview_data.set_live(preview_item, value); + } + preview_data + } + fn open_theme_picker(&mut self) { let codex_home = crate::legacy_core::config::find_codex_home().ok(); let terminal_width = self diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap new file mode 100644 index 0000000000..ccab3ca7d1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] project-root Project root directory (omitted when unavailable) + [x] git-branch Current Git branch (omitted when unavailable) + [x] thread-title Current thread title (omitted unless changed by user) + [ ] model-name Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] context-remaining Percentage of context window remaining (omitted when unknown) + [ ] context-used Percentage of context window used (omitted when unknown) + + my-project · feat/awesome-feature · thread title + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap new file mode 100644 index 0000000000..273e175114 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] project-root Project root directory (omitted when unavailable) + [x] git-branch Current Git branch (omitted when unavailable) + [x] thread-title Current thread title (omitted unless changed by user) + [ ] model-name Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] context-remaining Percentage of context window remaining (omitted when unknown) + [ ] context-used Percentage of context window used (omitted when unknown) + + preview-live-root · feature/live-preview-branch · Live preview thread + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap new file mode 100644 index 0000000000..0f0bfdd24a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] project-root Project root directory (omitted when unavailable) + [x] git-branch Current Git branch (omitted when unavailable) + [x] thread-title Current thread title (omitted unless changed by user) + [ ] model-name Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] context-remaining Percentage of context window remaining (omitted when unknown) + [ ] context-used Percentage of context window used (omitted when unknown) + + my-project · feature/mixed-preview · Mixed preview thread + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap new file mode 100644 index 0000000000..abf3df3c26 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: my-project · feat/awesome-feature · thread title +terminal title: thread title | feat/awesome-feature | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap new file mode 100644 index 0000000000..4cf1871af6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: preview-live-root · feature/live-preview-branch · Live preview thread +terminal title: preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap new file mode 100644 index 0000000000..ab50a69811 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: my-project · feature/mixed-preview · Mixed preview thread +terminal title: project | Mixed preview thread | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap new file mode 100644 index 0000000000..eb27cdd01b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] thread Current thread title (omitted until available) + [x] git-branch Current Git branch (omitted when unavailable) + [x] task-progress Latest task progress from update_plan (omitted until available) + [ ] app-name Codex app name + [ ] project Project name (falls back to current directory name) + [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] status Compact session status text (Ready, Working, Thinking) + [ ] model Current model name + + thread title | feat/awesome-feature | Tasks 0/0 + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap new file mode 100644 index 0000000000..bc2bf2502a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project Project name (falls back to current directory name) + [x] thread Current thread title (omitted until available) + [x] git-branch Current Git branch (omitted when unavailable) + [x] task-progress Latest task progress from update_plan (omitted until available) + [ ] app-name Codex app name + [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] status Compact session status text (Ready, Working, Thinking) + [ ] model Current model name + + preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5 + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap new file mode 100644 index 0000000000..4d2f3128d5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project Project name (falls back to current directory name) + [x] thread Current thread title (omitted until available) + [x] task-progress Latest task progress from update_plan (omitted until available) + [ ] app-name Codex app name + [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] status Compact session status text (Ready, Working, Thinking) + [ ] git-branch Current Git branch (omitted when unavailable) + [ ] model Current model name + + project | Mixed preview thread | Tasks 0/0 + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index a744711c2c..7045bbecfd 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -505,11 +505,41 @@ impl ChatWidget { } } + pub(super) fn status_surface_preview_value_for_item( + &mut self, + item: StatusSurfacePreviewItem, + ) -> Option { + let status_line_item = match item { + StatusSurfacePreviewItem::AppName => return Some("codex".to_string()), + StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(), + StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot, + StatusSurfacePreviewItem::Status => return Some(self.terminal_title_status_text()), + StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(), + StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, + StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, + StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, + StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining, + StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed, + StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit, + StatusSurfacePreviewItem::WeeklyLimit => StatusLineItem::WeeklyLimit, + StatusSurfacePreviewItem::CodexVersion => StatusLineItem::CodexVersion, + StatusSurfacePreviewItem::ContextWindowSize => StatusLineItem::ContextWindowSize, + StatusSurfacePreviewItem::UsedTokens => StatusLineItem::UsedTokens, + StatusSurfacePreviewItem::TotalInputTokens => StatusLineItem::TotalInputTokens, + StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens, + StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, + StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, + StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, + StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, + }; + self.status_line_value_for_item(&status_line_item) + } + /// Resolves one configured terminal-title item into a displayable segment. /// /// Returning `None` means "omit this segment for now" so callers can keep /// the configured order while hiding values that are not yet available. - fn terminal_title_value_for_item( + pub(super) fn terminal_title_value_for_item( &mut self, item: TerminalTitleItem, now: Instant, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4c4d74090c..d81544e4f7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -276,6 +276,7 @@ mod side; mod slash_commands; mod status_and_layout; mod status_command_tests; +mod status_surface_previews; pub(crate) use helpers::make_chatwidget_manual_with_sender; pub(crate) use helpers::set_chatgpt_auth; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs new file mode 100644 index 0000000000..b5ef67dd60 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::bottom_pane::preview_line_for_title_items; +use pretty_assertions::assert_eq; +use ratatui::text::Line; + +fn line_text(line: Line<'static>) -> String { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect() +} + +fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { + let preview_data = chat.status_surface_preview_data(); + let preview = preview_data + .line_for_items(items.iter().cloned().map(StatusLineItem::preview_item)) + .expect("status preview line"); + line_text(preview) +} + +fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String { + let preview_data = chat.terminal_title_preview_data(); + let preview = + preview_line_for_title_items(items, &preview_data).expect("terminal title preview line"); + line_text(preview) +} + +fn combined_preview_snapshot( + chat: &mut ChatWidget, + status_items: &[StatusLineItem], + title_items: &[TerminalTitleItem], +) -> String { + normalize_snapshot_paths(format!( + "status line: {}\nterminal title: {}", + status_preview_line(chat, status_items), + title_preview_line(chat, title_items), + )) +} + +fn status_line_popup_snapshot(chat: &mut ChatWidget) -> String { + chat.open_status_line_setup(); + normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup( + chat, /*width*/ 100, + ))) +} + +fn terminal_title_popup_snapshot(chat: &mut ChatWidget) -> String { + chat.open_terminal_title_setup(); + normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup( + chat, /*width*/ 100, + ))) +} + +fn cache_project_root(chat: &mut ChatWidget, root_name: &str) { + chat.status_line_project_root_name_cache = Some(CachedProjectRootName { + cwd: chat.config.cwd.to_path_buf(), + root_name: Some(root_name.to_string()), + }); +} + +#[tokio::test] +async fn status_surface_preview_lines_live_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_project_root(&mut chat, "preview-live-root"); + chat.status_line_branch = Some("feature/live-preview-branch".to_string()); + chat.thread_name = Some("Live preview thread".to_string()); + chat.last_plan_progress = Some((2, 5)); + + let snapshot = combined_preview_snapshot( + &mut chat, + &[ + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ThreadTitle, + ], + &[ + TerminalTitleItem::Project, + TerminalTitleItem::Thread, + TerminalTitleItem::GitBranch, + TerminalTitleItem::TaskProgress, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_live_only", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_live_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_project_root(&mut chat, "preview-live-root"); + chat.status_line_branch = Some("feature/live-preview-branch".to_string()); + chat.thread_name = Some("Live preview thread".to_string()); + chat.config.tui_status_line = Some(vec![ + "project-root".to_string(), + "git-branch".to_string(), + "thread-title".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_live_only", + status_line_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn status_surface_preview_lines_hardcoded_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let snapshot = combined_preview_snapshot( + &mut chat, + &[ + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ThreadTitle, + ], + &[ + TerminalTitleItem::Thread, + TerminalTitleItem::GitBranch, + TerminalTitleItem::TaskProgress, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_hardcoded_only", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_hardcoded_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec![ + "project-root".to_string(), + "git-branch".to_string(), + "thread-title".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_hardcoded_only", + status_line_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn status_surface_preview_lines_mixed_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_branch = Some("feature/mixed-preview".to_string()); + chat.thread_name = Some("Mixed preview thread".to_string()); + + let snapshot = combined_preview_snapshot( + &mut chat, + &[ + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ThreadTitle, + ], + &[ + TerminalTitleItem::Project, + TerminalTitleItem::Thread, + TerminalTitleItem::TaskProgress, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_mixed_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_branch = Some("feature/mixed-preview".to_string()); + chat.thread_name = Some("Mixed preview thread".to_string()); + chat.config.tui_status_line = Some(vec![ + "project-root".to_string(), + "git-branch".to_string(), + "thread-title".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_mixed", + status_line_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn terminal_title_setup_popup_live_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_project_root(&mut chat, "preview-live-root"); + chat.status_line_branch = Some("feature/live-preview-branch".to_string()); + chat.thread_name = Some("Live preview thread".to_string()); + chat.last_plan_progress = Some((2, 5)); + chat.config.tui_terminal_title = Some(vec![ + "project".to_string(), + "thread".to_string(), + "git-branch".to_string(), + "task-progress".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_live_only", + terminal_title_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn terminal_title_setup_popup_hardcoded_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_terminal_title = Some(vec![ + "thread".to_string(), + "git-branch".to_string(), + "task-progress".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_hardcoded_only", + terminal_title_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn terminal_title_setup_popup_mixed_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_name = Some("Mixed preview thread".to_string()); + chat.config.tui_terminal_title = Some(vec![ + "project".to_string(), + "thread".to_string(), + "task-progress".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_mixed", + terminal_title_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn missing_project_root_uses_different_status_and_title_preview_sources() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]); + let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]); + + assert_eq!(status_preview, "my-project"); + assert_eq!(title_preview, "project"); +} + +#[tokio::test] +async fn terminal_title_preview_uses_title_truncation_for_live_values() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let long_thread = "This thread title is intentionally much longer than forty-eight characters"; + let long_branch = "feature/this-branch-name-is-intentionally-longer-than-thirty-two"; + chat.thread_name = Some(long_thread.to_string()); + chat.status_line_branch = Some(long_branch.to_string()); + + let preview = title_preview_line( + &mut chat, + &[TerminalTitleItem::Thread, TerminalTitleItem::GitBranch], + ); + let truncated_thread = + ChatWidget::truncate_terminal_title_part(long_thread.to_string(), /*max_chars*/ 48); + let truncated_branch = + ChatWidget::truncate_terminal_title_part(long_branch.to_string(), /*max_chars*/ 32); + + assert_eq!(preview, format!("{truncated_thread} | {truncated_branch}")); +} From 3e56a654891087a470c3bef09a3fdc143f21b386 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 20 Apr 2026 17:50:33 -0700 Subject: [PATCH 2/4] analytics: add responses api call schema --- .../analytics/src/analytics_client_tests.rs | 5 + codex-rs/analytics/src/client.rs | 3 + codex-rs/analytics/src/events.rs | 41 +++ codex-rs/analytics/src/facts.rs | 86 +++++ codex-rs/analytics/src/lib.rs | 25 ++ codex-rs/analytics/src/reducer.rs | 1 + codex-rs/analytics/src/response_items.rs | 346 ++++++++++++++++++ 7 files changed, 507 insertions(+) create mode 100644 codex-rs/analytics/src/response_items.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bf97da4284..7589c0bd37 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -29,6 +29,11 @@ use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexResponseItemType; +use crate::facts::CodexResponsesApiCallFact; +use crate::facts::CodexResponsesApiCallStatus; +use crate::facts::CodexResponsesApiItemMetadata; +use crate::facts::CodexResponsesApiItemPhase; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 25ab40953f..9cbd97f923 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -8,6 +8,9 @@ use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexResponsesApiCallFact; +use crate::facts::CodexResponsesApiCallInput; +use crate::facts::CodexResponsesApiItemPhase; use crate::facts::CustomAnalyticsFact; use crate::facts::HookRunFact; use crate::facts::HookRunInput; diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b542f8268f..8a1b7d2046 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,7 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexResponsesApiCallStatus; +use crate::facts::CodexResponsesApiItemMetadata; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; @@ -55,6 +57,8 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), HookRun(CodexHookRunEventRequest), Compaction(Box), + #[allow(dead_code)] + ResponsesApiCall(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), @@ -311,6 +315,43 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexResponsesApiCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) responses_id: Option, + pub(crate) turn_responses_call_index: u64, + pub(crate) model: Option, + pub(crate) model_provider: Option, + pub(crate) reasoning_effort: Option, + pub(crate) status: CodexResponsesApiCallStatus, + pub(crate) error: Option, + pub(crate) started_at: u64, + pub(crate) completed_at: Option, + pub(crate) duration_ms: Option, + pub(crate) input_item_count: usize, + pub(crate) output_item_count: usize, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, + pub(crate) items: Vec, +} + +#[derive(Serialize)] +pub(crate) struct CodexResponsesApiCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexResponsesApiCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d371acb1c..04408c4c2e 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -13,6 +13,8 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; @@ -262,6 +264,90 @@ pub struct CodexCompactionEvent { pub duration_ms: Option, } +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexResponsesApiCallStatus { + Completed, + Failed, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CodexResponsesApiItemPhase { + Input, + Output, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexResponseItemType { + Message, + Reasoning, + LocalShellCall, + FunctionCall, + FunctionCallOutput, + CustomToolCall, + CustomToolCallOutput, + ToolSearchCall, + ToolSearchOutput, + WebSearchCall, + ImageGenerationCall, + GhostSnapshot, + Compaction, + Other, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct CodexResponsesApiItemMetadata { + pub item_phase: CodexResponsesApiItemPhase, + pub item_index: usize, + pub response_item_type: CodexResponseItemType, + pub role: Option, + pub status: Option, + pub message_phase: Option, + pub call_id: Option, + pub tool_name: Option, + pub payload_bytes: Option, + pub text_part_count: Option, + pub image_part_count: Option, +} + +#[derive(Clone, Debug)] +pub struct CodexResponsesApiCallInput { + pub responses_id: Option, + pub turn_responses_call_index: u64, + pub status: CodexResponsesApiCallStatus, + pub error: Option, + pub started_at: u64, + pub completed_at: Option, + pub duration_ms: Option, + pub input_items: Vec, + pub output_items: Vec, + pub token_usage: Option, +} + +#[derive(Clone, Debug)] +pub struct CodexResponsesApiCallFact { + pub thread_id: String, + pub turn_id: String, + pub responses_id: Option, + pub turn_responses_call_index: u64, + pub status: CodexResponsesApiCallStatus, + pub error: Option, + pub started_at: u64, + pub completed_at: Option, + pub duration_ms: Option, + pub input_item_count: usize, + pub output_item_count: usize, + pub input_tokens: Option, + pub cached_input_tokens: Option, + pub output_tokens: Option, + pub reasoning_output_tokens: Option, + pub total_tokens: Option, + pub items: Vec, +} + #[allow(dead_code)] pub(crate) enum AnalyticsFact { Initialize { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5c4cdfac79..cc95fa8587 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -2,7 +2,9 @@ mod client; mod events; mod facts; mod reducer; +mod response_items; +use serde::Serialize; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -18,6 +20,8 @@ pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexResponsesApiCallInput; +pub use facts::CodexResponsesApiCallStatus; pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; @@ -49,3 +53,24 @@ pub fn now_unix_seconds() -> u64 { .unwrap_or_default() .as_secs() } + +pub(crate) fn serialized_string(value: &T) -> Option { + match serde_json::to_value(value).ok()? { + serde_json::Value::String(value) => Some(value), + value => Some(value.to_string()), + } +} + +pub(crate) fn serialized_bytes(value: &T) -> Option { + serde_json::to_string(value) + .ok() + .map(|value| byte_len(&value)) +} + +pub(crate) fn nonzero_i64(value: i64) -> Option { + (value > 0).then_some(value) +} + +pub(crate) fn byte_len(value: &str) -> i64 { + i64::try_from(value.len()).unwrap_or(i64::MAX) +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a6ce3fc831..965b83c953 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -33,6 +33,7 @@ use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexResponsesApiCallFact; use crate::facts::CustomAnalyticsFact; use crate::facts::HookRunInput; use crate::facts::PluginState; diff --git a/codex-rs/analytics/src/response_items.rs b/codex-rs/analytics/src/response_items.rs new file mode 100644 index 0000000000..e32be92900 --- /dev/null +++ b/codex-rs/analytics/src/response_items.rs @@ -0,0 +1,346 @@ +use crate::byte_len; +use crate::facts::CodexResponseItemType; +use crate::facts::CodexResponsesApiItemMetadata; +use crate::facts::CodexResponsesApiItemPhase; +use crate::nonzero_i64; +use crate::serialized_bytes; +use crate::serialized_string; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ReasoningItemContent; +use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::models::ResponseItem; + +pub fn response_items_metadata( + phase: CodexResponsesApiItemPhase, + items: &[ResponseItem], +) -> Vec { + items + .iter() + .enumerate() + .map(|(item_index, item)| response_item_metadata(phase, item_index, item)) + .collect() +} + +fn response_item_metadata( + item_phase: CodexResponsesApiItemPhase, + item_index: usize, + item: &ResponseItem, +) -> CodexResponsesApiItemMetadata { + let mut metadata = new_metadata(item_phase, item_index, response_item_type(item)); + + match item { + ResponseItem::Message { + role, + content, + phase, + .. + } => { + metadata.role = Some(role.clone()); + metadata.message_phase = phase.clone(); + metadata.payload_bytes = nonzero_i64(message_content_text_bytes(content)); + let (text_part_count, image_part_count) = message_content_part_counts(content); + metadata.text_part_count = Some(text_part_count); + metadata.image_part_count = Some(image_part_count); + } + ResponseItem::Reasoning { + summary, + content, + encrypted_content, + .. + } => { + metadata.payload_bytes = encrypted_content + .as_ref() + .map(|value| byte_len(value)) + .or_else(|| nonzero_i64(reasoning_content_bytes(summary, content))); + metadata.text_part_count = + Some(summary.len() + content.as_ref().map(std::vec::Vec::len).unwrap_or_default()); + metadata.image_part_count = Some(0); + } + ResponseItem::LocalShellCall { + call_id, + status, + action, + .. + } => { + metadata.call_id = call_id.clone(); + metadata.tool_name = Some("local_shell".to_string()); + metadata.status = serialized_string(status); + metadata.payload_bytes = serialized_bytes(action); + } + ResponseItem::FunctionCall { + name, + arguments, + call_id, + .. + } => { + metadata.call_id = Some(call_id.clone()); + metadata.tool_name = Some(name.clone()); + metadata.payload_bytes = Some(byte_len(arguments)); + } + ResponseItem::ToolSearchCall { + call_id, + status, + arguments, + .. + } => { + metadata.call_id = call_id.clone(); + metadata.tool_name = Some("tool_search".to_string()); + metadata.status = status.clone(); + metadata.payload_bytes = serialized_bytes(arguments); + } + ResponseItem::FunctionCallOutput { call_id, output } => { + metadata.call_id = Some(call_id.clone()); + metadata.payload_bytes = function_call_output_bytes(output); + let (text_part_count, image_part_count) = function_call_output_part_counts(output); + metadata.text_part_count = text_part_count; + metadata.image_part_count = image_part_count; + } + ResponseItem::CustomToolCall { + status, + call_id, + name, + input, + .. + } => { + metadata.call_id = Some(call_id.clone()); + metadata.tool_name = Some(name.clone()); + metadata.status = status.clone(); + metadata.payload_bytes = Some(byte_len(input)); + } + ResponseItem::CustomToolCallOutput { + call_id, + name, + output, + } => { + metadata.call_id = Some(call_id.clone()); + metadata.tool_name = name.clone(); + metadata.payload_bytes = function_call_output_bytes(output); + let (text_part_count, image_part_count) = function_call_output_part_counts(output); + metadata.text_part_count = text_part_count; + metadata.image_part_count = image_part_count; + } + ResponseItem::ToolSearchOutput { + call_id, + status, + tools, + .. + } => { + metadata.call_id = call_id.clone(); + metadata.tool_name = Some("tool_search".to_string()); + metadata.status = Some(status.clone()); + metadata.payload_bytes = serialized_bytes(tools); + } + ResponseItem::WebSearchCall { status, action, .. } => { + metadata.tool_name = Some("web_search".to_string()); + metadata.status = status.clone(); + metadata.payload_bytes = action.as_ref().and_then(serialized_bytes); + } + ResponseItem::ImageGenerationCall { + id, + status, + revised_prompt, + result, + } => { + metadata.call_id = Some(id.clone()); + metadata.tool_name = Some("image_generation".to_string()); + metadata.status = Some(status.clone()); + metadata.payload_bytes = nonzero_i64(byte_len(result)) + .or_else(|| revised_prompt.as_ref().map(|value| byte_len(value))); + } + ResponseItem::Compaction { encrypted_content } => { + metadata.payload_bytes = Some(byte_len(encrypted_content)); + } + ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => {} + } + + metadata +} + +fn new_metadata( + item_phase: CodexResponsesApiItemPhase, + item_index: usize, + response_item_type: CodexResponseItemType, +) -> CodexResponsesApiItemMetadata { + CodexResponsesApiItemMetadata { + item_phase, + item_index, + response_item_type, + role: None, + status: None, + message_phase: None, + call_id: None, + tool_name: None, + payload_bytes: None, + text_part_count: None, + image_part_count: None, + } +} + +fn response_item_type(item: &ResponseItem) -> CodexResponseItemType { + match item { + ResponseItem::Message { .. } => CodexResponseItemType::Message, + ResponseItem::Reasoning { .. } => CodexResponseItemType::Reasoning, + ResponseItem::LocalShellCall { .. } => CodexResponseItemType::LocalShellCall, + ResponseItem::FunctionCall { .. } => CodexResponseItemType::FunctionCall, + ResponseItem::ToolSearchCall { .. } => CodexResponseItemType::ToolSearchCall, + ResponseItem::FunctionCallOutput { .. } => CodexResponseItemType::FunctionCallOutput, + ResponseItem::CustomToolCall { .. } => CodexResponseItemType::CustomToolCall, + ResponseItem::CustomToolCallOutput { .. } => CodexResponseItemType::CustomToolCallOutput, + ResponseItem::ToolSearchOutput { .. } => CodexResponseItemType::ToolSearchOutput, + ResponseItem::WebSearchCall { .. } => CodexResponseItemType::WebSearchCall, + ResponseItem::ImageGenerationCall { .. } => CodexResponseItemType::ImageGenerationCall, + ResponseItem::GhostSnapshot { .. } => CodexResponseItemType::GhostSnapshot, + ResponseItem::Compaction { .. } => CodexResponseItemType::Compaction, + ResponseItem::Other => CodexResponseItemType::Other, + } +} + +fn message_content_text_bytes(content: &[ContentItem]) -> i64 { + content + .iter() + .map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => byte_len(text), + ContentItem::InputImage { .. } => 0, + }) + .sum() +} + +fn message_content_part_counts(content: &[ContentItem]) -> (usize, usize) { + let mut text_part_count = 0; + let mut image_part_count = 0; + for item in content { + match item { + ContentItem::InputText { .. } | ContentItem::OutputText { .. } => { + text_part_count += 1; + } + ContentItem::InputImage { .. } => { + image_part_count += 1; + } + } + } + (text_part_count, image_part_count) +} + +fn reasoning_content_bytes( + summary: &[ReasoningItemReasoningSummary], + content: &Option>, +) -> i64 { + let summary_bytes = summary + .iter() + .map(|summary| match summary { + ReasoningItemReasoningSummary::SummaryText { text } => byte_len(text), + }) + .sum::(); + let content_bytes = content + .as_ref() + .map(|content| { + content + .iter() + .map(|content| match content { + ReasoningItemContent::ReasoningText { text } + | ReasoningItemContent::Text { text } => byte_len(text), + }) + .sum::() + }) + .unwrap_or_default(); + summary_bytes + content_bytes +} + +fn function_call_output_bytes(output: &FunctionCallOutputPayload) -> Option { + match &output.body { + FunctionCallOutputBody::Text(text) => Some(byte_len(text)), + FunctionCallOutputBody::ContentItems(items) => serialized_bytes(items), + } +} + +fn function_call_output_part_counts( + output: &FunctionCallOutputPayload, +) -> (Option, Option) { + let Some(content_items) = output.content_items() else { + return (None, None); + }; + let mut text_part_count = 0; + let mut image_part_count = 0; + for item in content_items { + match item { + FunctionCallOutputContentItem::InputText { .. } => { + text_part_count += 1; + } + FunctionCallOutputContentItem::InputImage { .. } => { + image_part_count += 1; + } + } + } + (Some(text_part_count), Some(image_part_count)) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ImageDetail; + use codex_protocol::models::MessagePhase; + + #[test] + fn maps_message_metadata() { + let items = vec![ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ + ContentItem::OutputText { + text: "hello".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }, + ], + end_turn: None, + phase: Some(MessagePhase::FinalAnswer), + }]; + + let metadata = response_items_metadata(CodexResponsesApiItemPhase::Output, &items); + + assert_eq!(metadata[0].item_phase, CodexResponsesApiItemPhase::Output); + assert_eq!( + metadata[0].response_item_type, + CodexResponseItemType::Message + ); + assert_eq!(metadata[0].role.as_deref(), Some("assistant")); + assert_eq!(metadata[0].message_phase, Some(MessagePhase::FinalAnswer)); + assert_eq!(metadata[0].payload_bytes, Some(5)); + assert_eq!(metadata[0].text_part_count, Some(1)); + assert_eq!(metadata[0].image_part_count, Some(1)); + } + + #[test] + fn maps_tool_call_output_metadata() { + let items = vec![ResponseItem::CustomToolCallOutput { + call_id: "call_1".to_string(), + name: Some("custom_tool".to_string()), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputText { + text: "result".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.test/image.png".to_string(), + detail: Some(ImageDetail::High), + }, + ]), + }]; + + let metadata = response_items_metadata(CodexResponsesApiItemPhase::Output, &items); + + assert_eq!( + metadata[0].response_item_type, + CodexResponseItemType::CustomToolCallOutput + ); + assert_eq!(metadata[0].call_id.as_deref(), Some("call_1")); + assert_eq!(metadata[0].tool_name.as_deref(), Some("custom_tool")); + assert!(metadata[0].payload_bytes.unwrap_or_default() > 0); + assert_eq!(metadata[0].text_part_count, Some(1)); + assert_eq!(metadata[0].image_part_count, Some(1)); + } +} From 8e29b28057b03fc2f0a50d7d97a91e8f8f36173a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 20 Apr 2026 17:50:33 -0700 Subject: [PATCH 3/4] analytics: reduce responses api call facts --- .../analytics/src/analytics_client_tests.rs | 200 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 40 ++++ codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 90 ++++++++ 4 files changed, 331 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7589c0bd37..ab52079d7b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,8 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexResponsesApiCallEventParams; +use crate::events::CodexResponsesApiCallEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::GuardianApprovalRequestSource; @@ -96,6 +98,7 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::models::MessagePhase; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; @@ -278,6 +281,25 @@ fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsag } } +fn sample_responses_api_item_metadata( + item_phase: CodexResponsesApiItemPhase, + item_index: usize, +) -> CodexResponsesApiItemMetadata { + CodexResponsesApiItemMetadata { + item_phase, + item_index, + response_item_type: CodexResponseItemType::Message, + role: Some("assistant".to_string()), + status: Some("completed".to_string()), + message_phase: Some(MessagePhase::FinalAnswer), + call_id: None, + tool_name: None, + payload_bytes: Some(12), + text_part_count: Some(1), + image_part_count: Some(0), + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -757,6 +779,102 @@ fn compaction_event_serializes_expected_shape() { ); } +#[test] +fn responses_api_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ResponsesApiCall(Box::new(CodexResponsesApiCallEventRequest { + event_type: "codex_responses_api_call_event", + event_params: CodexResponsesApiCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + responses_id: Some("resp_123".to_string()), + turn_responses_call_index: 2, + model: Some("gpt-5".to_string()), + model_provider: Some("openai".to_string()), + reasoning_effort: Some("high".to_string()), + status: CodexResponsesApiCallStatus::Completed, + error: None, + started_at: 100, + completed_at: Some(102), + duration_ms: Some(2345), + input_item_count: 1, + output_item_count: 1, + input_tokens: Some(10), + cached_input_tokens: Some(4), + output_tokens: Some(20), + reasoning_output_tokens: Some(3), + total_tokens: Some(30), + items: vec![ + sample_responses_api_item_metadata( + CodexResponsesApiItemPhase::Input, + /*item_index*/ 0, + ), + sample_responses_api_item_metadata( + CodexResponsesApiItemPhase::Output, + /*item_index*/ 0, + ), + ], + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize responses api call event"); + + assert_eq!(payload["event_type"], "codex_responses_api_call_event"); + assert_eq!(payload["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload["event_params"]["ephemeral"], false); + assert_eq!(payload["event_params"]["thread_source"], "user"); + assert_eq!(payload["event_params"]["initialization_mode"], "new"); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload["event_params"]["app_server_client"]["rpc_transport"], + "stdio" + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!(payload["event_params"]["responses_id"], "resp_123"); + assert_eq!(payload["event_params"]["turn_responses_call_index"], 2); + assert_eq!(payload["event_params"]["model"], "gpt-5"); + assert_eq!(payload["event_params"]["model_provider"], "openai"); + assert_eq!(payload["event_params"]["reasoning_effort"], "high"); + assert_eq!(payload["event_params"]["status"], "completed"); + assert_eq!(payload["event_params"]["error"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], 100); + assert_eq!(payload["event_params"]["completed_at"], 102); + assert_eq!(payload["event_params"]["duration_ms"], 2345); + assert_eq!(payload["event_params"]["input_item_count"], 1); + assert_eq!(payload["event_params"]["output_item_count"], 1); + assert_eq!(payload["event_params"]["input_tokens"], 10); + assert_eq!(payload["event_params"]["cached_input_tokens"], 4); + assert_eq!(payload["event_params"]["output_tokens"], 20); + assert_eq!(payload["event_params"]["reasoning_output_tokens"], 3); + assert_eq!(payload["event_params"]["total_tokens"], 30); + assert_eq!(payload["event_params"]["items"][0]["item_phase"], "input"); + assert_eq!( + payload["event_params"]["items"][0]["response_item_type"], + "message" + ); + assert_eq!(payload["event_params"]["items"][0]["role"], "assistant"); + assert_eq!( + payload["event_params"]["items"][0]["message_phase"], + "final_answer" + ); + assert_eq!(payload["event_params"]["items"][0]["text_part_count"], 1); + assert_eq!(payload["event_params"]["items"][0]["image_part_count"], 0); + assert_eq!(payload["event_params"]["items"][1]["item_phase"], "output"); +} + #[test] fn app_used_dedupe_is_keyed_by_turn_and_connector() { let (sender, _receiver) = mpsc::channel(1); @@ -1191,6 +1309,88 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); } +#[tokio::test] +async fn responses_api_call_event_ingests_custom_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut events, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ResponsesApiCall(Box::new( + CodexResponsesApiCallFact { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + responses_id: Some("resp_456".to_string()), + turn_responses_call_index: 1, + status: CodexResponsesApiCallStatus::Completed, + error: None, + started_at: 100, + completed_at: Some(101), + duration_ms: Some(1200), + input_item_count: 1, + output_item_count: 1, + input_tokens: Some(123), + cached_input_tokens: Some(45), + output_tokens: Some(140), + reasoning_output_tokens: Some(13), + total_tokens: Some(321), + items: vec![ + sample_responses_api_item_metadata( + CodexResponsesApiItemPhase::Input, + /*item_index*/ 0, + ), + sample_responses_api_item_metadata( + CodexResponsesApiItemPhase::Output, + /*item_index*/ 0, + ), + ], + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_responses_api_call_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-2"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-2"); + assert_eq!(payload[0]["event_params"]["ephemeral"], false); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); + assert_eq!(payload[0]["event_params"]["initialization_mode"], "new"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["runtime"]["runtime_os"], "macos"); + assert_eq!(payload[0]["event_params"]["responses_id"], "resp_456"); + assert_eq!(payload[0]["event_params"]["turn_responses_call_index"], 1); + assert_eq!(payload[0]["event_params"]["model"], "gpt-5"); + assert_eq!(payload[0]["event_params"]["model_provider"], "openai"); + assert_eq!(payload[0]["event_params"]["reasoning_effort"], json!(null)); + assert_eq!(payload[0]["event_params"]["status"], "completed"); + assert_eq!(payload[0]["event_params"]["input_tokens"], 123); + assert_eq!( + payload[0]["event_params"]["items"][0]["item_phase"], + "input" + ); + assert_eq!( + payload[0]["event_params"]["items"][1]["item_phase"], + "output" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 9cbd97f923..e0de1975dd 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -23,6 +23,7 @@ use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; +use crate::response_items::response_items_metadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; @@ -217,6 +218,45 @@ impl AnalyticsEventsClient { ))); } + pub fn track_responses_api_call( + &self, + tracking: TrackEventsContext, + input: CodexResponsesApiCallInput, + ) { + let mut items = + response_items_metadata(CodexResponsesApiItemPhase::Input, &input.input_items); + items.extend(response_items_metadata( + CodexResponsesApiItemPhase::Output, + &input.output_items, + )); + let token_usage = input.token_usage; + let event = CodexResponsesApiCallFact { + thread_id: tracking.thread_id, + turn_id: tracking.turn_id, + responses_id: input.responses_id, + turn_responses_call_index: input.turn_responses_call_index, + status: input.status, + error: input.error, + started_at: input.started_at, + completed_at: input.completed_at, + duration_ms: input.duration_ms, + input_item_count: input.input_items.len(), + output_item_count: input.output_items.len(), + input_tokens: token_usage.as_ref().map(|usage| usage.input_tokens), + cached_input_tokens: token_usage.as_ref().map(|usage| usage.cached_input_tokens), + output_tokens: token_usage.as_ref().map(|usage| usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: token_usage.as_ref().map(|usage| usage.total_tokens), + items, + }; + + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ResponsesApiCall(Box::new(event)), + )); + } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 04408c4c2e..5f8fb34317 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -381,6 +381,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + ResponsesApiCall(Box), GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 965b83c953..4d70242d09 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,6 +6,8 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexResponsesApiCallEventParams; +use crate::events::CodexResponsesApiCallEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; @@ -203,6 +205,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::ResponsesApiCall(input) => { + self.ingest_responses_api_call(*input, out); + } CustomAnalyticsFact::GuardianReview(input) => { self.ingest_guardian_review(*input, out); } @@ -740,6 +745,91 @@ impl AnalyticsReducer { ))); } + fn ingest_responses_api_call( + &mut self, + input: CodexResponsesApiCallFact, + out: &mut Vec, + ) { + let Some(connection_id) = self.thread_connections.get(&input.thread_id) else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + "dropping responses api call analytics event: missing thread connection metadata" + ); + return; + }; + let Some(connection_state) = self.connections.get(connection_id) else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + connection_id, + "dropping responses api call analytics event: missing connection metadata" + ); + return; + }; + let Some(thread_metadata) = self.thread_metadata.get(&input.thread_id) else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + "dropping responses api call analytics event: missing thread lifecycle metadata" + ); + return; + }; + let Some(turn_state) = self.turns.get(&input.turn_id) else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + "dropping responses api call analytics event: missing turn metadata" + ); + return; + }; + let Some(resolved_config) = turn_state.resolved_config.as_ref() else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + "dropping responses api call analytics event: missing turn resolved config" + ); + return; + }; + + out.push(TrackEventRequest::ResponsesApiCall(Box::new( + CodexResponsesApiCallEventRequest { + event_type: "codex_responses_api_call_event", + event_params: CodexResponsesApiCallEventParams { + thread_id: input.thread_id, + turn_id: input.turn_id, + ephemeral: resolved_config.ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + responses_id: input.responses_id, + turn_responses_call_index: input.turn_responses_call_index, + model: Some(resolved_config.model.clone()), + model_provider: Some(resolved_config.model_provider.clone()), + reasoning_effort: resolved_config + .reasoning_effort + .map(|value| value.to_string()), + status: input.status, + error: input.error, + started_at: input.started_at, + completed_at: input.completed_at, + duration_ms: input.duration_ms, + input_item_count: input.input_item_count, + output_item_count: input.output_item_count, + input_tokens: input.input_tokens, + cached_input_tokens: input.cached_input_tokens, + output_tokens: input.output_tokens, + reasoning_output_tokens: input.reasoning_output_tokens, + total_tokens: input.total_tokens, + items: input.items, + }, + }, + ))); + } + fn ingest_turn_steer_response( &mut self, connection_id: u64, From 4565f6dbb6111bb1f96776205e4fbfcc580798a9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 20 Apr 2026 17:50:33 -0700 Subject: [PATCH 4/4] core: emit responses api call analytics --- codex-rs/core/src/session/turn.rs | 168 +++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index efbcca6716..83a632488e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::SkillInjections; use crate::SkillLoadOutcome; @@ -58,6 +61,8 @@ use crate::unavailable_tool::collect_unavailable_called_tools; use crate::util::backoff; use crate::util::error_or_panic; use codex_analytics::AppInvocation; +use codex_analytics::CodexResponsesApiCallInput; +use codex_analytics::CodexResponsesApiCallStatus; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; @@ -90,6 +95,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::PlanDeltaEvent; use codex_protocol::protocol::ReasoningContentDeltaEvent; use codex_protocol::protocol::ReasoningRawContentDeltaEvent; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; @@ -394,6 +400,7 @@ pub(crate) async fn run_turn( // 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first. // 2. After auto-compact, when model/tool continuation needs to resume before any steer. let mut can_drain_pending_input = input.is_empty(); + let mut next_turn_responses_call_index: u64 = 0; loop { if run_pending_session_start_hooks(&sess, &turn_context).await { @@ -465,12 +472,15 @@ pub(crate) async fn run_turn( .map(|user_message| user_message.message()) .collect::>(); let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let turn_responses_call_index = next_turn_responses_call_index; + next_turn_responses_call_index = next_turn_responses_call_index.saturating_add(1); match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), &mut client_session, turn_metadata_header.as_deref(), + turn_responses_call_index, sampling_request_input, &explicitly_enabled_connectors, skills_outcome, @@ -991,6 +1001,116 @@ pub(crate) fn build_prompt( } } +const RESPONSES_API_ANALYTICS_ERROR_MAX_BYTES: usize = 1024; + +struct ResponsesApiCallAnalyticsState { + turn_responses_call_index: u64, + started_at: u64, + started_instant: Instant, + input_items: Vec, + output_items: Vec, + responses_id: Option, + token_usage: Option, +} + +impl ResponsesApiCallAnalyticsState { + fn new(turn_responses_call_index: u64) -> Self { + Self { + turn_responses_call_index, + started_at: current_unix_seconds(), + started_instant: Instant::now(), + input_items: Vec::new(), + output_items: Vec::new(), + responses_id: None, + token_usage: None, + } + } + + fn record_prompt(&mut self, prompt: &Prompt) { + self.input_items = prompt.input.clone(); + self.output_items.clear(); + self.responses_id = None; + self.token_usage = None; + } + + fn record_output_item_done(&mut self, item: &ResponseItem) { + self.output_items.push(item.clone()); + } + + fn record_completed(&mut self, response_id: String, token_usage: Option) { + self.responses_id = Some(response_id); + self.token_usage = token_usage; + } + + fn into_input( + self, + status: CodexResponsesApiCallStatus, + error: Option, + ) -> CodexResponsesApiCallInput { + let completed_at = current_unix_seconds(); + CodexResponsesApiCallInput { + responses_id: self.responses_id, + turn_responses_call_index: self.turn_responses_call_index, + status, + error, + started_at: self.started_at, + completed_at: Some(completed_at), + duration_ms: Some(self.started_instant.elapsed().as_millis() as u64), + input_items: self.input_items, + output_items: self.output_items, + token_usage: self.token_usage, + } + } +} + +fn current_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn truncate_responses_api_error(mut error: String) -> String { + if error.len() <= RESPONSES_API_ANALYTICS_ERROR_MAX_BYTES { + return error; + } + let mut truncate_at = RESPONSES_API_ANALYTICS_ERROR_MAX_BYTES; + while !error.is_char_boundary(truncate_at) { + truncate_at -= 1; + } + error.truncate(truncate_at); + error +} + +fn responses_api_call_error_status(err: &CodexErr) -> CodexResponsesApiCallStatus { + match err { + CodexErr::TurnAborted => CodexResponsesApiCallStatus::Interrupted, + _ => CodexResponsesApiCallStatus::Failed, + } +} + +fn track_responses_api_call_analytics( + sess: &Session, + turn_context: &TurnContext, + analytics: ResponsesApiCallAnalyticsState, + status: CodexResponsesApiCallStatus, + error: Option, +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + sess.services + .analytics_events_client + .track_responses_api_call( + build_track_events_context( + turn_context.model_info.slug.clone(), + sess.conversation_id.to_string(), + turn_context.sub_id.clone(), + ), + analytics.into_input(status, error), + ); +} + #[allow(clippy::too_many_arguments)] #[instrument(level = "trace", skip_all, @@ -1006,6 +1126,7 @@ async fn run_sampling_request( turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, + turn_responses_call_index: u64, input: Vec, explicitly_enabled_connectors: &HashSet, skills_outcome: Option<&SkillLoadOutcome>, @@ -1042,6 +1163,8 @@ async fn run_sampling_request( .await; let mut retries = 0; let mut initial_input = Some(input); + let mut responses_api_call_analytics = + ResponsesApiCallAnalyticsState::new(turn_responses_call_index); loop { let prompt_input = if let Some(input) = initial_input.take() { input @@ -1056,6 +1179,7 @@ async fn run_sampling_request( turn_context.as_ref(), base_instructions.clone(), ); + responses_api_call_analytics.record_prompt(&prompt); let err = match try_run_sampling_request( tool_runtime.clone(), Arc::clone(&sess), @@ -1065,15 +1189,30 @@ async fn run_sampling_request( Arc::clone(&turn_diff_tracker), server_model_warning_emitted_for_turn, &prompt, + &mut responses_api_call_analytics, cancellation_token.child_token(), ) .await { Ok(output) => { + track_responses_api_call_analytics( + sess.as_ref(), + turn_context.as_ref(), + responses_api_call_analytics, + CodexResponsesApiCallStatus::Completed, + /*error*/ None, + ); return Ok(output); } Err(CodexErr::ContextWindowExceeded) => { sess.set_total_tokens_full(&turn_context).await; + track_responses_api_call_analytics( + sess.as_ref(), + turn_context.as_ref(), + responses_api_call_analytics, + CodexResponsesApiCallStatus::Failed, + Some("context window exceeded".to_string()), + ); return Err(CodexErr::ContextWindowExceeded); } Err(CodexErr::UsageLimitReached(e)) => { @@ -1081,12 +1220,28 @@ async fn run_sampling_request( if let Some(rate_limits) = rate_limits { sess.update_rate_limits(&turn_context, *rate_limits).await; } + track_responses_api_call_analytics( + sess.as_ref(), + turn_context.as_ref(), + responses_api_call_analytics, + CodexResponsesApiCallStatus::Failed, + Some(truncate_responses_api_error(e.to_string())), + ); return Err(CodexErr::UsageLimitReached(e)); } Err(err) => err, }; if !err.is_retryable() { + let status = responses_api_call_error_status(&err); + let error = Some(truncate_responses_api_error(format!("{err:#}"))); + track_responses_api_call_analytics( + sess.as_ref(), + turn_context.as_ref(), + responses_api_call_analytics, + status, + error, + ); return Err(err); } @@ -1138,6 +1293,14 @@ async fn run_sampling_request( } tokio::time::sleep(delay).await; } else { + let error = Some(truncate_responses_api_error(format!("{err:#}"))); + track_responses_api_call_analytics( + sess.as_ref(), + turn_context.as_ref(), + responses_api_call_analytics, + CodexResponsesApiCallStatus::Failed, + error, + ); return Err(err); } } @@ -1850,6 +2013,7 @@ async fn try_run_sampling_request( turn_diff_tracker: SharedTurnDiffTracker, server_model_warning_emitted_for_turn: &mut bool, prompt: &Prompt, + responses_api_call_analytics: &mut ResponsesApiCallAnalyticsState, cancellation_token: CancellationToken, ) -> CodexResult { feedback_tags!( @@ -1925,6 +2089,7 @@ async fn try_run_sampling_request( match event { ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { + responses_api_call_analytics.record_output_item_done(&item); if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take() && let Some(event) = consumer.flush_on_complete() { @@ -2095,9 +2260,10 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id: _, + response_id, token_usage, } => { + responses_api_call_analytics.record_completed(response_id, token_usage.clone()); flush_assistant_text_segments_all( &sess, &turn_context,